predict next subnet and ip, use wan ips, and form validation

This commit is contained in:
Matt Hill
2025-11-01 15:51:25 -06:00
parent 304f8c3a97
commit 69d0391d12
5 changed files with 146 additions and 43 deletions

View File

@@ -4,7 +4,6 @@ import {
Component, Component,
computed, computed,
inject, inject,
Signal,
signal, signal,
} from '@angular/core' } from '@angular/core'
import { toSignal } from '@angular/core/rxjs-interop' import { toSignal } from '@angular/core/rxjs-interop'
@@ -14,6 +13,8 @@ import {
Validators, Validators,
} from '@angular/forms' } from '@angular/forms'
import { LoadingService } from '@start9labs/shared' import { LoadingService } from '@start9labs/shared'
import { utils } from '@start9labs/start-sdk'
import { IpNet } from '@start9labs/start-sdk/util'
import { import {
TUI_IS_MOBILE, TUI_IS_MOBILE,
TuiAutoFocus, TuiAutoFocus,
@@ -137,6 +138,7 @@ import { TunnelData, WgServer } from 'src/app/services/patch-db/data-model'
*tuiTextfieldDropdown *tuiTextfieldDropdown
new new
[items]="subnets()" [items]="subnets()"
(itemClick)="onSubnet($event)"
/> />
} }
</tui-textfield> </tui-textfield>
@@ -233,16 +235,21 @@ export default class Devices {
private readonly api = inject(ApiService) private readonly api = inject(ApiService)
private readonly loading = inject(LoadingService) private readonly loading = inject(LoadingService)
private readonly patch = inject<PatchDB<TunnelData>>(PatchDB) 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 dialog = signal(false)
protected readonly showConfig = signal(false) protected readonly showConfig = signal(false)
protected readonly config = signal('') protected readonly config = signal('')
protected readonly href = computed(
() => `data:text/plain;charset=utf-8,${encodeURIComponent(this.config())}`,
)
protected readonly editing = signal(false) protected readonly editing = signal(false)
protected readonly next = signal('')
protected readonly subnets = toSignal<MappedSubnet[], []>( protected readonly subnets = toSignal<MappedSubnet[], []>(
this.patch.watch$('wg', 'subnets').pipe( this.patch.watch$('wg', 'subnets').pipe(
@@ -257,6 +264,9 @@ export default class Devices {
{ initialValue: [] }, { initialValue: [] },
) )
protected readonly href = computed(
() => `data:text/plain;charset=utf-8,${encodeURIComponent(this.config())}`,
)
protected readonly devices = computed(() => protected readonly devices = computed(() =>
this.subnets().flatMap(subnet => this.subnets().flatMap(subnet =>
Object.entries(subnet.clients).map(([ip, { name }]) => ({ Object.entries(subnet.clients).map(([ip, { name }]) => ({
@@ -269,19 +279,12 @@ export default class Devices {
})), })),
), ),
) )
protected subnetDisplay: TuiStringHandler<MappedSubnet> = subnet =>
subnet.range ? `${subnet.name} (${subnet.range})` : ''
protected readonly label = computed(() => protected readonly label = computed(() =>
this.editing() ? 'Rename device' : 'Add device', this.editing() ? 'Rename device' : 'Add device',
) )
protected readonly mobile = inject(TUI_IS_MOBILE)
protected readonly form = inject(NonNullableFormBuilder).group({ protected subnetDisplay: TuiStringHandler<MappedSubnet> = subnet =>
name: ['', Validators.required], subnet.range ? `${subnet.name} (${subnet.range})` : ''
subnet: [{} as MappedDevice['subnet'], Validators.required],
ip: ['', Validators.required],
})
protected onAdd() { protected onAdd() {
this.editing.set(false) this.editing.set(false)
@@ -295,6 +298,25 @@ export default class Devices {
this.dialog.set(true) 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) { async onConfig(device: MappedDevice) {
const loader = this.loading.open().subscribe() const loader = this.loading.open().subscribe()
try { try {
@@ -375,3 +397,11 @@ type MappedDevice = {
ip: string ip: string
name: 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

@@ -2,7 +2,6 @@ import { AsyncPipe } from '@angular/common'
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
Component, Component,
computed,
inject, inject,
signal, signal,
} from '@angular/core' } from '@angular/core'
@@ -13,18 +12,25 @@ import {
Validators, Validators,
} from '@angular/forms' } from '@angular/forms'
import { LoadingService } from '@start9labs/shared' import { LoadingService } from '@start9labs/shared'
import { utils } from '@start9labs/start-sdk'
import { import {
TUI_IS_MOBILE, TUI_IS_MOBILE,
tuiMarkControlAsTouchedAndValidate, tuiMarkControlAsTouchedAndValidate,
TuiStringHandler, TuiStringHandler,
} from '@taiga-ui/cdk' } 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 { TuiDialog, TuiDialogService } from '@taiga-ui/experimental'
import { import {
TUI_CONFIRM, TUI_CONFIRM,
TuiChevron, TuiChevron,
TuiDataListWrapper, TuiDataListWrapper,
TuiFieldErrorPipe, TuiFieldErrorPipe,
TuiInputNumber,
TuiSelect, TuiSelect,
} from '@taiga-ui/kit' } from '@taiga-ui/kit'
import { TuiForm } from '@taiga-ui/layout' import { TuiForm } from '@taiga-ui/layout'
@@ -82,13 +88,13 @@ import { TunnelData } from 'src/app/services/patch-db/data-model'
<select <select
tuiSelect tuiSelect
formControlName="externalip" formControlName="externalip"
[items]="ips" [items]="ips()"
></select> ></select>
} @else { } @else {
<input tuiSelect formControlName="externalip" /> <input tuiSelect formControlName="externalip" />
} }
@if (!mobile) { @if (!mobile) {
<tui-data-list-wrapper *tuiTextfieldDropdown new [items]="ips" /> <tui-data-list-wrapper *tuiTextfieldDropdown new [items]="ips()" />
} }
</tui-textfield> </tui-textfield>
<tui-error <tui-error
@@ -97,7 +103,11 @@ import { TunnelData } from 'src/app/services/patch-db/data-model'
/> />
<tui-textfield> <tui-textfield>
<label tuiLabel>External Port</label> <label tuiLabel>External Port</label>
<input tuiTextfield formControlName="externalport" /> <input
tuiInputNumber
[tuiNumberFormat]="{ thousandSeparator: '' }"
formControlName="externalport"
/>
</tui-textfield> </tui-textfield>
<tui-error <tui-error
formControlName="externalport" formControlName="externalport"
@@ -128,13 +138,21 @@ import { TunnelData } from 'src/app/services/patch-db/data-model'
/> />
<tui-textfield> <tui-textfield>
<label tuiLabel>Internal Port</label> <label tuiLabel>Internal Port</label>
<input tuiTextfield formControlName="internalport" /> <input
tuiInputNumber
[tuiNumberFormat]="{ thousandSeparator: '' }"
formControlName="internalport"
/>
</tui-textfield> </tui-textfield>
<tui-error <tui-error
formControlName="internalport" formControlName="internalport"
[error]="[] | tuiFieldError | async" [error]="[] | tuiFieldError | async"
/> />
<footer><button tuiButton (click)="onSave()">Save</button></footer> <footer>
<button tuiButton [disabled]="form.invalid" (click)="onSave()">
Save
</button>
</footer>
</form> </form>
</ng-template> </ng-template>
`, `,
@@ -151,6 +169,8 @@ import { TunnelData } from 'src/app/services/patch-db/data-model'
TuiChevron, TuiChevron,
TuiSelect, TuiSelect,
TuiDataListWrapper, TuiDataListWrapper,
TuiInputNumber,
TuiNumberFormat,
], ],
}) })
export default class PortForwards { export default class PortForwards {
@@ -158,10 +178,36 @@ export default class PortForwards {
private readonly api = inject(ApiService) private readonly api = inject(ApiService)
private readonly loading = inject(LoadingService) private readonly loading = inject(LoadingService)
private readonly patch = inject<PatchDB<TunnelData>>(PatchDB) 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 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<MappedDevice[]> = this.patch protected readonly devices$: Observable<MappedDevice[]> = this.patch
.watch$('wg', 'subnets') .watch$('wg', 'subnets')
@@ -202,14 +248,6 @@ export default class PortForwards {
protected readonly deviceDisplay: TuiStringHandler<MappedDevice> = device => protected readonly deviceDisplay: TuiStringHandler<MappedDevice> = device =>
device.ip ? `${device.name} (${device.ip})` : '' 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 { protected onAdd(): void {
this.form.reset() this.form.reset()
this.dialog.set(true) this.dialog.set(true)

View File

@@ -102,6 +102,7 @@ export default class Settings {
const { password, confirm } = this.form.getRawValue() const { password, confirm } = this.form.getRawValue()
if (password !== confirm) { if (password !== confirm) {
// @TODO not working
this.form.controls.confirm.setErrors({ this.form.controls.confirm.setErrors({
notEqual: 'New passwords do not match', notEqual: 'New passwords do not match',
}) })

View File

@@ -13,6 +13,7 @@ import {
Validators, Validators,
} from '@angular/forms' } from '@angular/forms'
import { LoadingService } from '@start9labs/shared' import { LoadingService } from '@start9labs/shared'
import { utils } from '@start9labs/start-sdk'
import { TuiAutoFocus, tuiMarkControlAsTouchedAndValidate } from '@taiga-ui/cdk' import { TuiAutoFocus, tuiMarkControlAsTouchedAndValidate } from '@taiga-ui/cdk'
import { import {
TuiButton, TuiButton,
@@ -25,7 +26,7 @@ import { TuiDialog, TuiDialogService } from '@taiga-ui/experimental'
import { TUI_CONFIRM, TuiFieldErrorPipe } from '@taiga-ui/kit' import { TUI_CONFIRM, TuiFieldErrorPipe } from '@taiga-ui/kit'
import { TuiForm } from '@taiga-ui/layout' import { TuiForm } from '@taiga-ui/layout'
import { PatchDB } from 'patch-db-client' 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 { ApiService } from 'src/app/services/api/api.service'
import { TunnelData } from 'src/app/services/patch-db/data-model' 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 api = inject(ApiService)
private readonly loading = inject(LoadingService) private readonly loading = inject(LoadingService)
private readonly patch = inject<PatchDB<TunnelData>>(PatchDB) 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 dialog = signal(false)
protected readonly editing = signal(false) protected readonly editing = signal(false)
@@ -148,21 +161,22 @@ export default class Subnets {
) )
protected readonly next = computed(() => { protected readonly next = computed(() => {
const last = Number( const used = this.subnets().map(s => new utils.IpNet(s.range).octets.at(2))
this.subnets().at(-1)?.range.split('/')[0]?.split('.')[2] || '-1',
) for (let i = 0; i < 256; i++) {
return `10.59.${last + 1}.1/24` if (!used.includes(i)) {
return `10.59.${i}.0/24`
}
}
// No recommendation if /24 subnets are used
return ''
}) })
protected readonly label = computed(() => protected readonly label = computed(() =>
this.editing() ? 'Rename Subnet' : 'Add Subnet', this.editing() ? 'Rename Subnet' : 'Add Subnet',
) )
protected readonly form = inject(NonNullableFormBuilder).group({
name: ['', Validators.required],
subnet: ['', Validators.required],
})
protected onAdd(): void { protected onAdd(): void {
this.editing.set(false) this.editing.set(false)
this.form.reset({ subnet: this.next() }) this.form.reset({ subnet: this.next() })

View File

@@ -1,6 +1,9 @@
import { T } from '@start9labs/start-sdk'
export type TunnelData = { export type TunnelData = {
wg: WgServer wg: WgServer
portForwards: Record<string, string> portForwards: Record<string, string>
gateways: Record<string, T.NetworkInterfaceInfo>
} }
export type WgServer = { export type WgServer = {
@@ -19,7 +22,7 @@ export type WgClient = {
export const mockTunnelData: TunnelData = { export const mockTunnelData: TunnelData = {
wg: { wg: {
subnets: { subnets: {
'10.59.0.1/24': { '10.59.0.0/24': {
name: 'Family', name: 'Family',
clients: { clients: {
'10.59.0.2': { '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:443': '10.59.0.2:5443',
'69.1.1.42:3000': '10.59.0.2:3000', '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: [],
},
},
},
} }