diff --git a/.claude/settings.json b/.claude/settings.json index 671a08447..0967ef424 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -1,6 +1 @@ -{ - "attribution": { - "commit": "", - "pr": "" - } -} +{} diff --git a/TODO.md b/TODO.md index 2c3f67315..d7a06bb6d 100644 --- a/TODO.md +++ b/TODO.md @@ -21,7 +21,6 @@ Pending tasks for AI agents. Remove items when completed. ### Design **Key distinction**: There are two separate concepts for SSL port usage: - 1. **Port ownership** (`assigned_ssl_port`) — A port exclusively owned by a binding, allocated from `AvailablePorts`. Used for server hostnames (`.local`, mDNS, etc.) and iptables forwards. 2. **Domain SSL port** — The port used for domain-based vhost entries. A binding does NOT need to own @@ -62,7 +61,6 @@ Pending tasks for AI agents. Remove items when completed. `server.host.binding` and `package.host.binding`). **How disabling works per address type** (enforcement deferred to Section 3): - - **WAN/LAN IP:port**: Will be enforced via **source-IP gating** in the vhost layer (Section 3). - **Hostname-based addresses** (`.local`, domains): Disabled by **not creating the vhost/SNI entry** for that hostname. @@ -73,7 +71,7 @@ Pending tasks for AI agents. Remove items when completed. `net_controller.rs`) creates a bespoke dual-vhost setup: port 5443 for private-only access and port 443 for public (or public+private). This exists because both public and private traffic arrive on the same port 443 listener, and the current `InterfaceFilter`/`PublicFilter` model distinguishes - public/private by which *network interface* the connection arrived on — which doesn't work when both + public/private by which _network interface_ the connection arrived on — which doesn't work when both traffic types share a listener. **Solution**: Determine public vs private based on **source IP** at the vhost level. Traffic arriving @@ -81,7 +79,6 @@ Pending tasks for AI agents. Remove items when completed. anything from the gateway is potentially public). Traffic from LAN IPs is private. This applies to **all** vhost targets, not just port 443: - - **Add a `public` field to `ProxyTarget`** (or an enum: `Public`, `Private`, `Both`) indicating what traffic this target accepts, derived from the binding's user-controlled `public` field. - **Modify `VHostTarget::filter()`** (`vhost.rs:342`): Instead of (or in addition to) checking the @@ -109,7 +106,6 @@ Pending tasks for AI agents. Remove items when completed. #### 5. Simplify `update()` Domain Vhost Logic (`net_controller.rs`) With source-IP gating in the vhost controller: - - **Remove the `== 443` special case** and the 5443 secondary vhost. - For **server hostnames** (`.local`, mDNS, embassy, startos, localhost): use `assigned_ssl_port` (the port the binding owns). @@ -122,60 +118,18 @@ Pending tasks for AI agents. Remove items when completed. `ssl_port: assigned_ssl_port`. For domains, report `ssl_port: preferred_external_port` if it was successfully used for the domain vhost, otherwise report `ssl_port: assigned_ssl_port`. - #### 6. Frontend: Interfaces Page Overhaul (View/Manage Split) - - The current interfaces page is a single page showing gateways (with toggle), addresses, public - domains, and private domains. It gets split into two pages: **View** and **Manage**. - - **SDK**: `preferredExternalPort` is already exposed. No additional SDK changes needed. - - ##### View Page - - Displays all computed addresses for the interface (from `BindInfo.addresses`) as a flat list. For each - address, show: URL, type (IPv4, IPv6, .local, domain), access level (public/private), - gateway name, SSL indicator, enable/disable state, port forward info for public addresses, and a test button - for reachability (see Section 7). - - No gateway-level toggles. The old `gateways.component.ts` toggle UI is removed. - - **Note**: Exact UI element placement (where toggles, buttons, info badges go) is sensitive. - Prompt the user for specific placement decisions during implementation. - - ##### Manage Page - - Simple CRUD interface for configuring which addresses exist. Two sections: - - - **Public domains**: Add/remove. Uses existing RPC endpoints: - - `{server,package}.host.address.domain.public.add` - - `{server,package}.host.address.domain.public.remove` - - **Private domains**: Add/remove. Uses existing RPC endpoints: - - `{server,package}.host.address.domain.private.add` - - `{server,package}.host.address.domain.private.remove` - - ##### Key Frontend Files to Modify - - | File | Change | - |------|--------| - | `web/projects/ui/src/app/routes/portal/components/interfaces/` | Overhaul: split into view/manage | - | `web/projects/ui/src/app/routes/portal/components/interfaces/gateways.component.ts` | Remove (replaced by per-address toggles on View page) | - | `web/projects/ui/src/app/routes/portal/components/interfaces/interface.service.ts` | Update `MappedServiceInterface` to compute enabled addresses from `DerivedAddressInfo` | - | `web/projects/ui/src/app/routes/portal/components/interfaces/addresses/` | Refactor for View page with overflow menu (enable/disable) and test buttons | - | `web/projects/ui/src/app/routes/portal/routes/services/services.routes.ts` | Add routes for view/manage sub-pages | - | `web/projects/ui/src/app/routes/portal/routes/system/system.routes.ts` | Add routes for view/manage sub-pages | - - #### 7. Reachability Test Endpoint + #### 6. Reachability Test Endpoint New RPC endpoint that tests whether an address is actually reachable, with diagnostic info on failure. **RPC endpoint** (`binding.rs` or new file): - - **`test-address`** — Test reachability of a specific address. ```ts interface BindingTestAddressParams { - internalPort: number - address: HostnameInfo + internalPort: number; + address: HostnameInfo; } ``` @@ -185,8 +139,8 @@ Pending tasks for AI agents. Remove items when completed. ```ts interface TestAddressResult { - dns: string[] | null // resolved IPs, null if not a domain address or lookup failed - portOpen: boolean | null // TCP connect result, null if not applicable + dns: string[] | null; // resolved IPs, null if not a domain address or lookup failed + portOpen: boolean | null; // TCP connect result, null if not applicable } ``` @@ -205,17 +159,17 @@ Pending tasks for AI agents. Remove items when completed. ### Key Files - | File | Role | - |------|------| - | `core/src/net/forward.rs` | `AvailablePorts` — port pool allocation, `try_alloc()` for preferred ports | - | `core/src/net/host/binding.rs` | `Bindings` (Map wrapper for patchdb), `BindInfo`/`NetInfo`/`DerivedAddressInfo`/`AddressFilter` — per-address enable/disable, `set-address-enabled` RPC | - | `core/src/net/net_controller.rs:259` | `NetServiceData::update()` — computes `DerivedAddressInfo.possible`, vhost/forward/DNS reconciliation, 5443 hack removal | - | `core/src/net/vhost.rs` | `VHostController` / `ProxyTarget` — source-IP gating for public/private | - | `core/src/net/gateway.rs` | `InterfaceFilter` trait and filter types (`AddressFilter`, `PublicFilter`, etc.) | - | `core/src/net/service_interface.rs` | `HostnameInfo` — derives `Ord` for `BTreeSet` usage | - | `core/src/net/host/address.rs` | `HostAddress` (flattened struct), domain CRUD endpoints | - | `sdk/base/lib/interfaces/Host.ts` | SDK `MultiHost.bindPort()` — no changes needed | - | `core/src/db/model/public.rs` | Public DB model — port forward mapping | + | File | Role | + | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------- | + | `core/src/net/forward.rs` | `AvailablePorts` — port pool allocation, `try_alloc()` for preferred ports | + | `core/src/net/host/binding.rs` | `Bindings` (Map wrapper for patchdb), `BindInfo`/`NetInfo`/`DerivedAddressInfo`/`AddressFilter` — per-address enable/disable, `set-address-enabled` RPC | + | `core/src/net/net_controller.rs:259` | `NetServiceData::update()` — computes `DerivedAddressInfo.possible`, vhost/forward/DNS reconciliation, 5443 hack removal | + | `core/src/net/vhost.rs` | `VHostController` / `ProxyTarget` — source-IP gating for public/private | + | `core/src/net/gateway.rs` | `InterfaceFilter` trait and filter types (`AddressFilter`, `PublicFilter`, etc.) | + | `core/src/net/service_interface.rs` | `HostnameInfo` — derives `Ord` for `BTreeSet` usage | + | `core/src/net/host/address.rs` | `HostAddress` (flattened struct), domain CRUD endpoints | + | `sdk/base/lib/interfaces/Host.ts` | SDK `MultiHost.bindPort()` — no changes needed | + | `core/src/db/model/public.rs` | Public DB model — port forward mapping | - [ ] Extract TS-exported types into a lightweight sub-crate for fast binding generation diff --git a/web/projects/shared/src/i18n/dictionaries/de.ts b/web/projects/shared/src/i18n/dictionaries/de.ts index c9bd6f38c..2ea7a884a 100644 --- a/web/projects/shared/src/i18n/dictionaries/de.ts +++ b/web/projects/shared/src/i18n/dictionaries/de.ts @@ -692,4 +692,9 @@ export default { 727: '', 728: '', 729: '', + 730: '', + 731: '', + 732: '', + 733: '', + 734: '', } satisfies i18n diff --git a/web/projects/shared/src/i18n/dictionaries/en.ts b/web/projects/shared/src/i18n/dictionaries/en.ts index a121cfea8..3d653f162 100644 --- a/web/projects/shared/src/i18n/dictionaries/en.ts +++ b/web/projects/shared/src/i18n/dictionaries/en.ts @@ -691,5 +691,10 @@ export const ENGLISH: Record = { 'WireGuard Config File': 726, 'Inbound/Outbound': 727, 'StartTunnel (Inbound/Outbound)': 728, - 'Ethernet': 729 + 'Ethernet': 729, + 'Add Domain': 730, + 'Public Domain': 731, + 'Private Domain': 732, + 'Hide': 733, + 'default outbound': 734 } diff --git a/web/projects/shared/src/i18n/dictionaries/es.ts b/web/projects/shared/src/i18n/dictionaries/es.ts index a2e6b42ab..802400cc7 100644 --- a/web/projects/shared/src/i18n/dictionaries/es.ts +++ b/web/projects/shared/src/i18n/dictionaries/es.ts @@ -692,4 +692,9 @@ export default { 727: '', 728: '', 729: '', + 730: '', + 731: '', + 732: '', + 733: '', + 734: '', } satisfies i18n diff --git a/web/projects/shared/src/i18n/dictionaries/fr.ts b/web/projects/shared/src/i18n/dictionaries/fr.ts index ffb25d7f4..0d1d82b1d 100644 --- a/web/projects/shared/src/i18n/dictionaries/fr.ts +++ b/web/projects/shared/src/i18n/dictionaries/fr.ts @@ -692,4 +692,9 @@ export default { 727: '', 728: '', 729: '', + 730: '', + 731: '', + 732: '', + 733: '', + 734: '', } satisfies i18n diff --git a/web/projects/shared/src/i18n/dictionaries/pl.ts b/web/projects/shared/src/i18n/dictionaries/pl.ts index 09562f285..c4a1e97c7 100644 --- a/web/projects/shared/src/i18n/dictionaries/pl.ts +++ b/web/projects/shared/src/i18n/dictionaries/pl.ts @@ -692,4 +692,9 @@ export default { 727: '', 728: '', 729: '', + 730: '', + 731: '', + 732: '', + 733: '', + 734: '', } satisfies i18n diff --git a/web/projects/ui/src/app/routes/portal/components/interfaces/addresses/actions.component.ts b/web/projects/ui/src/app/routes/portal/components/interfaces/addresses/actions.component.ts index b39f23944..c51a0e910 100644 --- a/web/projects/ui/src/app/routes/portal/components/interfaces/addresses/actions.component.ts +++ b/web/projects/ui/src/app/routes/portal/components/interfaces/addresses/actions.component.ts @@ -5,7 +5,13 @@ import { input, signal, } from '@angular/core' -import { CopyService, DialogService, i18nPipe } from '@start9labs/shared' +import { + CopyService, + DialogService, + ErrorService, + i18nPipe, + LoadingService, +} from '@start9labs/shared' import { TUI_IS_MOBILE } from '@taiga-ui/cdk' import { TuiButton, @@ -16,37 +22,13 @@ import { } from '@taiga-ui/core' import { PolymorpheusComponent } from '@taiga-ui/polymorpheus' import { QRModal } from 'src/app/routes/portal/modals/qr.component' - -import { InterfaceAddressItemComponent } from './item.component' +import { ApiService } from 'src/app/services/api/embassy-api.service' +import { GatewayAddress, MappedServiceInterface } from '../interface.service' @Component({ selector: 'td[actions]', template: `
- @if (interface.address().masked) { - - } - @if (interface.address().ui) { - - {{ 'Open' | i18n }} - - } + @if (address().deletable) { + + }
- } - + + @if (address().deletable) { + + }
@@ -136,31 +127,23 @@ import { InterfaceAddressItemComponent } from './item.component' display: block; } } - - :host-context(tbody.uncommon-hidden) { - .desktop { - height: 0; - visibility: hidden; - } - - .mobile { - display: none; - } - } `, imports: [TuiButton, TuiDropdown, TuiDataList, i18nPipe, TuiTextfield], providers: [tuiButtonOptionsProvider({ appearance: 'icon' })], changeDetection: ChangeDetectionStrategy.OnPush, }) export class AddressActionsComponent { - readonly isMobile = inject(TUI_IS_MOBILE) - readonly dialog = inject(DialogService) + private readonly isMobile = inject(TUI_IS_MOBILE) + private readonly dialog = inject(DialogService) + private readonly api = inject(ApiService) + private readonly loader = inject(LoadingService) + private readonly errorService = inject(ErrorService) readonly copyService = inject(CopyService) - readonly interface = inject(InterfaceAddressItemComponent) readonly open = signal(false) - readonly href = input.required() - readonly bullets = input.required() + readonly address = input.required() + readonly packageId = input('') + readonly value = input() readonly disabled = input.required() showQR() { @@ -168,8 +151,84 @@ export class AddressActionsComponent { .openComponent(new PolymorpheusComponent(QRModal), { size: 'auto', closeable: this.isMobile, - data: this.href(), + data: this.address().url, }) .subscribe() } + + async toggleEnabled() { + const addr = this.address() + const iface = this.value() + if (!iface) return + + const enabled = !addr.enabled + const addressJson = JSON.stringify(addr.hostnameInfo) + const loader = this.loader.open('Saving').subscribe() + + try { + if (this.packageId()) { + await this.api.pkgBindingSetAddressEnabled({ + internalPort: iface.addressInfo.internalPort, + address: addressJson, + enabled, + package: this.packageId(), + host: iface.addressInfo.hostId, + }) + } else { + await this.api.serverBindingSetAddressEnabled({ + internalPort: 80, + address: addressJson, + enabled, + }) + } + } catch (e: any) { + this.errorService.handleError(e) + } finally { + loader.unsubscribe() + } + } + + async deleteDomain() { + const addr = this.address() + const iface = this.value() + if (!iface) return + + const confirmed = await this.dialog + .openConfirm({ label: 'Are you sure?', size: 's' }) + .toPromise() + + if (!confirmed) return + + const loader = this.loader.open('Removing').subscribe() + + try { + const host = addr.hostnameInfo.host + + if (addr.hostnameInfo.metadata.kind === 'public-domain') { + if (this.packageId()) { + await this.api.pkgRemovePublicDomain({ + fqdn: host, + package: this.packageId(), + host: iface.addressInfo.hostId, + }) + } else { + await this.api.osUiRemovePublicDomain({ fqdn: host }) + } + } else if (addr.hostnameInfo.metadata.kind === 'private-domain') { + if (this.packageId()) { + await this.api.pkgRemovePrivateDomain({ + fqdn: host, + package: this.packageId(), + host: iface.addressInfo.hostId, + }) + } else { + await this.api.osUiRemovePrivateDomain({ fqdn: host }) + } + } + } catch (e: any) { + this.errorService.handleError(e) + } finally { + loader.unsubscribe() + } + } } diff --git a/web/projects/ui/src/app/routes/portal/components/interfaces/addresses/addresses.component.ts b/web/projects/ui/src/app/routes/portal/components/interfaces/addresses/addresses.component.ts index ae4a472fc..02cc8e6a2 100644 --- a/web/projects/ui/src/app/routes/portal/components/interfaces/addresses/addresses.component.ts +++ b/web/projects/ui/src/app/routes/portal/components/interfaces/addresses/addresses.component.ts @@ -1,117 +1,323 @@ -import { ChangeDetectionStrategy, Component, input } from '@angular/core' -import { i18nPipe } from '@start9labs/shared' -import { TuiButton } from '@taiga-ui/core' -import { TuiAccordion } from '@taiga-ui/experimental' -import { TuiElasticContainer, TuiSkeleton } from '@taiga-ui/kit' +import { + ChangeDetectionStrategy, + Component, + inject, + input, + signal, +} from '@angular/core' +import { + DialogService, + ErrorService, + i18nKey, + i18nPipe, + LoadingService, +} from '@start9labs/shared' +import { ISB, utils } from '@start9labs/start-sdk' +import { + TuiButton, + TuiDataList, + TuiDropdown, + TuiTextfield, +} from '@taiga-ui/core' +import { PatchDB } from 'patch-db-client' +import { firstValueFrom } from 'rxjs' +import { + FormComponent, + FormContext, +} from 'src/app/routes/portal/components/form.component' import { PlaceholderComponent } from 'src/app/routes/portal/components/placeholder.component' import { TableComponent } from 'src/app/routes/portal/components/table.component' - -import { MappedServiceInterface } from '../interface.service' +import { ApiService } from 'src/app/services/api/embassy-api.service' +import { FormDialogService } from 'src/app/services/form-dialog.service' +import { DataModel } from 'src/app/services/patch-db/data-model' +import { toAuthorityName } from 'src/app/utils/acme' +import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec' +import { + GatewayAddressGroup, + MappedServiceInterface, +} from '../interface.service' +import { DNS, DnsGateway } from '../public-domains/dns.component' import { InterfaceAddressItemComponent } from './item.component' @Component({ - selector: 'section[addresses]', + selector: 'section[gatewayGroup]', template: ` -
{{ 'Addresses' | i18n }}
- - - - @for (address of addresses()?.common; track $index) { - - } @empty { - @if (addresses()) { - - - - } @else { - @for (_ of [0, 1]; track $index) { - - - - } - } - } - - @if (addresses()?.uncommon?.length && uncommon) { - - - - } - @for (address of addresses()?.uncommon; track $index) { - - } - - @if (addresses()?.uncommon?.length) { - - } -
- - {{ 'No addresses' | i18n }} - -
-
{{ 'Loading' | i18n }}
-
- -
-
+
+ {{ gatewayGroup().gatewayName }} + + + + +
+ + @for (address of gatewayGroup().addresses; track $index) { + + } @empty { + + + + } +
+ + {{ 'No addresses' | i18n }} + +
`, styles: ` :host ::ng-deep { - th:nth-child(2) { + th:first-child { width: 5rem; } - - th:nth-child(3) { - width: 4rem; - } - } - - .g-table:has(caption) { - border-bottom-left-radius: 0; - border-bottom-right-radius: 0; - } - - [tuiButton] { - width: 100%; - border-top-left-radius: 0; - border-top-right-radius: 0; - } - - :host-context(tui-root._mobile) { - [tuiButton] { - border-radius: var(--tui-radius-xs); - margin-block-end: 0.75rem; - } } `, host: { class: 'g-card' }, imports: [ - TuiSkeleton, TuiButton, + TuiDropdown, + TuiDataList, + TuiTextfield, TableComponent, PlaceholderComponent, i18nPipe, InterfaceAddressItemComponent, - TuiElasticContainer, ], changeDetection: ChangeDetectionStrategy.OnPush, }) export class InterfaceAddressesComponent { - readonly addresses = input.required< - MappedServiceInterface['addresses'] | undefined - >() + private readonly patch = inject>(PatchDB) + private readonly formDialog = inject(FormDialogService) + private readonly dialog = inject(DialogService) + private readonly loader = inject(LoadingService) + private readonly errorService = inject(ErrorService) + private readonly api = inject(ApiService) + private readonly i18n = inject(i18nPipe) + + readonly gatewayGroup = input.required() + readonly packageId = input('') + readonly value = input() readonly isRunning = input.required() - uncommon = false + readonly addOpen = signal(false) + + async addPrivateDomain() { + this.formDialog.open>(FormComponent, { + label: 'New private domain', + data: { + spec: await configBuilderToSpec( + ISB.InputSpec.of({ + fqdn: ISB.Value.text({ + name: this.i18n.transform('Domain'), + description: this.i18n.transform( + 'Enter a fully qualified domain name. Since the domain is for private use, it can be any domain you want, even one you do not control.', + ), + required: true, + default: null, + patterns: [utils.Patterns.domain], + }), + }), + ), + buttons: [ + { + text: this.i18n.transform('Save')!, + handler: async (value: { fqdn: string }) => + this.savePrivateDomain(value.fqdn), + }, + ], + }, + }) + } + + async addPublicDomain() { + const iface = this.value() + if (!iface) return + + const network = await firstValueFrom( + this.patch.watch$('serverInfo', 'network'), + ) + + const authorities = Object.keys(network.acme).reduce< + Record + >( + (obj, url) => ({ + ...obj, + [url]: toAuthorityName(url), + }), + { local: toAuthorityName(null) }, + ) + + const addSpec = ISB.InputSpec.of({ + fqdn: ISB.Value.text({ + name: this.i18n.transform('Domain'), + description: this.i18n.transform( + 'Enter a fully qualified domain name. For example, if you control domain.com, you could enter domain.com or subdomain.domain.com or another.subdomain.domain.com.', + ), + required: true, + default: null, + patterns: [utils.Patterns.domain], + }).map(f => f.toLocaleLowerCase()), + ...(iface.addSsl + ? { + authority: ISB.Value.select({ + name: this.i18n.transform('Certificate Authority'), + description: this.i18n.transform( + 'Select a Certificate Authority to issue SSL/TLS certificates for this domain', + ), + values: authorities, + default: '', + }), + } + : ({} as { authority: ReturnType })), + }) + + this.formDialog.open(FormComponent, { + label: 'Add public domain', + data: { + spec: await configBuilderToSpec(addSpec), + buttons: [ + { + text: this.i18n.transform('Save')!, + handler: (input: typeof addSpec._TYPE) => + this.savePublicDomain(input.fqdn, input.authority), + }, + ], + }, + }) + } + + private async savePrivateDomain(fqdn: string): Promise { + const iface = this.value() + const loader = this.loader.open('Saving').subscribe() + + try { + if (this.packageId()) { + await this.api.pkgAddPrivateDomain({ + fqdn, + package: this.packageId(), + host: iface?.addressInfo.hostId || '', + }) + } else { + await this.api.osUiAddPrivateDomain({ fqdn }) + } + return true + } catch (e: any) { + this.errorService.handleError(e) + return false + } finally { + loader.unsubscribe() + } + } + + private async savePublicDomain( + fqdn: string, + authority?: 'local' | string, + ): Promise { + const iface = this.value() + const gatewayId = this.gatewayGroup().gatewayId + const loader = this.loader.open('Saving').subscribe() + + const params = { + fqdn, + gateway: gatewayId, + acme: !authority || authority === 'local' ? null : authority, + } + + try { + let ip: string | null + if (this.packageId()) { + ip = await this.api.pkgAddPublicDomain({ + ...params, + package: this.packageId(), + host: iface?.addressInfo.hostId || '', + }) + } else { + ip = await this.api.osUiAddPublicDomain(params) + } + + const network = await this.patch + .watch$('serverInfo', 'network') + .pipe() + .toPromise() + const gateway = network?.gateways[gatewayId] + + if (gateway?.ipInfo) { + const wanIp = gateway.ipInfo.wanIp + const message = this.i18n.transform( + 'Create one of the DNS records below.', + ) as i18nKey + const gatewayData = { + id: gatewayId, + ...gateway, + ipInfo: gateway.ipInfo, + } + + if (!ip) { + setTimeout( + () => + this.showDns( + fqdn, + gatewayData, + `${this.i18n.transform('No DNS record detected for')} ${fqdn}. ${message}` as i18nKey, + ), + 250, + ) + } else if (ip !== wanIp) { + setTimeout( + () => + this.showDns( + fqdn, + gatewayData, + `${this.i18n.transform('Invalid DNS record')}. ${fqdn} ${this.i18n.transform('resolves to')} ${ip}. ${message}` as i18nKey, + ), + 250, + ) + } else { + setTimeout( + () => + this.dialog + .openAlert( + `${fqdn} ${this.i18n.transform('resolves to')} ${wanIp}` as i18nKey, + { label: 'DNS record detected!', appearance: 'positive' }, + ) + .subscribe(), + 250, + ) + } + } + + return true + } catch (e: any) { + this.errorService.handleError(e) + return false + } finally { + loader.unsubscribe() + } + } + + private showDns(fqdn: string, gateway: DnsGateway, message: i18nKey) { + this.dialog + .openComponent(DNS, { + label: 'DNS Records', + size: 'l', + data: { fqdn, gateway, message }, + }) + .subscribe() + } } diff --git a/web/projects/ui/src/app/routes/portal/components/interfaces/addresses/item.component.ts b/web/projects/ui/src/app/routes/portal/components/interfaces/addresses/item.component.ts index c212c26b9..a21dcff05 100644 --- a/web/projects/ui/src/app/routes/portal/components/interfaces/addresses/item.component.ts +++ b/web/projects/ui/src/app/routes/portal/components/interfaces/addresses/item.component.ts @@ -6,113 +6,94 @@ import { input, signal, } from '@angular/core' -import { DialogService, i18nKey, i18nPipe } from '@start9labs/shared' +import { ErrorService, i18nPipe, LoadingService } from '@start9labs/shared' import { TuiObfuscatePipe } from '@taiga-ui/cdk' -import { TuiButton, TuiIcon } from '@taiga-ui/core' +import { TuiButton } from '@taiga-ui/core' import { TuiBadge } from '@taiga-ui/kit' -import { DisplayAddress } from '../interface.service' +import { FormsModule } from '@angular/forms' +import { TuiSwitch } from '@taiga-ui/kit' +import { ApiService } from 'src/app/services/api/embassy-api.service' +import { GatewayAddress, MappedServiceInterface } from '../interface.service' import { AddressActionsComponent } from './actions.component' @Component({ selector: 'tr[address]', template: ` @if (address(); as address) { - -
- -
- - -
{{ address.type }}
- - -
- @if (address.access === 'public') { - - {{ 'public' | i18n }} - - } @else if (address.access === 'private') { - - {{ 'private' | i18n }} - - } @else { - - + {{ address.url | tuiObfuscate: recipe() }} + + @if (address.masked) { + }
- -
- {{ address.gatewayName || '-' }} -
- - -
- {{ address.url | tuiObfuscate: recipe() }} -
- } `, styles: ` :host { - white-space: nowrap; grid-template-columns: fit-content(10rem) 1fr 2rem 2rem; - - td:last-child { - padding-inline-start: 0; - } } - .info { - background: var(--tui-status-info); + .url { + display: flex; + align-items: center; + gap: 0.25rem; - &::after { - mask-size: 1.5rem; + span { + white-space: normal; + word-break: break-all; + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 1; + overflow: hidden; } } - :host-context(.uncommon-hidden) { - .wrapper { - height: 0; - visibility: hidden; - } - - td, - & { - padding-block: 0 !important; - border: hidden; - } - } - - div { - white-space: normal; - word-break: break-all; - display: -webkit-box; - -webkit-box-orient: vertical; - -webkit-line-clamp: 1; - overflow: hidden; - } - :host-context(tui-root._mobile) { td { width: auto !important; @@ -120,7 +101,7 @@ import { AddressActionsComponent } from './actions.component' } td:first-child { - grid-area: 1 / 3 / 4 / 3; + display: none; } td:nth-child(2) { @@ -135,16 +116,21 @@ import { AddressActionsComponent } from './actions.component' i18nPipe, AddressActionsComponent, TuiBadge, - TuiObfuscatePipe, TuiButton, - TuiIcon, + TuiObfuscatePipe, + TuiSwitch, + FormsModule, ], changeDetection: ChangeDetectionStrategy.OnPush, }) export class InterfaceAddressItemComponent { - private readonly dialogs = inject(DialogService) + private readonly api = inject(ApiService) + private readonly loader = inject(LoadingService) + private readonly errorService = inject(ErrorService) - readonly address = input.required() + readonly address = input.required() + readonly packageId = input('') + readonly value = input() readonly isRunning = input.required() readonly currentlyMasked = signal(true) @@ -152,14 +138,35 @@ export class InterfaceAddressItemComponent { this.address()?.masked && this.currentlyMasked() ? 'mask' : 'none', ) - viewDetails() { - this.dialogs - .openAlert( - `
    ${this.address() - .bullets.map(b => `
  • ${b}
  • `) - .join('')}
` as i18nKey, - { label: 'About this address' as i18nKey }, - ) - .subscribe() + async onToggleEnabled() { + const addr = this.address() + const iface = this.value() + if (!iface) return + + const enabled = !addr.enabled + const addressJson = JSON.stringify(addr.hostnameInfo) + const loader = this.loader.open('Saving').subscribe() + + try { + if (this.packageId()) { + await this.api.pkgBindingSetAddressEnabled({ + internalPort: iface.addressInfo.internalPort, + address: addressJson, + enabled, + package: this.packageId(), + host: iface.addressInfo.hostId, + }) + } else { + await this.api.serverBindingSetAddressEnabled({ + internalPort: 80, + address: addressJson, + enabled, + }) + } + } catch (e: any) { + this.errorService.handleError(e) + } finally { + loader.unsubscribe() + } } } diff --git a/web/projects/ui/src/app/routes/portal/components/interfaces/addresses/plugin.component.ts b/web/projects/ui/src/app/routes/portal/components/interfaces/addresses/plugin.component.ts new file mode 100644 index 000000000..7613175f9 --- /dev/null +++ b/web/projects/ui/src/app/routes/portal/components/interfaces/addresses/plugin.component.ts @@ -0,0 +1,176 @@ +import { + ChangeDetectionStrategy, + Component, + inject, + input, + signal, +} from '@angular/core' +import { + CopyService, + DialogService, + i18nPipe, +} from '@start9labs/shared' +import { TUI_IS_MOBILE } from '@taiga-ui/cdk' +import { + TuiButton, + tuiButtonOptionsProvider, + TuiDataList, + TuiDropdown, + TuiTextfield, +} from '@taiga-ui/core' +import { PolymorpheusComponent } from '@taiga-ui/polymorpheus' +import { PlaceholderComponent } from 'src/app/routes/portal/components/placeholder.component' +import { TableComponent } from 'src/app/routes/portal/components/table.component' +import { QRModal } from 'src/app/routes/portal/modals/qr.component' +import { PluginAddressGroup } from '../interface.service' + +@Component({ + selector: 'section[pluginGroup]', + template: ` +
+ {{ pluginGroup().pluginName }} +
+ + @for (address of pluginGroup().addresses; track $index) { + + + + + + } @empty { + + + + } +
{{ address.hostnameInfo.ssl ? 'HTTPS' : 'HTTP' }} + {{ address.url }} + +
+ + +
+
+ + + + +
+
+ + {{ 'No addresses' | i18n }} + +
+ `, + styles: ` + :host ::ng-deep { + th:first-child { + width: 5rem; + } + } + + .desktop { + display: flex; + white-space: nowrap; + } + + .url { + white-space: normal; + word-break: break-all; + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 1; + overflow: hidden; + } + + .mobile { + display: none; + } + + :host-context(tui-root._mobile) { + .desktop { + display: none; + } + + .mobile { + display: block; + } + + tr { + grid-template-columns: 1fr auto; + } + + td { + width: auto !important; + align-content: center; + } + } + `, + host: { class: 'g-card' }, + imports: [ + TuiButton, + TuiDropdown, + TuiDataList, + TuiTextfield, + TableComponent, + PlaceholderComponent, + i18nPipe, + ], + providers: [tuiButtonOptionsProvider({ appearance: 'icon' })], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class PluginAddressesComponent { + private readonly isMobile = inject(TUI_IS_MOBILE) + private readonly dialog = inject(DialogService) + readonly copyService = inject(CopyService) + readonly open = signal(false) + + readonly pluginGroup = input.required() + + showQR(url: string) { + this.dialog + .openComponent(new PolymorpheusComponent(QRModal), { + size: 'auto', + closeable: this.isMobile, + data: url, + }) + .subscribe() + } +} diff --git a/web/projects/ui/src/app/routes/portal/components/interfaces/gateways.component.ts b/web/projects/ui/src/app/routes/portal/components/interfaces/gateways.component.ts deleted file mode 100644 index 1a3a4d632..000000000 --- a/web/projects/ui/src/app/routes/portal/components/interfaces/gateways.component.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { CommonModule } from '@angular/common' -import { - ChangeDetectionStrategy, - Component, - input, - inject, -} from '@angular/core' -import { TuiIcon, TuiTitle } from '@taiga-ui/core' -import { TuiSkeleton, TuiSwitch, TuiTooltip } from '@taiga-ui/kit' -import { FormsModule } from '@angular/forms' -import { i18nPipe, LoadingService, ErrorService } from '@start9labs/shared' -import { TuiCell } from '@taiga-ui/layout' -import { InterfaceGateway } from './interface.service' -import { ApiService } from 'src/app/services/api/embassy-api.service' -import { InterfaceComponent } from './interface.component' - -@Component({ - selector: 'section[gateways]', - template: ` -
{{ 'Gateways' | i18n }}
- @for (gateway of gateways(); track $index) { - - } @empty { - @for (_ of [0, 1]; track $index) { - - } - } - `, - styles: ` - :host { - grid-column: span 3; - } - - [tuiCell]:has([tuiTooltip]) { - background: none !important; - } - - :host-context(tui-root:not(._mobile)) { - &:has(+ section table) header { - background: transparent; - } - } - `, - host: { class: 'g-card' }, - changeDetection: ChangeDetectionStrategy.OnPush, - imports: [ - CommonModule, - FormsModule, - TuiSwitch, - i18nPipe, - TuiCell, - TuiTitle, - TuiSkeleton, - TuiIcon, - TuiTooltip, - ], -}) -export class InterfaceGatewaysComponent { - private readonly loader = inject(LoadingService) - private readonly errorService = inject(ErrorService) - private readonly api = inject(ApiService) - readonly interface = inject(InterfaceComponent) - - readonly gateways = input.required() - - async onToggle(_gateway: InterfaceGateway) { - // TODO: Replace with per-address toggle UI (Section 6 frontend overhaul). - // Gateway-level toggle replaced by set-address-enabled RPC. - } -} diff --git a/web/projects/ui/src/app/routes/portal/components/interfaces/interface.component.ts b/web/projects/ui/src/app/routes/portal/components/interfaces/interface.component.ts index 1284e1396..422c0ea36 100644 --- a/web/projects/ui/src/app/routes/portal/components/interfaces/interface.component.ts +++ b/web/projects/ui/src/app/routes/portal/components/interfaces/interface.component.ts @@ -1,24 +1,23 @@ import { ChangeDetectionStrategy, Component, input } from '@angular/core' import { tuiButtonOptionsProvider } from '@taiga-ui/core' import { MappedServiceInterface } from './interface.service' -import { InterfaceGatewaysComponent } from './gateways.component' -import { PublicDomainsComponent } from './public-domains/pd.component' -import { InterfacePrivateDomainsComponent } from './private-domains.component' import { InterfaceAddressesComponent } from './addresses/addresses.component' +import { PluginAddressesComponent } from './addresses/plugin.component' @Component({ selector: 'service-interface', template: ` -
-
+ @for (group of value()?.gatewayGroups; track group.gatewayId) {
-
-
-
-
+ } + @for (group of value()?.pluginGroups; track group.pluginId) { +
+ } `, styles: ` :host { @@ -28,32 +27,16 @@ import { InterfaceAddressesComponent } from './addresses/addresses.component' color: var(--tui-text-secondary); font: var(--tui-font-text-l); - div { - display: grid; - grid-template-columns: repeat(10, 1fr); - gap: inherit; - flex-direction: column; - } - ::ng-deep [tuiSkeleton] { width: 100%; height: 1rem; border-radius: var(--tui-radius-s); } } - - :host-context(tui-root._mobile) div { - display: flex; - } `, changeDetection: ChangeDetectionStrategy.OnPush, providers: [tuiButtonOptionsProvider({ size: 'xs' })], - imports: [ - InterfaceGatewaysComponent, - PublicDomainsComponent, - InterfacePrivateDomainsComponent, - InterfaceAddressesComponent, - ], + imports: [InterfaceAddressesComponent, PluginAddressesComponent], }) export class InterfaceComponent { readonly packageId = input('') diff --git a/web/projects/ui/src/app/routes/portal/components/interfaces/interface.service.ts b/web/projects/ui/src/app/routes/portal/components/interfaces/interface.service.ts index f75d045ee..17409e39c 100644 --- a/web/projects/ui/src/app/routes/portal/components/interfaces/interface.service.ts +++ b/web/projects/ui/src/app/routes/portal/components/interfaces/interface.service.ts @@ -2,101 +2,54 @@ import { inject, Injectable } from '@angular/core' import { T, utils } from '@start9labs/start-sdk' import { ConfigService } from 'src/app/services/config.service' import { GatewayPlus } from 'src/app/services/gateway.service' -import { PublicDomain } from './public-domains/pd.service' -import { i18nKey, i18nPipe } from '@start9labs/shared' -type AddressWithInfo = { - url: string - info: T.HostnameInfo - gateway?: GatewayPlus - showSsl: boolean - masked: boolean - ui: boolean +function isPublicIp(h: T.HostnameInfo): boolean { + return ( + h.public && (h.metadata.kind === 'ipv4' || h.metadata.kind === 'ipv6') + ) } -function cmpWithRankedPredicates( - a: T, - b: T, - preds: ((x: T) => boolean)[], -): -1 | 0 | 1 { - for (const pred of preds) { - for (let [x, y, sign] of [[a, b, 1] as const, [b, a, -1] as const]) { - if (pred(y) && !pred(x)) return sign - } - } - return 0 -} - -type LanAddress = AddressWithInfo & { info: { public: false } } -function filterLan(a: AddressWithInfo): a is LanAddress { - return !a.info.public -} -function cmpLan(host: T.Host, a: LanAddress, b: LanAddress): -1 | 0 | 1 { - return cmpWithRankedPredicates(a, b, [ - x => - x.info.hostname.kind === 'domain' && - !!host.privateDomains.find(d => d === x.info.hostname.value), // private domain - x => x.info.hostname.kind === 'local', // .local - x => x.info.hostname.kind === 'ipv4', // ipv4 - x => x.info.hostname.kind === 'ipv6', // ipv6 - // remainder: public domains accessible privately - ]) -} - -type VpnAddress = AddressWithInfo & { - info: { - public: false - hostname: { kind: 'ipv4' | 'ipv6' | 'domain' } +function isEnabled(addr: T.DerivedAddressInfo, h: T.HostnameInfo): boolean { + if (isPublicIp(h)) { + if (h.port === null) return true + const sa = + h.metadata.kind === 'ipv6' + ? `[${h.host}]:${h.port}` + : `${h.host}:${h.port}` + return addr.enabled.includes(sa) + } else { + return !addr.disabled.some( + ([host, port]) => host === h.host && port === (h.port ?? 0), + ) } } -function filterVpn(a: AddressWithInfo): a is VpnAddress { - return !a.info.public && a.info.hostname.kind !== 'local' -} -function cmpVpn(host: T.Host, a: VpnAddress, b: VpnAddress): -1 | 0 | 1 { - return cmpWithRankedPredicates(a, b, [ - x => - x.info.hostname.kind === 'domain' && - !!host.privateDomains.find(d => d === x.info.hostname.value), // private domain - x => x.info.hostname.kind === 'ipv4', // ipv4 - x => x.info.hostname.kind === 'ipv6', // ipv6 - // remainder: public domains accessible privately - ]) -} -type ClearnetAddress = AddressWithInfo & { - info: { - public: true - hostname: { kind: 'ipv4' | 'ipv6' | 'domain' } +function getGatewayIds(h: T.HostnameInfo): string[] { + switch (h.metadata.kind) { + case 'ipv4': + case 'ipv6': + case 'public-domain': + return [h.metadata.gateway] + case 'private-domain': + return h.metadata.gateways + case 'plugin': + return [] } } -function filterClearnet(a: AddressWithInfo): a is ClearnetAddress { - return a.info.public -} -function cmpClearnet( - host: T.Host, - a: ClearnetAddress, - b: ClearnetAddress, -): -1 | 0 | 1 { - return cmpWithRankedPredicates(a, b, [ - x => - x.info.hostname.kind === 'domain' && - x.info.gateway.id === host.publicDomains[x.info.hostname.value]?.gateway, // public domain for this gateway - x => x.gateway?.public ?? false, // public gateway - x => x.info.hostname.kind === 'ipv4', // ipv4 - x => x.info.hostname.kind === 'ipv6', // ipv6 - // remainder: private domains / domains public on other gateways - ]) -} -export function getPublicDomains( - publicDomains: Record, - gateways: GatewayPlus[], -): PublicDomain[] { - return Object.entries(publicDomains).map(([fqdn, info]) => ({ - fqdn, - acme: info.acme, - gateway: gateways.find(g => g.id === info.gateway) || null, - })) +function getAddressType(h: T.HostnameInfo): string { + switch (h.metadata.kind) { + case 'ipv4': + return 'IPv4' + case 'ipv6': + return 'IPv6' + case 'public-domain': + return 'Public Domain' + case 'private-domain': + return h.host.endsWith('.local') ? 'mDNS' : 'Private Domain' + case 'plugin': + return 'Plugin' + } } @Injectable({ @@ -104,90 +57,101 @@ export function getPublicDomains( }) export class InterfaceService { private readonly config = inject(ConfigService) - private readonly i18n = inject(i18nPipe) - getAddresses( + getGatewayGroups( serviceInterface: T.ServiceInterface, host: T.Host, gateways: GatewayPlus[], - ): MappedServiceInterface['addresses'] { - const hostnamesInfos = this.hostnameInfo(serviceInterface, host) - - const addresses = { - common: [], - uncommon: [], - } - - if (!hostnamesInfos.length) return addresses + ): GatewayAddressGroup[] { + const binding = + host.bindings[serviceInterface.addressInfo.internalPort] + if (!binding) return [] + const addr = binding.addresses const masked = serviceInterface.masked const ui = serviceInterface.type === 'ui' - const allAddressesWithInfo: AddressWithInfo[] = hostnamesInfos.flatMap( - h => { - const { url, sslUrl } = utils.addressHostToUrl( - serviceInterface.addressInfo, - h, - ) - const info = h - const gateway = gateways.find(g => h.gateway.id === g.id) - const res = [] - if (url) { - res.push({ - url, - info, - gateway, - showSsl: false, - masked, - ui, - }) - } - if (sslUrl) { - res.push({ - url: sslUrl, - info, - gateway, - showSsl: !!url, - masked, - ui, - }) - } - return res - }, - ) + const groupMap = new Map() - const lanAddrs = allAddressesWithInfo - .filter(filterLan) - .sort((a, b) => cmpLan(host, a, b)) - const vpnAddrs = allAddressesWithInfo - .filter(filterVpn) - .sort((a, b) => cmpVpn(host, a, b)) - const clearnetAddrs = allAddressesWithInfo - .filter(filterClearnet) - .sort((a, b) => cmpClearnet(host, a, b)) - - let bestAddrs = [ - (clearnetAddrs[0]?.gateway?.public || - clearnetAddrs[0]?.info.hostname.kind === 'domain') && - clearnetAddrs[0], - lanAddrs[0], - vpnAddrs[0], - ] - .filter(a => !!a) - .reduce((acc, x) => { - if (!acc.includes(x)) acc.push(x) - return acc - }, [] as AddressWithInfo[]) - - return { - common: bestAddrs.map(a => this.toDisplayAddress(a, host.publicDomains)), - uncommon: allAddressesWithInfo - .filter(a => !bestAddrs.includes(a)) - .map(a => this.toDisplayAddress(a, host.publicDomains)), + for (const gateway of gateways) { + groupMap.set(gateway.id, []) } + + for (const h of addr.available) { + const enabled = isEnabled(addr, h) + const url = utils.addressHostToUrl(serviceInterface.addressInfo, h) + const type = getAddressType(h) + const isDomain = + h.metadata.kind === 'private-domain' || + h.metadata.kind === 'public-domain' + const isMdns = + h.metadata.kind === 'private-domain' && h.host.endsWith('.local') + + const address: GatewayAddress = { + enabled, + type, + access: h.public ? 'public' : 'private', + url, + hostnameInfo: h, + masked, + ui, + deletable: isDomain && !isMdns, + } + + const gatewayIds = getGatewayIds(h) + for (const gid of gatewayIds) { + const list = groupMap.get(gid) + if (list) { + list.push(address) + } + } + } + + return gateways + .filter(g => (groupMap.get(g.id)?.length ?? 0) > 0) + .map(g => ({ + gatewayId: g.id, + gatewayName: g.name, + addresses: groupMap.get(g.id)!, + })) + } + + getPluginGroups( + serviceInterface: T.ServiceInterface, + host: T.Host, + ): PluginAddressGroup[] { + const binding = + host.bindings[serviceInterface.addressInfo.internalPort] + if (!binding) return [] + + const addr = binding.addresses + const masked = serviceInterface.masked + const groupMap = new Map() + + for (const h of addr.available) { + if (h.metadata.kind !== 'plugin') continue + + const url = utils.addressHostToUrl(serviceInterface.addressInfo, h) + const pluginId = h.metadata.package + + if (!groupMap.has(pluginId)) { + groupMap.set(pluginId, []) + } + + groupMap.get(pluginId)!.push({ + url, + hostnameInfo: h, + masked, + }) + } + + return Array.from(groupMap.entries()).map(([pluginId, addresses]) => ({ + pluginId, + pluginName: pluginId.charAt(0).toUpperCase() + pluginId.slice(1), + addresses, + })) } - /** ${scheme}://${username}@${host}:${externalPort}${suffix} */ launchableAddress(ui: T.ServiceInterface, host: T.Host): string { const addresses = utils.filledAddress(host, ui.addressInfo) @@ -217,7 +181,7 @@ export class InterfaceService { matching = addresses.nonLocal .filter({ kind: 'ipv4', - predicate: h => h.hostname.value === this.config.hostname, + predicate: h => h.host === this.config.hostname, }) .format('urlstring')[0] onLan = true @@ -226,7 +190,7 @@ export class InterfaceService { matching = addresses.nonLocal .filter({ kind: 'ipv6', - predicate: h => h.hostname.value === this.config.hostname, + predicate: h => h.host === this.config.hostname, }) .format('urlstring')[0] break @@ -247,216 +211,39 @@ export class InterfaceService { if (bestPublic) return bestPublic return '' } +} - private hostnameInfo( - serviceInterface: T.ServiceInterface, - host: T.Host, - ): T.HostnameInfo[] { - const binding = - host.bindings[serviceInterface.addressInfo.internalPort] - if (!binding) return [] - const addr = binding.addresses - const enabled = addr.possible.filter(h => - addr.enabled.some(e => utils.deepEqual(e, h)) || - (!addr.disabled.some(d => utils.deepEqual(d, h)) && - !(h.public && (h.hostname.kind === 'ipv4' || h.hostname.kind === 'ipv6'))), - ) - return enabled.filter( - h => - this.config.accessType === 'localhost' || - !( - (h.hostname.kind === 'ipv6' && - utils.IPV6_LINK_LOCAL.contains(h.hostname.value)) || - h.gateway.id === 'lo' - ), - ) - } +export type GatewayAddress = { + enabled: boolean + type: string + access: 'public' | 'private' + url: string + hostnameInfo: T.HostnameInfo + masked: boolean + ui: boolean + deletable: boolean +} - private toDisplayAddress( - { info, url, gateway, showSsl, masked, ui }: AddressWithInfo, - publicDomains: Record, - ): DisplayAddress { - let access: DisplayAddress['access'] - let gatewayName: DisplayAddress['gatewayName'] - let type: DisplayAddress['type'] - let bullets: any[] +export type GatewayAddressGroup = { + gatewayId: string + gatewayName: string + addresses: GatewayAddress[] +} - const rootCaRequired = this.i18n.transform( - "Requires trusting your server's Root CA", - ) +export type PluginAddress = { + url: string + hostnameInfo: T.HostnameInfo + masked: boolean +} - { - const port = info.hostname.sslPort || info.hostname.port - gatewayName = info.gateway.name - - const gatewayLanIpv4 = gateway?.lanIpv4[0] - const isWireguard = gateway?.ipInfo.deviceType === 'wireguard' - - const localIdeal = this.i18n.transform('Ideal for local access') - const lanRequired = this.i18n.transform( - 'Requires being connected to the same Local Area Network (LAN) as your server, either physically or via VPN', - ) - const staticRequired = `${this.i18n.transform('Requires setting a static IP address for')} ${gatewayLanIpv4} ${this.i18n.transform('in your gateway')}` - const vpnAccess = this.i18n.transform('Ideal for VPN access via') - const routerWireguard = this.i18n.transform( - "your router's Wireguard server", - ) - const portForwarding = this.i18n.transform( - 'Requires port forwarding in gateway', - ) - const dnsFor = this.i18n.transform('Requires a DNS record for') - const resolvesTo = this.i18n.transform('that resolves to') - - // * Local * - if (info.hostname.kind === 'local') { - type = this.i18n.transform('Local') - access = 'private' - bullets = [ - localIdeal, - this.i18n.transform( - 'Not recommended for VPN access. VPNs do not support ".local" domains without advanced configuration', - ), - lanRequired, - rootCaRequired, - ] - // * IPv4 * - } else if (info.hostname.kind === 'ipv4') { - type = 'IPv4' - if (info.public) { - access = 'public' - bullets = [ - this.i18n.transform('Can be used for clearnet access'), - this.i18n.transform( - 'Not recommended in most cases. Using a public domain is more common and preferred', - ), - rootCaRequired, - ] - if (!info.gateway.public) { - bullets.push( - `${portForwarding} "${gatewayName}": ${port} -> ${port}`, - ) - } - } else { - access = 'private' - if (isWireguard) { - bullets = [`${vpnAccess} StartTunnel`, rootCaRequired] - } else { - bullets = [ - localIdeal, - `${vpnAccess} ${routerWireguard}`, - lanRequired, - rootCaRequired, - staticRequired, - ] - } - } - // * IPv6 * - } else if (info.hostname.kind === 'ipv6') { - type = 'IPv6' - access = 'private' - bullets = [ - this.i18n.transform('Can be used for local access'), - lanRequired, - rootCaRequired, - ] - // * Domain * - } else { - type = this.i18n.transform('Domain') - if (info.public) { - access = 'public' - bullets = [ - `${dnsFor} ${info.hostname.value} ${resolvesTo} ${gateway?.ipInfo.wanIp}`, - ] - - if (!info.gateway.public) { - bullets.push( - `${portForwarding} "${gatewayName}": ${port} -> ${port === 443 ? 5443 : port}`, - ) - } - - if (publicDomains[info.hostname.value]?.acme) { - bullets.unshift( - this.i18n.transform('Ideal for public access via the Internet'), - ) - } else { - bullets = [ - this.i18n.transform( - 'Can be used for personal access via the public Internet, but a VPN is more private and secure', - ), - this.i18n.transform( - `Not good for public access, since the certificate is signed by your Server's Root CA`, - ), - rootCaRequired, - ...bullets, - ] - } - } else { - access = 'private' - const ipPortBad = this.i18n.transform( - 'when using IP addresses and ports is undesirable', - ) - const customDnsRequired = `${dnsFor} ${info.hostname.value} ${resolvesTo} ${gatewayLanIpv4}` - if (isWireguard) { - bullets = [ - `${vpnAccess} StartTunnel ${ipPortBad}`, - customDnsRequired, - rootCaRequired, - ] - } else { - bullets = [ - `${localIdeal} ${ipPortBad}`, - `${vpnAccess} ${routerWireguard} ${ipPortBad}`, - customDnsRequired, - rootCaRequired, - lanRequired, - staticRequired, - ] - } - } - } - } - - if (showSsl) { - type = `${type} (SSL)` - - bullets.unshift( - this.i18n.transform('Should only needed for apps that enforce SSL'), - ) - } - - return { - url, - access, - gatewayName, - type, - bullets, - masked, - ui, - } - } +export type PluginAddressGroup = { + pluginId: string + pluginName: string + addresses: PluginAddress[] } export type MappedServiceInterface = T.ServiceInterface & { - gateways: InterfaceGateway[] - publicDomains: PublicDomain[] - privateDomains: string[] - addresses: { - common: DisplayAddress[] - uncommon: DisplayAddress[] - } + gatewayGroups: GatewayAddressGroup[] + pluginGroups: PluginAddressGroup[] addSsl: boolean } - -export type InterfaceGateway = GatewayPlus & { - enabled: boolean -} - -export type DisplayAddress = { - type: string - access: 'public' | 'private' | null - gatewayName: string | null - url: string - bullets: i18nKey[] - masked: boolean - ui: boolean -} diff --git a/web/projects/ui/src/app/routes/portal/components/interfaces/private-domains.component.ts b/web/projects/ui/src/app/routes/portal/components/interfaces/private-domains.component.ts deleted file mode 100644 index 40b78406e..000000000 --- a/web/projects/ui/src/app/routes/portal/components/interfaces/private-domains.component.ts +++ /dev/null @@ -1,184 +0,0 @@ -import { - ChangeDetectionStrategy, - Component, - inject, - input, -} from '@angular/core' -import { - DialogService, - DocsLinkDirective, - ErrorService, - i18nPipe, - LoadingService, -} from '@start9labs/shared' -import { ISB, utils } from '@start9labs/start-sdk' -import { TuiButton, TuiTitle } from '@taiga-ui/core' -import { TuiSkeleton } from '@taiga-ui/kit' -import { TuiCell } from '@taiga-ui/layout' -import { filter } from 'rxjs' -import { - FormComponent, - FormContext, -} from 'src/app/routes/portal/components/form.component' -import { PlaceholderComponent } from 'src/app/routes/portal/components/placeholder.component' -import { ApiService } from 'src/app/services/api/embassy-api.service' -import { FormDialogService } from 'src/app/services/form-dialog.service' -import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec' -import { InterfaceComponent } from './interface.component' - -@Component({ - selector: 'section[privateDomains]', - template: ` -
- {{ 'Private Domains' | i18n }} - - {{ 'Documentation' | i18n }} - - -
- @for (domain of privateDomains(); track domain) { -
- {{ domain }} - -
- } @empty { - @if (privateDomains()) { - - {{ 'No private domains' | i18n }} - - } @else { - @for (_ of [0, 1]; track $index) { - - } - } - } - `, - styles: ` - :host { - grid-column: span 4; - overflow-wrap: break-word; - } - `, - host: { class: 'g-card' }, - imports: [ - TuiCell, - TuiTitle, - TuiButton, - PlaceholderComponent, - i18nPipe, - DocsLinkDirective, - TuiSkeleton, - ], - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class InterfacePrivateDomainsComponent { - private readonly dialog = inject(DialogService) - private readonly formDialog = inject(FormDialogService) - private readonly loader = inject(LoadingService) - private readonly errorService = inject(ErrorService) - private readonly api = inject(ApiService) - private readonly interface = inject(InterfaceComponent) - private readonly i18n = inject(i18nPipe) - - readonly privateDomains = input.required() - - async add() { - this.formDialog.open>(FormComponent, { - label: 'New private domain', - data: { - spec: await configBuilderToSpec( - ISB.InputSpec.of({ - fqdn: ISB.Value.text({ - name: this.i18n.transform('Domain'), - description: this.i18n.transform( - 'Enter a fully qualified domain name. Since the domain is for private use, it can be any domain you want, even one you do not control.', - ), - required: true, - default: null, - patterns: [utils.Patterns.domain], - }), - }), - ), - buttons: [ - { - text: this.i18n.transform('Save')!, - handler: async value => this.save(value.fqdn), - }, - ], - }, - }) - } - - async remove(fqdn: string) { - this.dialog - .openConfirm({ label: 'Are you sure?', size: 's' }) - .pipe(filter(Boolean)) - .subscribe(async () => { - const loader = this.loader.open('Removing').subscribe() - - try { - if (this.interface.packageId()) { - await this.api.pkgRemovePrivateDomain({ - fqdn, - package: this.interface.packageId(), - host: this.interface.value()?.addressInfo.hostId || '', - }) - } else { - await this.api.osUiRemovePrivateDomain({ fqdn }) - } - return true - } catch (e: any) { - this.errorService.handleError(e) - return false - } finally { - loader.unsubscribe() - } - }) - } - - private async save(fqdn: string): Promise { - const loader = this.loader.open('Saving').subscribe() - - try { - if (this.interface.packageId) { - await this.api.pkgAddPrivateDomain({ - fqdn, - package: this.interface.packageId(), - host: this.interface.value()?.addressInfo.hostId || '', - }) - } else { - await this.api.osUiAddPrivateDomain({ fqdn }) - } - return true - } catch (e: any) { - this.errorService.handleError(e) - return false - } finally { - loader.unsubscribe() - } - } -} diff --git a/web/projects/ui/src/app/routes/portal/components/interfaces/public-domains/dns.component.ts b/web/projects/ui/src/app/routes/portal/components/interfaces/public-domains/dns.component.ts index e11135b07..8ea512551 100644 --- a/web/projects/ui/src/app/routes/portal/components/interfaces/public-domains/dns.component.ts +++ b/web/projects/ui/src/app/routes/portal/components/interfaces/public-domains/dns.component.ts @@ -16,8 +16,13 @@ import { import { injectContext, PolymorpheusComponent } from '@taiga-ui/polymorpheus' import { TableComponent } from 'src/app/routes/portal/components/table.component' import { ApiService } from 'src/app/services/api/embassy-api.service' +import { T } from '@start9labs/start-sdk' import { parse } from 'tldts' -import { GatewayWithId } from './pd.service' + +export type DnsGateway = T.NetworkInterfaceInfo & { + id: string + ipInfo: T.IpInfo +} @Component({ selector: 'dns', @@ -104,7 +109,7 @@ export class DnsComponent { injectContext< TuiDialogContext< void, - { fqdn: string; gateway: GatewayWithId; message: string } + { fqdn: string; gateway: DnsGateway; message: string } > >() diff --git a/web/projects/ui/src/app/routes/portal/components/interfaces/public-domains/pd.component.ts b/web/projects/ui/src/app/routes/portal/components/interfaces/public-domains/pd.component.ts deleted file mode 100644 index aa9d47a65..000000000 --- a/web/projects/ui/src/app/routes/portal/components/interfaces/public-domains/pd.component.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { - ChangeDetectionStrategy, - Component, - inject, - input, -} from '@angular/core' -import { DocsLinkDirective, i18nPipe } from '@start9labs/shared' -import { TuiButton } from '@taiga-ui/core' -import { TuiSkeleton } from '@taiga-ui/kit' -import { PlaceholderComponent } from 'src/app/routes/portal/components/placeholder.component' -import { TableComponent } from 'src/app/routes/portal/components/table.component' -import { PublicDomainsItemComponent } from './pd.item.component' -import { PublicDomain, PublicDomainService } from './pd.service' - -@Component({ - selector: 'section[publicDomains]', - template: ` -
- {{ 'Public Domains' | i18n }} - - {{ 'Documentation' | i18n }} - - @if (service.data()) { - - } -
- @if (publicDomains()?.length === 0) { - - {{ 'No public domains' | i18n }} - - } @else { - - @for (domain of publicDomains(); track $index) { - - } @empty { - @for (_ of [0]; track $index) { - - - - } - } -
-
{{ 'Loading' | i18n }}
-
- } - `, - styles: ` - :host { - grid-column: span 7; - } - `, - host: { class: 'g-card' }, - providers: [PublicDomainService], - imports: [ - TuiButton, - TableComponent, - PlaceholderComponent, - i18nPipe, - DocsLinkDirective, - PublicDomainsItemComponent, - TuiSkeleton, - ], - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class PublicDomainsComponent { - readonly service = inject(PublicDomainService) - - readonly publicDomains = input.required() - - readonly addSsl = input.required() -} diff --git a/web/projects/ui/src/app/routes/portal/components/interfaces/public-domains/pd.item.component.ts b/web/projects/ui/src/app/routes/portal/components/interfaces/public-domains/pd.item.component.ts deleted file mode 100644 index 2fc6b9d04..000000000 --- a/web/projects/ui/src/app/routes/portal/components/interfaces/public-domains/pd.item.component.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { - ChangeDetectionStrategy, - Component, - computed, - inject, - input, -} from '@angular/core' -import { i18nPipe, i18nKey } from '@start9labs/shared' -import { - TuiButton, - TuiDataList, - TuiDropdown, - TuiTextfield, -} from '@taiga-ui/core' -import { PublicDomain, PublicDomainService } from './pd.service' -import { toAuthorityName } from 'src/app/utils/acme' - -@Component({ - selector: 'tr[publicDomain]', - template: ` - {{ publicDomain().fqdn }} - {{ publicDomain().gateway?.name }} - {{ authority() }} - - - - - - - - - - - `, - styles: ` - :host { - grid-template-columns: min-content 1fr min-content; - } - - td:nth-child(2) { - order: -1; - grid-column: span 2; - } - - td:last-child { - grid-area: 1 / 3 / 3; - align-self: center; - text-align: right; - } - - :host-context(tui-root._mobile) { - .authority { - grid-column: span 2; - } - tui-badge { - vertical-align: bottom; - margin-inline-start: 0.25rem; - } - } - `, - changeDetection: ChangeDetectionStrategy.OnPush, - imports: [TuiButton, TuiDataList, TuiDropdown, i18nPipe, TuiTextfield], -}) -export class PublicDomainsItemComponent { - protected readonly service = inject(PublicDomainService) - - open = false - - readonly publicDomain = input.required() - readonly addSsl = input.required() - - readonly authority = computed(() => - toAuthorityName(this.publicDomain().acme, this.addSsl()), - ) - readonly dnsMessage = computed( - () => - `Create one of the DNS records below to cause ${this.publicDomain().fqdn} to resolve to ${this.publicDomain().gateway?.ipInfo.wanIp}` as i18nKey, - ) -} diff --git a/web/projects/ui/src/app/routes/portal/components/interfaces/public-domains/pd.service.ts b/web/projects/ui/src/app/routes/portal/components/interfaces/public-domains/pd.service.ts deleted file mode 100644 index 2991c8bf8..000000000 --- a/web/projects/ui/src/app/routes/portal/components/interfaces/public-domains/pd.service.ts +++ /dev/null @@ -1,277 +0,0 @@ -import { inject, Injectable } from '@angular/core' -import { - DialogService, - ErrorService, - i18nKey, - LoadingService, - i18nPipe, -} from '@start9labs/shared' -import { toSignal } from '@angular/core/rxjs-interop' -import { ISB, T, utils } from '@start9labs/start-sdk' -import { filter, map } from 'rxjs' -import { FormComponent } from 'src/app/routes/portal/components/form.component' -import { ApiService } from 'src/app/services/api/embassy-api.service' -import { FormDialogService } from 'src/app/services/form-dialog.service' -import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec' -import { PatchDB } from 'patch-db-client' -import { DataModel } from 'src/app/services/patch-db/data-model' -import { toAuthorityName } from 'src/app/utils/acme' -import { InterfaceComponent } from '../interface.component' -import { DNS } from './dns.component' - -export type PublicDomain = { - fqdn: string - gateway: GatewayWithId | null - acme: string | null -} - -export type GatewayWithId = T.NetworkInterfaceInfo & { - id: string - ipInfo: T.IpInfo -} - -@Injectable() -export class PublicDomainService { - private readonly patch = inject>(PatchDB) - private readonly loader = inject(LoadingService) - private readonly errorService = inject(ErrorService) - private readonly api = inject(ApiService) - private readonly formDialog = inject(FormDialogService) - private readonly dialog = inject(DialogService) - private readonly interface = inject(InterfaceComponent) - private readonly i18n = inject(i18nPipe) - - readonly data = toSignal( - this.patch.watch$('serverInfo', 'network').pipe( - map(({ gateways, acme }) => ({ - gateways: Object.entries(gateways) - .filter(([_, g]) => g.ipInfo) - .map(([id, g]) => ({ id, ...g })) as GatewayWithId[], - authorities: Object.keys(acme).reduce>( - (obj, url) => ({ - ...obj, - [url]: toAuthorityName(url), - }), - { local: toAuthorityName(null) }, - ), - })), - ), - ) - - async add(addSsl: boolean) { - const addSpec = ISB.InputSpec.of({ - fqdn: ISB.Value.text({ - name: this.i18n.transform('Domain'), - description: this.i18n.transform( - 'Enter a fully qualified domain name. For example, if you control domain.com, you could enter domain.com or subdomain.domain.com or another.subdomain.domain.com.', - ), - required: true, - default: null, - patterns: [utils.Patterns.domain], - }).map(f => f.toLocaleLowerCase()), - ...this.gatewaySpec(), - ...(addSsl - ? this.authoritySpec() - : ({} as ReturnType)), - }) - - this.formDialog.open(FormComponent, { - label: 'Add public domain', - data: { - spec: await configBuilderToSpec(addSpec), - buttons: [ - { - text: 'Save', - handler: (input: typeof addSpec._TYPE) => - this.save(input.fqdn, input.gateway, input.authority), - }, - ], - }, - }) - } - - async edit(domain: PublicDomain, addSsl: boolean) { - const editSpec = ISB.InputSpec.of({ - ...this.gatewaySpec(), - ...(addSsl - ? this.authoritySpec() - : ({} as ReturnType)), - }) - - this.formDialog.open(FormComponent, { - label: 'Edit public domain', - data: { - spec: await configBuilderToSpec(editSpec), - buttons: [ - { - text: 'Save', - handler: ({ gateway, authority }: typeof editSpec._TYPE) => - this.save(domain.fqdn, gateway, authority), - }, - ], - value: { - gateway: domain.gateway!.id, - authority: domain.acme, - }, - }, - }) - } - - remove(fqdn: string) { - this.dialog - .openConfirm({ label: 'Are you sure?', size: 's' }) - .pipe(filter(Boolean)) - .subscribe(async () => { - const loader = this.loader.open('Deleting').subscribe() - - try { - if (this.interface.packageId()) { - await this.api.pkgRemovePublicDomain({ - fqdn, - package: this.interface.packageId(), - host: this.interface.value()?.addressInfo.hostId || '', - }) - } else { - await this.api.osUiRemovePublicDomain({ fqdn }) - } - return true - } catch (e: any) { - this.errorService.handleError(e) - return false - } finally { - loader.unsubscribe() - } - }) - } - - showDns(fqdn: string, gateway: GatewayWithId, message: i18nKey) { - this.dialog - .openComponent(DNS, { - label: 'DNS Records', - size: 'l', - data: { - fqdn, - gateway, - message, - }, - }) - .subscribe() - } - - private async save( - fqdn: string, - gatewayId: string, - authority?: 'local' | string, - ) { - const gateway = this.data()!.gateways.find(g => g.id === gatewayId)! - - const loader = this.loader.open('Saving').subscribe() - const params = { - fqdn, - gateway: gatewayId, - acme: !authority || authority === 'local' ? null : authority, - } - try { - let ip: string | null - if (this.interface.packageId()) { - ip = await this.api.pkgAddPublicDomain({ - ...params, - package: this.interface.packageId(), - host: this.interface.value()?.addressInfo.hostId || '', - }) - } else { - ip = await this.api.osUiAddPublicDomain(params) - } - - const wanIp = gateway.ipInfo.wanIp - - let message = this.i18n.transform( - 'Create one of the DNS records below.', - ) as i18nKey - - if (!ip) { - setTimeout( - () => - this.showDns( - fqdn, - gateway, - `${this.i18n.transform('No DNS record detected for')} ${fqdn}. ${message}` as i18nKey, - ), - 250, - ) - } else if (ip !== wanIp) { - setTimeout( - () => - this.showDns( - fqdn, - gateway, - `${this.i18n.transform('Invalid DNS record')}. ${fqdn} ${this.i18n.transform('resolves to')} ${ip}. ${message}` as i18nKey, - ), - 250, - ) - } else { - setTimeout( - () => - this.dialog - .openAlert( - `${fqdn} ${this.i18n.transform('resolves to')} ${wanIp}` as i18nKey, - { label: 'DNS record detected!', appearance: 'positive' }, - ) - .subscribe(), - 250, - ) - } - - return true - } catch (e: any) { - this.errorService.handleError(e) - return false - } finally { - loader.unsubscribe() - } - } - - private gatewaySpec() { - const data = this.data()! - - const gateways = data.gateways.filter( - ({ ipInfo: { deviceType } }) => - deviceType !== 'loopback' && deviceType !== 'bridge', - ) - - return { - gateway: ISB.Value.dynamicSelect(() => ({ - name: this.i18n.transform('Gateway'), - description: this.i18n.transform( - 'Select a gateway to use for this domain.', - ), - values: gateways.reduce>( - (obj, gateway) => ({ - [gateway.id]: gateway.name || gateway.ipInfo.name, - ...obj, - }), - { '~/system/gateways': this.i18n.transform('New gateway') }, - ), - default: '', - disabled: gateways - .filter(g => !g.ipInfo.wanIp || utils.CGNAT.contains(g.ipInfo.wanIp)) - .map(g => g.id), - })), - } - } - - private authoritySpec() { - const data = this.data()! - - return { - authority: ISB.Value.select({ - name: this.i18n.transform('Certificate Authority'), - description: this.i18n.transform( - 'Select a Certificate Authority to issue SSL/TLS certificates for this domain', - ), - values: data.authorities, - default: '', - }), - } - } -} diff --git a/web/projects/ui/src/app/routes/portal/routes/services/routes/interface.component.ts b/web/projects/ui/src/app/routes/portal/routes/services/routes/interface.component.ts index 1daf86b01..f20c1d95b 100644 --- a/web/projects/ui/src/app/routes/portal/routes/services/routes/interface.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/services/routes/interface.component.ts @@ -17,10 +17,7 @@ import { PatchDB } from 'patch-db-client' import { InterfaceComponent } from 'src/app/routes/portal/components/interfaces/interface.component' import { DataModel } from 'src/app/services/patch-db/data-model' import { TitleDirective } from 'src/app/services/title.service' -import { - getPublicDomains, - InterfaceService, -} from '../../../components/interfaces/interface.service' +import { InterfaceService } from '../../../components/interfaces/interface.service' import { GatewayService } from 'src/app/services/gateway.service' import { getInstalledBaseStatus } from 'src/app/services/pkg-status-rendering.service' @@ -125,25 +122,16 @@ export default class ServiceInterfaceRoute { } const binding = host.bindings[port] - const gateways = this.gatewayService.gateways() || [] return { ...iFace, - addresses: this.interfaceService.getAddresses(iFace, host, gateways), - gateways: - gateways.map(g => ({ - enabled: - (binding?.addresses.enabled.some(a => a.gateway.id === g.id) || - (!binding?.addresses.disabled.some(a => a.gateway.id === g.id) && - binding?.addresses.possible.some(a => - a.gateway.id === g.id && - !(a.public && (a.hostname.kind === 'ipv4' || a.hostname.kind === 'ipv6')) - ))) ?? false, - ...g, - })) || [], - publicDomains: getPublicDomains(host.publicDomains, gateways), - privateDomains: host.privateDomains, + gatewayGroups: this.interfaceService.getGatewayGroups( + iFace, + host, + gateways, + ), + pluginGroups: this.interfaceService.getPluginGroups(iFace, host), addSsl: !!binding?.options.addSsl, } }) diff --git a/web/projects/ui/src/app/routes/portal/routes/system/routes/dns/dns.component.ts b/web/projects/ui/src/app/routes/portal/routes/system/routes/dns/dns.component.ts index 58e8170e8..90274ce3b 100644 --- a/web/projects/ui/src/app/routes/portal/routes/system/routes/dns/dns.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/system/routes/dns/dns.component.ts @@ -184,7 +184,7 @@ export default class SystemDnsComponent { if ( Object.values(pkgs).some(p => - Object.values(p.hosts).some(h => h?.privateDomains.length), + Object.values(p.hosts).some(h => Object.keys(h?.privateDomains || {}).length), ) ) { Object.values(gateways) diff --git a/web/projects/ui/src/app/routes/portal/routes/system/routes/gateways/gateways.component.ts b/web/projects/ui/src/app/routes/portal/routes/system/routes/gateways/gateways.component.ts index 983d9a4a0..254e40f54 100644 --- a/web/projects/ui/src/app/routes/portal/routes/system/routes/gateways/gateways.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/system/routes/gateways/gateways.component.ts @@ -15,7 +15,6 @@ import { GatewaysTableComponent } from './table.component' import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec' import { TitleDirective } from 'src/app/services/title.service' import { ISB } from '@start9labs/start-sdk' -import { RR } from 'src/app/services/api/api.types' @Component({ template: ` @@ -81,17 +80,6 @@ export default class GatewaysComponent { default: null, placeholder: 'StartTunnel 1', }), - type: ISB.Value.select({ - name: this.i18n.transform('Type'), - description: this.i18n.transform('The type of gateway'), - default: 'inbound-outbound', - values: { - 'inbound-outbound': this.i18n.transform( - 'StartTunnel (Inbound/Outbound)', - ), - 'outbound-only': this.i18n.transform('Outbound Only'), - }, - }), config: ISB.Value.union({ name: this.i18n.transform('WireGuard Config File'), default: 'paste', @@ -103,8 +91,8 @@ export default class GatewaysComponent { name: this.i18n.transform('File Contents'), default: null, required: true, - minRows: 16, - maxRows: 16, + minRows: 8, + maxRows: 8, }), }), }, @@ -146,7 +134,6 @@ export default class GatewaysComponent { input.config.selection === 'paste' ? input.config.value.file : await (input.config.value.file as any as File).text(), - type: input.type as RR.GatewayType, setAsDefaultOutbound: input.setAsDefaultOutbound, }) return true diff --git a/web/projects/ui/src/app/routes/portal/routes/system/routes/gateways/item.component.ts b/web/projects/ui/src/app/routes/portal/routes/system/routes/gateways/item.component.ts index 7ace53401..e2e7706f4 100644 --- a/web/projects/ui/src/app/routes/portal/routes/system/routes/gateways/item.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/system/routes/gateways/item.component.ts @@ -34,7 +34,9 @@ import { TuiBadge } from '@taiga-ui/kit' {{ gateway.name }} @if (gateway.isDefaultOutbound) { - Default outbound + + {{ 'default outbound' | i18n }} + } @@ -49,7 +51,7 @@ import { TuiBadge } from '@taiga-ui/kit' } @case ('wireguard') { - WireGuard' + WireGuard } @default { {{ gateway.ipInfo.deviceType }} @@ -58,13 +60,14 @@ import { TuiBadge } from '@taiga-ui/kit' @if (gateway.type === 'outbound-only') { - + {{ 'Outbound Only' | i18n }} } @else { - + {{ 'Inbound/Outbound' | i18n }} } + {{ gateway.lanIpv4.join(', ') || '-' }} {{ gateway.lanIpv4.join(', ') || '-' }} @@ -96,7 +98,6 @@ import { TuiBadge } from '@taiga-ui/kit'