feat: update start-tunnel web app for typed tunnel API

- Use generated TS types for tunnel API params and data models
- Simplify API service methods to use typed RPC calls
- Update port forward UI for optional labels
This commit is contained in:
Aiden McClelland
2026-03-12 13:38:42 -06:00
parent 6091314981
commit 517bf80fc8
9 changed files with 107 additions and 179 deletions

View File

@@ -1,8 +1,7 @@
import { Signal } from '@angular/core'
import { AbstractControl } from '@angular/forms'
import { utils } from '@start9labs/start-sdk'
import { T, utils } from '@start9labs/start-sdk'
import { IpNet } from '@start9labs/start-sdk/util'
import { WgServer } from 'src/app/services/patch-db/data-model'
export interface MappedDevice {
readonly subnet: {
@@ -16,7 +15,7 @@ export interface MappedDevice {
export interface MappedSubnet {
readonly range: string
readonly name: string
readonly clients: WgServer['subnets']['']['clients']
readonly clients: T.Tunnel.WgSubnetClients
}
export interface DeviceData {

View File

@@ -15,11 +15,12 @@ import {
import { TuiFieldErrorPipe } from '@taiga-ui/kit'
import { TuiForm } from '@taiga-ui/layout'
import { injectContext, PolymorpheusComponent } from '@taiga-ui/polymorpheus'
import { T } from '@start9labs/start-sdk'
import { ApiService } from 'src/app/services/api/api.service'
export interface EditLabelData {
readonly source: string
readonly label: string
readonly label: T.Tunnel.PortForwardEntry['label']
}
@Component({

View File

@@ -1,4 +1,5 @@
import { Signal } from '@angular/core'
import { T } from '@start9labs/start-sdk'
export interface MappedDevice {
readonly ip: string
@@ -10,8 +11,8 @@ export interface MappedForward {
readonly externalport: string
readonly device: MappedDevice
readonly internalport: string
readonly label: string
readonly enabled: boolean
readonly label: T.Tunnel.PortForwardEntry['label']
readonly enabled: T.Tunnel.PortForwardEntry['enabled']
}
export interface PortForwardsData {

View File

@@ -1,7 +1,8 @@
import { Injectable } from '@angular/core'
import { T } from '@start9labs/start-sdk'
import { Dump } from 'patch-db-client'
import { TunnelData } from '../patch-db/data-model'
import { Observable } from 'rxjs'
import { TunnelData } from '../patch-db/data-model'
@Injectable({
providedIn: 'root',
@@ -10,77 +11,43 @@ export abstract class ApiService {
abstract openWebsocket$<T>(guid: string): Observable<T>
abstract subscribe(): Promise<SubscribeRes> // db.subscribe
// auth
abstract login(params: LoginReq): Promise<null> // auth.login
abstract login(params: T.Tunnel.SetPasswordParams): Promise<null> // auth.login
abstract logout(): Promise<null> // auth.logout
abstract setPassword(params: LoginReq): Promise<null> // auth.set-password
abstract setPassword(params: T.Tunnel.SetPasswordParams): Promise<null> // auth.set-password
// subnets
abstract addSubnet(params: UpsertSubnetReq): Promise<null> // subnet.add
abstract editSubnet(params: UpsertSubnetReq): Promise<null> // subnet.add
abstract deleteSubnet(params: DeleteSubnetReq): Promise<null> // subnet.remove
abstract addSubnet(
params: T.Tunnel.SubnetParams & T.Tunnel.AddSubnetParams,
): Promise<null> // subnet.add
abstract editSubnet(
params: T.Tunnel.SubnetParams & T.Tunnel.AddSubnetParams,
): Promise<null> // subnet.edit
abstract deleteSubnet(params: T.Tunnel.SubnetParams): Promise<null> // subnet.remove
// devices
abstract addDevice(params: UpsertDeviceReq): Promise<null> // device.add
abstract editDevice(params: UpsertDeviceReq): Promise<null> // device.add
abstract deleteDevice(params: DeleteDeviceReq): Promise<null> // device.remove
abstract showDeviceConfig(params: DeleteDeviceReq): Promise<string> // device.show-config
abstract addDevice(params: T.Tunnel.AddDeviceParams): Promise<null> // device.add
abstract editDevice(params: T.Tunnel.AddDeviceParams): Promise<null> // device.edit
abstract deleteDevice(params: T.Tunnel.RemoveDeviceParams): Promise<null> // device.remove
abstract showDeviceConfig(
params: T.Tunnel.RemoveDeviceParams,
): Promise<string> // device.show-config
// forwards
abstract addForward(params: AddForwardReq): Promise<null> // port-forward.add
abstract deleteForward(params: DeleteForwardReq): Promise<null> // port-forward.remove
abstract updateForwardLabel(params: UpdateForwardLabelReq): Promise<null> // port-forward.update-label
abstract setForwardEnabled(params: SetForwardEnabledReq): Promise<null> // port-forward.set-enabled
abstract addForward(
params: T.Tunnel.AddPortForwardParams,
): Promise<null> // port-forward.add
abstract deleteForward(
params: T.Tunnel.RemovePortForwardParams,
): Promise<null> // port-forward.remove
abstract updateForwardLabel(
params: T.Tunnel.UpdatePortForwardLabelParams,
): Promise<null> // port-forward.update-label
abstract setForwardEnabled(
params: T.Tunnel.SetPortForwardEnabledParams,
): Promise<null> // port-forward.set-enabled
// update
abstract checkUpdate(): Promise<TunnelUpdateResult> // update.check
abstract applyUpdate(): Promise<TunnelUpdateResult> // update.apply
abstract checkUpdate(): Promise<T.Tunnel.TunnelUpdateResult> // update.check
abstract applyUpdate(): Promise<T.Tunnel.TunnelUpdateResult> // update.apply
}
export type SubscribeRes = {
dump: Dump<TunnelData>
guid: string
}
export type LoginReq = { password: string }
export type UpsertSubnetReq = {
name: string
subnet: string
}
export type DeleteSubnetReq = {
subnet: string
}
export type UpsertDeviceReq = {
name: string
subnet: string
ip: string
}
export type DeleteDeviceReq = {
subnet: string
ip: string
}
export type AddForwardReq = {
source: string // externalip:port
target: string // internalip:port
label: string
}
export type DeleteForwardReq = {
source: string
}
export type UpdateForwardLabelReq = {
source: string
label: string
}
export type SetForwardEnabledReq = {
source: string
enabled: boolean
}
export type TunnelUpdateResult = {
status: string
installed: string
candidate: string
}

View File

@@ -8,20 +8,8 @@ import {
} from '@start9labs/shared'
import { filter, firstValueFrom, Observable } from 'rxjs'
import { webSocket } from 'rxjs/webSocket'
import {
AddForwardReq,
ApiService,
DeleteDeviceReq,
DeleteForwardReq,
DeleteSubnetReq,
LoginReq,
SubscribeRes,
TunnelUpdateResult,
SetForwardEnabledReq,
UpdateForwardLabelReq,
UpsertDeviceReq,
UpsertSubnetReq,
} from './api.service'
import { T } from '@start9labs/start-sdk'
import { ApiService, SubscribeRes } from './api.service'
import { AuthService } from '../auth.service'
import { PATCH_CACHE } from '../patch-db/patch-db-source'
@@ -54,7 +42,7 @@ export class LiveApiService extends ApiService {
// auth
async login(params: LoginReq): Promise<null> {
async login(params: T.Tunnel.SetPasswordParams): Promise<null> {
return this.rpcRequest({ method: 'auth.login', params })
}
@@ -62,75 +50,87 @@ export class LiveApiService extends ApiService {
return this.rpcRequest({ method: 'auth.logout', params: {} })
}
async setPassword(params: LoginReq): Promise<null> {
async setPassword(params: T.Tunnel.SetPasswordParams): Promise<null> {
return this.rpcRequest({ method: 'auth.set-password', params })
}
async addSubnet(params: UpsertSubnetReq): Promise<null> {
async addSubnet(
params: T.Tunnel.SubnetParams & T.Tunnel.AddSubnetParams,
): Promise<null> {
return this.upsertSubnet(params)
}
async editSubnet(params: UpsertSubnetReq): Promise<null> {
async editSubnet(
params: T.Tunnel.SubnetParams & T.Tunnel.AddSubnetParams,
): Promise<null> {
return this.upsertSubnet(params)
}
async deleteSubnet(params: DeleteSubnetReq): Promise<null> {
async deleteSubnet(params: T.Tunnel.SubnetParams): Promise<null> {
return this.rpcRequest({ method: 'subnet.remove', params })
}
// devices
async addDevice(params: UpsertDeviceReq): Promise<null> {
async addDevice(params: T.Tunnel.AddDeviceParams): Promise<null> {
return this.upsertDevice(params)
}
async editDevice(params: UpsertDeviceReq): Promise<null> {
async editDevice(params: T.Tunnel.AddDeviceParams): Promise<null> {
return this.upsertDevice(params)
}
async deleteDevice(params: DeleteDeviceReq): Promise<null> {
async deleteDevice(params: T.Tunnel.RemoveDeviceParams): Promise<null> {
return this.rpcRequest({ method: 'device.remove', params })
}
async showDeviceConfig(params: DeleteDeviceReq): Promise<string> {
async showDeviceConfig(params: T.Tunnel.RemoveDeviceParams): Promise<string> {
return this.rpcRequest({ method: 'device.show-config', params })
}
// forwards
async addForward(params: AddForwardReq): Promise<null> {
async addForward(params: T.Tunnel.AddPortForwardParams): Promise<null> {
return this.rpcRequest({ method: 'port-forward.add', params })
}
async deleteForward(params: DeleteForwardReq): Promise<null> {
async deleteForward(
params: T.Tunnel.RemovePortForwardParams,
): Promise<null> {
return this.rpcRequest({ method: 'port-forward.remove', params })
}
async updateForwardLabel(params: UpdateForwardLabelReq): Promise<null> {
async updateForwardLabel(
params: T.Tunnel.UpdatePortForwardLabelParams,
): Promise<null> {
return this.rpcRequest({ method: 'port-forward.update-label', params })
}
async setForwardEnabled(params: SetForwardEnabledReq): Promise<null> {
async setForwardEnabled(
params: T.Tunnel.SetPortForwardEnabledParams,
): Promise<null> {
return this.rpcRequest({ method: 'port-forward.set-enabled', params })
}
// update
async checkUpdate(): Promise<TunnelUpdateResult> {
async checkUpdate(): Promise<T.Tunnel.TunnelUpdateResult> {
return this.rpcRequest({ method: 'update.check', params: {} })
}
async applyUpdate(): Promise<TunnelUpdateResult> {
async applyUpdate(): Promise<T.Tunnel.TunnelUpdateResult> {
return this.rpcRequest({ method: 'update.apply', params: {} })
}
// private
private async upsertSubnet(params: UpsertSubnetReq): Promise<null> {
private async upsertSubnet(
params: T.Tunnel.SubnetParams & T.Tunnel.AddSubnetParams,
): Promise<null> {
return this.rpcRequest({ method: 'subnet.add', params })
}
private async upsertDevice(params: UpsertDeviceReq): Promise<null> {
private async upsertDevice(params: T.Tunnel.AddDeviceParams): Promise<null> {
return this.rpcRequest({ method: 'device.add', params })
}

View File

@@ -1,21 +1,9 @@
import { inject, Injectable } from '@angular/core'
import { shareReplay, Subject, tap } from 'rxjs'
import { WebSocketSubject } from 'rxjs/webSocket'
import {
AddForwardReq,
ApiService,
DeleteDeviceReq,
DeleteForwardReq,
DeleteSubnetReq,
LoginReq,
SubscribeRes,
TunnelUpdateResult,
SetForwardEnabledReq,
UpdateForwardLabelReq,
UpsertDeviceReq,
UpsertSubnetReq,
} from './api.service'
import { ApiService, SubscribeRes } from './api.service'
import { pauseFor } from '@start9labs/shared'
import { T } from '@start9labs/start-sdk'
import { AuthService } from '../auth.service'
import {
AddOperation,
@@ -26,12 +14,7 @@ import {
Revision,
} from 'patch-db-client'
import { toObservable } from '@angular/core/rxjs-interop'
import {
mockTunnelData,
PortForwardEntry,
WgClient,
WgSubnet,
} from '../patch-db/data-model'
import { mockTunnelData } from '../patch-db/data-model'
@Injectable({
providedIn: 'root',
@@ -66,7 +49,7 @@ export class MockApiService extends ApiService {
}
}
async login(params: LoginReq): Promise<null> {
async login(params: T.Tunnel.SetPasswordParams): Promise<null> {
await pauseFor(1000)
return null
}
@@ -76,15 +59,15 @@ export class MockApiService extends ApiService {
return null
}
async setPassword(params: LoginReq): Promise<null> {
async setPassword(params: T.Tunnel.SetPasswordParams): Promise<null> {
await pauseFor(1000)
return null
}
async addSubnet(params: UpsertSubnetReq): Promise<null> {
async addSubnet(params: T.Tunnel.SubnetParams & T.Tunnel.AddSubnetParams): Promise<null> {
await pauseFor(1000)
const patch: AddOperation<WgSubnet>[] = [
const patch: AddOperation<T.Tunnel.WgSubnetConfig>[] = [
{
op: PatchOp.ADD,
path: `/wg/subnets/${replaceSlashes(params.subnet)}`,
@@ -96,7 +79,7 @@ export class MockApiService extends ApiService {
return null
}
async editSubnet(params: UpsertSubnetReq): Promise<null> {
async editSubnet(params: T.Tunnel.SubnetParams & T.Tunnel.AddSubnetParams): Promise<null> {
await pauseFor(1000)
const patch: ReplaceOperation<string>[] = [
@@ -111,7 +94,7 @@ export class MockApiService extends ApiService {
return null
}
async deleteSubnet(params: DeleteSubnetReq): Promise<null> {
async deleteSubnet(params: T.Tunnel.SubnetParams): Promise<null> {
await pauseFor(1000)
const patch: RemoveOperation[] = [
@@ -125,14 +108,14 @@ export class MockApiService extends ApiService {
return null
}
async addDevice(params: UpsertDeviceReq): Promise<null> {
async addDevice(params: T.Tunnel.AddDeviceParams): Promise<null> {
await pauseFor(1000)
const patch: AddOperation<WgClient>[] = [
const patch: AddOperation<T.Tunnel.WgConfig>[] = [
{
op: PatchOp.ADD,
path: `/wg/subnets/${replaceSlashes(params.subnet)}/clients/${params.ip}`,
value: { name: params.name },
value: { name: params.name, key: '', psk: '' },
},
]
this.mockRevision(patch)
@@ -140,7 +123,7 @@ export class MockApiService extends ApiService {
return null
}
async editDevice(params: UpsertDeviceReq): Promise<null> {
async editDevice(params: T.Tunnel.AddDeviceParams): Promise<null> {
await pauseFor(1000)
const patch: ReplaceOperation<string>[] = [
@@ -155,7 +138,7 @@ export class MockApiService extends ApiService {
return null
}
async deleteDevice(params: DeleteDeviceReq): Promise<null> {
async deleteDevice(params: T.Tunnel.RemoveDeviceParams): Promise<null> {
await pauseFor(1000)
const patch: RemoveOperation[] = [
@@ -169,22 +152,22 @@ export class MockApiService extends ApiService {
return null
}
async showDeviceConfig(params: DeleteDeviceReq): Promise<string> {
async showDeviceConfig(params: T.Tunnel.RemoveDeviceParams): Promise<string> {
await pauseFor(1000)
return MOCK_CONFIG
}
async addForward(params: AddForwardReq): Promise<null> {
async addForward(params: T.Tunnel.AddPortForwardParams): Promise<null> {
await pauseFor(1000)
const patch: AddOperation<PortForwardEntry>[] = [
const patch: AddOperation<T.Tunnel.PortForwardEntry>[] = [
{
op: PatchOp.ADD,
path: `/portForwards/${params.source}`,
value: {
target: params.target,
label: params.label || '',
label: params.label || null,
enabled: true,
},
},
@@ -194,10 +177,10 @@ export class MockApiService extends ApiService {
return null
}
async updateForwardLabel(params: UpdateForwardLabelReq): Promise<null> {
async updateForwardLabel(params: T.Tunnel.UpdatePortForwardLabelParams): Promise<null> {
await pauseFor(1000)
const patch: ReplaceOperation<string>[] = [
const patch: ReplaceOperation<string | null>[] = [
{
op: PatchOp.REPLACE,
path: `/portForwards/${params.source}/label`,
@@ -209,7 +192,7 @@ export class MockApiService extends ApiService {
return null
}
async setForwardEnabled(params: SetForwardEnabledReq): Promise<null> {
async setForwardEnabled(params: T.Tunnel.SetPortForwardEnabledParams): Promise<null> {
await pauseFor(1000)
const patch: ReplaceOperation<boolean>[] = [
@@ -224,7 +207,7 @@ export class MockApiService extends ApiService {
return null
}
async deleteForward(params: DeleteForwardReq): Promise<null> {
async deleteForward(params: T.Tunnel.RemovePortForwardParams): Promise<null> {
await pauseFor(1000)
const patch: RemoveOperation[] = [
@@ -238,7 +221,7 @@ export class MockApiService extends ApiService {
return null
}
async checkUpdate(): Promise<TunnelUpdateResult> {
async checkUpdate(): Promise<T.Tunnel.TunnelUpdateResult> {
await pauseFor(1000)
return {
status: 'update-available',
@@ -247,7 +230,7 @@ export class MockApiService extends ApiService {
}
}
async applyUpdate(): Promise<TunnelUpdateResult> {
async applyUpdate(): Promise<T.Tunnel.TunnelUpdateResult> {
await pauseFor(2000)
return {
status: 'updating',

View File

@@ -1,45 +1,21 @@
import { T } from '@start9labs/start-sdk'
export type PortForwardEntry = {
target: string
label: string
enabled: boolean
}
export type TunnelData = {
wg: WgServer
portForwards: Record<string, PortForwardEntry>
gateways: Record<string, T.NetworkInterfaceInfo>
}
export type WgServer = {
subnets: Record<string, WgSubnet>
}
export type WgSubnet = {
name: string
clients: Record<string, WgClient>
}
export type WgClient = {
name: string
}
export type TunnelData = Pick<
T.Tunnel.TunnelDatabase,
'wg' | 'portForwards' | 'gateways'
>
export const mockTunnelData: TunnelData = {
wg: {
port: 51820,
key: '',
subnets: {
'10.59.0.0/24': {
name: 'Family',
clients: {
'10.59.0.2': {
name: 'Start9 Server',
},
'10.59.0.3': {
name: 'Phone',
},
'10.59.0.4': {
name: 'Laptop',
},
'10.59.0.2': { name: 'Start9 Server', key: '', psk: '' },
'10.59.0.3': { name: 'Phone', key: '', psk: '' },
'10.59.0.4': { name: 'Laptop', key: '', psk: '' },
},
},
},

View File

@@ -14,7 +14,8 @@ import {
switchMap,
takeWhile,
} from 'rxjs'
import { ApiService, TunnelUpdateResult } from './api/api.service'
import { T } from '@start9labs/start-sdk'
import { ApiService } from './api/api.service'
import { AuthService } from './auth.service'
@Component({
@@ -34,7 +35,7 @@ export class UpdateService {
private readonly dialogs = inject(TuiDialogService)
private readonly errorService = inject(ErrorService)
readonly result = signal<TunnelUpdateResult | null>(null)
readonly result = signal<T.Tunnel.TunnelUpdateResult | null>(null)
readonly hasUpdate = computed(
() => this.result()?.status === 'update-available',
)
@@ -60,7 +61,7 @@ export class UpdateService {
this.setResult(result)
}
private setResult(result: TunnelUpdateResult): void {
private setResult(result: T.Tunnel.TunnelUpdateResult): void {
this.result.set(result)
if (result.status === 'updating') {

View File

@@ -239,7 +239,7 @@ export const mockPatchData: DataModel = {
caFingerprint: '63:2B:11:99:44:40:17:DF:37:FC:C3:DF:0F:3D:15',
ntpSynced: false,
smtp: null,
ifconfigUrl: 'https://ifconfig.co',
echoipUrls: ['https://ipconfig.me', 'https://ifconfig.co'],
platform: 'x86_64-nonfree',
zram: true,
governor: 'performance',