mirror of
https://github.com/Start9Labs/start-os.git
synced 2026-03-26 10:21:52 +00:00
predict next subnet and ip, use wan ips, and form validation
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
|
||||
@@ -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() })
|
||||
|
||||
@@ -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: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user