FE patchdb, mocks, and most endpoints

This commit is contained in:
Matt Hill
2025-10-25 13:21:22 -06:00
parent 82a3a435f5
commit 33b5f189e2
31 changed files with 5658 additions and 5912 deletions

View File

@@ -334,6 +334,104 @@
}
}
},
"start-tunnel": {
"projectType": "application",
"schematics": {},
"root": "projects/start-tunnel",
"sourceRoot": "projects/start-tunnel/src",
"prefix": "app",
"architect": {
"build": {
"builder": "@angular/build:application",
"options": {
"outputPath": {
"base": "dist/raw/start-tunnel",
"browser": ""
},
"index": "projects/start-tunnel/src/index.html",
"browser": "projects/start-tunnel/src/main.ts",
"polyfills": ["zone.js"],
"tsConfig": "projects/start-tunnel/tsconfig.json",
"inlineStyleLanguage": "scss",
"assets": [
{
"glob": "**/*",
"input": "projects/shared/assets",
"output": "assets"
},
{
"glob": "**/*",
"input": "node_modules/@taiga-ui/icons/src",
"output": "assets/taiga-ui/icons"
}
],
"styles": [
"node_modules/@taiga-ui/core/styles/taiga-ui-theme.less",
"node_modules/@taiga-ui/core/styles/taiga-ui-fonts.less",
"projects/start-tunnel/src/styles.scss"
],
"scripts": []
},
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "500kB",
"maximumError": "1MB"
},
{
"type": "anyComponentStyle",
"maximumWarning": "4kB",
"maximumError": "8kB"
}
],
"outputHashing": "all"
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true,
"namedChunks": true
},
"ci": {
"progress": false
}
},
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular/build:dev-server",
"configurations": {
"production": {
"buildTarget": "start-tunnel:build:production"
},
"development": {
"buildTarget": "start-tunnel:build:development"
}
},
"defaultConfiguration": "development",
"options": {
"port": 8100
}
},
"extract-i18n": {
"builder": "@angular/build:extract-i18n",
"options": {
"buildTarget": "start-tunnel:build"
}
},
"lint": {
"builder": "@angular-eslint/builder:lint",
"options": {
"lintFilePatterns": [
"projects/start-tunnel/src/**/*.ts",
"projects/start-tunnel/src/**/*.html"
]
}
}
}
},
"marketplace": {
"projectType": "library",
"root": "projects/marketplace",

View File

@@ -6,4 +6,5 @@ module.exports = {
'projects/marketplace/**/*.ts': () => 'npm run check:marketplace',
'projects/install-wizard/**/*.ts': () => 'npm run check:install',
'projects/setup-wizard/**/*.ts': () => 'npm run check:setup',
'projects/start-tunnel/**/*.ts': () => 'npm run check:tunnel',
}

9331
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -12,11 +12,13 @@
"check:install": "tsc --project projects/install-wizard/tsconfig.json --noEmit --skipLibCheck",
"check:setup": "tsc --project projects/setup-wizard/tsconfig.json --noEmit --skipLibCheck",
"check:ui": "tsc --project projects/ui/tsconfig.json --noEmit --skipLibCheck",
"check:tunnel": "tsc --project projects/start-tunnel/tsconfig.json --noEmit --skipLibCheck",
"build:deps": "rimraf .angular/cache && (cd ../sdk && make bundle) && (cd ../patch-db/client && npm ci && npm run build)",
"build:install": "ng run install-wizard:build",
"build:setup": "ng run setup-wizard:build",
"build:ui": "ng run ui:build",
"build:ui:dev": "ng run ui:build:development",
"build:tunnel": "ng run start-tunnel:build",
"build:all": "npm run build:deps && npm run build:setup && npm run build:ui && npm run build:install",
"build:shared": "ng build shared",
"build:marketplace": "npm run build:shared && ng build marketplace",
@@ -26,38 +28,39 @@
"start:install": "npm run-script build-config && ng serve --project install-wizard --host 0.0.0.0",
"start:setup": "npm run-script build-config && ng serve --project setup-wizard --host 0.0.0.0",
"start:ui": "npm run-script build-config && ng serve --project ui --host 0.0.0.0",
"start:tunnel": "ng serve --project start-tunnel --host 0.0.0.0",
"start:ui:proxy": "npm run-script build-config && ng serve --project ui --host 0.0.0.0 --proxy-config proxy.conf.json",
"build-config": "node build-config.js"
},
"dependencies": {
"@angular/animations": "^20.1.0",
"@angular/animations": "^20.3.0",
"@angular/cdk": "^20.1.0",
"@angular/common": "^20.1.0",
"@angular/compiler": "^20.1.0",
"@angular/core": "^20.1.0",
"@angular/forms": "^20.1.0",
"@angular/platform-browser": "^20.1.0",
"@angular/common": "^20.3.0",
"@angular/compiler": "^20.3.0",
"@angular/core": "^20.3.0",
"@angular/forms": "^20.3.0",
"@angular/platform-browser": "^20.3.0",
"@angular/platform-browser-dynamic": "^20.1.0",
"@angular/pwa": "^20.1.0",
"@angular/router": "^20.1.0",
"@angular/service-worker": "^20.1.0",
"@angular/pwa": "^20.3.0",
"@angular/router": "^20.3.0",
"@angular/service-worker": "^20.3.0",
"@materia-ui/ngx-monaco-editor": "^6.0.0",
"@noble/curves": "^1.4.0",
"@noble/hashes": "^1.4.0",
"@start9labs/argon2": "^0.3.0",
"@start9labs/start-sdk": "file:../sdk/baseDist",
"@taiga-ui/addon-charts": "4.52.0",
"@taiga-ui/addon-commerce": "4.52.0",
"@taiga-ui/addon-mobile": "4.52.0",
"@taiga-ui/addon-table": "4.52.0",
"@taiga-ui/cdk": "4.52.0",
"@taiga-ui/core": "4.52.0",
"@taiga-ui/addon-charts": "4.55.0",
"@taiga-ui/addon-commerce": "4.55.0",
"@taiga-ui/addon-mobile": "4.55.0",
"@taiga-ui/addon-table": "4.55.0",
"@taiga-ui/cdk": "4.55.0",
"@taiga-ui/core": "4.55.0",
"@taiga-ui/dompurify": "4.1.11",
"@taiga-ui/event-plugins": "4.7.0",
"@taiga-ui/experimental": "4.52.0",
"@taiga-ui/icons": "4.52.0",
"@taiga-ui/kit": "4.52.0",
"@taiga-ui/layout": "4.52.0",
"@taiga-ui/experimental": "4.55.0",
"@taiga-ui/icons": "4.55.0",
"@taiga-ui/kit": "4.55.0",
"@taiga-ui/layout": "4.55.0",
"@taiga-ui/polymorpheus": "4.9.0",
"ansi-to-html": "^0.7.2",
"base64-js": "^1.5.1",

View File

@@ -0,0 +1,35 @@
# StartTunnel
StartTunnel is a self-hosted Wiregaurd VPN optimized for reverse tunneling to personal servers.
You can think of StartTunnel as a "virtual router in the cloud".
Use it for private, remote access, to self-hosted services running on a personal server, or to expose self-hosted services to the public Internet without revealing the host server's IP address.
## Features
### Subnets
Create subnets (private networks/VLANs).
### Devices
Invite devices to join specific subnets. Each device receives a unique Wireguard config that can be copied, downloaded, or scanned to join the network.
### Port Forwards
Expose specific ports on specific devices to the public Internet.
## CLI
StartTunnel comes with a command line interface to manage Subnets, Devices, and Port Forwards.
## UI
The StartTunnel UI is available at `https://<IP Address>` and ships with a self-signed SSL certificate. Users will need to bypass the browser's security warning to access the interface.
Users can provide their own SSL certificate using the CLI:
```
st certificate add </path/to/cert.pem>
```

View File

@@ -0,0 +1,43 @@
import { tuiDropdownOptionsProvider } from '@taiga-ui/core'
import { provideEventPlugins } from '@taiga-ui/event-plugins'
import { provideAnimations } from '@angular/platform-browser/animations'
import {
ApplicationConfig,
provideBrowserGlobalErrorListeners,
provideZonelessChangeDetection,
} from '@angular/core'
import { provideRouter, withRouterConfig } from '@angular/router'
import { tuiDialogOptionsProvider } from '@taiga-ui/experimental'
import { PatchDB } from 'patch-db-client'
import {
PATCH_CACHE,
PatchDbSource,
} from 'src/app/services/patch-db/patch-db-source'
import { routes } from './app.routes'
import { ApiService } from './services/api/api.service'
import { LiveApiService } from './services/api/live-api.service'
import { MockApiService } from './services/api/mock-api.service'
import { WorkspaceConfig } from '@start9labs/shared'
const { useMocks } = require('../../../../config.json') as WorkspaceConfig
export const appConfig: ApplicationConfig = {
providers: [
provideAnimations(),
provideBrowserGlobalErrorListeners(),
provideZonelessChangeDetection(),
provideRouter(routes, withRouterConfig({ onSameUrlNavigation: 'reload' })),
provideEventPlugins(),
tuiDropdownOptionsProvider({ appearance: 'start-9' }),
tuiDialogOptionsProvider({ appearance: 'start-9 taiga' }),
{
provide: PatchDB,
deps: [PatchDbSource, PATCH_CACHE],
useClass: PatchDB,
},
{
provide: ApiService,
useClass: useMocks ? MockApiService : LiveApiService,
},
],
}

View File

@@ -0,0 +1,3 @@
<tui-root>
<router-outlet />
</tui-root>

View File

@@ -0,0 +1,17 @@
import { inject } from '@angular/core'
import { Routes } from '@angular/router'
import { AuthService } from 'src/app/services/auth.service'
export const routes: Routes = [
{
path: '',
loadChildren: () => import('./routes/home'),
canMatch: [() => inject(AuthService).authenticated()],
},
{
path: '',
loadComponent: () => import('./routes/login'),
canMatch: [() => !inject(AuthService).authenticated()],
},
{ path: '**', redirectTo: '' },
]

View File

@@ -0,0 +1,9 @@
:host {
height: 100%;
display: block;
}
tui-root {
height: 100%;
border-image: none;
}

View File

@@ -0,0 +1,17 @@
import { TuiRoot } from '@taiga-ui/core'
import { Component, inject } from '@angular/core'
import { RouterOutlet } from '@angular/router'
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'
import { PatchService } from './services/patch.service'
@Component({
selector: 'app-root',
imports: [RouterOutlet, TuiRoot],
templateUrl: './app.html',
styleUrl: './app.scss',
})
export class App {
readonly subscription = inject(PatchService)
.pipe(takeUntilDestroyed())
.subscribe()
}

View File

@@ -0,0 +1,57 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { TuiButton } from '@taiga-ui/core'
import { SidebarService } from 'src/app/services/sidebar.service'
@Component({
selector: 'header',
template: `
<img alt="Start9" src="assets/icons/favicon.svg" />
<h1>StartTunnel</h1>
<button
tuiIconButton
iconStart="@tui.menu"
appearance="action-grayscale"
(click.stop)="sidebars.start.set(!sidebars.start())"
>
Menu
</button>
`,
styles: `
:host {
grid-column: span 2;
display: flex;
align-items: center;
gap: 0.75rem;
padding-inline-start: 0.75rem;
background: var(--tui-background-neutral-2);
box-shadow: var(--tui-shadow-medium);
border-bottom: 1px solid var(--tui-border-normal);
}
h1 {
font: var(--tui-font-heading-6);
margin-inline-end: auto;
}
img {
width: 2rem;
}
button {
display: none;
}
:host-context(tui-root._mobile) {
grid-column: span 1;
button {
display: inherit;
}
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [TuiButton],
})
export class Header {
protected readonly sidebars = inject(SidebarService)
}

View File

@@ -0,0 +1,130 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { Router, RouterLink, RouterLinkActive } from '@angular/router'
import { LoadingService } from '@start9labs/shared'
import { TuiButton } from '@taiga-ui/core'
import { ApiService } from 'src/app/services/api/api.service'
import { AuthService } from 'src/app/services/auth.service'
import { SidebarService } from 'src/app/services/sidebar.service'
@Component({
selector: 'nav',
template: `
<div>
@for (route of routes; track $index) {
<a
tuiButton
size="s"
appearance="flat-grayscale"
routerLinkActive="active"
[iconStart]="route.icon"
[routerLink]="route.link"
>
{{ route.name }}
</a>
}
</div>
<button
tuiButton
iconStart="@tui.log-out"
appearance="neutral"
size="s"
(click)="logout()"
>
Logout
</button>
`,
styles: `
:host {
display: flex;
flex-direction: column;
background: var(--tui-background-neutral-1);
backdrop-filter: blur(1rem);
z-index: 1;
overflow: hidden;
transition: transform var(--tui-duration);
}
div {
flex: 1;
padding-top: 1rem;
}
a {
display: flex;
justify-content: start;
margin: 0 0.5rem;
&.active {
background: var(--tui-background-neutral-1);
}
}
button {
width: 100%;
border-radius: 0;
justify-content: flex-start;
}
:host-context(tui-root._mobile) {
position: absolute;
top: 3.5rem;
width: 14rem;
bottom: 0;
inset-inline-start: 0;
&:not(:focus-within, ._expanded) {
transform: translate3d(-100%, 0, 0);
}
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [TuiButton, RouterLink, RouterLinkActive],
host: {
'[class._expanded]': 'sidebars.start()',
'(document:click)': 'sidebars.start.set(false)',
'(mousedown.prevent)': '0',
},
})
export class Nav {
private readonly service = inject(AuthService)
private readonly router = inject(Router)
protected readonly sidebars = inject(SidebarService)
protected readonly api = inject(ApiService)
private readonly loader = inject(LoadingService)
protected readonly routes = [
{
name: 'Subnets',
icon: '@tui.network',
link: 'subnets',
},
{
name: 'Devices',
icon: '@tui.laptop',
link: 'devices',
},
{
name: 'Port Forwards',
icon: '@tui.globe',
link: 'port-forwards',
},
{
name: 'Settings',
icon: '@tui.settings',
link: 'settings',
},
] as const
protected async logout() {
const loader = this.loader.open().subscribe()
try {
await this.api.logout()
this.service.authenticated.set(false)
this.router.navigate(['.'])
} catch (e) {
console.error(e)
} finally {
loader.unsubscribe()
}
}
}

View File

@@ -0,0 +1,50 @@
import { ChangeDetectionStrategy, Component } from '@angular/core'
import { RouterOutlet } from '@angular/router'
import { TuiScrollbar } from '@taiga-ui/core'
import { Header } from 'src/app/routes/home/components/header'
import { Nav } from 'src/app/routes/home/components/nav'
@Component({
selector: 'app-outlet',
template: `
<header></header>
<nav></nav>
<main>
<tui-scrollbar>
<router-outlet />
</tui-scrollbar>
</main>
`,
styles: `
:host {
height: 100%;
display: grid;
grid-template: 3.5rem 1fr / 14rem 1fr;
overflow: hidden;
transition: grid-template var(--tui-duration);
}
main {
isolation: isolate;
overflow: hidden;
padding: 1rem;
}
tui-scrollbar {
max-width: 50rem;
margin: 0 auto;
border-radius: var(--tui-radius-s);
::ng-deep > tui-scroll-controls {
top: var(--tui-height-m);
}
}
:host-context(tui-root._mobile) {
grid-template: 3.5rem 1fr / 1fr;
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [Header, Nav, RouterOutlet, TuiScrollbar],
})
export class Outlet {}

View File

@@ -0,0 +1,28 @@
import { Routes } from '@angular/router'
import { Outlet } from 'src/app/routes/home/components/outlet'
export default [
{
path: '',
component: Outlet,
children: [
{
path: 'subnets',
loadComponent: () => import('./routes/subnets'),
},
{
path: 'devices',
loadComponent: () => import('./routes/devices'),
},
{
path: 'port-forwards',
loadComponent: () => import('./routes/port-forwards'),
},
{
path: 'settings',
loadComponent: () => import('./routes/settings'),
},
{ path: '**', redirectTo: 'subnets' },
],
},
] satisfies Routes

View File

@@ -0,0 +1,362 @@
import { AsyncPipe } from '@angular/common'
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
Signal,
signal,
} from '@angular/core'
import { toSignal } from '@angular/core/rxjs-interop'
import {
NonNullableFormBuilder,
ReactiveFormsModule,
Validators,
} from '@angular/forms'
import { LoadingService } from '@start9labs/shared'
import {
TUI_IS_MOBILE,
TuiAutoFocus,
tuiMarkControlAsTouchedAndValidate,
TuiStringHandler,
} from '@taiga-ui/cdk'
import {
TuiButton,
TuiDataList,
TuiDropdown,
TuiError,
TuiIcon,
TuiTextfield,
TuiTitle,
} from '@taiga-ui/core'
import { TuiDialog, TuiDialogService } from '@taiga-ui/experimental'
import {
TUI_CONFIRM,
TuiCopy,
TuiDataListWrapper,
TuiFieldErrorPipe,
TuiSegmented,
TuiSelect,
TuiTextarea,
} from '@taiga-ui/kit'
import { TuiForm, TuiHeader } from '@taiga-ui/layout'
import { QrCodeComponent } from 'ng-qrcode'
import { PatchDB } from 'patch-db-client'
import { filter, map } from 'rxjs'
import { ApiService } from 'src/app/services/api/api.service'
import { TunnelData, WgServer } from 'src/app/services/patch-db/data-model'
@Component({
template: `
<table class="g-table">
<thead>
<tr>
<th>Name</th>
<th>Subnet</th>
<th>LAN IP</th>
<th [style.padding-inline-end.rem]="0.625">
<button tuiButton size="xs" iconStart="@tui.plus" (click)="onAdd()">
Add
</button>
</th>
</tr>
</thead>
<tbody>
@for (device of devices(); track $index) {
<tr>
<td>{{ device.name }}</td>
<td>{{ device.subnetName }}</td>
<td>{{ device.ip }}</td>
<td>
<button
tuiIconButton
size="xs"
tuiDropdown
tuiDropdownOpen
appearance="flat-grayscale"
iconStart="@tui.ellipsis-vertical"
>
Actions
<tui-data-list *tuiTextfieldDropdown size="s">
<button
tuiOption
iconStart="@tui.pencil"
new
(click)="onEdit(device.name)"
>
Rename
</button>
<button
tuiOption
iconStart="@tui.settings"
new
(click)="config.set(true)"
>
View Config
</button>
<button
tuiOption
iconStart="@tui.trash"
new
(click)="onDelete(device)"
>
Delete
</button>
</tui-data-list>
</button>
</td>
</tr>
}
</tbody>
</table>
<ng-template [tuiDialogOptions]="{ label: label() }" [(tuiDialog)]="dialog">
<form tuiForm [formGroup]="form">
<tui-textfield>
<label tuiLabel>Name</label>
<input tuiTextfield tuiAutoFocus formControlName="name" />
</tui-textfield>
<tui-error
formControlName="name"
[error]="[] | tuiFieldError | async"
/>
@if (!editing()) {
<tui-textfield tuiChevron [stringify]="subnetDisplay">
<label tuiLabel>Subnet</label>
@if (mobile) {
<select
tuiSelect
formControlName="subnet"
[items]="subnets()"
></select>
} @else {
<input tuiSelect formControlName="subnet" />
}
@if (!mobile) {
<tui-data-list-wrapper
*tuiTextfieldDropdown
new
[items]="subnets()"
/>
}
</tui-textfield>
<tui-error
formControlName="subnet"
[error]="[] | tuiFieldError | async"
/>
@if (form.controls.subnet.value.range) {
<tui-textfield>
<label tuiLabel>LAN IP</label>
<input tuiTextfield tuiAutoFocus formControlName="ip" />
</tui-textfield>
<tui-error
formControlName="ip"
[error]="[] | tuiFieldError | async"
/>
}
}
<footer><button tuiButton (click)="onSave()">Save</button></footer>
</form>
</ng-template>
<ng-template [(tuiDialog)]="config">
<header tuiHeader>
<h2 tuiTitle>Device Config</h2>
<aside tuiAccessories>
<tui-segmented #segmented>
<button>
<tui-icon icon="@tui.file" />
File
</button>
<button>
<tui-icon icon="@tui.qr-code" />
QR
</button>
</tui-segmented>
</aside>
</header>
@if (segmented?.activeItemIndex) {
<qr-code [value]="mock" size="352" />
} @else {
<tui-textfield>
<textarea
tuiTextarea
[min]="16"
[max]="16"
[readOnly]="true"
[value]="mock"
></textarea>
<tui-icon tuiCopy />
<a
tuiIconButton
iconStart="@tui.download"
download="start-tunnel.conf"
size="s"
[href]="href"
>
Download
</a>
</tui-textfield>
}
</ng-template>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
AsyncPipe,
ReactiveFormsModule,
TuiButton,
TuiDropdown,
TuiDataList,
TuiTextfield,
TuiDialog,
TuiForm,
TuiError,
TuiFieldErrorPipe,
TuiAutoFocus,
TuiSelect,
TuiDataListWrapper,
TuiHeader,
TuiTitle,
TuiSegmented,
TuiIcon,
QrCodeComponent,
TuiTextarea,
TuiCopy,
],
})
export default class Devices {
private readonly dialogs = inject(TuiDialogService)
private readonly api = inject(ApiService)
private readonly loading = inject(LoadingService)
private readonly patch = inject<PatchDB<TunnelData>>(PatchDB)
protected readonly mock = MOCK
protected readonly href = `data:text/plain;charset=utf-8,${encodeURIComponent(MOCK)}`
protected readonly dialog = signal(false)
protected readonly config = signal(false)
protected readonly editing = signal(false)
protected readonly subnets = toSignal<MappedSubnet[], []>(
this.patch.watch$('wg', 'subnets').pipe(
map(s =>
Object.entries(s).map(([range, { name, clients }]) => ({
range,
name,
clients,
})),
),
),
{ initialValue: [] },
)
protected readonly devices = computed(() =>
this.subnets().flatMap(subnet =>
Object.entries(subnet.clients).map(([ip, { name }]) => ({
subnet: subnet.range,
subnetName: subnet.name,
ip,
name,
})),
),
)
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 MappedSubnet, Validators.required],
ip: ['', Validators.required],
})
protected onAdd(): void {
this.editing.set(false)
this.form.reset()
this.dialog.set(true)
}
protected onEdit(name: string): void {
this.editing.set(true)
this.form.reset({ name })
this.dialog.set(true)
}
protected async onSave() {
if (this.form.invalid) {
tuiMarkControlAsTouchedAndValidate(this.form)
return
}
const loader = this.loading.open().subscribe()
const { name, subnet, ip } = this.form.getRawValue()
const toSave = {
name,
subnet: subnet.range,
ip,
}
try {
this.editing()
? await this.api.editDevice(toSave)
: await this.api.addDevice(toSave)
} catch (e) {
console.error(e)
} finally {
loader.unsubscribe()
this.dialog.set(false)
}
}
protected onDelete(device: MappedDevice): void {
this.dialogs
.open(TUI_CONFIRM, { label: 'Are you sure?' })
.pipe(filter(Boolean))
.subscribe(async () => {
const loader = this.loading.open().subscribe()
try {
await this.api.deleteDevice({ subnet: device.subnet, ip: device.ip })
} catch (e) {
console.log(e)
} finally {
loader.unsubscribe()
this.dialog.set(false)
}
})
}
}
type MappedSubnet = {
range: string
name: string
clients: WgServer['subnets']['']['clients']
}
type MappedDevice = {
subnet: string
subnetName: string
ip: string
name: string
}
const MOCK = `[Interface]
# Server's private IP address for the WireGuard VPN subnet
Address = 10.20.10.1/24
# UDP port WireGuard listens on
ListenPort = 33333
# Server private key (generated)
PrivateKey = 4K68mdpQWdEz/FpdVuRoZYgWpQgpW63J9GFzn+iOulQ=
# Commands to run after starting/stopping WireGuard tunnel to enable forwarding and NAT (example)
PostUp = iptables -A FORWARD -i %i -j ACCEPT; iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE
PostDown = iptables -D FORWARD -i %i -j ACCEPT; iptables -t nat -D POSTROUTING -o eth0 -j MASQUERADE
# Add client peers below with their public keys and allowed IPs
[Peer]
# Client public key
PublicKey = MQBiYHxAj7u8paj3L4w4uav3P/9YBPbaN4gkWn90SSs=
# Allowed client IP address within VPN subnet`

View File

@@ -0,0 +1,272 @@
import { AsyncPipe } from '@angular/common'
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
signal,
} from '@angular/core'
import { toSignal } from '@angular/core/rxjs-interop'
import {
NonNullableFormBuilder,
ReactiveFormsModule,
Validators,
} from '@angular/forms'
import { LoadingService } from '@start9labs/shared'
import {
TUI_IS_MOBILE,
tuiMarkControlAsTouchedAndValidate,
TuiStringHandler,
} from '@taiga-ui/cdk'
import { TuiButton, TuiError, TuiTextfield } from '@taiga-ui/core'
import { TuiDialog, TuiDialogService } from '@taiga-ui/experimental'
import {
TUI_CONFIRM,
TuiChevron,
TuiDataListWrapper,
TuiFieldErrorPipe,
TuiSelect,
} from '@taiga-ui/kit'
import { TuiForm } from '@taiga-ui/layout'
import { PatchDB } from 'patch-db-client'
import { combineLatest, filter, map, Observable } from 'rxjs'
import { ApiService } from 'src/app/services/api/api.service'
import { TunnelData } from 'src/app/services/patch-db/data-model'
@Component({
template: `
<table class="g-table">
<thead>
<tr>
<th>External IP</th>
<th>External Port</th>
<th>Device</th>
<th>Internal Port</th>
<th [style.padding-inline-end.rem]="0.625">
<button tuiButton size="xs" iconStart="@tui.plus" (click)="onAdd()">
Add
</button>
</th>
</tr>
</thead>
<tbody>
@for (forward of forwards(); track $index) {
<tr>
<td>{{ forward.externalip }}</td>
<td>{{ forward.externalport }}</td>
<td>{{ forward.device.name }}</td>
<td>{{ forward.internalport }}</td>
<td>
<button
tuiIconButton
size="xs"
appearance="flat-grayscale"
iconStart="@tui.trash"
(click)="onDelete(forward)"
>
Actions
</button>
</td>
</tr>
}
</tbody>
</table>
<ng-template
[tuiDialogOptions]="{ label: 'Add port forward' }"
[(tuiDialog)]="dialog"
>
<form tuiForm [formGroup]="form">
<tui-textfield tuiChevron>
<label tuiLabel>External IP</label>
@if (mobile) {
<select
tuiSelect
formControlName="externalip"
[items]="ips"
></select>
} @else {
<input tuiSelect formControlName="externalip" />
}
@if (!mobile) {
<tui-data-list-wrapper *tuiTextfieldDropdown new [items]="ips" />
}
</tui-textfield>
<tui-error
formControlName="externalip"
[error]="[] | tuiFieldError | async"
/>
<tui-textfield>
<label tuiLabel>External Port</label>
<input tuiTextfield formControlName="externalport" />
</tui-textfield>
<tui-error
formControlName="externalport"
[error]="[] | tuiFieldError | async"
/>
<tui-textfield tuiChevron [stringify]="deviceDisplay">
<label tuiLabel>Device</label>
@if (mobile) {
<select
tuiSelect
formControlName="device"
[items]="devices()"
></select>
} @else {
<input tuiSelect formControlName="device" />
}
@if (!mobile) {
<tui-data-list-wrapper
*tuiTextfieldDropdown
new
[items]="devices()"
/>
}
</tui-textfield>
<tui-error
formControlName="device"
[error]="[] | tuiFieldError | async"
/>
<tui-textfield>
<label tuiLabel>Internal Port</label>
<input tuiTextfield formControlName="internalport" />
</tui-textfield>
<tui-error
formControlName="internalport"
[error]="[] | tuiFieldError | async"
/>
<footer><button tuiButton (click)="onSave()">Save</button></footer>
</form>
</ng-template>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
AsyncPipe,
ReactiveFormsModule,
TuiButton,
TuiTextfield,
TuiDialog,
TuiForm,
TuiError,
TuiFieldErrorPipe,
TuiChevron,
TuiSelect,
TuiDataListWrapper,
],
})
export default class PortForwards {
private readonly dialogs = inject(TuiDialogService)
private readonly api = inject(ApiService)
private readonly loading = inject(LoadingService)
private readonly patch = inject<PatchDB<TunnelData>>(PatchDB)
protected readonly dialog = signal(false)
protected readonly ips = ['69.1.1.42']
protected readonly devices$: Observable<MappedDevice[]> = this.patch
.watch$('wg', 'subnets')
.pipe(
map(s =>
Object.values(s).flatMap(({ clients }) =>
Object.entries(clients).map(([ip, { name }]) => ({
ip,
name,
})),
),
),
)
protected readonly devices = toSignal(this.devices$, {
initialValue: [],
})
protected readonly forwards = toSignal<MappedForward[], []>(
combineLatest([this.devices$, this.patch.watch$('port_forwards')]).pipe(
map(([devices, forwards]) =>
Object.entries(forwards).map(([source, target]) => {
const sourceSplit = source.split(':')
const targetSplit = target.split(':')
return {
externalip: sourceSplit[0]!,
externalport: sourceSplit[1]!,
device: devices.find(d => d.ip === targetSplit[0])!,
internalport: targetSplit[1]!,
}
}),
),
),
{ initialValue: [] },
)
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)
}
protected async onSave() {
if (this.form.invalid) {
tuiMarkControlAsTouchedAndValidate(this.form)
return
}
const loader = this.loading.open().subscribe()
const { externalip, externalport, device, internalport } =
this.form.getRawValue()
try {
await this.api.addForward({
source: `${externalip}:${externalport}`,
target: `${device.ip}:${internalport}`,
})
} catch (e) {
console.error(e)
} finally {
loader.unsubscribe()
this.dialog.set(false)
}
}
protected onDelete(forward: MappedForward): void {
this.dialogs
.open(TUI_CONFIRM, { label: 'Are you sure?' })
.pipe(filter(Boolean))
.subscribe(async () => {
const loader = this.loading.open().subscribe()
try {
await this.api.deleteForward({
source: `${forward.externalip}:${forward.externalport}`,
})
} catch (e) {
console.log(e)
} finally {
loader.unsubscribe()
this.dialog.set(false)
}
})
}
}
type MappedDevice = {
ip: string
name: string
}
type MappedForward = {
externalip: string
externalport: string
device: MappedDevice
internalport: string
}

View File

@@ -0,0 +1,128 @@
import { AsyncPipe } from '@angular/common'
import {
ChangeDetectionStrategy,
Component,
inject,
signal,
} from '@angular/core'
import {
NonNullableFormBuilder,
ReactiveFormsModule,
Validators,
} from '@angular/forms'
import { DialogService, ErrorService } from '@start9labs/shared'
import { TuiResponsiveDialogService } from '@taiga-ui/addon-mobile'
import {
TuiAlertService,
TuiButton,
TuiError,
TuiTextfield,
TuiTitle,
} from '@taiga-ui/core'
import { TuiButtonLoading, TuiFieldErrorPipe } from '@taiga-ui/kit'
import { TuiCard, TuiForm, TuiHeader } from '@taiga-ui/layout'
import { ApiService } from 'src/app/services/api/api.service'
@Component({
template: `
<form tuiCardLarge tuiForm [formGroup]="form">
<header tuiHeader>
<h2 tuiTitle>
Settings
<span tuiSubtitle>Change password</span>
</h2>
</header>
<tui-textfield>
<label tuiLabel>New password</label>
<input formControlName="password" tuiTextfield />
</tui-textfield>
<tui-error
formControlName="password"
[error]="[] | tuiFieldError | async"
/>
<tui-textfield>
<label tuiLabel>Confirm new password</label>
<input formControlName="confirm" tuiTextfield />
</tui-textfield>
<tui-error
formControlName="confirm"
[error]="[] | tuiFieldError | async"
/>
<footer>
<button
tuiButton
(click)="onSave()"
[disabled]="form.invalid"
[loading]="loading()"
>
Save
</button>
</footer>
</form>
`,
styles: `
form {
background: var(--tui-background-neutral-1);
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
ReactiveFormsModule,
AsyncPipe,
TuiCard,
TuiForm,
TuiHeader,
TuiTitle,
TuiTextfield,
TuiError,
TuiFieldErrorPipe,
TuiButton,
TuiButtonLoading,
],
})
export default class Settings {
private readonly api = inject(ApiService)
private readonly alerts = inject(TuiAlertService)
private readonly errorService = inject(ErrorService)
protected readonly loading = signal(false)
protected readonly form = inject(NonNullableFormBuilder).group({
password: [
'',
[Validators.required, Validators.minLength(8), Validators.maxLength(64)],
],
confirm: [
'',
[Validators.required, Validators.minLength(8), Validators.maxLength(64)],
],
})
protected async onSave() {
const { password, confirm } = this.form.getRawValue()
if (password !== confirm) {
this.form.controls.confirm.setErrors({
notEqual: 'New passwords do not match',
})
return
}
this.loading.set(true)
try {
await this.api.setPassword({ password })
this.alerts
.open('Password changed', {
label: 'Success',
appearance: 'positive',
})
.subscribe()
this.form.reset()
} catch (e: any) {
this.errorService.handleError(e)
} finally {
this.loading.set(false)
}
}
}

View File

@@ -0,0 +1,214 @@
import { AsyncPipe } from '@angular/common'
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
signal,
} from '@angular/core'
import { toSignal } from '@angular/core/rxjs-interop'
import {
NonNullableFormBuilder,
ReactiveFormsModule,
Validators,
} from '@angular/forms'
import { LoadingService } from '@start9labs/shared'
import { TuiAutoFocus, tuiMarkControlAsTouchedAndValidate } from '@taiga-ui/cdk'
import {
TuiButton,
TuiDataList,
TuiDropdown,
TuiError,
TuiTextfield,
} from '@taiga-ui/core'
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 { ApiService } from 'src/app/services/api/api.service'
import { TunnelData } from 'src/app/services/patch-db/data-model'
@Component({
template: `
<table class="g-table">
<thead>
<tr>
<th>Name</th>
<th>IP Range</th>
<th [style.padding-inline-end.rem]="0.625">
<button tuiButton size="xs" iconStart="@tui.plus" (click)="onAdd()">
Add
</button>
</th>
</tr>
</thead>
<tbody>
@for (subnet of subnets(); track $index) {
<tr>
<td>{{ subnet.name }}</td>
<td>{{ subnet.range }}</td>
<td>
<button
tuiIconButton
size="xs"
tuiDropdown
tuiDropdownOpen
appearance="flat-grayscale"
iconStart="@tui.ellipsis-vertical"
>
Actions
<tui-data-list *tuiTextfieldDropdown size="s">
<button
tuiOption
iconStart="@tui.pencil"
new
(click)="onEdit(subnet.name)"
>
Rename
</button>
<button
tuiOption
iconStart="@tui.trash"
new
(click)="onDelete($index)"
>
Delete
</button>
</tui-data-list>
</button>
</td>
</tr>
}
</tbody>
</table>
<ng-template [tuiDialogOptions]="{ label: label() }" [(tuiDialog)]="dialog">
<form tuiForm [formGroup]="form">
<tui-textfield>
<label tuiLabel>Name</label>
<input tuiTextfield tuiAutoFocus formControlName="name" />
</tui-textfield>
<tui-error
formControlName="name"
[error]="[] | tuiFieldError | async"
/>
@if (!editing()) {
<tui-textfield>
<label tuiLabel>IP Range</label>
<input tuiTextfield formControlName="subnet" />
</tui-textfield>
<tui-error
formControlName="subnet"
[error]="[] | tuiFieldError | async"
/>
}
<footer><button tuiButton (click)="onSave()">Save</button></footer>
</form>
</ng-template>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
AsyncPipe,
ReactiveFormsModule,
TuiButton,
TuiDropdown,
TuiDataList,
TuiTextfield,
TuiDialog,
TuiForm,
TuiError,
TuiFieldErrorPipe,
TuiAutoFocus,
],
})
export default class Subnets {
private readonly dialogs = inject(TuiDialogService)
private readonly api = inject(ApiService)
private readonly loading = inject(LoadingService)
private readonly patch = inject<PatchDB<TunnelData>>(PatchDB)
protected readonly dialog = signal(false)
protected readonly editing = signal(false)
protected readonly subnets = toSignal(
this.patch.watch$('wg', 'subnets').pipe(
map(s =>
Object.entries(s).map(([range, info]) => ({
range,
name: info.name,
hasClients: !!Object.keys(info.clients).length,
})),
),
),
{ initialValue: [] },
)
protected readonly next = computed(() => {
const last = Number(
this.subnets().at(-1)?.range.split('/')[0]?.split('.')[2] || '-1',
)
return `10.59.${last + 1}.1/24`
})
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() })
this.dialog.set(true)
}
protected onEdit(name: string): void {
this.editing.set(true)
this.form.reset({ name })
this.dialog.set(true)
}
protected async onSave() {
if (this.form.invalid) {
tuiMarkControlAsTouchedAndValidate(this.form)
return
}
const loader = this.loading.open().subscribe()
const value = this.form.getRawValue()
try {
this.editing()
? await this.api.editSubnet(value)
: await this.api.addSubnet(value)
} catch (e) {
console.log(e)
} finally {
loader.unsubscribe()
this.dialog.set(false)
}
}
protected onDelete(index: number): void {
this.dialogs
.open(TUI_CONFIRM, { label: 'Are you sure?' })
.pipe(filter(Boolean))
.subscribe(async () => {
const subnet = this.subnets().at(index)?.range
if (!subnet) return
const loader = this.loading.open().subscribe()
try {
await this.api.deleteSubnet({ subnet })
} catch (e) {
console.log(e)
} finally {
loader.unsubscribe()
this.dialog.set(false)
}
})
}
}

View File

@@ -0,0 +1,86 @@
import {
ChangeDetectionStrategy,
Component,
inject,
signal,
} from '@angular/core'
import { FormsModule } from '@angular/forms'
import { Router } from '@angular/router'
import { TuiButton, TuiError, TuiTextfield } from '@taiga-ui/core'
import { TuiButtonLoading } from '@taiga-ui/kit'
import { ApiService } from 'src/app/services/api/api.service'
import { AuthService } from 'src/app/services/auth.service'
@Component({
template: `
<img alt="Start9" src="assets/icons/favicon.svg" />
<form (ngSubmit)="login()">
<tui-textfield [tuiTextfieldCleaner]="false">
<input
tuiTextfield
type="password"
placeholder="Enter password"
[ngModelOptions]="{ standalone: true }"
[(ngModel)]="password"
(ngModelChange)="error.set(false)"
[disabled]="loading()"
/>
<button
tuiIconButton
appearance="action"
iconStart="@tui.log-in"
[loading]="loading()"
>
Login
</button>
</tui-textfield>
@if (error()) {
<tui-error error="Password is invalid" />
}
</form>
`,
styles: `
:host {
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2rem;
}
img {
width: 5rem;
height: 5rem;
}
tui-textfield {
width: 18rem;
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [TuiButton, TuiTextfield, FormsModule, TuiError, TuiButtonLoading],
})
export default class Login {
private readonly auth = inject(AuthService)
private readonly router = inject(Router)
private readonly api = inject(ApiService)
protected readonly error = signal(false)
protected readonly loading = signal(false)
password = ''
protected async login() {
this.loading.set(true)
try {
await this.api.login({ password: this.password })
this.auth.authenticated.set(true)
this.router.navigate(['.'])
} catch (e) {
this.error.set(true)
} finally {
this.loading.set(false)
}
}
}

View File

@@ -0,0 +1,63 @@
import { Injectable } from '@angular/core'
import { Dump } from 'patch-db-client'
import { TunnelData } from '../patch-db/data-model'
import { Observable } from 'rxjs'
@Injectable({
providedIn: 'root',
})
export abstract class ApiService {
abstract openWebsocket$<T>(guid: string): Observable<T>
abstract subscribe(): Promise<SubscribeRes>
// auth
abstract login(params: LoginReq): Promise<null>
abstract logout(): Promise<null>
abstract setPassword(params: LoginReq): Promise<null>
// subnets
abstract addSubnet(params: UpsertSubnetReq): Promise<null>
abstract editSubnet(params: UpsertSubnetReq): Promise<null>
abstract deleteSubnet(params: DeleteSubnetReq): Promise<null>
// devices
abstract addDevice(params: UpsertDeviceReq): Promise<null>
abstract editDevice(params: UpsertDeviceReq): Promise<null>
abstract deleteDevice(params: DeleteDeviceReq): Promise<null>
// forwards
abstract addForward(params: AddForwardReq): Promise<null>
abstract deleteForward(params: DeleteForwardReq): Promise<null>
}
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
}
export type DeleteForwardReq = {
source: string
}

View File

@@ -0,0 +1,133 @@
import { Injectable, DOCUMENT, inject } from '@angular/core'
import {
HttpService,
RPCOptions,
isRpcError,
RpcError,
} from '@start9labs/shared'
import { filter, firstValueFrom, Observable } from 'rxjs'
import { webSocket } from 'rxjs/webSocket'
import {
AddForwardReq,
ApiService,
DeleteDeviceReq,
DeleteForwardReq,
DeleteSubnetReq,
LoginReq,
SubscribeRes,
UpsertDeviceReq,
UpsertSubnetReq,
} from './api.service'
import { AuthService } from '../auth.service'
import { PATCH_CACHE } from '../patch-db/patch-db-source'
@Injectable({
providedIn: 'root',
})
export class LiveApiService extends ApiService {
private readonly http = inject(HttpService)
private readonly document = inject(DOCUMENT)
private readonly auth = inject(AuthService)
private readonly cache$ = inject(PATCH_CACHE)
constructor() {
super()
}
openWebsocket$<T>(guid: string): Observable<T> {
const { location } = this.document.defaultView!
const host = location.host
return webSocket({
url: `wss://${host}/ws/rpc/${guid}`,
})
}
async subscribe(): Promise<SubscribeRes> {
return this.rpcRequest({ method: 'db.subscribe', params: {} })
}
// auth
async login(params: LoginReq): Promise<null> {
return this.rpcRequest({ method: 'auth.login', params })
}
async logout(): Promise<null> {
return this.rpcRequest({ method: 'auth.logout', params: {} })
}
async setPassword(params: LoginReq): Promise<null> {
return this.rpcRequest({ method: 'auth.set-password', params })
}
async addSubnet(params: UpsertSubnetReq): Promise<null> {
return this.upsertSubnet(params)
}
async editSubnet(params: UpsertSubnetReq): Promise<null> {
return this.upsertSubnet(params)
}
async deleteSubnet(params: DeleteSubnetReq): Promise<null> {
return this.rpcRequest({ method: 'subnet.delete', params })
}
// devices
async addDevice(params: UpsertDeviceReq): Promise<null> {
return this.upsertDevice(params)
}
async editDevice(params: UpsertDeviceReq): Promise<null> {
return this.upsertDevice(params)
}
async deleteDevice(params: DeleteDeviceReq): Promise<null> {
return this.rpcRequest({ method: 'device.delete', params })
}
// forwards
async addForward(params: AddForwardReq): Promise<null> {
return this.rpcRequest({ method: 'forward.create', params })
}
async deleteForward(params: DeleteForwardReq): Promise<null> {
return this.rpcRequest({ method: 'forward.delete', params })
}
// private
private async upsertSubnet(params: UpsertSubnetReq): Promise<null> {
return this.rpcRequest({ method: 'subnet.upsert', params })
}
private async upsertDevice(params: UpsertDeviceReq): Promise<null> {
return this.rpcRequest({ method: 'device.upsert', params })
}
private async rpcRequest<T>(
options: RPCOptions,
urlOverride?: string,
): Promise<T> {
const res = await this.http.rpcRequest<T>(options, urlOverride)
const body = res.body
if (isRpcError(body)) {
if (body.error.code === 34) {
console.error('Unauthenticated, logging out')
this.auth.authenticated.set(false)
}
throw new RpcError(body.error)
}
const patchSequence = res.headers.get('x-patch-sequence')
if (patchSequence)
await firstValueFrom(
this.cache$.pipe(filter(({ id }) => id >= Number(patchSequence))),
)
return body.result
}
}

View File

@@ -0,0 +1,201 @@
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,
UpsertDeviceReq,
UpsertSubnetReq,
} from './api.service'
import { pauseFor } from '@start9labs/shared'
import { AuthService } from '../auth.service'
import {
AddOperation,
Operation,
PatchOp,
RemoveOperation,
ReplaceOperation,
Revision,
} from 'patch-db-client'
import { toObservable } from '@angular/core/rxjs-interop'
import { mockTunnelData, WgClient, WgSubnet } from '../patch-db/data-model'
@Injectable({
providedIn: 'root',
})
export class MockApiService extends ApiService {
private readonly auth = inject(AuthService)
readonly mockWsSource$ = new Subject<Revision>()
sequence = 1
constructor() {
super()
toObservable(this.auth.authenticated)
.pipe(
tap(() => {
this.sequence = 1
}),
)
.subscribe()
}
openWebsocket$<T>(guid: string): WebSocketSubject<T> {
return this.mockWsSource$.pipe(
tap(v => console.log('MOCK WS', v)),
shareReplay({ bufferSize: 1, refCount: true }),
) as WebSocketSubject<T>
}
async subscribe(): Promise<SubscribeRes> {
await pauseFor(1000)
return {
dump: { id: 1, value: mockTunnelData },
guid: 'patch-db-guid',
}
}
async login(params: LoginReq): Promise<null> {
await pauseFor(1000)
return null
}
async logout(): Promise<null> {
await pauseFor(1000)
return null
}
async setPassword(params: LoginReq): Promise<null> {
await pauseFor(1000)
return null
}
async addSubnet(params: UpsertSubnetReq): Promise<null> {
await pauseFor(1000)
const patch: AddOperation<WgSubnet>[] = [
{
op: PatchOp.ADD,
path: `/wg/subnets/${params.subnet}`,
value: { name: params.name, clients: {} },
},
]
this.mockRevision(patch)
return null
}
async editSubnet(params: UpsertSubnetReq): Promise<null> {
await pauseFor(1000)
const patch: ReplaceOperation<string>[] = [
{
op: PatchOp.REPLACE,
path: `/wg/subnets/${params.subnet}/name`,
value: params.name,
},
]
this.mockRevision(patch)
return null
}
async deleteSubnet(params: DeleteSubnetReq): Promise<null> {
await pauseFor(1000)
const patch: RemoveOperation[] = [
{
op: PatchOp.REMOVE,
path: `/wg/subnets/${params.subnet}`,
},
]
this.mockRevision(patch)
return null
}
async addDevice(params: UpsertDeviceReq): Promise<null> {
await pauseFor(1000)
const patch: AddOperation<WgClient>[] = [
{
op: PatchOp.ADD,
path: `/wg/subnets/${params.subnet}/clients/${params.ip}`,
value: { name: params.name },
},
]
this.mockRevision(patch)
return null
}
async editDevice(params: UpsertDeviceReq): Promise<null> {
await pauseFor(1000)
const patch: ReplaceOperation<string>[] = [
{
op: PatchOp.REPLACE,
path: `/wg/subnets/${params.subnet}/clients/${params.ip}/name`,
value: params.name,
},
]
this.mockRevision(patch)
return null
}
async deleteDevice(params: DeleteDeviceReq): Promise<null> {
await pauseFor(1000)
const patch: RemoveOperation[] = [
{
op: PatchOp.REMOVE,
path: `/wg/subnets/${params.subnet}/clients/${params.ip}`,
},
]
this.mockRevision(patch)
return null
}
async addForward(params: AddForwardReq): Promise<null> {
await pauseFor(1000)
const patch: AddOperation<string>[] = [
{
op: PatchOp.ADD,
path: `/port_forwards/${params.source}`,
value: params.target,
},
]
this.mockRevision(patch)
return null
}
async deleteForward(params: DeleteForwardReq): Promise<null> {
await pauseFor(1000)
const patch: RemoveOperation[] = [
{
op: PatchOp.REMOVE,
path: `/port_forwards/${params.source}`,
},
]
this.mockRevision(patch)
return null
}
private async mockRevision<T>(patch: Operation<T>[]): Promise<void> {
const revision = {
id: ++this.sequence,
patch,
}
this.mockWsSource$.next(revision)
}
}

View File

@@ -0,0 +1,8 @@
import { Injectable, signal } from '@angular/core'
@Injectable({
providedIn: 'root',
})
export class AuthService {
readonly authenticated = signal(false)
}

View File

@@ -0,0 +1,42 @@
export type TunnelData = {
wg: WgServer
port_forwards: Record<string, string>
}
export type WgServer = {
subnets: Record<string, WgSubnet>
}
export type WgSubnet = {
name: string
clients: Record<string, WgClient>
}
export type WgClient = {
name: string
}
export const mockTunnelData: TunnelData = {
wg: {
subnets: {
'10.59.0.1/24': {
name: 'Family',
clients: {
'10.59.0.2': {
name: 'Start9 Server',
},
'10.59.0.3': {
name: 'Phone',
},
'10.59.0.4': {
name: 'Laptop',
},
},
},
},
},
port_forwards: {
'69.1.1.42:443': '10.59.0.2:5443',
'69.1.1.42:3000': '10.59.0.2:3000',
},
}

View File

@@ -0,0 +1,47 @@
import { inject, Injectable, InjectionToken } from '@angular/core'
import { Dump, Revision, Update } from 'patch-db-client'
import { BehaviorSubject, EMPTY, Observable, timer } from 'rxjs'
import {
bufferTime,
catchError,
filter,
startWith,
switchMap,
} from 'rxjs/operators'
import { ApiService } from '../api/api.service'
import { AuthService } from '../auth.service'
import { TunnelData } from './data-model'
import { toObservable } from '@angular/core/rxjs-interop'
export const PATCH_CACHE = new InjectionToken('', {
factory: () =>
new BehaviorSubject<Dump<TunnelData>>({
id: 0,
value: {} as TunnelData,
}),
})
@Injectable({
providedIn: 'root',
})
export class PatchDbSource extends Observable<Update<TunnelData>[]> {
private readonly api = inject(ApiService)
private readonly auth = inject(AuthService)
private readonly stream$ = toObservable(this.auth.authenticated).pipe(
switchMap(verified => (verified ? this.api.subscribe() : EMPTY)),
switchMap(({ dump, guid }) =>
this.api.openWebsocket$<Revision>(guid).pipe(
bufferTime(250),
filter(revisions => !!revisions.length),
startWith([dump]),
),
),
catchError((_, watch$) => timer(500).pipe(switchMap(() => watch$))),
startWith([{ id: 0, value: {} as TunnelData }]),
)
constructor() {
super(subscriber => this.stream$.subscribe(subscriber))
}
}

View File

@@ -0,0 +1,22 @@
import { inject, Injectable } from '@angular/core'
import { tap, Observable } from 'rxjs'
import { PatchDB } from 'patch-db-client'
import { TunnelData } from './patch-db/data-model'
import { AuthService } from './auth.service'
import { toObservable } from '@angular/core/rxjs-interop'
@Injectable({
providedIn: 'root',
})
export class PatchService extends Observable<unknown> {
private readonly patch = inject<PatchDB<TunnelData>>(PatchDB)
private readonly auth = inject(AuthService)
private readonly stream$ = toObservable(this.auth.authenticated).pipe(
tap(authed => (authed ? this.patch.start() : this.patch.stop())),
)
constructor() {
super(subscriber => this.stream$.subscribe(subscriber))
}
}

View File

@@ -0,0 +1,8 @@
import { Injectable, signal } from '@angular/core'
@Injectable({
providedIn: 'root',
})
export class SidebarService {
readonly start = signal(false)
}

View File

@@ -0,0 +1,20 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>StartTunnel</title>
<base href="/" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link
rel="icon"
type="image/png"
href="/assets/icons/favicon-96x96.png"
sizes="96x96"
/>
<link rel="icon" type="image/svg+xml" href="/assets/icons/favicon.svg" />
<link rel="shortcut icon" href="/assets/icons/favicon.ico" />
</head>
<body tuiTheme="dark">
<app-root></app-root>
</body>
</html>

View File

@@ -0,0 +1,5 @@
import { bootstrapApplication } from '@angular/platform-browser'
import { appConfig } from './app/app.config'
import { App } from './app/app'
bootstrapApplication(App, appConfig).catch(err => console.error(err))

View File

@@ -0,0 +1,90 @@
:root {
height: 100%;
--tui-text-action: #428bf9;
--tui-text-action-hover: #165eca;
--tui-background-accent-1: #428bf9;
--tui-background-accent-1-hover: #126df7;
--tui-background-accent-1-pressed: #156ed4;
}
body {
height: 100%;
isolation: isolate;
overflow-x: hidden;
background:
conic-gradient(var(--tui-background-base)),
radial-gradient(circle at top right, #5240a8, transparent 40%),
radial-gradient(circle at bottom right, #9236c9, transparent),
radial-gradient(circle at 25% 100%, #5b65d5, transparent 30%),
radial-gradient(circle at bottom left, #0090c0, transparent 50%),
radial-gradient(circle at top left, #2a5ba8, transparent 50%),
linear-gradient(to bottom, #5654b2, transparent);
background-blend-mode: hard-light;
&:not([tuiTheme]) {
background-blend-mode: soft-light;
&::before {
content: '';
position: fixed;
inset: 0;
z-index: -1;
background: rgb(255 255 255 / 15%);
backdrop-filter: brightness(1.5);
}
}
}
tui-dropdown[data-appearance='start-9'] {
background: none;
backdrop-filter: blur(1rem);
}
tui-dialog[new][data-appearance~='start-9'] {
background: var(--tui-background-neutral-1);
backdrop-filter: blur(5rem);
}
.g-table {
width: 100%;
border-collapse: collapse;
border-radius: var(--tui-radius-s);
background: var(--tui-background-neutral-1);
box-shadow: inset 0 0 0 1px var(--tui-background-neutral-1);
thead tr {
position: sticky;
top: 0;
background: var(--tui-background-neutral-1);
backdrop-filter: blur(5rem);
z-index: 1;
}
tr:nth-child(even) {
backdrop-filter: brightness(0.9);
}
th,
td {
height: var(--tui-height-m);
padding: 0 1rem;
text-align: start;
background: transparent;
border: none;
box-shadow: inset 0 1px var(--tui-background-neutral-1);
&:last-child {
text-align: end;
}
}
}
qr-code {
display: flex;
justify-content: center;
}
tui-data-list {
--tui-text-action: var(--tui-text-primary);
}

View File

@@ -0,0 +1,9 @@
/* To learn more about this file see: https://angular.io/config/tsconfig. */
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"baseUrl": "./"
},
"files": ["src/main.ts"],
"include": ["src/**/*.d.ts"]
}