diff --git a/web/projects/start-tunnel/src/app/routes/home/routes/devices/index.ts b/web/projects/start-tunnel/src/app/routes/home/routes/devices/index.ts index d348f793f..789be5fb6 100644 --- a/web/projects/start-tunnel/src/app/routes/home/routes/devices/index.ts +++ b/web/projects/start-tunnel/src/app/routes/home/routes/devices/index.ts @@ -4,7 +4,6 @@ import { Component, computed, inject, - Signal, signal, } from '@angular/core' import { toSignal } from '@angular/core/rxjs-interop' @@ -14,6 +13,8 @@ import { 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, @@ -137,6 +138,7 @@ import { TunnelData, WgServer } from 'src/app/services/patch-db/data-model' *tuiTextfieldDropdown new [items]="subnets()" + (itemClick)="onSubnet($event)" /> } @@ -233,16 +235,21 @@ export default class Devices { private readonly api = inject(ApiService) private readonly loading = inject(LoadingService) private readonly patch = inject>(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 href = computed( - () => `data:text/plain;charset=utf-8,${encodeURIComponent(this.config())}`, - ) - protected readonly editing = signal(false) + protected readonly next = signal('') protected readonly subnets = toSignal( this.patch.watch$('wg', 'subnets').pipe( @@ -257,6 +264,9 @@ export default class Devices { { 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 }]) => ({ @@ -269,19 +279,12 @@ export default class Devices { })), ), ) - - protected subnetDisplay: TuiStringHandler = subnet => - subnet.range ? `${subnet.name} (${subnet.range})` : '' - protected readonly label = computed(() => this.editing() ? 'Rename device' : 'Add device', ) - 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], - }) + + protected subnetDisplay: TuiStringHandler = subnet => + subnet.range ? `${subnet.name} (${subnet.range})` : '' protected onAdd() { this.editing.set(false) @@ -295,6 +298,25 @@ export default class Devices { this.dialog.set(true) } + 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) { const loader = this.loading.open().subscribe() try { @@ -375,3 +397,11 @@ type MappedDevice = { 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) +} diff --git a/web/projects/start-tunnel/src/app/routes/home/routes/port-forwards/index.ts b/web/projects/start-tunnel/src/app/routes/home/routes/port-forwards/index.ts index 4d483c7e4..62444c592 100644 --- a/web/projects/start-tunnel/src/app/routes/home/routes/port-forwards/index.ts +++ b/web/projects/start-tunnel/src/app/routes/home/routes/port-forwards/index.ts @@ -2,7 +2,6 @@ import { AsyncPipe } from '@angular/common' import { ChangeDetectionStrategy, Component, - computed, inject, signal, } from '@angular/core' @@ -13,18 +12,25 @@ import { Validators, } 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, TuiTextfield } from '@taiga-ui/core' +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' @@ -82,13 +88,13 @@ import { TunnelData } from 'src/app/services/patch-db/data-model' } @else { } @if (!mobile) { - + } - + - + -
+
+ +
`, @@ -151,6 +169,8 @@ import { TunnelData } from 'src/app/services/patch-db/data-model' TuiChevron, TuiSelect, TuiDataListWrapper, + TuiInputNumber, + TuiNumberFormat, ], }) export default class PortForwards { @@ -158,10 +178,36 @@ export default class PortForwards { private readonly api = inject(ApiService) private readonly loading = inject(LoadingService) private readonly patch = inject>(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 = ['69.1.1.42'] + protected 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()), + ) + .map(val => val.ipInfo!.wanIp), + ), + ), + { initialValue: [] }, + ) protected readonly devices$: Observable = this.patch .watch$('wg', 'subnets') @@ -202,14 +248,6 @@ export default class PortForwards { protected readonly deviceDisplay: TuiStringHandler = device => device.ip ? `${device.name} (${device.ip})` : '' - protected readonly mobile = inject(TUI_IS_MOBILE) - protected readonly form = inject(NonNullableFormBuilder).group({ - externalip: ['', Validators.required], - externalport: ['', Validators.required], - device: [{} as MappedDevice, Validators.required], - internalport: ['', Validators.required], - }) - protected onAdd(): void { this.form.reset() this.dialog.set(true) diff --git a/web/projects/start-tunnel/src/app/routes/home/routes/settings/index.ts b/web/projects/start-tunnel/src/app/routes/home/routes/settings/index.ts index 9a043e16b..9dc3f6c3c 100644 --- a/web/projects/start-tunnel/src/app/routes/home/routes/settings/index.ts +++ b/web/projects/start-tunnel/src/app/routes/home/routes/settings/index.ts @@ -102,6 +102,7 @@ export default class Settings { const { password, confirm } = this.form.getRawValue() if (password !== confirm) { + // @TODO not working this.form.controls.confirm.setErrors({ notEqual: 'New passwords do not match', }) diff --git a/web/projects/start-tunnel/src/app/routes/home/routes/subnets/index.ts b/web/projects/start-tunnel/src/app/routes/home/routes/subnets/index.ts index c632c025a..f8729526c 100644 --- a/web/projects/start-tunnel/src/app/routes/home/routes/subnets/index.ts +++ b/web/projects/start-tunnel/src/app/routes/home/routes/subnets/index.ts @@ -13,6 +13,7 @@ import { Validators, } from '@angular/forms' import { LoadingService } from '@start9labs/shared' +import { utils } from '@start9labs/start-sdk' import { TuiAutoFocus, tuiMarkControlAsTouchedAndValidate } from '@taiga-ui/cdk' import { TuiButton, @@ -25,7 +26,7 @@ import { TuiDialog, TuiDialogService } from '@taiga-ui/experimental' import { TUI_CONFIRM, TuiFieldErrorPipe } from '@taiga-ui/kit' import { TuiForm } from '@taiga-ui/layout' import { PatchDB } from 'patch-db-client' -import { filter, map, tap } from 'rxjs' +import { filter, map } from 'rxjs' import { ApiService } from 'src/app/services/api/api.service' import { TunnelData } from 'src/app/services/patch-db/data-model' @@ -130,6 +131,18 @@ export default class Subnets { private readonly api = inject(ApiService) private readonly loading = inject(LoadingService) private readonly patch = inject>(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) @@ -148,21 +161,22 @@ export default class Subnets { ) protected readonly next = computed(() => { - const last = Number( - this.subnets().at(-1)?.range.split('/')[0]?.split('.')[2] || '-1', - ) - return `10.59.${last + 1}.1/24` + const used = this.subnets().map(s => new utils.IpNet(s.range).octets.at(2)) + + for (let i = 0; i < 256; i++) { + if (!used.includes(i)) { + return `10.59.${i}.0/24` + } + } + + // No recommendation if /24 subnets are used + return '' }) protected readonly label = computed(() => this.editing() ? 'Rename Subnet' : 'Add Subnet', ) - protected readonly form = inject(NonNullableFormBuilder).group({ - name: ['', Validators.required], - subnet: ['', Validators.required], - }) - protected onAdd(): void { this.editing.set(false) this.form.reset({ subnet: this.next() }) diff --git a/web/projects/start-tunnel/src/app/services/patch-db/data-model.ts b/web/projects/start-tunnel/src/app/services/patch-db/data-model.ts index 5d227924f..9a3dc4fa2 100644 --- a/web/projects/start-tunnel/src/app/services/patch-db/data-model.ts +++ b/web/projects/start-tunnel/src/app/services/patch-db/data-model.ts @@ -1,6 +1,9 @@ +import { T } from '@start9labs/start-sdk' + export type TunnelData = { wg: WgServer portForwards: Record + gateways: Record } export type WgServer = { @@ -19,7 +22,7 @@ export type WgClient = { export const mockTunnelData: TunnelData = { wg: { subnets: { - '10.59.0.1/24': { + '10.59.0.0/24': { name: 'Family', clients: { '10.59.0.2': { @@ -39,4 +42,21 @@ export const mockTunnelData: TunnelData = { '69.1.1.42:443': '10.59.0.2:5443', '69.1.1.42:3000': '10.59.0.2:3000', }, + gateways: { + eth0: { + name: null, + public: true, + secure: null, + ipInfo: { + name: 'Wired Connection 1', + scopeId: 1, + deviceType: 'ethernet', + subnets: ['10.59.0.0/24'], + wanIp: '203.0.113.45', + ntpServers: [], + lanIp: ['10.59.0.1'], + dnsServers: [], + }, + }, + }, }