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