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,
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)"
/>
}
</tui-textfield>
@@ -233,16 +235,21 @@ export default class Devices {
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 href = computed(
() => `data:text/plain;charset=utf-8,${encodeURIComponent(this.config())}`,
)
protected readonly editing = signal(false)
protected readonly next = signal('')
protected readonly subnets = toSignal<MappedSubnet[], []>(
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<MappedSubnet> = 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<MappedSubnet> = 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)
}

View File

@@ -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'
<select
tuiSelect
formControlName="externalip"
[items]="ips"
[items]="ips()"
></select>
} @else {
<input tuiSelect formControlName="externalip" />
}
@if (!mobile) {
<tui-data-list-wrapper *tuiTextfieldDropdown new [items]="ips" />
<tui-data-list-wrapper *tuiTextfieldDropdown new [items]="ips()" />
}
</tui-textfield>
<tui-error
@@ -97,7 +103,11 @@ import { TunnelData } from 'src/app/services/patch-db/data-model'
/>
<tui-textfield>
<label tuiLabel>External Port</label>
<input tuiTextfield formControlName="externalport" />
<input
tuiInputNumber
[tuiNumberFormat]="{ thousandSeparator: '' }"
formControlName="externalport"
/>
</tui-textfield>
<tui-error
formControlName="externalport"
@@ -128,13 +138,21 @@ import { TunnelData } from 'src/app/services/patch-db/data-model'
/>
<tui-textfield>
<label tuiLabel>Internal Port</label>
<input tuiTextfield formControlName="internalport" />
<input
tuiInputNumber
[tuiNumberFormat]="{ thousandSeparator: '' }"
formControlName="internalport"
/>
</tui-textfield>
<tui-error
formControlName="internalport"
[error]="[] | tuiFieldError | async"
/>
<footer><button tuiButton (click)="onSave()">Save</button></footer>
<footer>
<button tuiButton [disabled]="form.invalid" (click)="onSave()">
Save
</button>
</footer>
</form>
</ng-template>
`,
@@ -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<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 = ['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
.watch$('wg', 'subnets')
@@ -202,14 +248,6 @@ export default class PortForwards {
protected readonly deviceDisplay: TuiStringHandler<MappedDevice> = 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)

View File

@@ -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',
})

View File

@@ -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<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)
@@ -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() })

View File

@@ -1,6 +1,9 @@
import { T } from '@start9labs/start-sdk'
export type TunnelData = {
wg: WgServer
portForwards: Record<string, string>
gateways: Record<string, T.NetworkInterfaceInfo>
}
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: [],
},
},
},
}