diff --git a/core/src/tunnel/api.rs b/core/src/tunnel/api.rs index 10c2f21c2..85d226edd 100644 --- a/core/src/tunnel/api.rs +++ b/core/src/tunnel/api.rs @@ -11,6 +11,7 @@ use crate::db::model::public::NetworkInterfaceType; use crate::net::forward::add_iptables_rule; use crate::prelude::*; use crate::tunnel::context::TunnelContext; +use crate::tunnel::db::PortForwardEntry; use crate::tunnel::wg::{WIREGUARD_INTERFACE_NAME, WgConfig, WgSubnetClients, WgSubnetConfig}; use crate::util::serde::{HandlerExtSerde, display_serializable}; @@ -51,6 +52,14 @@ pub fn tunnel_api() -> ParentHandler { .no_display() .with_about("about.remove-port-forward") .with_call_remote::(), + ) + .subcommand( + "update-label", + from_fn_async(update_forward_label) + .with_metadata("sync_db", Value::Bool(true)) + .no_display() + .with_about("about.update-port-forward-label") + .with_call_remote::(), ), ) .subcommand( @@ -453,11 +462,17 @@ pub async fn show_config( pub struct AddPortForwardParams { source: SocketAddrV4, target: SocketAddrV4, + #[arg(long)] + label: String, } pub async fn add_forward( ctx: TunnelContext, - AddPortForwardParams { source, target }: AddPortForwardParams, + AddPortForwardParams { + source, + target, + label, + }: AddPortForwardParams, ) -> Result<(), Error> { let prefix = ctx .net_iface @@ -482,10 +497,12 @@ pub async fn add_forward( m.insert(source, rc); }); + let entry = PortForwardEntry { target, label }; + ctx.db .mutate(|db| { db.as_port_forwards_mut() - .insert(&source, &target) + .insert(&source, &entry) .and_then(|replaced| { if replaced.is_some() { Err(Error::new( @@ -523,3 +540,31 @@ pub async fn remove_forward( } Ok(()) } + +#[derive(Deserialize, Serialize, Parser)] +#[serde(rename_all = "camelCase")] +pub struct UpdatePortForwardLabelParams { + source: SocketAddrV4, + label: String, +} + +pub async fn update_forward_label( + ctx: TunnelContext, + UpdatePortForwardLabelParams { source, label }: UpdatePortForwardLabelParams, +) -> Result<(), Error> { + ctx.db + .mutate(|db| { + db.as_port_forwards_mut().mutate(|pf| { + let entry = pf.0.get_mut(&source).ok_or_else(|| { + Error::new( + eyre!("Port forward from {source} not found"), + ErrorKind::NotFound, + ) + })?; + entry.label = label.clone(); + Ok(()) + }) + }) + .await + .result +} diff --git a/core/src/tunnel/context.rs b/core/src/tunnel/context.rs index ac56eaa36..da9f61297 100644 --- a/core/src/tunnel/context.rs +++ b/core/src/tunnel/context.rs @@ -184,7 +184,8 @@ impl TunnelContext { } let mut active_forwards = BTreeMap::new(); - for (from, to) in peek.as_port_forwards().de()?.0 { + for (from, entry) in peek.as_port_forwards().de()?.0 { + let to = entry.target; let prefix = net_iface .peek(|i| { i.iter() diff --git a/core/src/tunnel/db.rs b/core/src/tunnel/db.rs index bd83305fd..da13209b5 100644 --- a/core/src/tunnel/db.rs +++ b/core/src/tunnel/db.rs @@ -53,7 +53,7 @@ impl Model { } self.as_port_forwards_mut().mutate(|pf| { Ok(pf.0.retain(|k, v| { - if keep_targets.contains(v.ip()) { + if keep_targets.contains(v.target.ip()) { keep_sources.insert(*k); true } else { @@ -70,11 +70,19 @@ fn export_bindings_tunnel_db() { TunnelDatabase::export_all_to("bindings/tunnel").unwrap(); } +#[derive(Clone, Debug, Deserialize, Serialize, TS)] +#[serde(rename_all = "camelCase")] +pub struct PortForwardEntry { + pub target: SocketAddrV4, + #[serde(default)] + pub label: String, +} + #[derive(Clone, Debug, Default, Deserialize, Serialize, TS)] -pub struct PortForwards(pub BTreeMap); +pub struct PortForwards(pub BTreeMap); impl Map for PortForwards { type Key = SocketAddrV4; - type Value = SocketAddrV4; + type Value = PortForwardEntry; fn key_str(key: &Self::Key) -> Result, Error> { Self::key_string(key) } diff --git a/web/projects/start-tunnel/src/app/routes/home/components/nav.ts b/web/projects/start-tunnel/src/app/routes/home/components/nav.ts index 0473d0b63..5d978f4ca 100644 --- a/web/projects/start-tunnel/src/app/routes/home/components/nav.ts +++ b/web/projects/start-tunnel/src/app/routes/home/components/nav.ts @@ -1,10 +1,7 @@ import { ChangeDetectionStrategy, Component, inject } from '@angular/core' -import { Router, RouterLink, RouterLinkActive } from '@angular/router' -import { ErrorService, LoadingService } from '@start9labs/shared' +import { RouterLink, RouterLinkActive } from '@angular/router' import { TuiButton } from '@taiga-ui/core' import { TuiBadgeNotification } from '@taiga-ui/kit' -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' import { UpdateService } from 'src/app/services/update.service' @@ -38,15 +35,6 @@ import { UpdateService } from 'src/app/services/update.service' } - `, styles: ` :host { @@ -79,12 +67,6 @@ import { UpdateService } from 'src/app/services/update.service' } } - button { - width: 100%; - border-radius: 0; - justify-content: flex-start; - } - :host-context(tui-root._mobile) { position: absolute; top: 3.5rem; @@ -106,12 +88,7 @@ import { UpdateService } from 'src/app/services/update.service' }, }) 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) - private readonly errorService = inject(ErrorService) protected readonly update = inject(UpdateService) protected readonly routes = [ @@ -131,18 +108,4 @@ export class Nav { link: 'port-forwards', }, ] 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: any) { - console.error(e) - this.errorService.handleError(e) - } finally { - loader.unsubscribe() - } - } } 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 index 195c3bf99..ab9f9560f 100644 --- 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 @@ -36,6 +36,11 @@ import { MappedDevice, PortForwardsData } from './utils' @Component({ template: `
+ + + + + @if (mobile) { @@ -161,6 +166,7 @@ export class PortForwardsAdd { injectContext>() protected readonly form = inject(NonNullableFormBuilder).group({ + label: ['', Validators.required], externalip: ['', Validators.required], externalport: [null as number | null, Validators.required], device: [null as MappedDevice | null, Validators.required], @@ -185,19 +191,21 @@ export class PortForwardsAdd { const loader = this.loading.open().subscribe() - const { externalip, externalport, device, internalport, also80 } = + const { label, externalip, externalport, device, internalport, also80 } = this.form.getRawValue() try { await this.api.addForward({ source: `${externalip}:${externalport}`, target: `${device!.ip}:${internalport}`, + label, }) if (externalport === 443 && internalport === 443 && also80) { await this.api.addForward({ source: `${externalip}:80`, target: `${device!.ip}:443`, + label: `${label} (HTTP redirect)`, }) } } catch (e: any) { diff --git a/web/projects/start-tunnel/src/app/routes/home/routes/port-forwards/edit-label.ts b/web/projects/start-tunnel/src/app/routes/home/routes/port-forwards/edit-label.ts new file mode 100644 index 000000000..3f98f0a74 --- /dev/null +++ b/web/projects/start-tunnel/src/app/routes/home/routes/port-forwards/edit-label.ts @@ -0,0 +1,83 @@ +import { AsyncPipe } from '@angular/common' +import { ChangeDetectionStrategy, Component, inject } from '@angular/core' +import { + NonNullableFormBuilder, + ReactiveFormsModule, + Validators, +} from '@angular/forms' +import { ErrorService, LoadingService } from '@start9labs/shared' +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' + +export interface EditLabelData { + readonly source: string + readonly label: string +} + +@Component({ + template: ` + + + + + + +
+ +
+ + `, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + AsyncPipe, + ReactiveFormsModule, + TuiButton, + TuiError, + TuiFieldErrorPipe, + TuiTextfield, + TuiForm, + ], +}) +export class PortForwardsEditLabel { + private readonly api = inject(ApiService) + private readonly loading = inject(LoadingService) + private readonly errorService = inject(ErrorService) + + protected readonly context = + injectContext>() + + protected readonly form = inject(NonNullableFormBuilder).group({ + label: [this.context.data.label, Validators.required], + }) + + protected async onSave() { + const loader = this.loading.open().subscribe() + + try { + await this.api.updateForwardLabel({ + source: this.context.data.source, + label: this.form.getRawValue().label, + }) + } catch (e: any) { + console.error(e) + this.errorService.handleError(e) + } finally { + loader.unsubscribe() + this.context.$implicit.complete() + } + } +} + +export const PORT_FORWARDS_EDIT_LABEL = new PolymorpheusComponent( + PortForwardsEditLabel, +) 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 b607f1876..ca7718288 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 @@ -6,15 +6,20 @@ import { Signal, } from '@angular/core' import { toSignal } from '@angular/core/rxjs-interop' -import { ReactiveFormsModule } from '@angular/forms' import { ErrorService, LoadingService } from '@start9labs/shared' import { utils } from '@start9labs/start-sdk' -import { TuiButton } from '@taiga-ui/core' +import { + TuiButton, + TuiDataList, + TuiDropdown, + TuiTextfield, +} from '@taiga-ui/core' 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 { PORT_FORWARDS_ADD } from 'src/app/routes/home/routes/port-forwards/add' +import { PORT_FORWARDS_EDIT_LABEL } from 'src/app/routes/home/routes/port-forwards/edit-label' import { ApiService } from 'src/app/services/api/api.service' import { TunnelData } from 'src/app/services/patch-db/data-model' @@ -25,6 +30,7 @@ import { MappedDevice, MappedForward } from './utils' + @@ -39,6 +45,7 @@ import { MappedDevice, MappedForward } from './utils' @for (forward of forwards(); track $index) { + @@ -47,11 +54,30 @@ import { MappedDevice, MappedForward } from './utils' + + @@ -62,7 +88,7 @@ import { MappedDevice, MappedForward } from './utils'
Label External IP External Port Device
{{ forward.label || '—' }} {{ forward.externalip }} {{ forward.externalport }} {{ forward.device.name }}
`, changeDetection: ChangeDetectionStrategy.OnPush, - imports: [ReactiveFormsModule, TuiButton], + imports: [TuiButton, TuiDropdown, TuiDataList, TuiTextfield], }) export default class PortForwards { private readonly dialogs = inject(TuiDialogService) @@ -100,15 +126,16 @@ export default class PortForwards { ) protected readonly forwards = computed(() => - Object.entries(this.portForwards() || {}).map(([source, target]) => { + Object.entries(this.portForwards() || {}).map(([source, entry]) => { const sourceSplit = source.split(':') - const targetSplit = target.split(':') + const targetSplit = entry.target.split(':') return { externalip: sourceSplit[0]!, externalport: sourceSplit[1]!, device: this.devices().find(d => d.ip === targetSplit[0])!, internalport: targetSplit[1]!, + label: entry.label, } }), ) @@ -122,6 +149,18 @@ export default class PortForwards { .subscribe() } + protected onEditLabel(forward: MappedForward): void { + this.dialogs + .open(PORT_FORWARDS_EDIT_LABEL, { + label: 'Edit label', + data: { + source: `${forward.externalip}:${forward.externalport}`, + label: forward.label, + }, + }) + .subscribe() + } + protected onDelete({ externalip, externalport }: MappedForward): void { this.dialogs .open(TUI_CONFIRM, { label: 'Are you sure?' }) 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 index 101c1eba9..973f3faa8 100644 --- 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 @@ -10,6 +10,7 @@ export interface MappedForward { readonly externalport: string readonly device: MappedDevice readonly internalport: string + readonly label: string } export interface PortForwardsData { 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 e2360e52f..4a871ae99 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 @@ -4,11 +4,14 @@ import { inject, signal, } from '@angular/core' -import { ErrorService } from '@start9labs/shared' +import { Router } from '@angular/router' +import { ErrorService, LoadingService } from '@start9labs/shared' import { TuiAppearance, TuiButton, TuiTitle } from '@taiga-ui/core' import { TuiDialogService } from '@taiga-ui/experimental' import { TuiBadge, TuiButtonLoading } from '@taiga-ui/kit' import { TuiCard, TuiCell } from '@taiga-ui/layout' +import { ApiService } from 'src/app/services/api/api.service' +import { AuthService } from 'src/app/services/auth.service' import { UpdateService } from 'src/app/services/update.service' import { CHANGE_PASSWORD } from './change-password' @@ -50,6 +53,20 @@ import { CHANGE_PASSWORD } from './change-password' +
+ + Logout + + +
`, changeDetection: ChangeDetectionStrategy.OnPush, @@ -66,6 +83,10 @@ import { CHANGE_PASSWORD } from './change-password' export default class Settings { private readonly dialogs = inject(TuiDialogService) private readonly errorService = inject(ErrorService) + private readonly api = inject(ApiService) + private readonly auth = inject(AuthService) + private readonly router = inject(Router) + private readonly loading = inject(LoadingService) protected readonly update = inject(UpdateService) protected readonly checking = signal(false) @@ -98,4 +119,18 @@ export default class Settings { this.applying.set(false) } } + + protected async onLogout() { + const loader = this.loading.open().subscribe() + + try { + await this.api.logout() + this.auth.authenticated.set(false) + this.router.navigate(['/']) + } catch (e: any) { + this.errorService.handleError(e) + } finally { + loader.unsubscribe() + } + } } diff --git a/web/projects/start-tunnel/src/app/services/api/api.service.ts b/web/projects/start-tunnel/src/app/services/api/api.service.ts index 401d7f43c..e91f5fa78 100644 --- a/web/projects/start-tunnel/src/app/services/api/api.service.ts +++ b/web/projects/start-tunnel/src/app/services/api/api.service.ts @@ -25,6 +25,7 @@ export abstract class ApiService { // forwards abstract addForward(params: AddForwardReq): Promise // port-forward.add abstract deleteForward(params: DeleteForwardReq): Promise // port-forward.remove + abstract updateForwardLabel(params: UpdateForwardLabelReq): Promise // port-forward.update-label // update abstract checkUpdate(): Promise // update.check abstract applyUpdate(): Promise // update.apply @@ -60,12 +61,18 @@ export type DeleteDeviceReq = { export type AddForwardReq = { source: string // externalip:port target: string // internalip:port + label: string } export type DeleteForwardReq = { source: string } +export type UpdateForwardLabelReq = { + source: string + label: string +} + export type TunnelUpdateResult = { status: string installed: string diff --git a/web/projects/start-tunnel/src/app/services/api/live-api.service.ts b/web/projects/start-tunnel/src/app/services/api/live-api.service.ts index cabf8200f..cb3896d97 100644 --- a/web/projects/start-tunnel/src/app/services/api/live-api.service.ts +++ b/web/projects/start-tunnel/src/app/services/api/live-api.service.ts @@ -17,6 +17,7 @@ import { LoginReq, SubscribeRes, TunnelUpdateResult, + UpdateForwardLabelReq, UpsertDeviceReq, UpsertSubnetReq, } from './api.service' @@ -104,6 +105,10 @@ export class LiveApiService extends ApiService { return this.rpcRequest({ method: 'port-forward.remove', params }) } + async updateForwardLabel(params: UpdateForwardLabelReq): Promise { + return this.rpcRequest({ method: 'port-forward.update-label', params }) + } + // update async checkUpdate(): Promise { 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 6f82c597f..1dc50bdef 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 @@ -10,6 +10,7 @@ import { LoginReq, SubscribeRes, TunnelUpdateResult, + UpdateForwardLabelReq, UpsertDeviceReq, UpsertSubnetReq, } from './api.service' @@ -24,7 +25,12 @@ import { Revision, } from 'patch-db-client' import { toObservable } from '@angular/core/rxjs-interop' -import { mockTunnelData, WgClient, WgSubnet } from '../patch-db/data-model' +import { + mockTunnelData, + PortForwardEntry, + WgClient, + WgSubnet, +} from '../patch-db/data-model' @Injectable({ providedIn: 'root', @@ -171,11 +177,26 @@ export class MockApiService extends ApiService { async addForward(params: AddForwardReq): Promise { await pauseFor(1000) - const patch: AddOperation[] = [ + const patch: AddOperation[] = [ { op: PatchOp.ADD, path: `/portForwards/${params.source}`, - value: params.target, + value: { target: params.target, label: params.label || '' }, + }, + ] + this.mockRevision(patch) + + return null + } + + async updateForwardLabel(params: UpdateForwardLabelReq): Promise { + await pauseFor(1000) + + const patch: ReplaceOperation[] = [ + { + op: PatchOp.REPLACE, + path: `/portForwards/${params.source}/label`, + value: params.label, }, ] 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 9df4fac6d..c4c046c93 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 @@ -1,8 +1,13 @@ import { T } from '@start9labs/start-sdk' +export type PortForwardEntry = { + target: string + label: string +} + export type TunnelData = { wg: WgServer - portForwards: Record + portForwards: Record gateways: Record } @@ -39,8 +44,8 @@ export const mockTunnelData: TunnelData = { }, }, portForwards: { - '69.1.1.42:443': '10.59.0.2:443', - '69.1.1.42:3000': '10.59.0.2:3000', + '69.1.1.42:443': { target: '10.59.0.2:443', label: 'HTTPS' }, + '69.1.1.42:3000': { target: '10.59.0.2:3000', label: 'Grafana' }, }, gateways: { eth0: {