Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions projects/start-os/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,17 @@ Full per-release notes are published on the
[GitHub releases page](https://github.com/Start9Labs/start-technologies/releases). This
file tracks notable changes since the move to the monorepo.

## [Unreleased]

### Added

- **IPv6 GUA exposure tri-state.** On a service interface, an IPv6 global-unicast
address (GUA) now offers a **Disabled / LAN / LAN+WAN** control instead of an
on/off toggle. **LAN** (the default) keeps it reachable on the local network
only — traffic from outside the subnet is rejected; **LAN+WAN** also exposes it
to the Internet and attempts an automatic gateway pinhole (PCP). IPv6 ULAs and
IPv4 are unchanged.

## [0.4.0-beta.10]

### Added
Expand Down
11 changes: 10 additions & 1 deletion projects/start-os/docs/src/interfaces.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ Each table has the following columns:

| Column | Description |
| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **Toggle** | Enable or disable the address. This directly affects iptables and firewall rules — disabling an address blocks traffic to it. Public IPv4 addresses are off by default. All other addresses are on by default. |
| **Toggle** | Enable or disable the address. This directly affects iptables and firewall rules — disabling an address blocks traffic to it. Public IPv4 addresses are off by default. All other addresses are on by default. **IPv6 global-unicast (GUA) addresses use a three-way control instead** — see the note below. |
| **Access** | **Public** or **Private**. Public addresses are reachable from the Internet. Private addresses are only reachable on the LAN or via VPN. |
| **Type** | The address type: `IPv4`, `IPv6`, `Domain`, or `mDNS` (mDNS is only available on router gateways). |
| **Certificate Authority** | Who signs the SSL certificate for this address: **Root CA** (your server's own CA), **Let's Encrypt** (publicly trusted), or **None** (non-SSL, e.g. plain HTTP). |
Expand All @@ -32,6 +32,15 @@ Each table has the following columns:
> [!NOTE]
> The Settings button appears for addresses that require external configuration: [public domains](clearnet.md) (DNS + port forwarding), [private domains](private-domains.md) (DNS), and [public IP addresses](public-ip.md) (port forwarding).

> [!NOTE]
> Unlike a private LAN address, an IPv6 **global-unicast address (GUA)** is a single globally-routable address — so instead of an on/off toggle it offers three states:
>
> - **Disabled** — not reachable.
> - **LAN** (default) — reachable on the local network only; traffic from outside your subnet is rejected.
> - **LAN+WAN** — also reachable from the Internet. StartOS attempts to open the matching pinhole on your gateway automatically (via PCP); if your gateway doesn't support it you may need to allow inbound traffic to that address and port manually.
>
> This only applies to IPv6 GUAs. IPv6 ULAs (private) stay a simple toggle, and IPv4 keeps its separate LAN and WAN address rows.

### Adding Domains

You can add domains to a gateway table by clicking "Add Domain" on the gateway and choosing either:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -253,14 +253,13 @@ export class GatewayActionsComponent {
if (!iface) return

const enabled = !addr.enabled
const addressJson = JSON.stringify(addr.hostnameInfo)
const loader = this.loader.open('Saving').subscribe()

try {
if (this.packageId()) {
const params = {
internalPort: iface.addressInfo.internalPort,
address: addressJson,
address: addr.hostnameInfo,
enabled,
package: this.packageId(),
host: iface.addressInfo.hostId,
Expand All @@ -275,7 +274,7 @@ export class GatewayActionsComponent {
} else {
await this.api.serverBindingSetAddressEnabled({
internalPort: 80,
address: addressJson,
address: addr.hostnameInfo,
enabled,
})
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Component, computed, inject, input, signal } from '@angular/core'
import { FormsModule } from '@angular/forms'
import { ErrorService, i18nPipe } from '@start9labs/shared'
import { T } from '@start9labs/start-core'
import { TuiButton, TuiIcon } from '@taiga-ui/core'
import {
TuiBadge,
Expand All @@ -21,17 +22,32 @@ import { DomainHealthService } from './domain-health.service'
template: `
@if (address(); as address) {
<td>
<input
type="checkbox"
tuiSwitch
size="s"
[showIcons]="false"
[disabled]="
toggling() || address.hostnameInfo.metadata.kind === 'mdns'
"
[ngModel]="address.enabled"
(ngModelChange)="onToggleEnabled()"
/>
@if (address.guaAccess !== null) {
<!-- An IPv6 GUA is reachable LAN-only or also from the WAN, so it gets
a Disabled / LAN / LAN+WAN tri-state instead of an on/off toggle. -->
<select
class="gua-access"
[disabled]="toggling()"
[ngModel]="address.guaAccess"
(ngModelChange)="onSetGuaAccess($event)"
>
<option value="disabled">{{ 'Disabled' | i18n }}</option>
<option value="lan">{{ 'LAN' | i18n }}</option>
<option value="lan-wan">{{ 'LAN+WAN' | i18n }}</option>
</select>
} @else {
<input
type="checkbox"
tuiSwitch
size="s"
[showIcons]="false"
[disabled]="
toggling() || address.hostnameInfo.metadata.kind === 'mdns'
"
[ngModel]="address.enabled"
(ngModelChange)="onToggleEnabled()"
/>
}
</td>
<td class="access">
<tui-icon
Expand Down Expand Up @@ -289,14 +305,13 @@ export class GatewayItemComponent {

this.toggling.set(true)
const enabled = !addr.enabled
const addressJson = JSON.stringify(addr.hostnameInfo)
const loader = this.loader.open('Saving').subscribe()

try {
if (this.packageId()) {
const params = {
internalPort: iface.addressInfo.internalPort,
address: addressJson,
address: addr.hostnameInfo,
enabled,
package: this.packageId(),
host: iface.addressInfo.hostId,
Expand All @@ -311,7 +326,7 @@ export class GatewayItemComponent {
} else {
await this.api.serverBindingSetAddressEnabled({
internalPort: 80,
address: addressJson,
address: addr.hostnameInfo,
enabled,
})
}
Expand Down Expand Up @@ -351,4 +366,36 @@ export class GatewayItemComponent {
this.toggling.set(false)
}
}

async onSetGuaAccess(access: T.GuaAccess) {
const addr = this.address()
const iface = this.value()
if (!iface) return

this.toggling.set(true)
const loader = this.loader.open('Saving').subscribe()

try {
if (this.packageId()) {
await this.api.pkgBindingSetGuaAccess({
internalPort: iface.addressInfo.internalPort,
address: addr.hostnameInfo,
access,
package: this.packageId(),
host: iface.addressInfo.hostId,
})
} else {
await this.api.serverBindingSetGuaAccess({
internalPort: 80,
address: addr.hostnameInfo,
access,
})
}
} catch (e: any) {
this.errorService.handleError(e)
} finally {
loader.unsubscribe()
this.toggling.set(false)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,41 @@ function isPublicIp(h: T.HostnameInfo): boolean {
return h.public && (h.metadata.kind === 'ipv4' || h.metadata.kind === 'ipv6')
}

// An IPv6 global-unicast address (GUA) — not loopback / ULA (fc00::/7) /
// link-local (fe80::/10). Mirrors the backend's `ipv6_is_local` complement, so
// the UI shows the tri-state for exactly the addresses the backend treats as GUAs.
function isGua(h: T.HostnameInfo): boolean {
if (h.metadata.kind !== 'ipv6') return false
const first = h.hostname.split(':')[0]
if (!first) return false // leading "::" (loopback/unspecified) is not a GUA
const hextet = parseInt(first, 16)
if (Number.isNaN(hextet)) return false
const isUla = (hextet & 0xfe00) === 0xfc00
const isLinkLocal = (hextet & 0xffc0) === 0xfe80
return !isUla && !isLinkLocal
}

// The `gua_access` map key — the GUA's SocketAddrV6, formatted as Rust's
// `SocketAddrV6` Display (`[addr]:port`).
function guaKey(h: T.HostnameInfo): string | null {
if (!isGua(h) || h.port === null) return null
return `[${h.hostname}]:${h.port}`
}

// A GUA's current exposure (Disabled / LAN / LAN+WAN), or null for any other
// address. Untouched GUAs default to LAN.
function getGuaAccess(
addr: T.DerivedAddressInfo,
h: T.HostnameInfo,
): T.GuaAccess | null {
const key = guaKey(h)
if (key === null) return null
return addr.guaAccess?.[key] ?? 'lan'
}

function isEnabled(addr: T.DerivedAddressInfo, h: T.HostnameInfo): boolean {
const gua = getGuaAccess(addr, h)
if (gua !== null) return gua !== 'disabled'
if (isPublicIp(h)) {
if (h.port === null) return true
const sa =
Expand Down Expand Up @@ -165,6 +199,7 @@ export class InterfaceService {
if (!list) continue
list.push({
enabled: isEnabled(addr, h),
guaAccess: getGuaAccess(addr, h),
type: getAddressType(h),
access: h.public ? 'public' : 'private',
// A port range exposes a span of ports, not one, so its URL is the
Expand Down Expand Up @@ -364,6 +399,9 @@ export class InterfaceService {

export type GatewayAddress = {
enabled: boolean
// For an IPv6 GUA, its tri-state exposure (Disabled / LAN / LAN+WAN); null for
// every other address (which use the on/off `enabled` toggle instead).
guaAccess: T.GuaAccess | null
type: string
access: 'public' | 'private'
url: string
Expand Down
3 changes: 3 additions & 0 deletions projects/start-os/web/ui/src/app/services/api/api.fixures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2250,6 +2250,7 @@ For the full changelog, see https://github.com/bitcoin/bitcoin/blob/v27.0.0/doc/
addresses: {
enabled: [],
disabled: [],
guaAccess: {},
available: [
{
ssl: true,
Expand Down Expand Up @@ -2332,6 +2333,7 @@ For the full changelog, see https://github.com/bitcoin/bitcoin/blob/v27.0.0/doc/
addresses: {
enabled: [],
disabled: [],
guaAccess: {},
available: [],
},
options: {
Expand Down Expand Up @@ -2375,6 +2377,7 @@ For the full changelog, see https://github.com/bitcoin/bitcoin/blob/v27.0.0/doc/
addresses: {
enabled: [],
disabled: [],
guaAccess: {},
available: [],
},
options: {
Expand Down
19 changes: 18 additions & 1 deletion projects/start-os/web/ui/src/app/services/api/api.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export type FollowServerLogsReq = Omit<T.LogsParams, 'before'>
export type ServerBindingSetAddressEnabledReq = {
// server.host.binding.set-address-enabled
internalPort: 80
address: string // JSON-serialized HostnameInfo
address: T.HostnameInfo
enabled: boolean | null // null = reset to default
}

Expand All @@ -41,6 +41,23 @@ export type PkgBindingSetAddressEnabledReq = Omit<
host: T.HostId // string
}

export type ServerBindingSetGuaAccessReq = {
// server.host.binding.set-gua-access
internalPort: 80
address: T.HostnameInfo
access: T.GuaAccess // disabled | lan | lan-wan
}

export type PkgBindingSetGuaAccessReq = Omit<
ServerBindingSetGuaAccessReq,
'internalPort'
> & {
// package.host.binding.set-gua-access
internalPort: number
package: T.PackageId // string
host: T.HostId // string
}

// package domains

export type PkgAddPublicDomainReq = T.AddPublicDomainParams & {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,11 @@ import {
PkgAddPrivateDomainReq,
PkgAddPublicDomainReq,
PkgBindingSetAddressEnabledReq,
PkgBindingSetGuaAccessReq,
PkgRemovePrivateDomainReq,
PkgRemovePublicDomainReq,
ServerBindingSetAddressEnabledReq,
ServerBindingSetGuaAccessReq,
ServerState,
WebsocketConfig,
} from './api.types'
Expand Down Expand Up @@ -360,6 +362,14 @@ export abstract class ApiService {
params: PkgBindingSetAddressEnabledReq,
): Promise<null>

abstract serverBindingSetGuaAccess(
params: ServerBindingSetGuaAccessReq,
): Promise<null>

abstract pkgBindingSetGuaAccess(
params: PkgBindingSetGuaAccessReq,
): Promise<null>

abstract pkgAddPublicDomain(
params: PkgAddPublicDomainReq,
): Promise<T.AddPublicDomainRes>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,11 @@ import {
PkgAddPrivateDomainReq,
PkgAddPublicDomainReq,
PkgBindingSetAddressEnabledReq,
PkgBindingSetGuaAccessReq,
PkgRemovePrivateDomainReq,
PkgRemovePublicDomainReq,
ServerBindingSetAddressEnabledReq,
ServerBindingSetGuaAccessReq,
ServerState,
WebsocketConfig,
} from './api.types'
Expand Down Expand Up @@ -682,6 +684,24 @@ export class LiveApiService extends ApiService {
})
}

async serverBindingSetGuaAccess(
params: ServerBindingSetGuaAccessReq,
): Promise<null> {
return this.rpcRequest({
method: 'server.host.binding.set-gua-access',
params,
})
}

async pkgBindingSetGuaAccess(
params: PkgBindingSetGuaAccessReq,
): Promise<null> {
return this.rpcRequest({
method: 'package.host.binding.set-gua-access',
params,
})
}

async pkgAddPublicDomain(
params: PkgAddPublicDomainReq,
): Promise<T.AddPublicDomainRes> {
Expand Down
Loading
Loading