refactor: break big components apart and address todos (#3043)

* refactor: break big components apart and address todos

* starttunnel readme, fix pf mocks, fix adding tor domain in startos

---------

Co-authored-by: Matt Hill <mattnine@protonmail.com>
This commit is contained in:
Alex Inkin
2025-11-05 01:15:25 +07:00
committed by GitHub
parent 69d0391d12
commit 58d9f5ef6a
19 changed files with 850 additions and 715 deletions

59
START-TUNNEL.md Normal file
View File

@@ -0,0 +1,59 @@
# StartTunnel
A self-hosted Wiregaurd VPN optimized for creating VLANs and reverse tunneling to personal servers.
You can think of StartTunnel as "virtual router in the cloud"
Use it for private, remote access, to self-hosted services running on a personal server, or to expose self-hosted services to the public Internet without revealing the host server's IP address.
## Installation
1. Rent a low cost VPS. For most use cases, the cheapest option should be enough.
- It must have a dedicated public IP address.
- For (CPU), memory (RAM), and storage (disk), choose the minimum spec.
- For transfer (bandwidth), it depends on (1) your use case and (2) your home Internet's _upload_ speed. Even if you intend to serve large files or stream content from your server, there is no reason to pay for speeds that exceed your home Internet's upload speed.
1. Provision the VPS with the latest version of Debian.
1. Access the VPS via SSH.
1. Install StartTunnel:
@TODO
## Features
- **Create Subnets**: Each subnet creates a private, virtual local area network (VLAN), similar to the LAN created by a home router.
- **Add Devices**: When you add a device (server, phone, laptop) to a subnet, it receives a LAN IP address on that subnet as well as a unique Wireguard config that must be copied, downloaded, or scanned into the device.
- **Forward Ports**: Forwarding a port creates a "reverse tunnel", exposing a specific port on a specific device to the public Internet.
## CLI
By default, StartTunnel is managed via the `start-tunnel` command line interface, which is self-documented.
```
start-tunnel --help
```
## Web Interface
If you choose to enable the web interface (recommended in most cases), StartTunnel can be accessed as a website from the browser, or programmatically via API.
1. Initialize the web interface.
start-tunnel web init
1. When prompted, select the IP address at which to host the web interface. In many cases, there will be only one IP address.
1. When prompted, enter the port at which to host the web interface. The default is 8443, and we recommend using it. If you change the default, choose an uncommon port to avoid conflicts.
1. Select whether to autogenerate a self-signed certificate or provide your own certificate and key. If you choose to autogenerate, you will be asked to list all IP addresses and domains for which to sign the certificate. For example, if you intend to access your StartTunnel web UI at a domain, include the domain in the list.
1. You will receive a success message that the webserver is running at the chosen IP:port, as well as your SSL certificate and an autogenerated UI password.
1. If not already, trust the certificate in your system keychain and/or browser.
1. If you lose/forget your password, you can reset it using the CLI.

View File

@@ -350,7 +350,7 @@
},
"index": "projects/start-tunnel/src/index.html",
"browser": "projects/start-tunnel/src/main.ts",
"polyfills": ["zone.js"],
"polyfills": [],
"tsConfig": "projects/start-tunnel/tsconfig.json",
"inlineStyleLanguage": "scss",
"assets": [

View File

@@ -1,35 +0,0 @@
# StartTunnel
StartTunnel is a self-hosted Wiregaurd VPN optimized for reverse tunneling to personal servers.
You can think of StartTunnel as a "virtual router in the cloud".
Use it for private, remote access, to self-hosted services running on a personal server, or to expose self-hosted services to the public Internet without revealing the host server's IP address.
## Features
### Subnets
Create subnets (private networks/VLANs).
### Devices
Invite devices to join specific subnets. Each device receives a unique Wireguard config that can be copied, downloaded, or scanned to join the network.
### Port Forwards
Expose specific ports on specific devices to the public Internet.
## CLI
StartTunnel comes with a command line interface to manage Subnets, Devices, and Port Forwards.
## UI
The StartTunnel UI is available at `https://<IP Address>` and ships with a self-signed SSL certificate. Users will need to bypass the browser's security warning to access the interface.
Users can provide their own SSL certificate using the CLI:
```
st certificate add </path/to/cert.pem>
```

View File

@@ -1,3 +0,0 @@
<tui-root>
<router-outlet />
</tui-root>

View File

@@ -1,9 +0,0 @@
:host {
height: 100%;
display: block;
}
tui-root {
height: 100%;
border-image: none;
}

View File

@@ -7,8 +7,18 @@ import { PatchService } from './services/patch.service'
@Component({
selector: 'app-root',
imports: [RouterOutlet, TuiRoot],
templateUrl: './app.html',
styleUrl: './app.scss',
template: '<tui-root><router-outlet /></tui-root>',
styles: `
:host {
height: 100%;
display: block;
}
tui-root {
height: 100%;
border-image: none;
}
`,
})
export class App {
readonly subscription = inject(PatchService)

View File

@@ -0,0 +1,170 @@
import { AsyncPipe } from '@angular/common'
import {
ChangeDetectionStrategy,
Component,
inject,
Signal,
} from '@angular/core'
import {
NonNullableFormBuilder,
ReactiveFormsModule,
Validators,
} from '@angular/forms'
import { LoadingService } from '@start9labs/shared'
import { utils } from '@start9labs/start-sdk'
import {
TUI_IS_MOBILE,
TuiAutoFocus,
tuiMarkControlAsTouchedAndValidate,
} from '@taiga-ui/cdk'
import {
TuiButton,
TuiDialogContext,
TuiError,
TuiTextfield,
} from '@taiga-ui/core'
import {
TuiChevron,
TuiDataListWrapper,
TuiElasticContainer,
TuiFieldErrorPipe,
TuiSelect,
} from '@taiga-ui/kit'
import { TuiForm } from '@taiga-ui/layout'
import { injectContext, PolymorpheusComponent } from '@taiga-ui/polymorpheus'
import { ApiService } from 'src/app/services/api/api.service'
import { getIp, DeviceData, MappedSubnet, subnetValidator } from './utils'
@Component({
template: `
<form tuiForm [formGroup]="form">
<tui-textfield>
<label tuiLabel>Name</label>
<input tuiTextfield tuiAutoFocus formControlName="name" />
</tui-textfield>
<tui-error formControlName="name" [error]="[] | tuiFieldError | async" />
@if (!context.data.device) {
<tui-textfield tuiChevron [stringify]="stringify">
<label tuiLabel>Subnet</label>
@if (mobile) {
<select
tuiSelect
formControlName="subnet"
[items]="context.data.subnets()"
></select>
} @else {
<input tuiSelect formControlName="subnet" />
}
@if (!mobile) {
<tui-data-list-wrapper
*tuiTextfieldDropdown
new
[items]="context.data.subnets()"
(itemClick)="onSubnet($event)"
/>
}
</tui-textfield>
<tui-error
formControlName="subnet"
[error]="[] | tuiFieldError | async"
/>
<tui-elastic-container>
@if (form.controls.subnet.value?.range) {
<tui-textfield>
<label tuiLabel>LAN IP</label>
<input tuiTextfield tuiAutoFocus formControlName="ip" />
</tui-textfield>
}
</tui-elastic-container>
@if (form.controls.subnet.value?.range) {
<tui-error
formControlName="ip"
[error]="[] | tuiFieldError | async"
/>
}
}
<footer>
<button tuiButton (click)="onSave()" [disabled]="form.invalid">
Save
</button>
</footer>
</form>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
AsyncPipe,
ReactiveFormsModule,
TuiAutoFocus,
TuiButton,
TuiDataListWrapper,
TuiError,
TuiFieldErrorPipe,
TuiForm,
TuiSelect,
TuiTextfield,
TuiChevron,
TuiElasticContainer,
],
})
export class DevicesAdd {
private readonly loading = inject(LoadingService)
private readonly api = inject(ApiService)
protected readonly mobile = inject(TUI_IS_MOBILE)
protected readonly context =
injectContext<TuiDialogContext<void, DeviceData>>()
protected readonly form = inject(NonNullableFormBuilder).group({
name: [this.context.data.device?.name || '', Validators.required],
subnet: [
this.context.data.device?.subnet,
[Validators.required, subnetValidator],
],
ip: [
this.context.data.device?.ip || '',
[Validators.required, Validators.pattern(utils.Patterns.ipv4.regex)],
],
})
protected readonly stringify = ({ range, name }: MappedSubnet) =>
range ? `${name} (${range})` : ''
protected onSubnet(subnet: MappedSubnet) {
const ip = getIp(subnet)
if (ip) {
this.form.controls.ip.setValue(ip)
} else {
this.form.controls.ip.disable()
}
this.form.controls.subnet.markAsTouched()
}
protected async onSave() {
if (this.form.invalid) {
tuiMarkControlAsTouchedAndValidate(this.form)
return
}
const loader = this.loading.open().subscribe()
const { ip, name, subnet } = this.form.getRawValue()
const data = { ip, name, subnet: subnet?.range || '' }
try {
this.context.data.device
? await this.api.editDevice(data)
: await this.api.addDevice(data)
} catch (e) {
console.error(e)
} finally {
loader.unsubscribe()
this.context.$implicit.complete()
}
}
}
export const DEVICES_ADD = new PolymorpheusComponent(DevicesAdd)

View File

@@ -0,0 +1,74 @@
import { ChangeDetectionStrategy, Component } from '@angular/core'
import {
TuiButton,
TuiDialogContext,
TuiIcon,
TuiTextfield,
TuiTitle,
} from '@taiga-ui/core'
import { TuiCopy, TuiSegmented, TuiTextarea } from '@taiga-ui/kit'
import { TuiHeader } from '@taiga-ui/layout'
import { injectContext, PolymorpheusComponent } from '@taiga-ui/polymorpheus'
import { QrCodeComponent } from 'ng-qrcode'
@Component({
template: `
<header tuiHeader>
<h2 tuiTitle>Device Config</h2>
<aside tuiAccessories>
<tui-segmented #segmented>
<button>
<tui-icon icon="@tui.file" />
File
</button>
<button>
<tui-icon icon="@tui.qr-code" />
QR
</button>
</tui-segmented>
</aside>
</header>
@if (segmented?.activeItemIndex) {
<qr-code [value]="config" size="352" />
} @else {
<tui-textfield>
<textarea
tuiTextarea
[min]="16"
[max]="16"
[readOnly]="true"
[value]="config"
></textarea>
<tui-icon tuiCopy />
<a
tuiIconButton
iconStart="@tui.download"
download="start-tunnel.conf"
size="s"
[href]="href"
>
Download
</a>
</tui-textfield>
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
QrCodeComponent,
TuiButton,
TuiHeader,
TuiIcon,
TuiTitle,
TuiSegmented,
TuiTextfield,
TuiTextarea,
TuiCopy,
],
})
export class DevicesConfig {
protected readonly config =
injectContext<TuiDialogContext<void, string>>().data
protected readonly href = `data:text/plain;charset=utf-8,${encodeURIComponent(this.config)}`
}
export const DEVICES_CONFIG = new PolymorpheusComponent(DevicesConfig)

View File

@@ -1,51 +1,28 @@
import { AsyncPipe } from '@angular/common'
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
signal,
Signal,
} from '@angular/core'
import { toSignal } from '@angular/core/rxjs-interop'
import {
NonNullableFormBuilder,
ReactiveFormsModule,
Validators,
} from '@angular/forms'
import { LoadingService } from '@start9labs/shared'
import { utils } from '@start9labs/start-sdk'
import { IpNet } from '@start9labs/start-sdk/util'
import {
TUI_IS_MOBILE,
TuiAutoFocus,
tuiMarkControlAsTouchedAndValidate,
TuiStringHandler,
} from '@taiga-ui/cdk'
import {
TuiButton,
TuiDataList,
TuiDropdown,
TuiError,
TuiIcon,
TuiTextfield,
TuiTitle,
} from '@taiga-ui/core'
import { TuiDialog, TuiDialogService } from '@taiga-ui/experimental'
import {
TUI_CONFIRM,
TuiCopy,
TuiDataListWrapper,
TuiFieldErrorPipe,
TuiSegmented,
TuiSelect,
TuiTextarea,
} from '@taiga-ui/kit'
import { TuiForm, TuiHeader } from '@taiga-ui/layout'
import { QrCodeComponent } from 'ng-qrcode'
import { TuiDialogService } from '@taiga-ui/experimental'
import { TUI_CONFIRM } from '@taiga-ui/kit'
import { PatchDB } from 'patch-db-client'
import { filter, map } from 'rxjs'
import { ApiService } from 'src/app/services/api/api.service'
import { TunnelData, WgServer } from 'src/app/services/patch-db/data-model'
import { TunnelData } from 'src/app/services/patch-db/data-model'
import { DEVICES_ADD } from './add'
import { DEVICES_CONFIG } from './config'
import { MappedDevice, MappedSubnet } from './utils'
@Component({
template: `
@@ -110,163 +87,30 @@ import { TunnelData, WgServer } from 'src/app/services/patch-db/data-model'
}
</tbody>
</table>
<ng-template [tuiDialogOptions]="{ label: label() }" [(tuiDialog)]="dialog">
<form tuiForm [formGroup]="form">
<tui-textfield>
<label tuiLabel>Name</label>
<input tuiTextfield tuiAutoFocus formControlName="name" />
</tui-textfield>
<tui-error
formControlName="name"
[error]="[] | tuiFieldError | async"
/>
@if (!editing()) {
<tui-textfield tuiChevron [stringify]="subnetDisplay">
<label tuiLabel>Subnet</label>
@if (mobile) {
<select
tuiSelect
formControlName="subnet"
[items]="subnets()"
></select>
} @else {
<input tuiSelect formControlName="subnet" />
}
@if (!mobile) {
<tui-data-list-wrapper
*tuiTextfieldDropdown
new
[items]="subnets()"
(itemClick)="onSubnet($event)"
/>
}
</tui-textfield>
<tui-error
formControlName="subnet"
[error]="[] | tuiFieldError | async"
/>
@if (form.controls.subnet.value.range) {
<tui-textfield>
<label tuiLabel>LAN IP</label>
<input tuiTextfield tuiAutoFocus formControlName="ip" />
</tui-textfield>
<tui-error
formControlName="ip"
[error]="[] | tuiFieldError | async"
/>
}
}
<footer>
<button tuiButton (click)="onSave()" [disabled]="form.invalid">
Save
</button>
</footer>
</form>
</ng-template>
<ng-template [(tuiDialog)]="showConfig">
<header tuiHeader>
<h2 tuiTitle>Device Config</h2>
<aside tuiAccessories>
<tui-segmented #segmented>
<button>
<tui-icon icon="@tui.file" />
File
</button>
<button>
<tui-icon icon="@tui.qr-code" />
QR
</button>
</tui-segmented>
</aside>
</header>
@if (segmented?.activeItemIndex) {
<qr-code [value]="config()" size="352" />
} @else {
<tui-textfield>
<textarea
tuiTextarea
[min]="16"
[max]="16"
[readOnly]="true"
[value]="config()"
></textarea>
<tui-icon tuiCopy />
<a
tuiIconButton
iconStart="@tui.download"
download="start-tunnel.conf"
size="s"
[href]="href()"
>
Download
</a>
</tui-textfield>
}
</ng-template>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
AsyncPipe,
ReactiveFormsModule,
TuiButton,
TuiDropdown,
TuiDataList,
TuiTextfield,
TuiDialog,
TuiForm,
TuiError,
TuiFieldErrorPipe,
TuiAutoFocus,
TuiSelect,
TuiDataListWrapper,
TuiHeader,
TuiTitle,
TuiSegmented,
TuiIcon,
QrCodeComponent,
TuiTextarea,
TuiCopy,
],
imports: [TuiButton, TuiDropdown, TuiDataList, TuiTextfield],
})
export default class Devices {
private readonly dialogs = inject(TuiDialogService)
private readonly api = inject(ApiService)
private readonly loading = inject(LoadingService)
private readonly patch = inject<PatchDB<TunnelData>>(PatchDB)
protected readonly mobile = inject(TUI_IS_MOBILE)
protected readonly form = inject(NonNullableFormBuilder).group({
name: ['', Validators.required],
subnet: [{} as MappedDevice['subnet'], Validators.required],
ip: [
'',
[Validators.required, Validators.pattern(utils.Patterns.ipv4.regex)],
],
})
protected readonly dialog = signal(false)
protected readonly showConfig = signal(false)
protected readonly config = signal('')
protected readonly editing = signal(false)
protected readonly next = signal('')
protected readonly subnets = toSignal<MappedSubnet[], []>(
this.patch.watch$('wg', 'subnets').pipe(
map(s =>
Object.entries(s).map(([range, { name, clients }]) => ({
range,
name,
clients,
})),
protected readonly subnets: Signal<readonly MappedSubnet[]> = toSignal(
inject<PatchDB<TunnelData>>(PatchDB)
.watch$('wg', 'subnets')
.pipe(
map(subnets =>
Object.entries(subnets).map(([range, { name, clients }]) => ({
range,
name,
clients,
})),
),
),
),
{ initialValue: [] },
)
protected readonly href = computed(
() => `data:text/plain;charset=utf-8,${encodeURIComponent(this.config())}`,
)
protected readonly devices = computed(() =>
this.subnets().flatMap(subnet =>
Object.entries(subnet.clients).map(([ip, { name }]) => ({
@@ -279,129 +123,51 @@ export default class Devices {
})),
),
)
protected readonly label = computed(() =>
this.editing() ? 'Rename device' : 'Add device',
)
protected subnetDisplay: TuiStringHandler<MappedSubnet> = subnet =>
subnet.range ? `${subnet.name} (${subnet.range})` : ''
protected onAdd() {
this.editing.set(false)
this.form.reset()
this.dialog.set(true)
this.dialogs
.open(DEVICES_ADD, {
label: 'Add device',
data: { subnets: this.subnets },
})
.subscribe()
}
protected onEdit(device: MappedDevice) {
this.editing.set(true)
this.form.reset(device)
this.dialog.set(true)
this.dialogs
.open(DEVICES_ADD, {
label: 'Rename device',
data: { device, subnets: this.subnets },
})
.subscribe()
}
protected onSubnet(subnet: MappedSubnet) {
const ipNet = new IpNet(subnet.range)
const used = Object.keys(subnet.clients).map(ip =>
new utils.IpAddress(ip).octets.at(3),
)
for (let i = 2; i < totalHosts(ipNet.prefix); i++) {
if (!used.includes(i)) {
return this.form.controls.ip.setValue(
[...ipNet.octets.slice(0, 3), i].join('.'),
)
}
}
// @TODO not working
this.form.controls.subnet.setErrors({ noHosts: 'No hosts available' })
this.form.controls.ip.disable()
}
async onConfig(device: MappedDevice) {
async onConfig({ subnet, ip }: MappedDevice) {
const loader = this.loading.open().subscribe()
try {
const config = await this.api.showDeviceConfig({
subnet: device.subnet.range,
ip: device.ip,
})
const data = await this.api.showDeviceConfig({ subnet: subnet.range, ip })
this.config.set(config)
this.showConfig.set(true)
this.dialogs.open(DEVICES_CONFIG, { data }).subscribe()
} catch (e) {
console.log(e)
} finally {
loader.unsubscribe()
this.dialog.set(false)
}
}
protected async onSave() {
if (this.form.invalid) {
tuiMarkControlAsTouchedAndValidate(this.form)
return
}
const loader = this.loading.open().subscribe()
const { name, subnet, ip } = this.form.getRawValue()
const toSave = {
name,
subnet: subnet.range,
ip,
}
try {
this.editing()
? await this.api.editDevice(toSave)
: await this.api.addDevice(toSave)
} catch (e) {
console.error(e)
} finally {
loader.unsubscribe()
this.dialog.set(false)
}
}
protected onDelete(device: MappedDevice): void {
protected onDelete({ subnet, ip }: MappedDevice): void {
this.dialogs
.open(TUI_CONFIRM, { label: 'Are you sure?' })
.pipe(filter(Boolean))
.subscribe(async () => {
const loader = this.loading.open().subscribe()
try {
await this.api.deleteDevice({
subnet: device.subnet.range,
ip: device.ip,
})
await this.api.deleteDevice({ subnet: subnet.range, ip })
} catch (e) {
console.log(e)
} finally {
loader.unsubscribe()
this.dialog.set(false)
}
})
}
}
type MappedSubnet = {
range: string
name: string
clients: WgServer['subnets']['']['clients']
}
type MappedDevice = {
subnet: {
name: string
range: string
}
ip: string
name: string
}
function totalHosts(prefix: number) {
// Handle special cases per RFC 3021
if (prefix === 31) return 4 // point-to-point, 2 usable addresses
if (prefix === 32) return 3 // single host, 1 usable address
return Math.pow(2, 32 - prefix)
}

View File

@@ -0,0 +1,52 @@
import { Signal } from '@angular/core'
import { AbstractControl } from '@angular/forms'
import { utils } from '@start9labs/start-sdk'
import { IpNet } from '@start9labs/start-sdk/util'
import { WgServer } from 'src/app/services/patch-db/data-model'
export interface MappedDevice {
readonly subnet: {
readonly name: string
readonly range: string
}
readonly ip: string
readonly name: string
}
export interface MappedSubnet {
readonly range: string
readonly name: string
readonly clients: WgServer['subnets']['']['clients']
}
export interface DeviceData {
readonly subnets: Signal<readonly MappedSubnet[]>
readonly device?: MappedDevice
}
export function subnetValidator({ value }: AbstractControl<MappedSubnet>) {
return value && getIp(value) ? null : { noHosts: 'No hosts available' }
}
export function getIp({ clients, range }: MappedSubnet) {
const { prefix, octets } = new IpNet(range)
const used = Object.keys(clients).map(ip =>
new utils.IpAddress(ip).octets.at(3),
)
for (let i = 2; i < totalHosts(prefix); i++) {
if (!used.includes(i)) {
return [...octets.slice(0, 3), i].join('.')
}
}
return ''
}
function totalHosts(prefix: number) {
// Handle special cases per RFC 3021
if (prefix === 31) return 4 // point-to-point, 2 usable addresses
if (prefix === 32) return 3 // single host, 1 usable address
return Math.pow(2, 32 - prefix)
}

View File

@@ -0,0 +1,176 @@
import { AsyncPipe } from '@angular/common'
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import {
NonNullableFormBuilder,
ReactiveFormsModule,
Validators,
} from '@angular/forms'
import { LoadingService } from '@start9labs/shared'
import {
TUI_IS_MOBILE,
tuiMarkControlAsTouchedAndValidate,
} from '@taiga-ui/cdk'
import {
TuiButton,
TuiDialogContext,
TuiError,
TuiNumberFormat,
TuiTextfield,
} from '@taiga-ui/core'
import {
TuiChevron,
TuiDataListWrapper,
TuiFieldErrorPipe,
TuiInputNumber,
TuiSelect,
} from '@taiga-ui/kit'
import { TuiForm } from '@taiga-ui/layout'
import { injectContext, PolymorpheusComponent } from '@taiga-ui/polymorpheus'
import { ApiService } from 'src/app/services/api/api.service'
import { MappedDevice, PortForwardsData } from './utils'
@Component({
template: `
<form tuiForm [formGroup]="form">
<tui-textfield tuiChevron>
<label tuiLabel>External IP</label>
@if (mobile) {
<select
tuiSelect
formControlName="externalip"
[items]="context.data.ips()"
></select>
} @else {
<input tuiSelect formControlName="externalip" />
}
@if (!mobile) {
<tui-data-list-wrapper
*tuiTextfieldDropdown
new
[items]="context.data.ips()"
/>
}
</tui-textfield>
<tui-error
formControlName="externalip"
[error]="[] | tuiFieldError | async"
/>
<tui-textfield>
<label tuiLabel>External Port</label>
<input
tuiInputNumber
formControlName="externalport"
[min]="0"
[max]="65535"
[tuiNumberFormat]="{ thousandSeparator: '' }"
/>
</tui-textfield>
<tui-error
formControlName="externalport"
[error]="[] | tuiFieldError | async"
/>
<tui-textfield tuiChevron [stringify]="stringify">
<label tuiLabel>Device</label>
@if (mobile) {
<select
tuiSelect
formControlName="device"
[items]="context.data.devices()"
></select>
} @else {
<input tuiSelect formControlName="device" />
}
@if (!mobile) {
<tui-data-list-wrapper
*tuiTextfieldDropdown
new
[items]="context.data.devices()"
/>
}
</tui-textfield>
<tui-error
formControlName="device"
[error]="[] | tuiFieldError | async"
/>
<tui-textfield>
<label tuiLabel>Internal Port</label>
<input
tuiInputNumber
formControlName="internalport"
[min]="0"
[max]="65535"
[tuiNumberFormat]="{ thousandSeparator: '' }"
/>
</tui-textfield>
<tui-error
formControlName="internalport"
[error]="[] | tuiFieldError | async"
/>
<footer>
<button tuiButton [disabled]="form.invalid" (click)="onSave()">
Save
</button>
</footer>
</form>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
AsyncPipe,
ReactiveFormsModule,
TuiButton,
TuiChevron,
TuiDataListWrapper,
TuiError,
TuiInputNumber,
TuiNumberFormat,
TuiFieldErrorPipe,
TuiTextfield,
TuiSelect,
TuiForm,
],
})
export class PortForwardsAdd {
private readonly api = inject(ApiService)
private readonly loading = inject(LoadingService)
protected readonly mobile = inject(TUI_IS_MOBILE)
protected readonly context =
injectContext<TuiDialogContext<void, PortForwardsData>>()
protected readonly form = inject(NonNullableFormBuilder).group({
externalip: ['', Validators.required],
externalport: [null as number | null, Validators.required],
device: [null as MappedDevice | null, Validators.required],
internalport: [null as number | null, Validators.required],
})
protected readonly stringify = ({ ip, name }: MappedDevice) =>
ip ? `${name} (${ip})` : ''
protected async onSave() {
if (this.form.invalid) {
tuiMarkControlAsTouchedAndValidate(this.form)
return
}
const loader = this.loading.open().subscribe()
const { externalip, externalport, device, internalport } =
this.form.getRawValue()
try {
await this.api.addForward({
source: `${externalip}:${externalport}`,
target: `${device?.ip}:${internalport}`,
})
} catch (e) {
console.error(e)
} finally {
loader.unsubscribe()
this.context.$implicit.complete()
}
}
}
export const PORT_FORWARDS_ADD = new PolymorpheusComponent(PortForwardsAdd)

View File

@@ -1,44 +1,25 @@
import { AsyncPipe } from '@angular/common'
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
signal,
Signal,
} from '@angular/core'
import { toSignal } from '@angular/core/rxjs-interop'
import {
NonNullableFormBuilder,
ReactiveFormsModule,
Validators,
} from '@angular/forms'
import { ReactiveFormsModule } from '@angular/forms'
import { LoadingService } from '@start9labs/shared'
import { utils } from '@start9labs/start-sdk'
import {
TUI_IS_MOBILE,
tuiMarkControlAsTouchedAndValidate,
TuiStringHandler,
} from '@taiga-ui/cdk'
import {
TuiButton,
TuiError,
TuiNumberFormat,
TuiTextfield,
} from '@taiga-ui/core'
import { TuiDialog, TuiDialogService } from '@taiga-ui/experimental'
import {
TUI_CONFIRM,
TuiChevron,
TuiDataListWrapper,
TuiFieldErrorPipe,
TuiInputNumber,
TuiSelect,
} from '@taiga-ui/kit'
import { TuiForm } from '@taiga-ui/layout'
import { TuiButton } from '@taiga-ui/core'
import { TuiDialogService } from '@taiga-ui/experimental'
import { TUI_CONFIRM } from '@taiga-ui/kit'
import { PatchDB } from 'patch-db-client'
import { combineLatest, filter, map, Observable } from 'rxjs'
import { filter, map } from 'rxjs'
import { PORT_FORWARDS_ADD } from 'src/app/routes/home/routes/port-forwards/add'
import { ApiService } from 'src/app/services/api/api.service'
import { TunnelData } from 'src/app/services/patch-db/data-model'
import { MappedDevice, MappedForward } from './utils'
@Component({
template: `
<table class="g-table">
@@ -77,234 +58,81 @@ import { TunnelData } from 'src/app/services/patch-db/data-model'
}
</tbody>
</table>
<ng-template
[tuiDialogOptions]="{ label: 'Add port forward' }"
[(tuiDialog)]="dialog"
>
<form tuiForm [formGroup]="form">
<tui-textfield tuiChevron>
<label tuiLabel>External IP</label>
@if (mobile) {
<select
tuiSelect
formControlName="externalip"
[items]="ips()"
></select>
} @else {
<input tuiSelect formControlName="externalip" />
}
@if (!mobile) {
<tui-data-list-wrapper *tuiTextfieldDropdown new [items]="ips()" />
}
</tui-textfield>
<tui-error
formControlName="externalip"
[error]="[] | tuiFieldError | async"
/>
<tui-textfield>
<label tuiLabel>External Port</label>
<input
tuiInputNumber
[tuiNumberFormat]="{ thousandSeparator: '' }"
formControlName="externalport"
/>
</tui-textfield>
<tui-error
formControlName="externalport"
[error]="[] | tuiFieldError | async"
/>
<tui-textfield tuiChevron [stringify]="deviceDisplay">
<label tuiLabel>Device</label>
@if (mobile) {
<select
tuiSelect
formControlName="device"
[items]="devices()"
></select>
} @else {
<input tuiSelect formControlName="device" />
}
@if (!mobile) {
<tui-data-list-wrapper
*tuiTextfieldDropdown
new
[items]="devices()"
/>
}
</tui-textfield>
<tui-error
formControlName="device"
[error]="[] | tuiFieldError | async"
/>
<tui-textfield>
<label tuiLabel>Internal Port</label>
<input
tuiInputNumber
[tuiNumberFormat]="{ thousandSeparator: '' }"
formControlName="internalport"
/>
</tui-textfield>
<tui-error
formControlName="internalport"
[error]="[] | tuiFieldError | async"
/>
<footer>
<button tuiButton [disabled]="form.invalid" (click)="onSave()">
Save
</button>
</footer>
</form>
</ng-template>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
AsyncPipe,
ReactiveFormsModule,
TuiButton,
TuiTextfield,
TuiDialog,
TuiForm,
TuiError,
TuiFieldErrorPipe,
TuiChevron,
TuiSelect,
TuiDataListWrapper,
TuiInputNumber,
TuiNumberFormat,
],
imports: [ReactiveFormsModule, TuiButton],
})
export default class PortForwards {
private readonly dialogs = inject(TuiDialogService)
private readonly api = inject(ApiService)
private readonly loading = inject(LoadingService)
private readonly patch = inject<PatchDB<TunnelData>>(PatchDB)
protected readonly mobile = inject(TUI_IS_MOBILE)
protected readonly form = inject(NonNullableFormBuilder).group({
externalip: ['', Validators.required],
externalport: [
null,
[Validators.required, Validators.min(0), Validators.max(65535)],
],
device: [{} as MappedDevice, Validators.required],
internalport: [
null,
[Validators.required, Validators.min(0), Validators.max(65535)],
],
})
protected readonly dialog = signal(false)
protected readonly ips = toSignal(
private readonly portForwards = toSignal(this.patch.watch$('portForwards'))
private readonly ips = toSignal(
this.patch.watch$('gateways').pipe(
map(g =>
Object.values(g)
.filter(
val =>
val.public ??
val.ipInfo?.subnets.some(s => new utils.IpNet(s).isPublic()),
.flatMap(
val => val.ipInfo?.subnets.map(s => new utils.IpNet(s)) || [],
)
.map(val => val.ipInfo!.wanIp),
.filter(s => s.isIpv4() && s.isPublic())
.map(s => s.address),
),
),
{ initialValue: [] },
)
protected readonly devices$: Observable<MappedDevice[]> = this.patch
.watch$('wg', 'subnets')
.pipe(
map(s =>
Object.values(s).flatMap(({ clients }) =>
Object.entries(clients).map(([ip, { name }]) => ({
ip,
name,
})),
private readonly devices: Signal<MappedDevice[]> = toSignal(
this.patch
.watch$('wg', 'subnets')
.pipe(
map(subnets =>
Object.values(subnets).flatMap(({ clients }) =>
Object.entries(clients).map(([ip, { name }]) => ({ ip, name })),
),
),
),
)
protected readonly devices = toSignal(this.devices$, {
initialValue: [],
})
protected readonly forwards = toSignal<MappedForward[], []>(
combineLatest([this.devices$, this.patch.watch$('portForwards')]).pipe(
map(([devices, forwards]) =>
Object.entries(forwards).map(([source, target]) => {
const sourceSplit = source.split(':')
const targetSplit = target.split(':')
return {
externalip: sourceSplit[0]!,
externalport: sourceSplit[1]!,
device: devices.find(d => d.ip === targetSplit[0])!,
internalport: targetSplit[1]!,
}
}),
),
),
{ initialValue: [] },
)
protected readonly deviceDisplay: TuiStringHandler<MappedDevice> = device =>
device.ip ? `${device.name} (${device.ip})` : ''
protected readonly forwards = computed(() =>
Object.entries(this.portForwards() || {}).map(([source, target]) => {
const sourceSplit = source.split(':')
const targetSplit = target.split(':')
return {
externalip: sourceSplit[0]!,
externalport: sourceSplit[1]!,
device: this.devices().find(d => d.ip === targetSplit[0])!,
internalport: targetSplit[1]!,
}
}),
)
protected onAdd(): void {
this.form.reset()
this.dialog.set(true)
}
protected async onSave() {
if (this.form.invalid) {
tuiMarkControlAsTouchedAndValidate(this.form)
return
}
const loader = this.loading.open().subscribe()
const { externalip, externalport, device, internalport } =
this.form.getRawValue()
try {
await this.api.addForward({
source: `${externalip}:${externalport}`,
target: `${device.ip}:${internalport}`,
this.dialogs
.open(PORT_FORWARDS_ADD, {
label: 'Add port forward',
data: { ips: this.ips, devices: this.devices },
})
} catch (e) {
console.error(e)
} finally {
loader.unsubscribe()
this.dialog.set(false)
}
.subscribe()
}
protected onDelete(forward: MappedForward): void {
protected onDelete({ externalip, externalport }: MappedForward): void {
this.dialogs
.open(TUI_CONFIRM, { label: 'Are you sure?' })
.pipe(filter(Boolean))
.subscribe(async () => {
const loader = this.loading.open().subscribe()
const source = `${externalip}:${externalport}`
try {
await this.api.deleteForward({
source: `${forward.externalip}:${forward.externalport}`,
})
await this.api.deleteForward({ source })
} catch (e) {
console.log(e)
} finally {
loader.unsubscribe()
this.dialog.set(false)
}
})
}
}
type MappedDevice = {
ip: string
name: string
}
type MappedForward = {
externalip: string
externalport: string
device: MappedDevice
internalport: string
}

View File

@@ -0,0 +1,18 @@
import { Signal } from '@angular/core'
export interface MappedDevice {
readonly ip: string
readonly name: string
}
export interface MappedForward {
readonly externalip: string
readonly externalport: string
readonly device: MappedDevice
readonly internalport: string
}
export interface PortForwardsData {
readonly ips: Signal<any>
readonly devices: Signal<readonly MappedDevice[]>
}

View File

@@ -5,27 +5,36 @@ import {
inject,
signal,
} from '@angular/core'
import { toSignal } from '@angular/core/rxjs-interop'
import {
NonNullableFormBuilder,
ReactiveFormsModule,
ValidatorFn,
Validators,
} from '@angular/forms'
import { DialogService, ErrorService } from '@start9labs/shared'
import { TuiResponsiveDialogService } from '@taiga-ui/addon-mobile'
import { tuiMarkControlAsTouchedAndValidate, TuiValidator } from '@taiga-ui/cdk'
import {
TuiAlertService,
TuiAppearance,
TuiButton,
TuiError,
TuiTextfield,
TuiTitle,
} from '@taiga-ui/core'
import { TuiButtonLoading, TuiFieldErrorPipe } from '@taiga-ui/kit'
import {
TuiButtonLoading,
TuiFieldErrorPipe,
tuiValidationErrorsProvider,
} from '@taiga-ui/kit'
import { TuiCard, TuiForm, TuiHeader } from '@taiga-ui/layout'
import { map } from 'rxjs'
import { ApiService } from 'src/app/services/api/api.service'
@Component({
template: `
<form tuiCardLarge tuiForm [formGroup]="form">
<form tuiCardLarge tuiAppearance="neutral" tuiForm [formGroup]="form">
<header tuiHeader>
<h2 tuiTitle>
Settings
@@ -42,29 +51,29 @@ import { ApiService } from 'src/app/services/api/api.service'
/>
<tui-textfield>
<label tuiLabel>Confirm new password</label>
<input formControlName="confirm" tuiTextfield />
<input
formControlName="confirm"
tuiTextfield
[tuiValidator]="matchValidator()"
/>
</tui-textfield>
<tui-error
formControlName="confirm"
[error]="[] | tuiFieldError | async"
/>
<footer>
<button
tuiButton
(click)="onSave()"
[disabled]="form.invalid"
[loading]="loading()"
>
Save
</button>
<button tuiButton (click)="onSave()" [loading]="loading()">Save</button>
</footer>
</form>
`,
styles: `
form {
background: var(--tui-background-neutral-1);
}
`,
providers: [
tuiValidationErrorsProvider({
required: 'This field is required',
minlength: 'Password must be at least 8 characters',
maxlength: 'Password cannot exceed 64 characters',
match: 'Passwords do not match',
}),
],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
ReactiveFormsModule,
@@ -78,6 +87,8 @@ import { ApiService } from 'src/app/services/api/api.service'
TuiFieldErrorPipe,
TuiButton,
TuiButtonLoading,
TuiValidator,
TuiAppearance,
],
})
export default class Settings {
@@ -86,7 +97,6 @@ export default class Settings {
private readonly errorService = inject(ErrorService)
protected readonly loading = signal(false)
protected readonly form = inject(NonNullableFormBuilder).group({
password: [
'',
@@ -98,26 +108,30 @@ export default class Settings {
],
})
protected async onSave() {
const { password, confirm } = this.form.getRawValue()
protected readonly matchValidator = toSignal(
this.form.controls.password.valueChanges.pipe(
map(
(password): ValidatorFn =>
({ value }) =>
value === password ? null : { match: true },
),
),
{ initialValue: Validators.nullValidator },
)
protected async onSave() {
if (this.form.invalid) {
tuiMarkControlAsTouchedAndValidate(this.form)
if (password !== confirm) {
// @TODO not working
this.form.controls.confirm.setErrors({
notEqual: 'New passwords do not match',
})
return
}
this.loading.set(true)
try {
await this.api.setPassword({ password })
await this.api.setPassword({ password: this.form.getRawValue().password })
this.alerts
.open('Password changed', {
label: 'Success',
appearance: 'positive',
})
.open('Password changed', { label: 'Success', appearance: 'positive' })
.subscribe()
this.form.reset()
} catch (e: any) {

View File

@@ -0,0 +1,102 @@
import { AsyncPipe } from '@angular/common'
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import {
NonNullableFormBuilder,
ReactiveFormsModule,
Validators,
} from '@angular/forms'
import { LoadingService } from '@start9labs/shared'
import { TuiAutoFocus, tuiMarkControlAsTouchedAndValidate } from '@taiga-ui/cdk'
import {
TuiButton,
TuiDialogContext,
TuiError,
TuiTextfield,
} from '@taiga-ui/core'
import { TuiFieldErrorPipe } from '@taiga-ui/kit'
import { TuiForm } from '@taiga-ui/layout'
import { injectContext, PolymorpheusComponent } from '@taiga-ui/polymorpheus'
import { ApiService } from 'src/app/services/api/api.service'
@Component({
template: `
<form tuiForm [formGroup]="form">
<tui-textfield>
<label tuiLabel>Name</label>
<input tuiTextfield tuiAutoFocus formControlName="name" />
</tui-textfield>
<tui-error formControlName="name" [error]="[] | tuiFieldError | async" />
@if (!context.data.name) {
<tui-textfield>
<label tuiLabel>IP Range</label>
<input tuiTextfield formControlName="subnet" />
</tui-textfield>
<tui-error
formControlName="subnet"
[error]="[] | tuiFieldError | async"
/>
}
<footer>
<button tuiButton (click)="onSave()">Save</button>
</footer>
</form>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
AsyncPipe,
ReactiveFormsModule,
TuiAutoFocus,
TuiButton,
TuiError,
TuiFieldErrorPipe,
TuiForm,
TuiTextfield,
],
})
export class SubnetsAdd {
private readonly api = inject(ApiService)
private readonly loading = inject(LoadingService)
protected readonly context = injectContext<TuiDialogContext<void, Data>>()
protected readonly form = inject(NonNullableFormBuilder).group({
name: [this.context.data.name, Validators.required],
subnet: [
this.context.data.subnet,
[
Validators.required,
Validators.pattern(
'^(?:(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)\\.){3}(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)/(?:[12]?\\d|3[0-2])$',
),
],
],
})
protected async onSave() {
if (this.form.invalid) {
tuiMarkControlAsTouchedAndValidate(this.form)
return
}
const loader = this.loading.open().subscribe()
const value = this.form.getRawValue()
try {
this.context.data.name
? await this.api.editSubnet(value)
: await this.api.addSubnet(value)
} catch (e) {
console.log(e)
} finally {
loader.unsubscribe()
this.context.$implicit.complete()
}
}
}
export const SUBNETS_ADD = new PolymorpheusComponent(SubnetsAdd)
interface Data {
name: string
subnet: string
}

View File

@@ -1,35 +1,22 @@
import { AsyncPipe } from '@angular/common'
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
signal,
} from '@angular/core'
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { toSignal } from '@angular/core/rxjs-interop'
import {
NonNullableFormBuilder,
ReactiveFormsModule,
Validators,
} from '@angular/forms'
import { LoadingService } from '@start9labs/shared'
import { utils } from '@start9labs/start-sdk'
import { TuiAutoFocus, tuiMarkControlAsTouchedAndValidate } from '@taiga-ui/cdk'
import {
TuiButton,
TuiDataList,
TuiDropdown,
TuiError,
TuiTextfield,
} from '@taiga-ui/core'
import { TuiDialog, TuiDialogService } from '@taiga-ui/experimental'
import { TUI_CONFIRM, TuiFieldErrorPipe } from '@taiga-ui/kit'
import { TuiForm } from '@taiga-ui/layout'
import { TuiDialogService } from '@taiga-ui/experimental'
import { TUI_CONFIRM } from '@taiga-ui/kit'
import { PatchDB } from 'patch-db-client'
import { filter, map } from 'rxjs'
import { ApiService } from 'src/app/services/api/api.service'
import { TunnelData } from 'src/app/services/patch-db/data-model'
import { SUBNETS_ADD } from './add'
@Component({
template: `
<table class="g-table">
@@ -83,84 +70,67 @@ import { TunnelData } from 'src/app/services/patch-db/data-model'
}
</tbody>
</table>
<ng-template [tuiDialogOptions]="{ label: label() }" [(tuiDialog)]="dialog">
<form tuiForm [formGroup]="form">
<tui-textfield>
<label tuiLabel>Name</label>
<input tuiTextfield tuiAutoFocus formControlName="name" />
</tui-textfield>
<tui-error
formControlName="name"
[error]="[] | tuiFieldError | async"
/>
@if (!editing()) {
<tui-textfield>
<label tuiLabel>IP Range</label>
<input tuiTextfield formControlName="subnet" />
</tui-textfield>
<tui-error
formControlName="subnet"
[error]="[] | tuiFieldError | async"
/>
}
<footer>
<button tuiButton (click)="onSave()" [disabled]="form.invalid">
Save
</button>
</footer>
</form>
</ng-template>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
AsyncPipe,
ReactiveFormsModule,
TuiButton,
TuiDropdown,
TuiDataList,
TuiTextfield,
TuiDialog,
TuiForm,
TuiError,
TuiFieldErrorPipe,
TuiAutoFocus,
],
imports: [TuiButton, TuiDropdown, TuiDataList, TuiTextfield],
})
export default class Subnets {
private readonly dialogs = inject(TuiDialogService)
private readonly api = inject(ApiService)
private readonly loading = inject(LoadingService)
private readonly patch = inject<PatchDB<TunnelData>>(PatchDB)
protected readonly form = inject(NonNullableFormBuilder).group({
name: ['', Validators.required],
subnet: [
'',
[
Validators.required,
Validators.pattern(
'^(?:(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)\\.){3}(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)/(?:[12]?\\d|3[0-2])$',
),
],
],
})
protected readonly dialog = signal(false)
protected readonly editing = signal(false)
protected readonly subnets = toSignal<MappedSubnet[], []>(
this.patch.watch$('wg', 'subnets').pipe(
map(s =>
Object.entries(s).map(([range, info]) => ({
range,
name: info.name,
hasClients: !!Object.keys(info.clients).length,
})),
inject<PatchDB<TunnelData>>(PatchDB)
.watch$('wg', 'subnets')
.pipe(
map(s =>
Object.entries(s).map(([range, info]) => ({
range,
name: info.name,
hasClients: !!Object.keys(info.clients).length,
})),
),
),
),
{ initialValue: [] },
)
protected readonly next = computed(() => {
protected onAdd(): void {
this.dialogs
.open(SUBNETS_ADD, {
label: 'Add Subnet',
data: { subnet: this.getNext() },
})
.subscribe()
}
protected onEdit({ range, name }: MappedSubnet): void {
this.dialogs
.open(SUBNETS_ADD, {
label: 'Rename Subnet',
data: { subnet: range, name },
})
.subscribe()
}
protected onDelete(index: number): void {
this.dialogs
.open(TUI_CONFIRM, { label: 'Are you sure?' })
.pipe(filter(Boolean))
.subscribe(async () => {
const subnet = this.subnets()[index]?.range || ''
const loader = this.loading.open().subscribe()
try {
await this.api.deleteSubnet({ subnet })
} catch (e) {
console.log(e)
} finally {
loader.unsubscribe()
}
})
}
private getNext(): string {
const used = this.subnets().map(s => new utils.IpNet(s.range).octets.at(2))
for (let i = 0; i < 256; i++) {
@@ -171,63 +141,6 @@ export default class Subnets {
// No recommendation if /24 subnets are used
return ''
})
protected readonly label = computed(() =>
this.editing() ? 'Rename Subnet' : 'Add Subnet',
)
protected onAdd(): void {
this.editing.set(false)
this.form.reset({ subnet: this.next() })
this.dialog.set(true)
}
protected onEdit(subnet: MappedSubnet): void {
this.editing.set(true)
this.form.reset({ subnet: subnet.range, name: subnet.name })
this.dialog.set(true)
}
protected async onSave() {
if (this.form.invalid) {
tuiMarkControlAsTouchedAndValidate(this.form)
return
}
const loader = this.loading.open().subscribe()
const value = this.form.getRawValue()
try {
this.editing()
? await this.api.editSubnet(value)
: await this.api.addSubnet(value)
} catch (e) {
console.log(e)
} finally {
loader.unsubscribe()
this.dialog.set(false)
}
}
protected onDelete(index: number): void {
this.dialogs
.open(TUI_CONFIRM, { label: 'Are you sure?' })
.pipe(filter(Boolean))
.subscribe(async () => {
const subnet = this.subnets().at(index)?.range
if (!subnet) return
const loader = this.loading.open().subscribe()
try {
await this.api.deleteSubnet({ subnet })
} catch (e) {
console.log(e)
} finally {
loader.unsubscribe()
this.dialog.set(false)
}
})
}
}

View File

@@ -173,7 +173,7 @@ export class MockApiService extends ApiService {
const patch: AddOperation<string>[] = [
{
op: PatchOp.ADD,
path: `/port_forwards/${params.source}`,
path: `/portForwards/${params.source}`,
value: params.target,
},
]
@@ -188,7 +188,7 @@ export class MockApiService extends ApiService {
const patch: RemoveOperation[] = [
{
op: PatchOp.REMOVE,
path: `/port_forwards/${params.source}`,
path: `/portForwards/${params.source}`,
},
]
this.mockRevision(patch)

View File

@@ -45,14 +45,14 @@ export const mockTunnelData: TunnelData = {
gateways: {
eth0: {
name: null,
public: true,
public: null,
secure: null,
ipInfo: {
name: 'Wired Connection 1',
scopeId: 1,
deviceType: 'ethernet',
subnets: ['10.59.0.0/24'],
wanIp: '203.0.113.45',
subnets: ['69.1.1.42/24'],
wanIp: null,
ntpServers: [],
lanIp: ['10.59.0.1'],
dnsServers: [],

View File

@@ -172,7 +172,7 @@ export class InterfaceTorDomainsComponent {
? await this.api.addTorKey({ key })
: await this.api.generateTorKey({})
if (this.interface.packageId) {
if (this.interface.packageId()) {
await this.api.pkgAddOnion({
onion,
package: this.interface.packageId(),