mirror of
https://github.com/Start9Labs/start-os.git
synced 2026-03-26 02:11:53 +00:00
FE patchdb, mocks, and most endpoints
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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
9331
web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
35
web/projects/start-tunnel/README.md
Normal file
35
web/projects/start-tunnel/README.md
Normal 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>
|
||||
```
|
||||
43
web/projects/start-tunnel/src/app/app.config.ts
Normal file
43
web/projects/start-tunnel/src/app/app.config.ts
Normal 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,
|
||||
},
|
||||
],
|
||||
}
|
||||
3
web/projects/start-tunnel/src/app/app.html
Normal file
3
web/projects/start-tunnel/src/app/app.html
Normal file
@@ -0,0 +1,3 @@
|
||||
<tui-root>
|
||||
<router-outlet />
|
||||
</tui-root>
|
||||
17
web/projects/start-tunnel/src/app/app.routes.ts
Normal file
17
web/projects/start-tunnel/src/app/app.routes.ts
Normal 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: '' },
|
||||
]
|
||||
9
web/projects/start-tunnel/src/app/app.scss
Normal file
9
web/projects/start-tunnel/src/app/app.scss
Normal file
@@ -0,0 +1,9 @@
|
||||
:host {
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
tui-root {
|
||||
height: 100%;
|
||||
border-image: none;
|
||||
}
|
||||
17
web/projects/start-tunnel/src/app/app.ts
Normal file
17
web/projects/start-tunnel/src/app/app.ts
Normal 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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
130
web/projects/start-tunnel/src/app/routes/home/components/nav.ts
Normal file
130
web/projects/start-tunnel/src/app/routes/home/components/nav.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
28
web/projects/start-tunnel/src/app/routes/home/index.ts
Normal file
28
web/projects/start-tunnel/src/app/routes/home/index.ts
Normal 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
|
||||
@@ -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`
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
86
web/projects/start-tunnel/src/app/routes/login/index.ts
Normal file
86
web/projects/start-tunnel/src/app/routes/login/index.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { Injectable, signal } from '@angular/core'
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class AuthService {
|
||||
readonly authenticated = signal(false)
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
22
web/projects/start-tunnel/src/app/services/patch.service.ts
Normal file
22
web/projects/start-tunnel/src/app/services/patch.service.ts
Normal 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))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { Injectable, signal } from '@angular/core'
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class SidebarService {
|
||||
readonly start = signal(false)
|
||||
}
|
||||
20
web/projects/start-tunnel/src/index.html
Normal file
20
web/projects/start-tunnel/src/index.html
Normal 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>
|
||||
5
web/projects/start-tunnel/src/main.ts
Normal file
5
web/projects/start-tunnel/src/main.ts
Normal 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))
|
||||
90
web/projects/start-tunnel/src/styles.scss
Normal file
90
web/projects/start-tunnel/src/styles.scss
Normal 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);
|
||||
}
|
||||
9
web/projects/start-tunnel/tsconfig.json
Normal file
9
web/projects/start-tunnel/tsconfig.json
Normal 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"]
|
||||
}
|
||||
Reference in New Issue
Block a user