diff --git a/START-TUNNEL.md b/START-TUNNEL.md new file mode 100644 index 000000000..1fea658bb --- /dev/null +++ b/START-TUNNEL.md @@ -0,0 +1,59 @@ +# StartTunnel + +A self-hosted Wiregaurd VPN optimized for creating VLANs and reverse tunneling to personal servers. + +You can think of StartTunnel as "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. + +## Installation + +1. Rent a low cost VPS. For most use cases, the cheapest option should be enough. + + - It must have a dedicated public IP address. + - For (CPU), memory (RAM), and storage (disk), choose the minimum spec. + - For transfer (bandwidth), it depends on (1) your use case and (2) your home Internet's _upload_ speed. Even if you intend to serve large files or stream content from your server, there is no reason to pay for speeds that exceed your home Internet's upload speed. + +1. Provision the VPS with the latest version of Debian. + +1. Access the VPS via SSH. + +1. Install StartTunnel: + + @TODO + +## Features + +- **Create Subnets**: Each subnet creates a private, virtual local area network (VLAN), similar to the LAN created by a home router. + +- **Add Devices**: When you add a device (server, phone, laptop) to a subnet, it receives a LAN IP address on that subnet as well as a unique Wireguard config that must be copied, downloaded, or scanned into the device. + +- **Forward Ports**: Forwarding a port creates a "reverse tunnel", exposing a specific port on a specific device to the public Internet. + +## CLI + +By default, StartTunnel is managed via the `start-tunnel` command line interface, which is self-documented. + +``` +start-tunnel --help +``` + +## Web Interface + +If you choose to enable the web interface (recommended in most cases), StartTunnel can be accessed as a website from the browser, or programmatically via API. + +1. Initialize the web interface. + + start-tunnel web init + +1. When prompted, select the IP address at which to host the web interface. In many cases, there will be only one IP address. + +1. When prompted, enter the port at which to host the web interface. The default is 8443, and we recommend using it. If you change the default, choose an uncommon port to avoid conflicts. + +1. Select whether to autogenerate a self-signed certificate or provide your own certificate and key. If you choose to autogenerate, you will be asked to list all IP addresses and domains for which to sign the certificate. For example, if you intend to access your StartTunnel web UI at a domain, include the domain in the list. + +1. You will receive a success message that the webserver is running at the chosen IP:port, as well as your SSL certificate and an autogenerated UI password. + +1. If not already, trust the certificate in your system keychain and/or browser. + +1. If you lose/forget your password, you can reset it using the CLI. diff --git a/web/angular.json b/web/angular.json index 2930be646..56ec6f307 100644 --- a/web/angular.json +++ b/web/angular.json @@ -350,7 +350,7 @@ }, "index": "projects/start-tunnel/src/index.html", "browser": "projects/start-tunnel/src/main.ts", - "polyfills": ["zone.js"], + "polyfills": [], "tsConfig": "projects/start-tunnel/tsconfig.json", "inlineStyleLanguage": "scss", "assets": [ diff --git a/web/projects/start-tunnel/README.md b/web/projects/start-tunnel/README.md deleted file mode 100644 index 6a2ac6093..000000000 --- a/web/projects/start-tunnel/README.md +++ /dev/null @@ -1,35 +0,0 @@ -# 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://` 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 -``` diff --git a/web/projects/start-tunnel/src/app/app.html b/web/projects/start-tunnel/src/app/app.html deleted file mode 100644 index 348582106..000000000 --- a/web/projects/start-tunnel/src/app/app.html +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/web/projects/start-tunnel/src/app/app.scss b/web/projects/start-tunnel/src/app/app.scss deleted file mode 100644 index ac8ebdcdf..000000000 --- a/web/projects/start-tunnel/src/app/app.scss +++ /dev/null @@ -1,9 +0,0 @@ -:host { - height: 100%; - display: block; -} - -tui-root { - height: 100%; - border-image: none; -} diff --git a/web/projects/start-tunnel/src/app/app.ts b/web/projects/start-tunnel/src/app/app.ts index e2afa0ea3..3d982cb57 100644 --- a/web/projects/start-tunnel/src/app/app.ts +++ b/web/projects/start-tunnel/src/app/app.ts @@ -7,8 +7,18 @@ import { PatchService } from './services/patch.service' @Component({ selector: 'app-root', imports: [RouterOutlet, TuiRoot], - templateUrl: './app.html', - styleUrl: './app.scss', + template: '', + styles: ` + :host { + height: 100%; + display: block; + } + + tui-root { + height: 100%; + border-image: none; + } + `, }) export class App { readonly subscription = inject(PatchService) diff --git a/web/projects/start-tunnel/src/app/routes/home/routes/devices/add.ts b/web/projects/start-tunnel/src/app/routes/home/routes/devices/add.ts new file mode 100644 index 000000000..d88dfb6be --- /dev/null +++ b/web/projects/start-tunnel/src/app/routes/home/routes/devices/add.ts @@ -0,0 +1,170 @@ +import { AsyncPipe } from '@angular/common' +import { + ChangeDetectionStrategy, + Component, + inject, + Signal, +} from '@angular/core' +import { + NonNullableFormBuilder, + ReactiveFormsModule, + Validators, +} from '@angular/forms' +import { LoadingService } from '@start9labs/shared' +import { utils } from '@start9labs/start-sdk' +import { + TUI_IS_MOBILE, + TuiAutoFocus, + tuiMarkControlAsTouchedAndValidate, +} from '@taiga-ui/cdk' +import { + TuiButton, + TuiDialogContext, + TuiError, + TuiTextfield, +} from '@taiga-ui/core' +import { + TuiChevron, + TuiDataListWrapper, + TuiElasticContainer, + TuiFieldErrorPipe, + TuiSelect, +} from '@taiga-ui/kit' +import { TuiForm } from '@taiga-ui/layout' +import { injectContext, PolymorpheusComponent } from '@taiga-ui/polymorpheus' +import { ApiService } from 'src/app/services/api/api.service' + +import { getIp, DeviceData, MappedSubnet, subnetValidator } from './utils' + +@Component({ + template: ` +
+ + + + + + + @if (!context.data.device) { + + + @if (mobile) { + + } @else { + + } + @if (!mobile) { + + } + + + + + @if (form.controls.subnet.value?.range) { + + + + + } + + @if (form.controls.subnet.value?.range) { + + } + } +
+ +
+ + `, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + AsyncPipe, + ReactiveFormsModule, + TuiAutoFocus, + TuiButton, + TuiDataListWrapper, + TuiError, + TuiFieldErrorPipe, + TuiForm, + TuiSelect, + TuiTextfield, + TuiChevron, + TuiElasticContainer, + ], +}) +export class DevicesAdd { + private readonly loading = inject(LoadingService) + private readonly api = inject(ApiService) + + protected readonly mobile = inject(TUI_IS_MOBILE) + protected readonly context = + injectContext>() + + protected readonly form = inject(NonNullableFormBuilder).group({ + name: [this.context.data.device?.name || '', Validators.required], + subnet: [ + this.context.data.device?.subnet, + [Validators.required, subnetValidator], + ], + ip: [ + this.context.data.device?.ip || '', + [Validators.required, Validators.pattern(utils.Patterns.ipv4.regex)], + ], + }) + + protected readonly stringify = ({ range, name }: MappedSubnet) => + range ? `${name} (${range})` : '' + + protected onSubnet(subnet: MappedSubnet) { + const ip = getIp(subnet) + + if (ip) { + this.form.controls.ip.setValue(ip) + } else { + this.form.controls.ip.disable() + } + + this.form.controls.subnet.markAsTouched() + } + + protected async onSave() { + if (this.form.invalid) { + tuiMarkControlAsTouchedAndValidate(this.form) + return + } + + const loader = this.loading.open().subscribe() + const { ip, name, subnet } = this.form.getRawValue() + const data = { ip, name, subnet: subnet?.range || '' } + + try { + this.context.data.device + ? await this.api.editDevice(data) + : await this.api.addDevice(data) + } catch (e) { + console.error(e) + } finally { + loader.unsubscribe() + this.context.$implicit.complete() + } + } +} + +export const DEVICES_ADD = new PolymorpheusComponent(DevicesAdd) diff --git a/web/projects/start-tunnel/src/app/routes/home/routes/devices/config.ts b/web/projects/start-tunnel/src/app/routes/home/routes/devices/config.ts new file mode 100644 index 000000000..746dbff72 --- /dev/null +++ b/web/projects/start-tunnel/src/app/routes/home/routes/devices/config.ts @@ -0,0 +1,74 @@ +import { ChangeDetectionStrategy, Component } from '@angular/core' +import { + TuiButton, + TuiDialogContext, + TuiIcon, + TuiTextfield, + TuiTitle, +} from '@taiga-ui/core' +import { TuiCopy, TuiSegmented, TuiTextarea } from '@taiga-ui/kit' +import { TuiHeader } from '@taiga-ui/layout' +import { injectContext, PolymorpheusComponent } from '@taiga-ui/polymorpheus' +import { QrCodeComponent } from 'ng-qrcode' + +@Component({ + template: ` +
+

Device Config

+ +
+ @if (segmented?.activeItemIndex) { + + } @else { + + + + + Download + + + } + `, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + QrCodeComponent, + TuiButton, + TuiHeader, + TuiIcon, + TuiTitle, + TuiSegmented, + TuiTextfield, + TuiTextarea, + TuiCopy, + ], +}) +export class DevicesConfig { + protected readonly config = + injectContext>().data + protected readonly href = `data:text/plain;charset=utf-8,${encodeURIComponent(this.config)}` +} + +export const DEVICES_CONFIG = new PolymorpheusComponent(DevicesConfig) diff --git a/web/projects/start-tunnel/src/app/routes/home/routes/devices/index.ts b/web/projects/start-tunnel/src/app/routes/home/routes/devices/index.ts index 789be5fb6..011d4768f 100644 --- a/web/projects/start-tunnel/src/app/routes/home/routes/devices/index.ts +++ b/web/projects/start-tunnel/src/app/routes/home/routes/devices/index.ts @@ -1,51 +1,28 @@ -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 { utils } from '@start9labs/start-sdk' -import { IpNet } from '@start9labs/start-sdk/util' -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 { TuiDialogService } from '@taiga-ui/experimental' +import { TUI_CONFIRM } from '@taiga-ui/kit' 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' +import { TunnelData } from 'src/app/services/patch-db/data-model' + +import { DEVICES_ADD } from './add' +import { DEVICES_CONFIG } from './config' +import { MappedDevice, MappedSubnet } from './utils' @Component({ template: ` @@ -110,163 +87,30 @@ import { TunnelData, WgServer } from 'src/app/services/patch-db/data-model' } - -
- - - - - - - @if (!editing()) { - - - @if (mobile) { - - } @else { - - } - @if (!mobile) { - - } - - - - @if (form.controls.subnet.value.range) { - - - - - - } - } -
- -
- -
- -
-

Device Config

- -
- @if (segmented?.activeItemIndex) { - - } @else { - - - - - Download - - - } -
`, changeDetection: ChangeDetectionStrategy.OnPush, - imports: [ - AsyncPipe, - ReactiveFormsModule, - TuiButton, - TuiDropdown, - TuiDataList, - TuiTextfield, - TuiDialog, - TuiForm, - TuiError, - TuiFieldErrorPipe, - TuiAutoFocus, - TuiSelect, - TuiDataListWrapper, - TuiHeader, - TuiTitle, - TuiSegmented, - TuiIcon, - QrCodeComponent, - TuiTextarea, - TuiCopy, - ], + imports: [TuiButton, TuiDropdown, TuiDataList, TuiTextfield], }) export default class Devices { private readonly dialogs = inject(TuiDialogService) private readonly api = inject(ApiService) private readonly loading = inject(LoadingService) - private readonly patch = inject>(PatchDB) - protected readonly mobile = inject(TUI_IS_MOBILE) - protected readonly form = inject(NonNullableFormBuilder).group({ - name: ['', Validators.required], - subnet: [{} as MappedDevice['subnet'], Validators.required], - ip: [ - '', - [Validators.required, Validators.pattern(utils.Patterns.ipv4.regex)], - ], - }) - protected readonly dialog = signal(false) - protected readonly showConfig = signal(false) - protected readonly config = signal('') - protected readonly editing = signal(false) - protected readonly next = signal('') - - protected readonly subnets = toSignal( - this.patch.watch$('wg', 'subnets').pipe( - map(s => - Object.entries(s).map(([range, { name, clients }]) => ({ - range, - name, - clients, - })), + protected readonly subnets: Signal = toSignal( + inject>(PatchDB) + .watch$('wg', 'subnets') + .pipe( + map(subnets => + Object.entries(subnets).map(([range, { name, clients }]) => ({ + range, + name, + clients, + })), + ), ), - ), { initialValue: [] }, ) - protected readonly href = computed( - () => `data:text/plain;charset=utf-8,${encodeURIComponent(this.config())}`, - ) protected readonly devices = computed(() => this.subnets().flatMap(subnet => Object.entries(subnet.clients).map(([ip, { name }]) => ({ @@ -279,129 +123,51 @@ export default class Devices { })), ), ) - protected readonly label = computed(() => - this.editing() ? 'Rename device' : 'Add device', - ) - - protected subnetDisplay: TuiStringHandler = subnet => - subnet.range ? `${subnet.name} (${subnet.range})` : '' protected onAdd() { - this.editing.set(false) - this.form.reset() - this.dialog.set(true) + this.dialogs + .open(DEVICES_ADD, { + label: 'Add device', + data: { subnets: this.subnets }, + }) + .subscribe() } protected onEdit(device: MappedDevice) { - this.editing.set(true) - this.form.reset(device) - this.dialog.set(true) + this.dialogs + .open(DEVICES_ADD, { + label: 'Rename device', + data: { device, subnets: this.subnets }, + }) + .subscribe() } - protected onSubnet(subnet: MappedSubnet) { - const ipNet = new IpNet(subnet.range) - - const used = Object.keys(subnet.clients).map(ip => - new utils.IpAddress(ip).octets.at(3), - ) - - for (let i = 2; i < totalHosts(ipNet.prefix); i++) { - if (!used.includes(i)) { - return this.form.controls.ip.setValue( - [...ipNet.octets.slice(0, 3), i].join('.'), - ) - } - } - // @TODO not working - this.form.controls.subnet.setErrors({ noHosts: 'No hosts available' }) - this.form.controls.ip.disable() - } - - async onConfig(device: MappedDevice) { + async onConfig({ subnet, ip }: MappedDevice) { const loader = this.loading.open().subscribe() try { - const config = await this.api.showDeviceConfig({ - subnet: device.subnet.range, - ip: device.ip, - }) + const data = await this.api.showDeviceConfig({ subnet: subnet.range, ip }) - this.config.set(config) - this.showConfig.set(true) + this.dialogs.open(DEVICES_CONFIG, { data }).subscribe() } catch (e) { console.log(e) } finally { loader.unsubscribe() - this.dialog.set(false) } } - 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 { + protected onDelete({ subnet, ip }: 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.range, - ip: device.ip, - }) + await this.api.deleteDevice({ subnet: subnet.range, 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: { - name: string - range: string - } - ip: string - name: string -} - -function totalHosts(prefix: number) { - // Handle special cases per RFC 3021 - if (prefix === 31) return 4 // point-to-point, 2 usable addresses - if (prefix === 32) return 3 // single host, 1 usable address - - return Math.pow(2, 32 - prefix) -} diff --git a/web/projects/start-tunnel/src/app/routes/home/routes/devices/utils.ts b/web/projects/start-tunnel/src/app/routes/home/routes/devices/utils.ts new file mode 100644 index 000000000..6e083ea4f --- /dev/null +++ b/web/projects/start-tunnel/src/app/routes/home/routes/devices/utils.ts @@ -0,0 +1,52 @@ +import { Signal } from '@angular/core' +import { AbstractControl } from '@angular/forms' +import { utils } from '@start9labs/start-sdk' +import { IpNet } from '@start9labs/start-sdk/util' +import { WgServer } from 'src/app/services/patch-db/data-model' + +export interface MappedDevice { + readonly subnet: { + readonly name: string + readonly range: string + } + readonly ip: string + readonly name: string +} + +export interface MappedSubnet { + readonly range: string + readonly name: string + readonly clients: WgServer['subnets']['']['clients'] +} + +export interface DeviceData { + readonly subnets: Signal + readonly device?: MappedDevice +} + +export function subnetValidator({ value }: AbstractControl) { + return value && getIp(value) ? null : { noHosts: 'No hosts available' } +} + +export function getIp({ clients, range }: MappedSubnet) { + const { prefix, octets } = new IpNet(range) + const used = Object.keys(clients).map(ip => + new utils.IpAddress(ip).octets.at(3), + ) + + for (let i = 2; i < totalHosts(prefix); i++) { + if (!used.includes(i)) { + return [...octets.slice(0, 3), i].join('.') + } + } + + return '' +} + +function totalHosts(prefix: number) { + // Handle special cases per RFC 3021 + if (prefix === 31) return 4 // point-to-point, 2 usable addresses + if (prefix === 32) return 3 // single host, 1 usable address + + return Math.pow(2, 32 - prefix) +} diff --git a/web/projects/start-tunnel/src/app/routes/home/routes/port-forwards/add.ts b/web/projects/start-tunnel/src/app/routes/home/routes/port-forwards/add.ts new file mode 100644 index 000000000..96657b288 --- /dev/null +++ b/web/projects/start-tunnel/src/app/routes/home/routes/port-forwards/add.ts @@ -0,0 +1,176 @@ +import { AsyncPipe } from '@angular/common' +import { ChangeDetectionStrategy, Component, inject } from '@angular/core' +import { + NonNullableFormBuilder, + ReactiveFormsModule, + Validators, +} from '@angular/forms' +import { LoadingService } from '@start9labs/shared' +import { + TUI_IS_MOBILE, + tuiMarkControlAsTouchedAndValidate, +} from '@taiga-ui/cdk' +import { + TuiButton, + TuiDialogContext, + TuiError, + TuiNumberFormat, + TuiTextfield, +} from '@taiga-ui/core' +import { + TuiChevron, + TuiDataListWrapper, + TuiFieldErrorPipe, + TuiInputNumber, + TuiSelect, +} from '@taiga-ui/kit' +import { TuiForm } from '@taiga-ui/layout' +import { injectContext, PolymorpheusComponent } from '@taiga-ui/polymorpheus' +import { ApiService } from 'src/app/services/api/api.service' + +import { MappedDevice, PortForwardsData } from './utils' + +@Component({ + template: ` +
+ + + @if (mobile) { + + } @else { + + } + @if (!mobile) { + + } + + + + + + + + + + @if (mobile) { + + } @else { + + } + @if (!mobile) { + + } + + + + + + + +
+ +
+ + `, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + AsyncPipe, + ReactiveFormsModule, + TuiButton, + TuiChevron, + TuiDataListWrapper, + TuiError, + TuiInputNumber, + TuiNumberFormat, + TuiFieldErrorPipe, + TuiTextfield, + TuiSelect, + TuiForm, + ], +}) +export class PortForwardsAdd { + private readonly api = inject(ApiService) + private readonly loading = inject(LoadingService) + + protected readonly mobile = inject(TUI_IS_MOBILE) + protected readonly context = + injectContext>() + + protected readonly form = inject(NonNullableFormBuilder).group({ + externalip: ['', Validators.required], + externalport: [null as number | null, Validators.required], + device: [null as MappedDevice | null, Validators.required], + internalport: [null as number | null, Validators.required], + }) + + protected readonly stringify = ({ ip, name }: MappedDevice) => + ip ? `${name} (${ip})` : '' + + 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.context.$implicit.complete() + } + } +} + +export const PORT_FORWARDS_ADD = new PolymorpheusComponent(PortForwardsAdd) diff --git a/web/projects/start-tunnel/src/app/routes/home/routes/port-forwards/index.ts b/web/projects/start-tunnel/src/app/routes/home/routes/port-forwards/index.ts index 62444c592..0564116d4 100644 --- a/web/projects/start-tunnel/src/app/routes/home/routes/port-forwards/index.ts +++ b/web/projects/start-tunnel/src/app/routes/home/routes/port-forwards/index.ts @@ -1,44 +1,25 @@ -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 { ReactiveFormsModule } from '@angular/forms' import { LoadingService } from '@start9labs/shared' import { utils } from '@start9labs/start-sdk' -import { - TUI_IS_MOBILE, - tuiMarkControlAsTouchedAndValidate, - TuiStringHandler, -} from '@taiga-ui/cdk' -import { - TuiButton, - TuiError, - TuiNumberFormat, - TuiTextfield, -} from '@taiga-ui/core' -import { TuiDialog, TuiDialogService } from '@taiga-ui/experimental' -import { - TUI_CONFIRM, - TuiChevron, - TuiDataListWrapper, - TuiFieldErrorPipe, - TuiInputNumber, - TuiSelect, -} from '@taiga-ui/kit' -import { TuiForm } from '@taiga-ui/layout' +import { TuiButton } from '@taiga-ui/core' +import { TuiDialogService } from '@taiga-ui/experimental' +import { TUI_CONFIRM } from '@taiga-ui/kit' import { PatchDB } from 'patch-db-client' -import { combineLatest, filter, map, Observable } from 'rxjs' +import { filter, map } from 'rxjs' +import { PORT_FORWARDS_ADD } from 'src/app/routes/home/routes/port-forwards/add' import { ApiService } from 'src/app/services/api/api.service' import { TunnelData } from 'src/app/services/patch-db/data-model' +import { MappedDevice, MappedForward } from './utils' + @Component({ template: ` @@ -77,234 +58,81 @@ import { TunnelData } from 'src/app/services/patch-db/data-model' }
- -
- - - @if (mobile) { - - } @else { - - } - @if (!mobile) { - - } - - - - - - - - - - @if (mobile) { - - } @else { - - } - @if (!mobile) { - - } - - - - - - - -
- -
- -
`, changeDetection: ChangeDetectionStrategy.OnPush, - imports: [ - AsyncPipe, - ReactiveFormsModule, - TuiButton, - TuiTextfield, - TuiDialog, - TuiForm, - TuiError, - TuiFieldErrorPipe, - TuiChevron, - TuiSelect, - TuiDataListWrapper, - TuiInputNumber, - TuiNumberFormat, - ], + imports: [ReactiveFormsModule, TuiButton], }) export default class PortForwards { private readonly dialogs = inject(TuiDialogService) private readonly api = inject(ApiService) private readonly loading = inject(LoadingService) private readonly patch = inject>(PatchDB) - protected readonly mobile = inject(TUI_IS_MOBILE) - protected readonly form = inject(NonNullableFormBuilder).group({ - externalip: ['', Validators.required], - externalport: [ - null, - [Validators.required, Validators.min(0), Validators.max(65535)], - ], - device: [{} as MappedDevice, Validators.required], - internalport: [ - null, - [Validators.required, Validators.min(0), Validators.max(65535)], - ], - }) - - protected readonly dialog = signal(false) - - protected readonly ips = toSignal( + private readonly portForwards = toSignal(this.patch.watch$('portForwards')) + private readonly ips = toSignal( this.patch.watch$('gateways').pipe( map(g => Object.values(g) - .filter( - val => - val.public ?? - val.ipInfo?.subnets.some(s => new utils.IpNet(s).isPublic()), + .flatMap( + val => val.ipInfo?.subnets.map(s => new utils.IpNet(s)) || [], ) - .map(val => val.ipInfo!.wanIp), + .filter(s => s.isIpv4() && s.isPublic()) + .map(s => s.address), ), ), { initialValue: [] }, ) - protected readonly devices$: Observable = this.patch - .watch$('wg', 'subnets') - .pipe( - map(s => - Object.values(s).flatMap(({ clients }) => - Object.entries(clients).map(([ip, { name }]) => ({ - ip, - name, - })), + private readonly devices: Signal = toSignal( + this.patch + .watch$('wg', 'subnets') + .pipe( + map(subnets => + Object.values(subnets).flatMap(({ clients }) => + Object.entries(clients).map(([ip, { name }]) => ({ ip, name })), + ), ), ), - ) - - protected readonly devices = toSignal(this.devices$, { - initialValue: [], - }) - - protected readonly forwards = toSignal( - combineLatest([this.devices$, this.patch.watch$('portForwards')]).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 = device => - device.ip ? `${device.name} (${device.ip})` : '' + protected readonly forwards = computed(() => + Object.entries(this.portForwards() || {}).map(([source, target]) => { + const sourceSplit = source.split(':') + const targetSplit = target.split(':') + + return { + externalip: sourceSplit[0]!, + externalport: sourceSplit[1]!, + device: this.devices().find(d => d.ip === targetSplit[0])!, + internalport: targetSplit[1]!, + } + }), + ) 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}`, + this.dialogs + .open(PORT_FORWARDS_ADD, { + label: 'Add port forward', + data: { ips: this.ips, devices: this.devices }, }) - } catch (e) { - console.error(e) - } finally { - loader.unsubscribe() - this.dialog.set(false) - } + .subscribe() } - protected onDelete(forward: MappedForward): void { + protected onDelete({ externalip, externalport }: MappedForward): void { this.dialogs .open(TUI_CONFIRM, { label: 'Are you sure?' }) .pipe(filter(Boolean)) .subscribe(async () => { const loader = this.loading.open().subscribe() + const source = `${externalip}:${externalport}` + try { - await this.api.deleteForward({ - source: `${forward.externalip}:${forward.externalport}`, - }) + await this.api.deleteForward({ source }) } 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 -} diff --git a/web/projects/start-tunnel/src/app/routes/home/routes/port-forwards/utils.ts b/web/projects/start-tunnel/src/app/routes/home/routes/port-forwards/utils.ts new file mode 100644 index 000000000..101c1eba9 --- /dev/null +++ b/web/projects/start-tunnel/src/app/routes/home/routes/port-forwards/utils.ts @@ -0,0 +1,18 @@ +import { Signal } from '@angular/core' + +export interface MappedDevice { + readonly ip: string + readonly name: string +} + +export interface MappedForward { + readonly externalip: string + readonly externalport: string + readonly device: MappedDevice + readonly internalport: string +} + +export interface PortForwardsData { + readonly ips: Signal + readonly devices: Signal +} diff --git a/web/projects/start-tunnel/src/app/routes/home/routes/settings/index.ts b/web/projects/start-tunnel/src/app/routes/home/routes/settings/index.ts index 9dc3f6c3c..f0f82d900 100644 --- a/web/projects/start-tunnel/src/app/routes/home/routes/settings/index.ts +++ b/web/projects/start-tunnel/src/app/routes/home/routes/settings/index.ts @@ -5,27 +5,36 @@ import { inject, signal, } from '@angular/core' +import { toSignal } from '@angular/core/rxjs-interop' import { NonNullableFormBuilder, ReactiveFormsModule, + ValidatorFn, Validators, } from '@angular/forms' import { DialogService, ErrorService } from '@start9labs/shared' import { TuiResponsiveDialogService } from '@taiga-ui/addon-mobile' +import { tuiMarkControlAsTouchedAndValidate, TuiValidator } from '@taiga-ui/cdk' import { TuiAlertService, + TuiAppearance, TuiButton, TuiError, TuiTextfield, TuiTitle, } from '@taiga-ui/core' -import { TuiButtonLoading, TuiFieldErrorPipe } from '@taiga-ui/kit' +import { + TuiButtonLoading, + TuiFieldErrorPipe, + tuiValidationErrorsProvider, +} from '@taiga-ui/kit' import { TuiCard, TuiForm, TuiHeader } from '@taiga-ui/layout' +import { map } from 'rxjs' import { ApiService } from 'src/app/services/api/api.service' @Component({ template: ` -
+

Settings @@ -42,29 +51,29 @@ import { ApiService } from 'src/app/services/api/api.service' /> - +
- +
`, - styles: ` - form { - background: var(--tui-background-neutral-1); - } - `, + providers: [ + tuiValidationErrorsProvider({ + required: 'This field is required', + minlength: 'Password must be at least 8 characters', + maxlength: 'Password cannot exceed 64 characters', + match: 'Passwords do not match', + }), + ], changeDetection: ChangeDetectionStrategy.OnPush, imports: [ ReactiveFormsModule, @@ -78,6 +87,8 @@ import { ApiService } from 'src/app/services/api/api.service' TuiFieldErrorPipe, TuiButton, TuiButtonLoading, + TuiValidator, + TuiAppearance, ], }) export default class Settings { @@ -86,7 +97,6 @@ export default class Settings { private readonly errorService = inject(ErrorService) protected readonly loading = signal(false) - protected readonly form = inject(NonNullableFormBuilder).group({ password: [ '', @@ -98,26 +108,30 @@ export default class Settings { ], }) - protected async onSave() { - const { password, confirm } = this.form.getRawValue() + protected readonly matchValidator = toSignal( + this.form.controls.password.valueChanges.pipe( + map( + (password): ValidatorFn => + ({ value }) => + value === password ? null : { match: true }, + ), + ), + { initialValue: Validators.nullValidator }, + ) + + protected async onSave() { + if (this.form.invalid) { + tuiMarkControlAsTouchedAndValidate(this.form) - if (password !== confirm) { - // @TODO not working - this.form.controls.confirm.setErrors({ - notEqual: 'New passwords do not match', - }) return } this.loading.set(true) try { - await this.api.setPassword({ password }) + await this.api.setPassword({ password: this.form.getRawValue().password }) this.alerts - .open('Password changed', { - label: 'Success', - appearance: 'positive', - }) + .open('Password changed', { label: 'Success', appearance: 'positive' }) .subscribe() this.form.reset() } catch (e: any) { diff --git a/web/projects/start-tunnel/src/app/routes/home/routes/subnets/add.ts b/web/projects/start-tunnel/src/app/routes/home/routes/subnets/add.ts new file mode 100644 index 000000000..6a25dbb54 --- /dev/null +++ b/web/projects/start-tunnel/src/app/routes/home/routes/subnets/add.ts @@ -0,0 +1,102 @@ +import { AsyncPipe } from '@angular/common' +import { ChangeDetectionStrategy, Component, inject } from '@angular/core' +import { + NonNullableFormBuilder, + ReactiveFormsModule, + Validators, +} from '@angular/forms' +import { LoadingService } from '@start9labs/shared' +import { TuiAutoFocus, tuiMarkControlAsTouchedAndValidate } from '@taiga-ui/cdk' +import { + TuiButton, + TuiDialogContext, + TuiError, + TuiTextfield, +} from '@taiga-ui/core' +import { TuiFieldErrorPipe } from '@taiga-ui/kit' +import { TuiForm } from '@taiga-ui/layout' +import { injectContext, PolymorpheusComponent } from '@taiga-ui/polymorpheus' +import { ApiService } from 'src/app/services/api/api.service' + +@Component({ + template: ` +
+ + + + + + @if (!context.data.name) { + + + + + + } +
+ +
+ + `, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + AsyncPipe, + ReactiveFormsModule, + TuiAutoFocus, + TuiButton, + TuiError, + TuiFieldErrorPipe, + TuiForm, + TuiTextfield, + ], +}) +export class SubnetsAdd { + private readonly api = inject(ApiService) + private readonly loading = inject(LoadingService) + + protected readonly context = injectContext>() + protected readonly form = inject(NonNullableFormBuilder).group({ + name: [this.context.data.name, Validators.required], + subnet: [ + this.context.data.subnet, + [ + Validators.required, + Validators.pattern( + '^(?:(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)\\.){3}(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)/(?:[12]?\\d|3[0-2])$', + ), + ], + ], + }) + + protected async onSave() { + if (this.form.invalid) { + tuiMarkControlAsTouchedAndValidate(this.form) + + return + } + + const loader = this.loading.open().subscribe() + const value = this.form.getRawValue() + + try { + this.context.data.name + ? await this.api.editSubnet(value) + : await this.api.addSubnet(value) + } catch (e) { + console.log(e) + } finally { + loader.unsubscribe() + this.context.$implicit.complete() + } + } +} + +export const SUBNETS_ADD = new PolymorpheusComponent(SubnetsAdd) + +interface Data { + name: string + subnet: string +} diff --git a/web/projects/start-tunnel/src/app/routes/home/routes/subnets/index.ts b/web/projects/start-tunnel/src/app/routes/home/routes/subnets/index.ts index f8729526c..6663fedaa 100644 --- a/web/projects/start-tunnel/src/app/routes/home/routes/subnets/index.ts +++ b/web/projects/start-tunnel/src/app/routes/home/routes/subnets/index.ts @@ -1,35 +1,22 @@ -import { AsyncPipe } from '@angular/common' -import { - ChangeDetectionStrategy, - Component, - computed, - inject, - signal, -} from '@angular/core' +import { ChangeDetectionStrategy, Component, inject } from '@angular/core' import { toSignal } from '@angular/core/rxjs-interop' -import { - NonNullableFormBuilder, - ReactiveFormsModule, - Validators, -} from '@angular/forms' import { LoadingService } from '@start9labs/shared' import { utils } from '@start9labs/start-sdk' -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 { TuiDialogService } from '@taiga-ui/experimental' +import { TUI_CONFIRM } from '@taiga-ui/kit' import { PatchDB } from 'patch-db-client' import { filter, map } from 'rxjs' import { ApiService } from 'src/app/services/api/api.service' import { TunnelData } from 'src/app/services/patch-db/data-model' +import { SUBNETS_ADD } from './add' + @Component({ template: ` @@ -83,84 +70,67 @@ import { TunnelData } from 'src/app/services/patch-db/data-model' }
- -
- - - - - - @if (!editing()) { - - - - - - } -
- -
- -
`, changeDetection: ChangeDetectionStrategy.OnPush, - imports: [ - AsyncPipe, - ReactiveFormsModule, - TuiButton, - TuiDropdown, - TuiDataList, - TuiTextfield, - TuiDialog, - TuiForm, - TuiError, - TuiFieldErrorPipe, - TuiAutoFocus, - ], + imports: [TuiButton, TuiDropdown, TuiDataList, TuiTextfield], }) export default class Subnets { private readonly dialogs = inject(TuiDialogService) private readonly api = inject(ApiService) private readonly loading = inject(LoadingService) - private readonly patch = inject>(PatchDB) - protected readonly form = inject(NonNullableFormBuilder).group({ - name: ['', Validators.required], - subnet: [ - '', - [ - Validators.required, - Validators.pattern( - '^(?:(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)\\.){3}(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)/(?:[12]?\\d|3[0-2])$', - ), - ], - ], - }) - - protected readonly dialog = signal(false) - protected readonly editing = signal(false) 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, - })), + inject>(PatchDB) + .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(() => { + protected onAdd(): void { + this.dialogs + .open(SUBNETS_ADD, { + label: 'Add Subnet', + data: { subnet: this.getNext() }, + }) + .subscribe() + } + + protected onEdit({ range, name }: MappedSubnet): void { + this.dialogs + .open(SUBNETS_ADD, { + label: 'Rename Subnet', + data: { subnet: range, name }, + }) + .subscribe() + } + + protected onDelete(index: number): void { + this.dialogs + .open(TUI_CONFIRM, { label: 'Are you sure?' }) + .pipe(filter(Boolean)) + .subscribe(async () => { + const subnet = this.subnets()[index]?.range || '' + const loader = this.loading.open().subscribe() + + try { + await this.api.deleteSubnet({ subnet }) + } catch (e) { + console.log(e) + } finally { + loader.unsubscribe() + } + }) + } + + private getNext(): string { const used = this.subnets().map(s => new utils.IpNet(s.range).octets.at(2)) for (let i = 0; i < 256; i++) { @@ -171,63 +141,6 @@ export default class Subnets { // No recommendation if /24 subnets are used return '' - }) - - protected readonly label = computed(() => - this.editing() ? 'Rename Subnet' : 'Add Subnet', - ) - - protected onAdd(): void { - this.editing.set(false) - this.form.reset({ subnet: this.next() }) - this.dialog.set(true) - } - - protected onEdit(subnet: MappedSubnet): void { - this.editing.set(true) - this.form.reset({ subnet: subnet.range, name: subnet.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) - } - }) } } diff --git a/web/projects/start-tunnel/src/app/services/api/mock-api.service.ts b/web/projects/start-tunnel/src/app/services/api/mock-api.service.ts index 1111a1b77..7f35f6a05 100644 --- a/web/projects/start-tunnel/src/app/services/api/mock-api.service.ts +++ b/web/projects/start-tunnel/src/app/services/api/mock-api.service.ts @@ -173,7 +173,7 @@ export class MockApiService extends ApiService { const patch: AddOperation[] = [ { op: PatchOp.ADD, - path: `/port_forwards/${params.source}`, + path: `/portForwards/${params.source}`, value: params.target, }, ] @@ -188,7 +188,7 @@ export class MockApiService extends ApiService { const patch: RemoveOperation[] = [ { op: PatchOp.REMOVE, - path: `/port_forwards/${params.source}`, + path: `/portForwards/${params.source}`, }, ] this.mockRevision(patch) diff --git a/web/projects/start-tunnel/src/app/services/patch-db/data-model.ts b/web/projects/start-tunnel/src/app/services/patch-db/data-model.ts index 9a3dc4fa2..f84546717 100644 --- a/web/projects/start-tunnel/src/app/services/patch-db/data-model.ts +++ b/web/projects/start-tunnel/src/app/services/patch-db/data-model.ts @@ -45,14 +45,14 @@ export const mockTunnelData: TunnelData = { gateways: { eth0: { name: null, - public: true, + public: null, secure: null, ipInfo: { name: 'Wired Connection 1', scopeId: 1, deviceType: 'ethernet', - subnets: ['10.59.0.0/24'], - wanIp: '203.0.113.45', + subnets: ['69.1.1.42/24'], + wanIp: null, ntpServers: [], lanIp: ['10.59.0.1'], dnsServers: [], diff --git a/web/projects/ui/src/app/routes/portal/components/interfaces/tor-domains.component.ts b/web/projects/ui/src/app/routes/portal/components/interfaces/tor-domains.component.ts index c0861e075..066e93495 100644 --- a/web/projects/ui/src/app/routes/portal/components/interfaces/tor-domains.component.ts +++ b/web/projects/ui/src/app/routes/portal/components/interfaces/tor-domains.component.ts @@ -172,7 +172,7 @@ export class InterfaceTorDomainsComponent { ? await this.api.addTorKey({ key }) : await this.api.generateTorKey({}) - if (this.interface.packageId) { + if (this.interface.packageId()) { await this.api.pkgAddOnion({ onion, package: this.interface.packageId(),