From 101cac077e2866e4bcfa02e70802922ff34c1943 Mon Sep 17 00:00:00 2001 From: Matt Hill <9935159+MattDHill@users.noreply.github.com> Date: Wed, 8 Jul 2026 17:44:06 -0600 Subject: [PATCH] feat(start-tunnel): help sidebar, i18n, table sorting; rename Port Forwards to Published Ports - contextual help sidebar (per-screen + in-dialog) with links to the docs - i18n infrastructure + language switcher; English and Spanish dictionaries - sortable columns on the subnets, devices, published-ports, and DNS tables - rename Port Forwards -> Published Ports across UI + docs (CLI/RPC unchanged); device 'Auto Port Forward' capability -> 'Auto-publish' - DNS 'Name' column -> 'Hostname'; expand subnets/DNS docs Rolled into the unreleased 1.1.0 changelog; no version bump. Co-Authored-By: Claude Opus 4.8 (1M context) --- package.json | 3 +- projects/start-tunnel/AGENTS.md | 2 +- projects/start-tunnel/CHANGELOG.md | 17 +- projects/start-tunnel/docs/src/README.md | 2 +- projects/start-tunnel/docs/src/SUMMARY.md | 2 +- .../start-tunnel/docs/src/architecture.md | 12 +- .../start-tunnel/docs/src/cli-reference.md | 2 +- projects/start-tunnel/docs/src/devices.md | 14 +- projects/start-tunnel/docs/src/dns-records.md | 30 +- projects/start-tunnel/docs/src/faq.md | 10 +- .../start-tunnel/docs/src/http-redirects.md | 16 +- projects/start-tunnel/docs/src/installing.md | 6 +- projects/start-tunnel/docs/src/ipv6.md | 6 +- .../start-tunnel/docs/src/port-forwarding.md | 44 --- .../start-tunnel/docs/src/published-ports.md | 44 +++ projects/start-tunnel/docs/src/subnets.md | 28 +- .../start-tunnel/web/scripts/check-i18n.mjs | 162 +++++++++++ .../start-tunnel/web/src/app/app.config.ts | 17 +- .../start-tunnel/web/src/app/help/aside.ts | 80 ++++++ .../web/src/app/help/content/de.ts | 5 + .../web/src/app/help/content/en.ts | 263 ++++++++++++++++++ .../web/src/app/help/content/es.ts | 251 +++++++++++++++++ .../web/src/app/help/content/fr.ts | 5 + .../web/src/app/help/content/pl.ts | 5 + .../start-tunnel/web/src/app/help/help.ts | 76 +++++ .../web/src/app/help/modal-help.ts | 113 ++++++++ .../web/src/app/i18n/dictionaries/de.ts | 8 + .../web/src/app/i18n/dictionaries/en.ts | 144 ++++++++++ .../web/src/app/i18n/dictionaries/es.ts | 135 +++++++++ .../web/src/app/i18n/dictionaries/fr.ts | 8 + .../web/src/app/i18n/dictionaries/pl.ts | 8 + .../web/src/app/i18n/i18n.pipe.ts | 27 ++ .../web/src/app/i18n/i18n.providers.ts | 75 +++++ .../web/src/app/i18n/validation-errors.ts | 79 ++++++ .../src/app/routes/home/components/outlet.ts | 39 ++- .../web/src/app/routes/home/index.ts | 6 +- .../src/app/routes/home/routes/devices/add.ts | 44 +-- .../app/routes/home/routes/devices/config.ts | 14 +- .../app/routes/home/routes/devices/index.ts | 159 +++++++---- .../app/routes/home/routes/devices/utils.ts | 29 +- .../web/src/app/routes/home/routes/dns/add.ts | 33 ++- .../src/app/routes/home/routes/dns/index.ts | 89 +++--- .../{port-forwards => published-ports}/add.ts | 62 +++-- .../edit-label.ts | 24 +- .../index.ts | 134 +++++---- .../utils.ts | 2 +- .../home/routes/settings/change-password.ts | 22 +- .../app/routes/home/routes/settings/index.ts | 104 +++++-- .../src/app/routes/home/routes/subnets/add.ts | 44 ++- .../app/routes/home/routes/subnets/index.ts | 114 +++++--- .../web/src/app/routes/login/index.ts | 16 +- projects/start-tunnel/web/src/styles.scss | 84 ++++++ shared-libs/ts-modules/scripts/check-i18n.mjs | 4 +- 53 files changed, 2320 insertions(+), 402 deletions(-) delete mode 100644 projects/start-tunnel/docs/src/port-forwarding.md create mode 100644 projects/start-tunnel/docs/src/published-ports.md create mode 100644 projects/start-tunnel/web/scripts/check-i18n.mjs create mode 100644 projects/start-tunnel/web/src/app/help/aside.ts create mode 100644 projects/start-tunnel/web/src/app/help/content/de.ts create mode 100644 projects/start-tunnel/web/src/app/help/content/en.ts create mode 100644 projects/start-tunnel/web/src/app/help/content/es.ts create mode 100644 projects/start-tunnel/web/src/app/help/content/fr.ts create mode 100644 projects/start-tunnel/web/src/app/help/content/pl.ts create mode 100644 projects/start-tunnel/web/src/app/help/help.ts create mode 100644 projects/start-tunnel/web/src/app/help/modal-help.ts create mode 100644 projects/start-tunnel/web/src/app/i18n/dictionaries/de.ts create mode 100644 projects/start-tunnel/web/src/app/i18n/dictionaries/en.ts create mode 100644 projects/start-tunnel/web/src/app/i18n/dictionaries/es.ts create mode 100644 projects/start-tunnel/web/src/app/i18n/dictionaries/fr.ts create mode 100644 projects/start-tunnel/web/src/app/i18n/dictionaries/pl.ts create mode 100644 projects/start-tunnel/web/src/app/i18n/i18n.pipe.ts create mode 100644 projects/start-tunnel/web/src/app/i18n/i18n.providers.ts create mode 100644 projects/start-tunnel/web/src/app/i18n/validation-errors.ts rename projects/start-tunnel/web/src/app/routes/home/routes/{port-forwards => published-ports}/add.ts (81%) rename projects/start-tunnel/web/src/app/routes/home/routes/{port-forwards => published-ports}/edit-label.ts (78%) rename projects/start-tunnel/web/src/app/routes/home/routes/{port-forwards => published-ports}/index.ts (67%) rename projects/start-tunnel/web/src/app/routes/home/routes/{port-forwards => published-ports}/utils.ts (99%) diff --git a/package.json b/package.json index a45dce4b10..2fbb3f753c 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "license": "MIT", "scripts": { "ng": "ng", - "check": "npm run check:i18n && npm run check:i18n:wrt && npm run check:shared && npm run check:marketplace && npm run check:ui && npm run check:setup && npm run check:brochure && npm run check:wrt", + "check": "npm run check:i18n && npm run check:i18n:tunnel && npm run check:i18n:wrt && npm run check:shared && npm run check:marketplace && npm run check:ui && npm run check:setup && npm run check:brochure && npm run check:wrt", "check:shared": "tsc --project shared-libs/ts-modules/shared/tsconfig.json --noEmit --skipLibCheck", "check:marketplace": "tsc --project shared-libs/ts-modules/marketplace/tsconfig.json --noEmit --skipLibCheck", "check:setup": "tsc --project projects/start-os/web/setup-wizard/tsconfig.json --noEmit --skipLibCheck", @@ -15,6 +15,7 @@ "check:brochure": "tsc --project projects/brochure-marketplace/tsconfig.json --noEmit --skipLibCheck", "check:wrt": "tsc --project projects/start-wrt/web/tsconfig.json --noEmit --skipLibCheck", "check:i18n": "node shared-libs/ts-modules/scripts/check-i18n.mjs", + "check:i18n:tunnel": "node projects/start-tunnel/web/scripts/check-i18n.mjs", "check:i18n:wrt": "node projects/start-wrt/web/scripts/check-i18n.mjs", "build:patch-db": "cd shared-libs/crates/patch-db/client && npm ci && npm run build", "build:core": "cd shared-libs/ts-modules/start-core && make dist", diff --git a/projects/start-tunnel/AGENTS.md b/projects/start-tunnel/AGENTS.md index ec4acd3a8d..4d848a67da 100644 --- a/projects/start-tunnel/AGENTS.md +++ b/projects/start-tunnel/AGENTS.md @@ -102,4 +102,4 @@ rest of the Angular workspace via `make web-format`. User-facing changes (UI, CLI flags/output, install flow, subnet/device/forward behavior) must update `docs/src/` in the same change. The mdbook is published at `start9.com/start-tunnel/`. Reference: `docs/src/cli-reference.md`, -`installing.md`, `subnets.md`, `devices.md`, `port-forwarding.md`. +`installing.md`, `subnets.md`, `devices.md`, `published-ports.md`. diff --git a/projects/start-tunnel/CHANGELOG.md b/projects/start-tunnel/CHANGELOG.md index d3d9ed6434..011719707f 100644 --- a/projects/start-tunnel/CHANGELOG.md +++ b/projects/start-tunnel/CHANGELOG.md @@ -11,23 +11,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- **HTTP→HTTPS redirects on port 80.** StartTunnel now runs an HTTP→HTTPS redirect on port `80` of every public IPv4 it holds, so a plain `http://` request to an exposed service is answered with a redirect to the same host over `https://` instead of a connection error. These are **on by default** — every public IPv4 gets one on a fresh install and after an update — and reuse the same redirect handler the OS serves on its own TLS ports. Each address has a toggle in the **HTTP Redirect (80 → 443)** section of the `Settings` page (and `start-tunnel http-redirect list` / `set-enabled [--enabled]` on the CLI) to turn it off; your choice persists. A redirect and a port-80 forward are **mutually exclusive and never both enabled**: forwarding port 80 is rejected while the redirect is on (turn it off first), and enabling the redirect is rejected while port 80 is forwarded (delete the forward first). Port 80 is also never auto-forwarded — StartTunnel refuses PCP/UPnP requests to map it — and the add-forward dialog no longer offers to also forward `80 → 443`. See the HTTP Redirects page in the docs. +- **HTTP→HTTPS redirects on port 80.** StartTunnel now runs an HTTP→HTTPS redirect on port `80` of every public IPv4 it holds, so a plain `http://` request to an exposed service is answered with a redirect to the same host over `https://` instead of a connection error. These are **on by default** — every public IPv4 gets one on a fresh install and after an update — and reuse the same redirect handler the OS serves on its own TLS ports. Each address has a toggle in the **HTTP Redirect (80 → 443)** section of the `Settings` page (and `start-tunnel http-redirect list` / `set-enabled [--enabled]` on the CLI) to turn it off; your choice persists. A redirect and a port-80 forward are **mutually exclusive and never both enabled**: forwarding port 80 is rejected while the redirect is on (turn it off first), and enabling the redirect is rejected while port 80 is forwarded (delete the forward first). Port 80 is also never auto-forwarded — StartTunnel refuses PCP/UPnP requests to map it — and the Add published port dialog no longer offers to also forward `80 → 443`. See the HTTP Redirects page in the docs. - **Per-subnet IPv6.** A subnet can now carry a routed IPv6 prefix your VPS delegates, and every host on it — the tunnel and each device — gets one globally-routable `/128` with its tunnel IPv4 embedded (`prefix-network | tunnel-IPv4`), so a device's address is stable and derivable from its IPv4 alone. Configure it per subnet with `start-tunnel subnet set-ipv6 --prefix ` or the subnet's Add/Edit dialog (disable by omitting the prefix). Configuring per subnet lets a server with multiple disjoint allocations point different subnets at different prefixes. On the common single-/64 case the tunnel answers Neighbor Discovery for each device's address on the VPS network; device IPv6 is carried full-tunnel (`AllowedIPs = ::/0`) so replies return through the tunnel. Devices can make outbound IPv6 connections, and — on a device running a current StartOS (0.4.0-beta.10+) — accept unsolicited inbound connections too, so a service can be hosted over IPv6. The subnets and devices tables show the prefix and each device's computed address. See the IPv6 page in the docs. - **`subnet … set-ipv6` validates the server can route the prefix.** Because a device with an IPv6 assignment routes all its IPv6 full-tunnel (`AllowedIPs = ::/0`), a prefix delegated on a server without working IPv6 egress just blackholes. The command **hard-errors** (leaving the config unchanged) when the server has no IPv6 default route, and logs an actionable warning when the prefix is neither on-link on a WAN interface nor otherwise verifiable — so operators catch a misconfigured VPS at set-time instead of discovering dead IPv6 on their devices. -- **Port-range forwarding.** A manual port forward can now span a contiguous range of ports. Set "Number of Ports" in the Add Port Forward dialog — or `--count` on `start-tunnel port-forward add` — to forward that many consecutive ports counting up from both the external and internal port. Ranges are plain port forwards and cannot be combined with SNI demux. (Automatic PCP PORT_SET range forwarding requested by connected devices was already supported; this exposes it to manually-added forwards.) -- **IPv6 port forwarding (firewall pinholes).** A port forward can now expose a device over IPv6, not just IPv4. Because each device has its own globally-routable address (its GUA — see the IPv6 page), an IPv6 forward is a firewall _pinhole_ on `[GUA]:port` with no NAT, rather than a DNAT from a shared public IPv4. The Add Port Forward dialog gained an **IP Version** selector (`IPv4` / `IPv6` / `IPv4 + IPv6`) so one dialog covers both stacks — the external/internal port fields apply to whichever you pick — and each forward's **External IP** is the public IPv4 (v4) or the device's GUA (v6). Choosing an external port different from the internal one (e.g. the `80 → 443` redirect) turns the v6 side into a port-only translation on the same GUA. IPv6 requires the selected server's subnet to have a routed prefix; the dialog says so when it doesn't. Manage them from the CLI with `start-tunnel pinhole add|remove|set-enabled|update-label`. Connected StartOS servers open v6 pinholes **automatically** via PCP (including the `80 → 443` redirect) the same way they already do for IPv4, so hosting a service over IPv6 works end to end. +- **Port-range forwarding.** A manual port forward can now span a contiguous range of ports. Set "Number of Ports" in the Add published port dialog — or `--count` on `start-tunnel port-forward add` — to forward that many consecutive ports counting up from both the external and internal port. Ranges are plain port forwards and cannot be combined with SNI demux. (Automatic PCP PORT_SET range forwarding requested by connected devices was already supported; this exposes it to manually-added forwards.) +- **IPv6 port forwarding (firewall pinholes).** A port forward can now expose a device over IPv6, not just IPv4. Because each device has its own globally-routable address (its GUA — see the IPv6 page), an IPv6 forward is a firewall _pinhole_ on `[GUA]:port` with no NAT, rather than a DNAT from a shared public IPv4. The Add published port dialog gained an **IP Version** selector (`IPv4` / `IPv6` / `IPv4 + IPv6`) so one dialog covers both stacks — the external/internal port fields apply to whichever you pick — and each published port's **IP** is the public IPv4 (v4) or the device's GUA (v6). Choosing an external port different from the internal one (e.g. the `80 → 443` redirect) turns the v6 side into a port-only translation on the same GUA. IPv6 requires the selected server's subnet to have a routed prefix; the dialog says so when it doesn't. Manage them from the CLI with `start-tunnel pinhole add|remove|set-enabled|update-label`. Connected StartOS servers open v6 pinholes **automatically** via PCP (including the `80 → 443` redirect) the same way they already do for IPv4, so hosting a service over IPv6 works end to end. +- Contextual help sidebar with per-screen guidance, linking out to the docs. +- Internationalization: a translatable UI with a language selector; English and Spanish included. +- Sortable columns on the Subnets, Devices, Published Ports, and DNS tables. ### Changed - **Automatic (PCP) mappings now honor their lease.** A forward, pinhole, or SNI route opened automatically by a connected device carries a finite lease that the device renews while it still wants the port. The tunnel now expires and tears down an automatic mapping whose device stops renewing it — because it went offline, rotated its key, or withdrew the exposure — instead of leaving a stale forward in place indefinitely. Manually-added forwards are unaffected and remain persistent. -- **Admin actions retire a device's forwards immediately.** Deleting a device or demoting it to a client now clears all of its forwards, SNI routes, and IPv6 pinholes (previously a deleted device's v6 pinholes could linger); disabling **automatic port forwarding** for a device clears its automatic forwards while leaving any you added manually. Cleanup no longer waits for the lease to lapse. +- **Admin actions retire a device's forwards immediately.** Deleting a device or demoting it to a client now clears all of its forwards, SNI routes, and IPv6 pinholes (previously a deleted device's v6 pinholes could linger); disabling **auto-publish** for a device clears its automatic forwards while leaving any you added manually. Cleanup no longer waits for the lease to lapse. +- Renamed "Port Forwards" to "Published Ports" (and the device "Auto Port Forward" capability to "Auto-publish"); CLI/RPC unchanged. +- Relabeled the DNS "Name" column as "Hostname". ### Fixed - **`--version` now reports StartTunnel's own version** (`1.1.0`) instead of the StartOS platform version. +### Documentation + +- Expanded the Subnets and DNS Records pages, and renamed the Port Forwarding page to Published Ports. + ## [1.0.0] - **Independent versioning.** `start-tunnel` now carries its own version (starting at `1.0.0`) in its `Cargo.toml`, decoupled from the StartOS release line; its `.deb` is versioned from the manifest. diff --git a/projects/start-tunnel/docs/src/README.md b/projects/start-tunnel/docs/src/README.md index 1936e4d676..85c2f56054 100644 --- a/projects/start-tunnel/docs/src/README.md +++ b/projects/start-tunnel/docs/src/README.md @@ -12,7 +12,7 @@ StartTunnel is a virtual private router (VPR) — a minimal, self-hosted router - [Subnets](subnets.md) - [Devices](devices.md) -- [Port Forwarding](port-forwarding.md) +- [Published Ports](published-ports.md) - [Updating](updating.md) - [Uninstalling](uninstalling.md) diff --git a/projects/start-tunnel/docs/src/SUMMARY.md b/projects/start-tunnel/docs/src/SUMMARY.md index 9b99303446..c74b63a49b 100644 --- a/projects/start-tunnel/docs/src/SUMMARY.md +++ b/projects/start-tunnel/docs/src/SUMMARY.md @@ -14,7 +14,7 @@ - [Subnets](subnets.md) - [Devices](devices.md) -- [Port Forwarding](port-forwarding.md) +- [Published Ports](published-ports.md) - [HTTP Redirects](http-redirects.md) - [IPv6](ipv6.md) - [DNS Records](dns-records.md) diff --git a/projects/start-tunnel/docs/src/architecture.md b/projects/start-tunnel/docs/src/architecture.md index d47192b70a..92a8d446e1 100644 --- a/projects/start-tunnel/docs/src/architecture.md +++ b/projects/start-tunnel/docs/src/architecture.md @@ -14,7 +14,7 @@ StartTunnel is a virtual private router (VPR) — a minimal, self-hosted router - **Create Subnets** — Each subnet is a private LAN, just like the one your home router creates - **Add Devices** — Servers, phones, laptops join the LAN and get an IP address and WireGuard config -- **Forward Ports** — Expose specific ports on specific devices to the public Internet, just like port forwarding on a home router. StartTunnel also acts as a port-control gateway (PCP and UPnP), so a StartOS device can open its own ports automatically +- **Publish Ports** — Expose specific ports on specific devices to the public Internet, the way a home router forwards ports to devices on your LAN. StartTunnel also acts as a port-control gateway (PCP and UPnP), so a StartOS device can open its own ports automatically ## How StartTunnel Compares @@ -22,7 +22,7 @@ StartTunnel occupies a unique position between Cloudflare Tunnel and Tailscale. ### Architecture -**StartTunnel** is a virtual private router that runs on a VPS you control. Like a home router, it creates private networks, assigns IPs, and forwards ports — but using WireGuard tunnels instead of physical cables. Port forwarding uses kernel-level iptables NAT (Layer 3/4) to route public traffic to devices on the VPN. There is no central service, no coordination server, and no third party in the data path. +**StartTunnel** is a virtual private router that runs on a VPS you control. Like a home router, it creates private networks, assigns IPs, and forwards ports — but using WireGuard tunnels instead of physical cables. Publishing a port uses kernel-level iptables NAT (Layer 3/4) to route public traffic to devices on the VPN. There is no central service, no coordination server, and no third party in the data path. **Cloudflare Tunnel** runs a daemon (`cloudflared`) on your machine that makes outbound connections to Cloudflare's global edge network. Public traffic hits Cloudflare's CDN first, where Cloudflare terminates TLS, inspects the request at Layer 7, and proxies it to your origin through the tunnel. @@ -32,7 +32,7 @@ StartTunnel occupies a unique position between Cloudflare Tunnel and Tailscale. This is the most important difference. It comes down to: **who can see your traffic?** -**StartTunnel**: Nobody but you. Port forwarding operates at Layer 3/4 (iptables DNAT), meaning the VPS rewrites IP headers and forwards packets without inspecting payloads. If a service uses HTTPS, TLS terminates at the service itself — the VPS never sees plaintext. For VPN traffic between devices, WireGuard provides end-to-end encryption. Since you own the VPS, there is no third party with access to your traffic or metadata. +**StartTunnel**: Nobody but you. Published ports operate at Layer 3/4 (iptables DNAT), meaning the VPS rewrites IP headers and forwards packets without inspecting payloads. If a service uses HTTPS, TLS terminates at the service itself — the VPS never sees plaintext. For VPN traffic between devices, WireGuard provides end-to-end encryption. Since you own the VPS, there is no third party with access to your traffic or metadata. **Cloudflare Tunnel**: Cloudflare terminates TLS at their edge and re-encrypts to your origin. This means Cloudflare can — and does — see plaintext traffic. They offer "TLS inspection" as a feature and can scan request bodies, filter content, and inject responses. Using Cloudflare Tunnel requires trusting a publicly traded company not to misuse its position as a man-in-the-middle on all your traffic. @@ -77,7 +77,7 @@ This is the most important difference. It comes down to: **who can see your traf StartTunnel prioritizes sovereignty over convenience. That means: -- **No DDoS protection** — Your VPS IP is exposed on forwarded ports. Use your VPS provider's DDoS protection, or place a CDN in front if needed. +- **No DDoS protection** — Your VPS IP is exposed on published ports. Use your VPS provider's DDoS protection, or place a CDN in front if needed. - **No global edge network** — Traffic routes through one VPS, not a global CDN. Latency depends on VPS location. - **No built-in DNS** — You manage your own DNS records. - **No identity provider integration** — Authentication is key-based and password-based, not SSO. @@ -92,14 +92,14 @@ StartTunnel is built on [WireGuard](https://www.wireguard.com/), a modern VPN pr - **Encryption**: ChaCha20-Poly1305 for symmetric encryption, Curve25519 for key exchange, BLAKE2s for hashing - **Pre-shared keys**: Each peer connection uses an additional pre-shared key (PSK) for a layer of post-quantum resistance - **Key isolation**: Private keys are generated on-device and never leave the device. Only public keys are exchanged. -- **TLS passthrough**: Port-forwarded traffic is not decrypted by the VPS. If your service uses HTTPS, TLS terminates at the service, not the tunnel. +- **TLS passthrough**: Traffic to a published port is not decrypted by the VPS. If your service uses HTTPS, TLS terminates at the service, not the tunnel. ## Requirements - Debian 13 - x86_64, aarch64, or riscv64 - Root access -- Public IP (required for clearnet port forwarding; not required for private VPN use) +- Public IP (required for publishing ports to the clearnet; not required for private VPN use) ## Source Code diff --git a/projects/start-tunnel/docs/src/cli-reference.md b/projects/start-tunnel/docs/src/cli-reference.md index 6d6b691a97..0711fafe69 100644 --- a/projects/start-tunnel/docs/src/cli-reference.md +++ b/projects/start-tunnel/docs/src/cli-reference.md @@ -89,7 +89,7 @@ Manage devices within a subnet. Each device gets a unique WireGuard configuratio Add a device to a subnet. Optionally assign a specific IP address. -- `--kind ` — Device kind (default `client`). A `server` enables gateway autoconfiguration (DNS injection + auto port forwarding) by default. +- `--kind ` — Device kind (default `client`). A `server` enables gateway autoconfiguration (DNS injection + auto-publish) by default. ### `start-tunnel device list ` diff --git a/projects/start-tunnel/docs/src/devices.md b/projects/start-tunnel/docs/src/devices.md index f51dfa22d3..d440bceac0 100644 --- a/projects/start-tunnel/docs/src/devices.md +++ b/projects/start-tunnel/docs/src/devices.md @@ -2,19 +2,19 @@ Every device on a StartTunnel subnet gets its own WireGuard configuration. Devices come in two kinds, listed in separate tables on the `Devices` page: -- **Servers** — a StartOS server that may configure the gateway on its own behalf: injecting DNS records for its private domains and auto-creating port forwards (PCP/UPnP). Both capabilities are on by default for a Server. +- **Servers** — a StartOS server that may configure the gateway on its own behalf: injecting DNS records for its private domains and auto-publishing ports (via PCP/UPnP). Both capabilities are on by default for a Server. - **Clients** — a plain peer such as a phone or laptop that uses the tunnel for connectivity only, with no gateway-configuration abilities. ## Adding a Device -1. In StartTunnel, navigate to `Devices` and click "Add". +1. In StartTunnel, navigate to `Devices` and click **Add** — on the **Servers** table for a StartOS box, or on the **Clients** table for a phone or laptop. Which button you click sets the device's kind (there is no kind selector in the form). 1. Fill out the form: - Give the device a name (e.g. "Start9 Server", "Phone", "Laptop"). - - Choose the **kind**: **Server** for a StartOS box, **Client** for a phone or laptop. - - For a Server, **Allow DNS injection** and **Allow auto port forwarding** are enabled by default; uncheck either to withhold that capability. A Client has neither. - Select a subnet (the default is fine). - Accept or choose an IP address on the subnet (the default is fine). + - Optionally set the [outbound IP](#outbound-ip) — the WAN address the device's traffic leaves from. + - For a Server, **Allow DNS injection** and **Allow auto-publish** are enabled by default; uncheck either to withhold that capability. A Client has neither. - Click "Save". 1. Download the resulting `start-tunnel.conf` (or copy to your clipboard). @@ -29,7 +29,7 @@ Every device on a StartTunnel subnet gets its own WireGuard configuration. Devic A Server has two independently-toggleable capabilities, shown as switches in the Servers table: - **DNS injection** — lets the server manage the DNS records StartTunnel serves for your private domains (see [DNS Records](/start-tunnel/dns-records.html)). -- **Auto port forwarding** — lets the server create its own port forwards via PCP/UPnP (see [Port Forwarding](/start-tunnel/port-forwarding.html)). +- **Auto-publish** — lets the server publish its own ports via PCP/UPnP (see [Published Ports](/start-tunnel/published-ports.html)). Only enable these for servers you trust. Clients have neither capability. @@ -37,6 +37,10 @@ Only enable these for servers you trust. Clients have neither capability. Use a device's actions menu to **Change to Server** or **Change to Client**. Changing to Server turns both Server capabilities on; changing to Client turns them off and moves the device to the Clients table. +## Outbound IP + +By default a device's outbound traffic leaves from its subnet's [outbound IP](/start-tunnel/subnets.html#outbound-ip). On a VPS with more than one public IPv4 address, you can override this per device with the **WAN IP** field in the device's Add/Edit dialog — choose **Subnet default** to inherit the subnet's setting (the address it resolves to is shown in parentheses), or a specific address to pin this device's egress. On a single-IP VPS there is only one choice, so leave it on **Subnet default**. + ## Removing a Device 1. Navigate to `Devices`, select the device, and click "Remove". diff --git a/projects/start-tunnel/docs/src/dns-records.md b/projects/start-tunnel/docs/src/dns-records.md index 59bce3197d..d4427f5dcd 100644 --- a/projects/start-tunnel/docs/src/dns-records.md +++ b/projects/start-tunnel/docs/src/dns-records.md @@ -1,22 +1,38 @@ # DNS Records -StartTunnel can serve DNS records for your private domains, so names resolve to the right device for everyone connected to the tunnel. Records can be added by hand, or injected automatically over [RFC 2136](https://www.rfc-editor.org/rfc/rfc2136) by devices you have explicitly trusted. +StartTunnel runs a private DNS resolver for your tunnel. It lets you reach the services you host on tunnel devices by a memorable hostname — e.g. `home.example.com` or `git.lan` — instead of memorizing each device's tunnel IP. These names are **private to your tunnel**: they resolve for connected devices only, and are never published to the public Internet. + +## Why use it + +- **Reach services by name.** Point `git.example.com` at your server once; every connected device can use it, and it keeps working even if you rebuild the service. +- **One source of truth.** Everyone on the tunnel resolves the same names to the same device — no per-device `hosts` files to maintain. +- **Private.** The records exist only inside your tunnel; the outside world can't see or resolve them. + +## How it works + +Devices connected to the tunnel use StartTunnel as their DNS resolver. When a device looks up one of your private hostnames, StartTunnel answers from the records on the `DNS Records` page; any other lookup falls through to the subnet's configured upstream resolver (see [Subnets › DNS](/start-tunnel/subnets.html#dns)). A record is one of: + +- **A** / **AAAA** — a hostname → a device's tunnel IPv4 / IPv6 address. +- **CNAME** — a hostname → another hostname (an alias). +- **TXT** — a hostname → arbitrary text (e.g. a verification string). + +Records come from two places: you add them **by hand**, or a trusted device **injects** them automatically over [RFC 2136](https://www.rfc-editor.org/rfc/rfc2136). > [!NOTE] -> A StartOS server using this tunnel injects the records for its private domains **automatically**, once you allow DNS injection for its device (see below). You normally won't need to add records by hand. +> A StartOS server using this tunnel injects the records for its private domains **automatically**, as long as its device is a Server with DNS injection enabled (which it is by default — see below). You normally won't need to add records by hand. ## Allowing a device to inject records -DNS injection is **off by default** for every device. Only enable it for devices you control and trust. +DNS injection is a **Server** capability: a device added as a Server has it **on by default** (along with auto-publish), and Clients don't have it at all. Leave it enabled only for devices you control and trust — turn it off from the Servers table for any server that shouldn't manage your DNS. > [!WARNING] -> A device allowed to inject DNS records can create, overwrite, or delete any record StartTunnel serves. Enable this only for trusted devices, such as your own StartOS server. +> A device allowed to inject DNS records can create, overwrite, or delete any record StartTunnel serves. Keep this enabled only for trusted devices, such as your own StartOS server. -1. In StartTunnel, navigate to `Devices`. DNS injection is a **Server** capability — if the device is a Client, change it to a Server first (see [Devices](/start-tunnel/devices.html)). +1. In StartTunnel, navigate to `Devices`. DNS injection is a **Server** capability — if the device is a Client, change it to a Server first (see [Devices](/start-tunnel/devices.html)). A newly-added Server (or a Client promoted to Server) already has DNS injection on. -1. In the Servers table, toggle **DNS injection** on for the device. +1. In the Servers table, the **DNS injection** toggle controls the capability — leave it on to allow injection, or turn it off to withhold it. -The device may now add, update, and remove records via RFC 2136 DNS UPDATE. StartTunnel authorizes each request by the device's tunnel IP, so only that device's allowance is in effect. +An enabled device may add, update, and remove records via RFC 2136 DNS UPDATE. StartTunnel authorizes each request by the device's tunnel IP, so only that device's allowance is in effect. ## Viewing and managing records diff --git a/projects/start-tunnel/docs/src/faq.md b/projects/start-tunnel/docs/src/faq.md index aad5a0ee71..2134d47ae6 100644 --- a/projects/start-tunnel/docs/src/faq.md +++ b/projects/start-tunnel/docs/src/faq.md @@ -4,7 +4,7 @@ Answers to common questions about StartTunnel's security model, compatibility, V ## Can anyone else see my traffic? -No. Port forwarding operates at Layer 3/4 (iptables DNAT), meaning the VPS rewrites IP headers and forwards packets without inspecting payloads. If your service uses HTTPS, TLS terminates at the service itself — the VPS never sees plaintext. For VPN traffic between devices, WireGuard provides end-to-end encryption. Since you own the VPS, there is no third party in the data path. +No. Published ports operate at Layer 3/4 (iptables DNAT), meaning the VPS rewrites IP headers and forwards packets without inspecting payloads. If your service uses HTTPS, TLS terminates at the service itself — the VPS never sees plaintext. For VPN traffic between devices, WireGuard provides end-to-end encryption. Since you own the VPS, there is no third party in the data path. ## Do I need a WireGuard client on my devices? @@ -16,7 +16,7 @@ No. StartTunnel manages its own firewall rules and disables UFW. It is designed ## Does StartTunnel work behind CGNAT? -Yes. WireGuard clients initiate outbound UDP connections, so CGNAT is not a problem for connecting devices to the VPN. Port forwarding still works because public traffic arrives at the VPS's public IP. +Yes. WireGuard clients initiate outbound UDP connections, so CGNAT is not a problem for connecting devices to the VPN. Publishing ports still works because public traffic arrives at the VPS's public IP. ## What if I forget my password? @@ -39,14 +39,14 @@ StartTunnel is designed to run on a dedicated VPS. To remove it, simply destroy Any provider that offers Debian 13 with root access and a **dedicated public IPv4 address**. Common choices include Hetzner, DigitalOcean, Linode, Vultr, and OVH. Budget VPS providers (~$5/mo) work fine — StartTunnel has minimal resource requirements. > [!WARNING] -> StartTunnel's port forwarding (clearnet hosting) requires a dedicated public IPv4 address. Shared IPv4 addresses (CGNAT, shared NAT, load-balanced IPs) will not work. Some budget providers and IPv6-only tiers do not include a dedicated IPv4 — confirm with your provider before purchasing. +> StartTunnel's published ports (clearnet hosting) require a dedicated public IPv4 address. Shared IPv4 addresses (CGNAT, shared NAT, load-balanced IPs) will not work. Some budget providers and IPv6-only tiers do not include a dedicated IPv4 — confirm with your provider before purchasing. Some providers (AWS, Google Cloud, Azure, Oracle Cloud, IONOS) have cloud-panel firewalls that block WireGuard (UDP 51820) by default. See [Installing — Cloud firewalls](installing.md#cloud-firewalls) for setup instructions. ## Does StartTunnel work on an IPv6-only VPS? -Partially. The WireGuard tunnel itself works over IPv6, so devices with IPv6 connectivity can join your private VPN and reach each other through the VPS. However, **port forwarding (clearnet hosting) is IPv4-only** and cannot be used on an IPv6-only VPS. Additionally, any device joining the VPN must have IPv6 connectivity on its current network — most modern carriers and home ISPs are dual-stack, but some are still IPv4-only. For clearnet hosting, choose a VPS with a dedicated public IPv4 address. +Partially. The WireGuard tunnel itself works over IPv6, so devices with IPv6 connectivity can join your private VPN and reach each other through the VPS. However, **published ports for clearnet hosting are IPv4-only** and cannot be used on an IPv6-only VPS. Additionally, any device joining the VPN must have IPv6 connectivity on its current network — most modern carriers and home ISPs are dual-stack, but some are still IPv4-only. For clearnet hosting, choose a VPS with a dedicated public IPv4 address. ## Does StartTunnel provide DDoS protection? -No. Your VPS IP is exposed on forwarded ports. Use your VPS provider's built-in DDoS protection, or place a CDN in front if needed. See the [Architecture](./architecture.md) page for a full comparison of trade-offs. +No. Your VPS IP is exposed on published ports. Use your VPS provider's built-in DDoS protection, or place a CDN in front if needed. See the [Architecture](./architecture.md) page for a full comparison of trade-offs. diff --git a/projects/start-tunnel/docs/src/http-redirects.md b/projects/start-tunnel/docs/src/http-redirects.md index b095c23b5c..30bd6bae01 100644 --- a/projects/start-tunnel/docs/src/http-redirects.md +++ b/projects/start-tunnel/docs/src/http-redirects.md @@ -6,24 +6,24 @@ These redirects are **on by default** — on a fresh install and after an update ## Where they run -A redirect binds a lightweight listener directly on `:80` on the tunnel host. Only **public** IPv4 addresses are eligible (loopback and private/RFC1918 ranges are skipped) — the same set the web UI lists. IPv6 is handled separately: the `80 → 443` upgrade for a device's IPv6 address is a [pinhole](./port-forwarding.md) port translation, not one of these listeners. +A redirect binds a lightweight listener directly on `:80` on the tunnel host. Only **public** IPv4 addresses are eligible (loopback and private/RFC1918 ranges are skipped) — the same set the web UI lists. IPv6 is handled separately: the `80 → 443` upgrade for a device's IPv6 address is a [pinhole](./published-ports.md) port translation, not one of these listeners. -## Mutually exclusive with a port-80 forward +## Mutually exclusive with a published port 80 -A redirect and a [port forward](./port-forwarding.md) cannot both own port 80 on the same IP — they are never both enabled. The two directions are enforced explicitly rather than by one silently overriding the other: +A redirect and a [published port](./published-ports.md) cannot both own port 80 on the same IP — they are never both enabled. The two directions are enforced explicitly rather than by one silently overriding the other: -- **Forwarding port 80 while the redirect is on is rejected.** To forward port 80 to a device, first turn the redirect off for that address. -- **Enabling the redirect while port 80 is forwarded is rejected.** Delete the port-80 forward first. +- **Publishing port 80 while the redirect is on is rejected.** To publish port 80 to a device, first turn the redirect off for that address. +- **Enabling the redirect while port 80 is published is rejected.** Delete the published port 80 first. -Port 80 is also never opened automatically: StartTunnel refuses PCP/UPnP requests to auto-forward it, since the redirect is the intended behavior there. +Port 80 is also never opened automatically: StartTunnel refuses PCP/UPnP requests to auto-publish it, since the redirect is the intended behavior there. ## Managing them in the UI The **HTTP Redirect (80 → 443)** section on the `Settings` page lists every public IPv4 with a toggle: - **On** — the port-80 HTTP→HTTPS redirect is running on that address. -- **Off** — you have turned it off; port 80 there simply refuses plain HTTP (and is free to forward). -- **Disabled toggle** — a port forward currently occupies port 80 on that address. Remove the forward to re-enable the redirect. +- **Off** — you have turned it off; port 80 there simply refuses plain HTTP (and is free to publish). +- **Disabled toggle** — a published port currently occupies port 80 on that address. Remove the published port to re-enable the redirect. (Most VPSes have a single public IPv4, so this is usually just one toggle.) diff --git a/projects/start-tunnel/docs/src/installing.md b/projects/start-tunnel/docs/src/installing.md index 205b7c0006..4eab47bb48 100644 --- a/projects/start-tunnel/docs/src/installing.md +++ b/projects/start-tunnel/docs/src/installing.md @@ -24,13 +24,13 @@ Rent a cheap VPS with a dedicated public IP. Minimum CPU/RAM/disk is fine. For b - Debian 13 - Root access -- Dedicated public IPv4 address (required for clearnet port forwarding) +- Dedicated public IPv4 address (required for publishing ports to the clearnet) > [!IMPORTANT] > StartTunnel is designed to be the sole application on your VPS. The installer disables UFW and manages its own firewall rules via iptables. Do not run other Internet-facing services on the same VPS. > [!WARNING] -> Port forwarding requires a **dedicated public IPv4 address** assigned to your VPS. Shared IPv4 addresses (CGNAT, shared NAT, or load-balanced IPs) will not work. IPv6-only VPSes will not work for clearnet hosting either — see [Can I use an IPv6-only VPS?](faq.md#does-starttunnel-work-on-an-ipv6-only-vps) in the FAQ. Confirm with your VPS provider that the IPv4 address is dedicated to your VM before purchasing. +> Publishing ports requires a **dedicated public IPv4 address** assigned to your VPS. Shared IPv4 addresses (CGNAT, shared NAT, or load-balanced IPs) will not work. IPv6-only VPSes will not work for clearnet hosting either — see [Can I use an IPv6-only VPS?](faq.md#does-starttunnel-work-on-an-ipv6-only-vps) in the FAQ. Confirm with your VPS provider that the IPv4 address is dedicated to your VM before purchasing. ### Cloud firewalls @@ -253,4 +253,4 @@ Trust the Root CA on each device that will access the web UI. Select your operat - [Subnets](subnets.md) — Create isolated VLANs - [Devices](devices.md) — Add servers, phones, and laptops -- [Port Forwarding](port-forwarding.md) — Expose ports on your VPS's public IP +- [Published Ports](published-ports.md) — Expose ports on your VPS's public IP diff --git a/projects/start-tunnel/docs/src/ipv6.md b/projects/start-tunnel/docs/src/ipv6.md index f1b53ec3b1..da27c18056 100644 --- a/projects/start-tunnel/docs/src/ipv6.md +++ b/projects/start-tunnel/docs/src/ipv6.md @@ -3,7 +3,7 @@ StartTunnel can give the devices on a subnet a real, globally-routable IPv6 address drawn from a prefix your VPS delegates. IPv6 is configured **per subnet** — each subnet can point at its own prefix (or none). This is optional and off by -default; IPv4 port forwarding works without it. +default; IPv4 published ports work without it. ## What your VPS provides @@ -51,8 +51,8 @@ to pick it up. > [!NOTE] > Devices can make **outbound** IPv6 connections and receive their replies. To > accept **unsolicited inbound** connections to a device's IPv6 address (hosting -> a service over IPv6), open a forward for it — see -> [Port Forwarding](./port-forwarding.md). Over IPv6 a forward is a firewall +> a service over IPv6), publish a port for it — see +> [Published Ports](./published-ports.md). Over IPv6 a published port is a firewall > _pinhole_ on the device's global address (no NAT); a connected StartOS server > also opens these automatically via PCP. diff --git a/projects/start-tunnel/docs/src/port-forwarding.md b/projects/start-tunnel/docs/src/port-forwarding.md deleted file mode 100644 index 6f53a322e9..0000000000 --- a/projects/start-tunnel/docs/src/port-forwarding.md +++ /dev/null @@ -1,44 +0,0 @@ -# Port Forwarding - -Port forwarding exposes a device's port to the public Internet. StartTunnel can do this over **IPv4** (a DNAT from your VPS's public IPv4) and over **IPv6** (a firewall _pinhole_ on the device's own global address) — one dialog handles both. - -> [!WARNING] -> **IPv4** forwarding requires a **dedicated public IPv4 address** on your VPS. Shared IPv4 addresses (CGNAT, shared NAT, load-balanced IPs) cannot be used to expose services to the clearnet. **IPv6** forwarding instead needs a routed IPv6 prefix delegated to the subnet (see [IPv6](./ipv6.md)); each device then has its own globally-routable address, so a dedicated public IPv4 isn't required for the IPv6 path. - -> [!NOTE] -> StartTunnel acts as a port-control gateway for connected devices, speaking PCP (preferred) and UPnP. A StartOS server using this tunnel opens the ports it needs **automatically** — over IPv4 and, on a current StartOS (0.4.0-beta.10+) with an IPv6 prefix, over IPv6 too — when you enable a public address, and removes them when the address is disabled or deleted. Each automatic PCP mapping carries a **lease** that the device renews while it still wants the port; if the device stops renewing (it goes offline, or you withdraw the exposure), the tunnel drops the forward on its own rather than letting it linger. For security, an automatically created forward or pinhole always targets the requesting device's own address; a device can only open ports to itself. The steps below are for adding or managing forwards manually. - -## IPv4 forwards vs. IPv6 pinholes - -- An **IPv4** forward is a DNAT: clients connect to your VPS's public IPv4 on the external port, and the tunnel rewrites the destination to the device's tunnel IP and internal port. -- An **IPv6** forward is a _pinhole_: the device already has a globally-routable address (its GUA — see [IPv6](./ipv6.md)), so there is no NAT. The tunnel simply permits inbound to `[GUA]:port`. If you pick an external port different from the internal port (e.g. an `80 → 443` redirect) it becomes a port-only translation on that same address. - -Because each device has its own IPv6 address, two different devices can both publish on the same external port over IPv6 (whereas over IPv4 they share one public address, so external ports must be unique). - -> [!NOTE] -> Port 80 on each public IPv4 is claimed by default by an [HTTP→HTTPS redirect](./http-redirects.md), so you rarely need to forward it. A redirect and a port-80 forward are mutually exclusive and never both enabled: forwarding port 80 is rejected while the redirect is on (turn it off first, under `Settings`). See [HTTP Redirects](./http-redirects.md). - -## Manual and automatic forwards - -The `Port Forwards` page shows two tables: **Manual** forwards you added by hand, and **Automatic** forwards opened by connected devices via PCP/UPnP. A row's **External IP** is your VPS's public IPv4 (a v4 forward) or the device's IPv6 GUA (a v6 pinhole). You can enable, disable, or remove either; automatic forwards have no editable label (they're owned by the device that created them) and may be re-created if you remove one while the device still wants it. Manual forwards are persistent — they stay until you delete them. Automatic forwards are lease-based: one that stops being renewed (its device went offline or no longer wants the port) expires and is removed on its own. - -Deleting a device or demoting it to a client clears all of its forwards (manual and automatic, IPv4 and IPv6). Turning off **automatic port forwarding** for a device clears its automatic forwards but leaves any you added by hand. - -## Add a forward manually - -1. In StartTunnel, navigate to `Port Forwards` and click "Add". - -1. Enter the **External Port**, select the **Server** (the device to forward to), and enter the **Internal Port**. In almost all cases they are the same. - -1. Choose the **IP Version** — `IPv4`, `IPv6`, or `IPv4 + IPv6`. `IPv6` and `IPv4 + IPv6` require the selected server to have an IPv6 address, which means its subnet must carry a routed IPv6 prefix (see [IPv6](./ipv6.md)); the dialog tells you when the chosen server has none. - -1. To forward a **range** of ports, set "Number of Ports" to the size of the range. It counts up from both the external and internal ports — e.g. external `49152`, internal `49152`, count `100` forwards `49152–49251` on each side. Leave it at `1` for a single port. - -1. Click "Save". - -> [!NOTE] -> There is no option to also forward port `80 → 443` — HTTP→HTTPS on port 80 is handled by the [HTTP redirect](./http-redirects.md) that runs by default on every public IPv4. - -## SNI hostnames (IPv4 only) - -When IP Version includes IPv4 (`IPv4` or `IPv4 + IPv6`), an optional **Hostname** routes by TLS SNI so several hostnames can share one external port. SNI demultiplexing is IPv4-only — in `IPv4 + IPv6` mode it applies to the IPv4 side only, and the IPv6 side is a plain pinhole (each device already has its own address, so no demux is needed) — and it cannot be combined with a port range. diff --git a/projects/start-tunnel/docs/src/published-ports.md b/projects/start-tunnel/docs/src/published-ports.md new file mode 100644 index 0000000000..71ebc4c6d1 --- /dev/null +++ b/projects/start-tunnel/docs/src/published-ports.md @@ -0,0 +1,44 @@ +# Published Ports + +Publishing a port exposes a device's port to the public Internet. StartTunnel can do this over **IPv4** (a DNAT from your VPS's public IPv4) and over **IPv6** (a firewall _pinhole_ on the device's own global address) — one dialog handles both. + +> [!WARNING] +> **IPv4** publishing requires a **dedicated public IPv4 address** on your VPS. Shared IPv4 addresses (CGNAT, shared NAT, load-balanced IPs) cannot be used to expose services to the clearnet. **IPv6** publishing instead needs a routed IPv6 prefix delegated to the subnet (see [IPv6](./ipv6.md)); each device then has its own globally-routable address, so a dedicated public IPv4 isn't required for the IPv6 path. + +> [!NOTE] +> StartTunnel acts as a port-control gateway for connected devices, speaking PCP (preferred) and UPnP. A StartOS server using this tunnel opens the ports it needs **automatically** — over IPv4 and, on a current StartOS (0.4.0-beta.10+) with an IPv6 prefix, over IPv6 too — when you enable a public address, and removes them when the address is disabled or deleted. Each automatic PCP mapping carries a **lease** that the device renews while it still wants the port; if the device stops renewing (it goes offline, or you withdraw the exposure), the tunnel drops the published port on its own rather than letting it linger. For security, an automatically published port or pinhole always targets the requesting device's own address; a device can only publish ports to itself. The steps below are for adding or managing published ports manually. + +## IPv4 published ports vs. IPv6 pinholes + +- An **IPv4** published port is a DNAT: clients connect to your VPS's public IPv4 on the external port, and the tunnel rewrites the destination to the device's tunnel IP and internal port. +- An **IPv6** published port is a _pinhole_: the device already has a globally-routable address (its GUA — see [IPv6](./ipv6.md)), so there is no NAT. The tunnel simply permits inbound to `[GUA]:port`. If you pick an external port different from the internal port (e.g. an `80 → 443` redirect) it becomes a port-only translation on that same address. + +Because each device has its own IPv6 address, two different devices can both publish on the same external port over IPv6 (whereas over IPv4 they share one public address, so external ports must be unique). + +> [!NOTE] +> Port 80 on each public IPv4 is claimed by default by an [HTTP→HTTPS redirect](./http-redirects.md), so you rarely need to publish it. A redirect and a published port 80 are mutually exclusive and never both enabled: publishing port 80 is rejected while the redirect is on (turn it off first, under `Settings`). See [HTTP Redirects](./http-redirects.md). + +## Manual and automatic ports + +The `Published Ports` page shows two tables: **Manual** ports you added by hand, and **Automatic** ports opened by connected devices via PCP/UPnP. A row's **External IP** is your VPS's public IPv4 (a v4 published port) or the device's IPv6 GUA (a v6 pinhole). You can enable, disable, or remove either; automatic ports have no editable label (they're owned by the device that created them) and may be re-created if you remove one while the device still wants it. Manual ports are persistent — they stay until you delete them. Automatic ports are lease-based: one that stops being renewed (its device went offline or no longer wants the port) expires and is removed on its own. + +Deleting a device or demoting it to a client clears all of its published ports (manual and automatic, IPv4 and IPv6). Turning off **auto-publish** for a device clears its automatic ports but leaves any you added by hand. + +## Add a port manually + +1. In StartTunnel, navigate to `Published Ports` and click "Add". + +1. Enter the **External Port**, select the **Server** (the device to publish to), and enter the **Internal Port**. In almost all cases they are the same. + +1. Choose the **IP Version** — `IPv4`, `IPv6`, or `IPv4 + IPv6`. `IPv6` and `IPv4 + IPv6` require the selected server to have an IPv6 address, which means its subnet must carry a routed IPv6 prefix (see [IPv6](./ipv6.md)); the dialog tells you when the chosen server has none. + +1. To publish a **range** of ports, set "Number of Ports" to the size of the range. It counts up from both the external and internal ports — e.g. external `49152`, internal `49152`, count `100` publishes `49152–49251` on each side. Leave it at `1` for a single port. + +1. Click "Save". + +> [!NOTE] +> There is no option to also publish port `80 → 443` — HTTP→HTTPS on port 80 is handled by the [HTTP redirect](./http-redirects.md) that runs by default on every public IPv4. + +## SNI hostnames (IPv4 only) + +When IP Version includes IPv4 (`IPv4` or `IPv4 + IPv6`), an optional **Hostname** routes by TLS SNI so several hostnames can share one external port. SNI demultiplexing is IPv4-only — in `IPv4 + IPv6` mode it applies to the IPv4 side only, and the IPv6 side is a plain pinhole (each device already has its own address, so no demux is needed) — and it cannot be combined with a port range. diff --git a/projects/start-tunnel/docs/src/subnets.md b/projects/start-tunnel/docs/src/subnets.md index 0420794c50..8910eee2c4 100644 --- a/projects/start-tunnel/docs/src/subnets.md +++ b/projects/start-tunnel/docs/src/subnets.md @@ -1,15 +1,39 @@ # Subnets -A subnet is an isolated `/24` VLAN (up to 254 devices). All devices on the same subnet can communicate with each other. +A subnet is an isolated private network (a VLAN) whose devices can all communicate with each other. By default a subnet is a `/24` (254 devices), but the range is configurable: you can give it a larger block (a smaller prefix, e.g. `/16`) for more devices. `/24` is the smallest allowed — StartTunnel rejects a range smaller than `/24`. StartTunnel comes with a default subnet, which is sufficient for most users. You can create additional subnets to isolate groups of devices from each other. ## Creating a Subnet 1. In StartTunnel, navigate to `Subnets` and click "Add". -1. Name the subnet and accept or customize the IP range. +1. Name the subnet and accept or customize the **IP range** — the block its devices draw addresses from (a `/24` by default, or a larger block). The range is fixed once the subnet is created; everything else can be changed later by editing the subnet. +1. Optionally set the subnet's [DNS resolver](#dns), [outbound IP](#outbound-ip), and [IPv6 prefix](#ipv6) (all covered below). 1. Click "Save". +## DNS + +Each subnet resolves domain names through one of three modes, chosen with the **DNS** field in the Add/Edit dialog: + +- **Default (VPS provider)** — use the resolvers your VPS provides. The simplest option, and the default. +- **Device** — designate one device already on the subnet as its resolver (at least one device must exist first). Use this to point the subnet at a resolver you run yourself. +- **Custom** — enter up to three DNS server IP addresses to query directly. + +This chooses the _upstream_ resolver the subnet uses. It is separate from the private DNS _records_ StartTunnel serves for your own hostnames — see [DNS Records](/start-tunnel/dns-records.html) for those. + +## Outbound IP + +If your VPS has more than one public IPv4 address, you can choose which one a subnet's outbound traffic leaves from — its egress (SNAT) address — with the **WAN IP** field: + +- **System default** — let StartTunnel choose (masquerade through the server's primary address). When the address it resolves to is known, it's shown in parentheses, e.g. `System default (203.0.113.10)`. +- A specific address — pin egress to one of the VPS's detected public IPv4 addresses. + +A single-IP VPS has only one choice, so you can leave this on **System default**. Individual devices can override their subnet's choice — see [Devices › Outbound IP](/start-tunnel/devices.html#outbound-ip). + +## IPv6 + +A subnet can carry a routed IPv6 prefix so every device on it gets a stable, globally-routable address. Set the **IPv6 Prefix** field (e.g. `2001:db8:abcd::/64`) in the Add/Edit dialog, or leave it blank for none. See [IPv6](/start-tunnel/ipv6.html) for prefix sizing, routing, and VPS requirements. + ## Removing a Subnet 1. Navigate to `Subnets`, select the subnet, and click "Remove". diff --git a/projects/start-tunnel/web/scripts/check-i18n.mjs b/projects/start-tunnel/web/scripts/check-i18n.mjs new file mode 100644 index 0000000000..e1cbf5fdc5 --- /dev/null +++ b/projects/start-tunnel/web/scripts/check-i18n.mjs @@ -0,0 +1,162 @@ +import { readFileSync, readdirSync, statSync, existsSync } from 'fs' +import { join, relative, dirname } from 'path' +import { fileURLToPath } from 'url' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = dirname(__filename) +const root = join(__dirname, '..') // web/ + +const dictDir = join(root, 'src/app/i18n/dictionaries') + +// Extract dictionary keys from en.ts (the source of truth) +const enPath = join(dictDir, 'en.ts') +const enSource = readFileSync(enPath, 'utf-8') +const validKeys = new Set() + +// Keys may be single- or double-quoted (double is used when the key itself +// contains an apostrophe). Backreference \1 matches the opening quote. +const EN_KEY_RE = /^\s+(['"])(.+?)\1:\s*(\d+)\s*,?\s*$/gm + +for (const match of enSource.matchAll(EN_KEY_RE)) { + validKeys.add(match[2]) +} + +if (validKeys.size === 0) { + console.error('ERROR: Could not parse any keys from en.ts') + process.exit(1) +} + +console.log(`Loaded ${validKeys.size} i18n keys from en.ts`) + +// Collect all .ts and .html files under src/ +function walk(dir, files = []) { + for (const entry of readdirSync(dir)) { + const full = join(dir, entry) + if (entry === 'node_modules' || entry === 'dist') continue + const stat = statSync(full) + if (stat.isDirectory()) { + walk(full, files) + } else if (full.endsWith('.ts') || full.endsWith('.html')) { + files.push(full) + } + } + return files +} + +const files = walk(join(root, 'src')) + +// Patterns that mark a string as an i18n key. Scanned against the whole file +// (not line-by-line) so usages prettier wrapped across multiple lines are still +// caught. +const KEY_PATTERNS = [ + /i18n\.transform\(\s*'([^']+)'\s*,?\s*\)/g, // i18n.transform('Key') (opt. trailing comma) + /i18n\.transform\(\s*"([^"]+)"\s*,?\s*\)/g, // i18n.transform("Key") + /'([^']+)'\s*\|\s*i18n\b/g, // 'Key' | i18n + /"([^"]+)"\s*\|\s*i18n\b/g, // "Key" | i18n +] + +const errors = [] + +for (const file of files) { + // Skip the dictionary files themselves + if (file.includes('/i18n/dictionaries/')) continue + + const source = readFileSync(file, 'utf-8') + + for (const pattern of KEY_PATTERNS) { + for (const m of source.matchAll(pattern)) { + if (validKeys.has(m[1])) continue + const line = source.slice(0, m.index).split('\n').length + errors.push({ file, line, key: m[1] }) + } + } +} + +if (errors.length > 0) { + console.error(`\nFound ${errors.length} invalid i18n key(s):\n`) + for (const { file, line, key } of errors) { + console.error(` ${relative(root, file)}:${line} "${key}"`) + } + console.error() +} + +// Cross-language completeness. Translations are added incrementally, so an EMPTY +// dictionary is allowed (the pipe falls back to English). But once a language +// has ANY entry it must cover every id in en.ts — this forces a language, once +// started, to be finished rather than left half-translated. +const enNumericKeys = new Set() +for (const match of enSource.matchAll(EN_KEY_RE)) { + enNumericKeys.add(Number(match[3])) +} + +const otherLangs = ['de', 'es', 'fr', 'pl'] +const dictErrors = [] + +for (const lang of otherLangs) { + const dictSource = readFileSync(join(dictDir, `${lang}.ts`), 'utf-8') + const dictKeys = new Set() + + for (const match of dictSource.matchAll(/^\s*(\d+):/gm)) { + dictKeys.add(Number(match[1])) + } + + if (dictKeys.size === 0) continue // untranslated scaffold — skip + + const missing = [...enNumericKeys] + .filter(k => !dictKeys.has(k)) + .sort((a, b) => a - b) + + if (missing.length > 0) dictErrors.push({ lang, missing }) +} + +if (dictErrors.length > 0) { + console.error(`\nMissing i18n dictionary keys:\n`) + for (const { lang, missing } of dictErrors) { + console.error(` ${lang}.ts is missing keys: ${missing.join(', ')}`) + } + console.error() +} + +// Help content is split per language (mirroring the i18n dictionaries): English +// is the eager source of truth (content/en.ts) and the universal fallback; each +// other language ships a lazy file (content/{es,de,fr,pl}.ts). Like the +// dictionaries, an EMPTY translation file is allowed, but once it has any route +// it must cover every route in en.ts. Route keys look like ` '/route': `. +const helpContentDir = join(root, 'src/app/help/content') +const helpLangs = ['es', 'de', 'fr', 'pl'] +const helpErrors = [] +const ROUTE_RE = /^ {2}('\/[^']+'):/gm +if (existsSync(helpContentDir) && existsSync(join(helpContentDir, 'en.ts'))) { + const enSrc = readFileSync(join(helpContentDir, 'en.ts'), 'utf-8') + const enRoutes = [...enSrc.matchAll(ROUTE_RE)].map(m => m[1]) + + for (const lang of helpLangs) { + const path = join(helpContentDir, `${lang}.ts`) + if (!existsSync(path)) { + helpErrors.push(`${lang}.ts is missing`) + continue + } + const langRoutes = [...readFileSync(path, 'utf-8').matchAll(ROUTE_RE)].map( + m => m[1], + ) + if (langRoutes.length === 0) continue // untranslated scaffold — skip + + const set = new Set(langRoutes) + const missing = enRoutes.filter(r => !set.has(r)) + if (missing.length) { + helpErrors.push(`${lang}.ts is missing routes: ${missing.join(', ')}`) + } + } + + if (helpErrors.length > 0) { + console.error(`\nHelp content missing routes:\n`) + for (const e of helpErrors) console.error(` ${e}`) + console.error() + } +} + +if (errors.length > 0 || dictErrors.length > 0 || helpErrors.length > 0) { + process.exit(1) +} else { + console.log('All i18n keys are valid.') +} diff --git a/projects/start-tunnel/web/src/app/app.config.ts b/projects/start-tunnel/web/src/app/app.config.ts index 2c36ac688b..88fd036941 100644 --- a/projects/start-tunnel/web/src/app/app.config.ts +++ b/projects/start-tunnel/web/src/app/app.config.ts @@ -5,11 +5,19 @@ import { } from '@angular/common/http' import { ApplicationConfig, + inject, + provideAppInitializer, provideBrowserGlobalErrorListeners, provideZonelessChangeDetection, } from '@angular/core' import { provideRouter, TitleStrategy, withRouterConfig } from '@angular/router' -import { RELATIVE_URL, WorkspaceConfig } from '@start9labs/shared' +import { + i18nService, + Languages, + RELATIVE_URL, + WorkspaceConfig, +} from '@start9labs/shared' +import { LANG_STORAGE_KEY, TUNNEL_I18N_PROVIDERS } from './i18n/i18n.providers' import { provideTaiga, tuiDialogOptionsProvider, @@ -37,6 +45,13 @@ export const appConfig: ApplicationConfig = { provideZonelessChangeDetection(), provideRouter(routes, withRouterConfig({ onSameUrlNavigation: 'reload' })), provideTaiga({ mode: 'dark' }), + TUNNEL_I18N_PROVIDERS, + // Restore the saved language on boot (StartTunnel persists it in localStorage). + provideAppInitializer(() => { + inject(i18nService).setLangLocal( + (localStorage.getItem(LANG_STORAGE_KEY) as Languages) || 'en_US', + ) + }), tuiHintOptionsProvider({ appearance: 'primary-grayscale' }), tuiDialogOptionsProvider({ size: 's' }), { diff --git a/projects/start-tunnel/web/src/app/help/aside.ts b/projects/start-tunnel/web/src/app/help/aside.ts new file mode 100644 index 0000000000..8efda89ec5 --- /dev/null +++ b/projects/start-tunnel/web/src/app/help/aside.ts @@ -0,0 +1,80 @@ +import { Component, computed, inject } from '@angular/core' +import { toSignal } from '@angular/core/rxjs-interop' +import { NavigationEnd, Router } from '@angular/router' +import { MarkdownPipe } from '@start9labs/shared' +import { TuiScrollbar } from '@taiga-ui/core' +import { NgDompurifyPipe } from '@taiga-ui/dompurify' +import { filter, map } from 'rxjs' +import { HELP_OPEN, HelpService } from 'src/app/help/help' + +/** + * The persistent contextual help sidebar. Keyed by the current router URL, so + * each top-level screen (`/subnets`, `/devices`, …) shows its own help. Toggled + * by the Help switch in the header (`HELP_OPEN`). Dialogs, which don't change + * the URL, use `modal-help.ts` instead. Collapses to zero width when closed so + * the main content reclaims the space. + */ +@Component({ + selector: '[appAside]', + template: ` + +
+
+ `, + styles: ` + :host { + flex: 0 0 auto; + inline-size: 20.75rem; + overflow: hidden; + background: var(--tui-background-base); + box-shadow: + inset 0.25rem 0 var(--tui-theme-color), + 0 -0.25rem var(--tui-theme-color); + border-start-start-radius: var(--tui-radius-s); + transition: inline-size var(--tui-duration); + + &[inert] { + inline-size: 0; + } + } + + // Fixed inner width so the content doesn't reflow while the host collapses. + tui-scrollbar { + block-size: 100%; + inline-size: 20.75rem; + } + + // On mobile, overlay the content (slide in from the right) instead of + // reflowing it, matching the nav aside's behavior. + :host-context(tui-root._mobile) { + position: absolute; + z-index: 1; + inset-block: 0; + inset-inline-end: 0; + inline-size: min(20.75rem, calc(100vw - 3rem)); + transition: transform var(--tui-duration); + + &[inert] { + inline-size: min(20.75rem, calc(100vw - 3rem)); + transform: translate3d(100%, 0, 0); + } + } + `, + host: { '[attr.inert]': '!open() || null' }, + imports: [TuiScrollbar, MarkdownPipe, NgDompurifyPipe], +}) +export class Aside { + protected readonly open = inject(HELP_OPEN) + private readonly helpService = inject(HelpService) + private readonly router = inject(Router) + private readonly url = toSignal( + this.router.events.pipe( + filter(e => e instanceof NavigationEnd), + map(({ urlAfterRedirects }) => urlAfterRedirects.split('?')[0] ?? ''), + ), + { initialValue: this.router.url.split('?')[0] ?? '' }, + ) + protected readonly help = computed( + () => this.helpService.content()[this.url()] ?? '', + ) +} diff --git a/projects/start-tunnel/web/src/app/help/content/de.ts b/projects/start-tunnel/web/src/app/help/content/de.ts new file mode 100644 index 0000000000..9eb109d6db --- /dev/null +++ b/projects/start-tunnel/web/src/app/help/content/de.ts @@ -0,0 +1,5 @@ +// prettier-ignore +/** German help content (lazy-loaded on language switch). Empty scaffold. */ +const HELP_DE: Record = {} + +export default HELP_DE diff --git a/projects/start-tunnel/web/src/app/help/content/en.ts b/projects/start-tunnel/web/src/app/help/content/en.ts new file mode 100644 index 0000000000..2b9a45085c --- /dev/null +++ b/projects/start-tunnel/web/src/app/help/content/en.ts @@ -0,0 +1,263 @@ +// prettier-ignore +/** + * English help content (source of truth, eager). Key -> markdown. + * + * Keys are either a top-level route (`/subnets`, `/devices`, …) resolved by the + * URL-keyed sidebar (`aside.ts`), or a synthetic dialog key (`/subnets/add`, …) + * provided by a dialog via `provideHelp()` + `ModalHelp`. Each entry opens with + * a `## Title` (hidden by `.g-help h2`, used as its search/label) and closes with + * a "Learn more" link into https://start9.com/start-tunnel/. + * + * Rendered through marked -> DOMPurify, so inline HTML is allowed — use + * `` rather than markdown backticks (backticks would close this template + * literal). + */ +export const HELP_EN: Record = { + '/subnets': `## Subnets + +A subnet is an isolated private network (a VLAN). A /24 (254 devices) by default, but the range is configurable — you can make it larger. Every device you add belongs to one subnet, and devices on the same subnet can reach each other. StartTunnel ships with one default subnet, which is all most setups need. + +### Name + +A friendly label so you can tell your networks apart. + +### IPv4 Range + +The block of private addresses devices on this subnet draw from — a /24 by default, or larger (e.g. 10.59.7.0/24). Fixed once the subnet is created. + +### DNS + +How the subnet resolves domain names — the VPS provider's resolvers, a device on the subnet, or your own custom servers. + +### WAN IPv4 & IPv6 Prefix + +Which public address the subnet's traffic exits from, and its optional routed IPv6 block. + +Learn more →`, + + '/subnets/add': `## Add / Edit Subnet + +Create a private network or change an existing one. The IP range can only be set when creating — everything else can be edited later. + +### Name + +A friendly label for the subnet (required). + +### IP Range + +The private IP block for this subnet, in CIDR form — a /24 by default (254 devices), or any larger block (e.g. /16) for more; a range smaller than /24 isn't allowed. Pre-filled with a free suggestion. Only shown when creating — the range can't change afterward. + +### DNS + +- **Default (VPS provider)** — use the resolvers your VPS provides (simplest). +- **Device** — point the subnet at a device on it that runs its own resolver. +- **Custom** — enter up to three DNS server addresses. + +This is the subnet's upstream resolver, separate from the DNS records StartTunnel serves for your own hostnames. + +### WAN IP + +Which of the VPS's public IPv4 addresses this subnet's outbound traffic leaves from. **System default** lets StartTunnel choose (the address is shown in parentheses). Only matters if your VPS has more than one public IP. + +### IPv6 Prefix + +An optional routed IPv6 block (e.g. 2001:db8:abcd::/64) so every device gets a stable global address. Leave blank for none. + +Learn more →`, + + '/devices': `## Devices + +Every device on your tunnel, split into two tables. + +### Servers + +Devices that host services others reach (typically a StartOS server). When you allow it, a server can manage the tunnel's DNS records and publish its own ports automatically — the two switches on each row. + +### Clients + +Phones, laptops, and other peers that only connect out; they have no gateway-configuration abilities. + +### Columns + +Name, subnet, LAN IPv4, the public address traffic exits from (WAN IPv4), and each device's IPv6. Use a row's menu to edit it, view its WireGuard config, switch its role, or remove it. + +Learn more →`, + + '/devices/add': `## Add / Edit Device + +Add a device to a subnet, or rename and re-home an existing one. Whether it's a **Server** or **Client** is set by which "Add" button you used. After you save a new device, its WireGuard config opens automatically. + +### Name + +A friendly name for the device (required). + +### Subnet + +The private network to place the device in. Auto-selected when only one subnet exists. Only shown when adding. + +### LAN IP + +The device's address within the subnet. Pre-filled with the next free address; appears once a subnet is chosen (adding only). + +### WAN IP + +Which public address this device's outbound traffic leaves from. **Subnet default** inherits the subnet's setting (shown in parentheses); or pin a specific address. + +### Allow DNS Injection / Allow auto-publish + +Servers only. Let this server manage the tunnel's DNS records, and publish its own ports via PCP/UPnP. On by default — enable only for devices you trust. + +Learn more →`, + + '/devices/config': `## Device Configuration + +The WireGuard configuration for this device — use it to connect the device to the tunnel. Opens automatically after adding a device, and any time via **View Config**. + +### File + +The configuration as text. **Copy** it to the clipboard, or **Download** it as a start-tunnel.conf file to import into a WireGuard client. + +### QR + +A QR code of the same configuration. Scan it with the WireGuard mobile app to set up a phone or tablet without typing anything. + +For a StartOS server, add it under System › Gateways instead of a WireGuard app. + +Learn more →`, + + '/published-ports': `## Published Ports + +Route incoming traffic from a public address and port to a port on one of your servers. Two tables: + +### Manual + +Ports you publish here — toggle each on or off, rename, or delete it. + +### Automatic + +Ports a server publishes for itself automatically (PCP/UPnP). Read-only here; manage them where auto-publish is configured on the device. + +### Columns + +Label, the target **Server**, an optional TLS **Hostname** (SNI), the External and Internal ports, the protocol (always TCP/UDP), and the public **IP** it's reachable on. + +Learn more →`, + + '/published-ports/add': `## Add published port + +Send traffic from a public port to a chosen server and internal port. + +### Label + +A name to identify this published port (required). + +### External Port + +The public port people connect to. With a range, this is where it starts. + +### Server + +Which server receives the traffic (clients can't). Its public address becomes the published port's public IP automatically. + +### Internal Port + +The port on the server that receives the traffic. + +### Number of Ports + +Publish several consecutive ports at once, counting up from the external and internal ports. Leave at 1 for a single port. A range can't use an SNI hostname. + +### IP Version + +Reach the service over IPv4, IPv6, or both. IPv6 requires the server's subnet to have an IPv6 prefix. + +### Hostname (optional) + +A TLS/SSL domain (SNI) so several hostnames can share one external port. IPv4 only, and not available for ranges. + +Learn more →`, + + '/published-ports/edit-label': `## Edit Label + +Rename this published port, or add a label if it doesn't have one yet. The label is only for your reference in the list — it doesn't affect routing. + +Learn more →`, + + '/dns': `## DNS Records + +Private DNS for your tunnel. These records let devices on the tunnel reach the services you host by a memorable hostname (e.g. home.example.com) instead of a raw tunnel IP — and they resolve only inside your tunnel, never on the public internet. + +### Manual + +Records you add by hand: point a hostname at one of your servers (A/AAAA), alias it (CNAME), or store text (TXT). + +### Automatic + +A trusted server with **DNS injection** enabled registers its own service domains here automatically, so you don't maintain them by hand. Read-only here — the devices manage them. + +Each row shows the record's Hostname, Type, target Server (or value), and TTL. + +Learn more →`, + + '/dns/add': `## Add DNS Record + +Map a hostname to a device, or to a custom value. + +### Hostname + +The hostname this record answers for, e.g. home.example.com (required). + +### Type + +- **A / AAAA** — point a name at an IPv4 / IPv6 address. +- **CNAME** — alias to another name. +- **TXT** — arbitrary text. + +### Server / Value + +For A and AAAA, pick one of your server devices — or **Other (custom)** to type an address by hand. For CNAME and TXT, enter the target name or text in **Value**. + +### TTL (seconds) + +How long other systems may cache this record before re-checking. Defaults to 300. + +Learn more →`, + + '/settings': `## Settings + +### Version + +The installed StartTunnel version. **Check for updates** looks for a newer release; when one is available, **Update to …** downloads and installs it. + +### HTTP Redirect (80 → 443) + +For each public IPv4, whether plain http:// visitors are redirected to secure https://. On by default. An address can't have both a redirect and a published port on port 80 — the toggle is disabled while port 80 is published. See HTTP Redirects. + +### Language + +The language the interface is shown in. + +### Account + +Change your login password, reboot the VPS, or log out. + +Learn more →`, + + '/settings/change-password': `## Change Password + +Set a new password for logging in to this StartTunnel VPS. + +### New password + +Your new password — must be 8–64 characters. + +### Confirm new password + +Re-type it exactly; you'll see an error if the two don't match. + +If you ever forget it, reset it from the VPS with start-tunnel auth reset-password. + +Learn more →`, +} + +export default HELP_EN diff --git a/projects/start-tunnel/web/src/app/help/content/es.ts b/projects/start-tunnel/web/src/app/help/content/es.ts new file mode 100644 index 0000000000..7112c1148f --- /dev/null +++ b/projects/start-tunnel/web/src/app/help/content/es.ts @@ -0,0 +1,251 @@ +// prettier-ignore +/** Spanish help content (lazy-loaded on language switch). Key -> markdown. */ +const HELP_ES: Record = { + '/subnets': `## Subredes + +Una subred es una red privada aislada (una VLAN). De forma predeterminada es una /24 (254 dispositivos), pero el rango es configurable: puedes hacerla más grande. Cada dispositivo que añadas pertenece a una subred, y los dispositivos de la misma subred pueden comunicarse entre sí. StartTunnel incluye una subred predeterminada, que es suficiente para la mayoría de las configuraciones. + +### Nombre + +Una etiqueta descriptiva para distinguir tus redes. + +### Rango IPv4 + +El bloque de direcciones privadas del que obtienen dirección los dispositivos de esta subred: una /24 de forma predeterminada, o mayor (p. ej. 10.59.7.0/24). Fijo una vez creada la subred. + +### DNS + +Cómo resuelve la subred los nombres de dominio: los resolutores del proveedor de VPS, un dispositivo de la subred o tus propios servidores personalizados. + +### IPv4 de WAN y prefijo IPv6 + +Desde qué dirección pública sale el tráfico de la subred, y su bloque IPv6 enrutado opcional. + +Más información →`, + + '/subnets/add': `## Añadir o editar subred + +Crea una red privada o modifica una existente. El rango de IP solo puede establecerse al crearla; todo lo demás puede editarse después. + +### Nombre + +Una etiqueta descriptiva para la subred (obligatorio). + +### Rango de IP + +El bloque de IP privado de esta subred, en formato CIDR: una /24 de forma predeterminada (254 dispositivos), o un bloque mayor (p. ej. /16) para más; no se permite un rango menor que /24. Se rellena con una sugerencia libre. Solo se muestra al crear: el rango no puede cambiarse después. + +### DNS + +- **Predeterminado (proveedor de VPS)**: usa los resolutores que proporciona tu VPS (lo más sencillo). +- **Dispositivo**: dirige la subred a un dispositivo de ella que ejecute su propio resolutor. +- **Personalizado**: introduce hasta tres direcciones de servidores DNS. + +Este es el resolutor ascendente de la subred, distinto de los registros DNS que StartTunnel sirve para tus propios nombres de host. + +### IP de WAN + +Desde cuál de las direcciones IPv4 públicas del VPS sale el tráfico saliente de esta subred. **Predeterminado del sistema** deja que StartTunnel elija (la dirección se muestra entre paréntesis). Solo importa si tu VPS tiene más de una IP pública. + +### Prefijo IPv6 + +Un bloque IPv6 enrutado opcional (p. ej. 2001:db8:abcd::/64) para que cada dispositivo tenga una dirección global estable. Déjalo en blanco para ninguno. + +Más información →`, + + '/devices': `## Dispositivos + +Todos los dispositivos de tu túnel, divididos en dos tablas. + +### Servidores + +Dispositivos que alojan servicios a los que otros acceden (normalmente un servidor StartOS). Cuando lo permites, un servidor puede gestionar los registros DNS del túnel y publicar sus propios puertos automáticamente: los dos interruptores de cada fila. + +### Clientes + +Teléfonos, portátiles y otros pares que solo se conectan hacia fuera; no tienen capacidades de configuración de la puerta de enlace. + +### Columnas + +Nombre, subred, IPv4 de LAN, la dirección pública desde la que sale el tráfico (IPv4 de WAN) y la IPv6 de cada dispositivo. Usa el menú de una fila para editarlo, ver su configuración de WireGuard, cambiar su rol o quitarlo. + +Más información →`, + + '/devices/add': `## Añadir o editar dispositivo + +Añade un dispositivo a una subred, o renombra y reubica uno existente. Que sea **servidor** o **cliente** lo determina el botón «Añadir» que hayas usado. Al guardar un dispositivo nuevo, su configuración de WireGuard se abre automáticamente. + +### Nombre + +Un nombre descriptivo para el dispositivo (obligatorio). + +### Subred + +La red privada en la que colocar el dispositivo. Se selecciona sola cuando solo existe una subred. Solo se muestra al añadir. + +### IP de LAN + +La dirección del dispositivo dentro de la subred. Se rellena con la siguiente dirección libre; aparece una vez elegida la subred (solo al añadir). + +### IP de WAN + +Desde qué dirección pública sale el tráfico saliente de este dispositivo. **Predeterminado de la subred** hereda el ajuste de la subred (se muestra entre paréntesis); o fija una dirección concreta. + +### Permitir inyección de DNS / Permitir publicación automática + +Solo para servidores. Permiten que este servidor gestione los registros DNS del túnel y publique sus propios puertos mediante PCP/UPnP. Activados de forma predeterminada: actívalos solo para dispositivos de confianza. + +Más información →`, + + '/devices/config': `## Configuración del dispositivo + +La configuración de WireGuard de este dispositivo: úsala para conectarlo al túnel. Se abre automáticamente tras añadir un dispositivo, y en cualquier momento con **Ver configuración**. + +### Archivo + +La configuración como texto. **Cópiala** al portapapeles o **descárgala** como archivo start-tunnel.conf para importarla en un cliente de WireGuard. + +### QR + +Un código QR de la misma configuración. Escanéalo con la app móvil de WireGuard para configurar un teléfono o tableta sin teclear nada. + +Para un servidor StartOS, añádela en Sistema › Puertas de enlace en lugar de una app de WireGuard. + +Más información →`, + + '/published-ports': `## Puertos publicados + +Enruta el tráfico entrante desde una dirección y puerto públicos hacia un puerto de uno de tus servidores. Dos tablas: + +### Manual + +Puertos que publicas aquí: activa o desactiva cada uno, renómbralo o elimínalo. + +### Automático + +Puertos que un servidor publica por sí mismo automáticamente (PCP/UPnP). Son de solo lectura aquí; gestiónalos donde se configura la publicación automática en el dispositivo. + +### Columnas + +Etiqueta, el **servidor** de destino, un **nombre de host** TLS opcional (SNI), los puertos externo e interno, el protocolo (siempre TCP/UDP) y la **IP** pública en la que es accesible. + +Más información →`, + + '/published-ports/add': `## Añadir puerto publicado + +Envía el tráfico desde un puerto público a un servidor y puerto interno elegidos. + +### Etiqueta + +Un nombre para identificar este puerto publicado (obligatorio). + +### Puerto externo + +El puerto público al que se conecta la gente. Con un rango, es donde empieza. + +### Servidor + +Qué servidor recibe el tráfico (los clientes no pueden). Su dirección pública pasa a ser automáticamente la IP pública del puerto publicado. + +### Puerto interno + +El puerto del servidor que recibe el tráfico. + +### Número de puertos + +Publica varios puertos consecutivos a la vez, contando a partir de los puertos externo e interno. Déjalo en 1 para un solo puerto. Un rango no puede usar un nombre de host SNI. + +### Versión de IP + +Accede al servicio por IPv4, IPv6 o ambos. IPv6 requiere que la subred del servidor tenga un prefijo IPv6. + +### Nombre de host (opcional) + +Un dominio TLS/SSL (SNI) para que varios nombres de host compartan un mismo puerto externo. Solo IPv4, y no disponible para rangos. + +Más información →`, + + '/published-ports/edit-label': `## Editar etiqueta + +Renombra este puerto publicado, o añádele una etiqueta si aún no tiene. La etiqueta es solo para tu referencia en la lista: no afecta al enrutamiento. + +Más información →`, + + '/dns': `## Registros DNS + +DNS privado para tu túnel. Estos registros permiten que los dispositivos del túnel accedan a los servicios que alojas mediante un nombre de host fácil de recordar (p. ej. home.example.com) en lugar de una IP del túnel, y solo se resuelven dentro de tu túnel, nunca en la red pública. + +### Manual + +Registros que añades a mano: apunta un nombre de host a uno de tus servidores (A/AAAA), crea un alias (CNAME) o guarda texto (TXT). + +### Automático + +Un servidor de confianza con la **inyección de DNS** activada registra aquí sus propios dominios de servicio automáticamente, para que no tengas que mantenerlos a mano. Son de solo lectura aquí: los gestionan los dispositivos. + +Cada fila muestra el nombre de host, el tipo, el servidor de destino (o valor) y el TTL del registro. + +Más información →`, + + '/dns/add': `## Añadir registro DNS + +Asigna un nombre de host a un dispositivo, o a un valor personalizado. + +### Nombre de host + +El nombre de host al que responde este registro, p. ej. home.example.com (obligatorio). + +### Tipo + +- **A / AAAA**: apunta un nombre a una dirección IPv4 / IPv6. +- **CNAME**: alias a otro nombre. +- **TXT**: texto arbitrario. + +### Servidor / Valor + +Para A y AAAA, elige uno de tus dispositivos servidor, o **Otro (personalizado)** para escribir una dirección a mano. Para CNAME y TXT, introduce el nombre o el texto de destino en **Valor**. + +### TTL (segundos) + +Cuánto tiempo pueden otros sistemas almacenar en caché este registro antes de volver a consultarlo. El valor predeterminado es 300. + +Más información →`, + + '/settings': `## Ajustes + +### Versión + +La versión de StartTunnel instalada. **Buscar actualizaciones** comprueba si hay una versión más reciente; cuando la hay, **Actualizar a…** la descarga e instala. + +### Redirección HTTP (80 → 443) + +Para cada IPv4 pública, si los visitantes por http:// simple se redirigen a https:// seguro. Activado de forma predeterminada. Una dirección no puede tener a la vez una redirección y un puerto publicado en el puerto 80: el interruptor se desactiva mientras el puerto 80 esté publicado. Consulta Redirecciones HTTP. + +### Idioma + +El idioma en el que se muestra la interfaz. + +### Cuenta + +Cambia tu contraseña de acceso, reinicia el VPS o cierra sesión. + +Más información →`, + + '/settings/change-password': `## Cambiar contraseña + +Establece una nueva contraseña para acceder a este VPS de StartTunnel. + +### Nueva contraseña + +Tu nueva contraseña: debe tener entre 8 y 64 caracteres. + +### Confirmar nueva contraseña + +Vuelve a escribirla exactamente; verás un error si las dos no coinciden. + +Si alguna vez la olvidas, restablécela desde el VPS con start-tunnel auth reset-password. + +Más información →`, +} + +export default HELP_ES diff --git a/projects/start-tunnel/web/src/app/help/content/fr.ts b/projects/start-tunnel/web/src/app/help/content/fr.ts new file mode 100644 index 0000000000..d9edefae55 --- /dev/null +++ b/projects/start-tunnel/web/src/app/help/content/fr.ts @@ -0,0 +1,5 @@ +// prettier-ignore +/** French help content (lazy-loaded on language switch). Empty scaffold. */ +const HELP_FR: Record = {} + +export default HELP_FR diff --git a/projects/start-tunnel/web/src/app/help/content/pl.ts b/projects/start-tunnel/web/src/app/help/content/pl.ts new file mode 100644 index 0000000000..2d75c6d2b2 --- /dev/null +++ b/projects/start-tunnel/web/src/app/help/content/pl.ts @@ -0,0 +1,5 @@ +// prettier-ignore +/** Polish help content (lazy-loaded on language switch). Empty scaffold. */ +const HELP_PL: Record = {} + +export default HELP_PL diff --git a/projects/start-tunnel/web/src/app/help/help.ts b/projects/start-tunnel/web/src/app/help/help.ts new file mode 100644 index 0000000000..db07312f5e --- /dev/null +++ b/projects/start-tunnel/web/src/app/help/help.ts @@ -0,0 +1,76 @@ +import { + computed, + effect, + inject, + Injectable, + InjectionToken, + signal, +} from '@angular/core' +import { I18N, i18nService, Languages } from '@start9labs/shared' +import HELP_EN from './content/en' + +/** Whether the contextual help sidebar is expanded. Toggled from the header. */ +export const HELP_OPEN = new InjectionToken('Help sidebar open status', { + factory: () => signal(false), +}) + +/** The help-content key for the current screen/dialog (see ./content/en.ts). */ +export const HELP_URL = new InjectionToken('Help content key') + +/** + * Lazy loaders for non-English help content. English is bundled eagerly (it is + * the source of truth + fallback); the other languages are fetched only when + * selected, mirroring the i18n dictionary loaders in `i18n.providers.ts`. + */ +const HELP_LOADERS: Record< + Exclude, + () => Promise> +> = { + es_ES: () => import('./content/es').then(m => m.default), + de_DE: () => import('./content/de').then(m => m.default), + fr_FR: () => import('./content/fr').then(m => m.default), + pl_PL: () => import('./content/pl').then(m => m.default), +} + +@Injectable({ providedIn: 'root' }) +export class HelpService { + private readonly i18nService = inject(i18nService) + private readonly i18n = inject(I18N) + private readonly loaded = signal< + Partial>> + >({ en_US: HELP_EN }) + + /** + * Route -> help content resolved to the active language, falling back to + * English. Recomputes on language switch (and once a lazily-loaded language + * finishes loading). + */ + readonly content = computed>(() => { + this.i18n() // re-run on language switch (the dictionary signal flips) + const lang = this.i18nService.lang + const translation = this.loaded()[lang] + const out: Record = {} + for (const route of Object.keys(HELP_EN)) { + out[route] = + (lang !== 'en_US' && translation?.[route]) || HELP_EN[route] || '' + } + return out + }) + + constructor() { + // Fetch the active language's help content on switch (English is eager). + effect(() => { + this.i18n() + const lang = this.i18nService.lang + if (lang !== 'en_US' && !this.loaded()[lang]) { + HELP_LOADERS[lang]().then(data => + this.loaded.update(m => ({ ...m, [lang]: data })), + ) + } + }) + } +} + +export function provideHelp(useValue: string) { + return { provide: HELP_URL, useValue } +} diff --git a/projects/start-tunnel/web/src/app/help/modal-help.ts b/projects/start-tunnel/web/src/app/help/modal-help.ts new file mode 100644 index 0000000000..9cc36199d5 --- /dev/null +++ b/projects/start-tunnel/web/src/app/help/modal-help.ts @@ -0,0 +1,113 @@ +import { + Component, + computed, + Directive, + inject, + Injector, + OnDestroy, +} from '@angular/core' +import { FormsModule } from '@angular/forms' +import { MarkdownPipe } from '@start9labs/shared' +import { TuiAnimated } from '@taiga-ui/cdk' +import { TuiPopup, TuiPopupService, TuiScrollbar } from '@taiga-ui/core' +import { NgDompurifyPipe } from '@taiga-ui/dompurify' +import { TuiBlock, TuiSwitch } from '@taiga-ui/kit' +import { + injectContext, + PolymorpheusComponent, + provideContext, +} from '@taiga-ui/polymorpheus' +import { HELP_OPEN, HELP_URL, HelpService } from 'src/app/help/help' +import { i18nPipe } from 'src/app/i18n/i18n.pipe' + +/** + * In-dialog contextual help. Dialogs do not change the router URL, so the + * URL-keyed sidebar (`aside.ts`) can't reach them — instead a dialog opts in + * with `hostDirectives: [ModalHelp]` + `providers: [provideHelp('/key')]` and + * this renders a floating "Help" toggle that pops the matching content. + */ +@Component({ + template: ` + + +
+
+ `, + styles: ` + :host { + position: fixed; + inset-block-start: 0.5rem; + inset-inline-end: 3rem; + backdrop-filter: blur(1rem); + } + + :host, + [tuiBlock] { + border-radius: 10rem; + } + + .modal-help { + position: fixed; + inset: 3rem 1rem 1rem auto; + width: 18rem; + border-radius: var(--tui-radius-l); + background: var(--tui-background-elevation-1); + box-shadow: var(--tui-shadow-popup); + outline: 1px solid var(--tui-border-normal); + outline-offset: -1px; + + --tui-from: translate3d(150%, 0, 0); + + &.tui-enter, + &.tui-leave { + animation-name: tuiSlide; + } + } + + ::ng-deep tui-root._mobile .modal-help { + width: calc(100% - 2rem); + } + `, + imports: [ + FormsModule, + TuiBlock, + TuiSwitch, + TuiPopup, + TuiAnimated, + TuiScrollbar, + i18nPipe, + MarkdownPipe, + NgDompurifyPipe, + ], +}) +class ModalToggle { + protected readonly open = inject(HELP_OPEN) + private readonly help = inject(HelpService) + private readonly url = injectContext() + // Resolve reactively so the panel re-translates if the language switches + // while it is open (and once a lazily-loaded language finishes loading). + protected readonly content = computed( + () => this.help.content()[this.url] ?? '', + ) +} + +@Directive({ + selector: 'ng-template[modalHelp]', +}) +export class ModalHelp implements OnDestroy { + readonly ref = inject(TuiPopupService).add( + new PolymorpheusComponent( + ModalToggle, + Injector.create({ + providers: [provideContext(inject(HELP_URL))], + }), + ), + ) + + ngOnDestroy() { + this.ref.destroy() + } +} diff --git a/projects/start-tunnel/web/src/app/i18n/dictionaries/de.ts b/projects/start-tunnel/web/src/app/i18n/dictionaries/de.ts new file mode 100644 index 0000000000..8d1d76ff68 --- /dev/null +++ b/projects/start-tunnel/web/src/app/i18n/dictionaries/de.ts @@ -0,0 +1,8 @@ +// prettier-ignore +/** + * German (de_DE) UI dictionary: numeric id -> translated string. + * Empty scaffold — populate against the ids in `./en.ts` to translate. + */ +const de: Record = {} + +export default de diff --git a/projects/start-tunnel/web/src/app/i18n/dictionaries/en.ts b/projects/start-tunnel/web/src/app/i18n/dictionaries/en.ts new file mode 100644 index 0000000000..6cebb3d327 --- /dev/null +++ b/projects/start-tunnel/web/src/app/i18n/dictionaries/en.ts @@ -0,0 +1,144 @@ +// prettier-ignore +/** + * Source of truth: English string -> numeric id. Every `'English' | i18n` / + * `i18n.transform('English')` key used in the StartTunnel UI must appear here. + * Numeric ids are the stable join key into the other-language dictionaries + * (`./{es,de,fr,pl}.ts`); assign the next free id, never reuse or renumber one. + * + * Translations are added later — until a language dictionary is populated the + * i18n pipe falls back to the English key, so English always renders. + */ +export const ENGLISH: Record = { + // App shell / navigation (some referenced via dynamic bindings) + 'Subnets': 1, + 'Devices': 2, + 'Published Ports': 3, + 'DNS': 4, + 'Settings': 5, + 'Collapse': 6, + 'Expand': 7, + 'Help': 8, + 'Language': 9, + // Feature screens, dialogs, and messages + 'Actions': 10, + 'Add': 11, + 'Add a device to this subnet first.': 12, + 'Add client': 13, + 'Add DNS record': 14, + 'Add label': 15, + 'Add published port': 16, + 'Add server': 17, + 'Add Subnet': 18, + 'Address cannot be the broadcast address': 19, + 'Address cannot be the zero address': 20, + 'Address is not part of': 21, + 'Allow auto-publish': 22, + 'Allow DNS Injection': 23, + 'Are you sure?': 24, + 'Auto-publish': 25, + 'Automatic': 26, + 'Change password': 27, + 'Change Password': 28, + 'Change to Client': 29, + 'Change to Server': 30, + 'Check for updates': 31, + 'Clients': 32, + 'Confirm new password': 33, + 'Current': 34, + 'custom': 35, + 'default': 36, + 'Delete': 37, + 'Device': 38, + 'Device Config': 39, + 'DNS Injection': 40, + 'Download': 41, + 'Edit': 42, + 'Edit device': 43, + 'Edit label': 44, + 'Edit Subnet': 45, + 'Enabled': 46, + 'Enter password': 47, + 'IP': 48, + 'External Port': 49, + 'File': 50, + 'Hostname': 51, + 'HTTP Redirect (80 → 443)': 52, + 'Internal Port': 53, + 'IP Range': 54, + 'IP Version': 55, + 'IPv4 address': 56, + 'IPv4 Range': 57, + 'IPv6': 58, + 'IPv6 address': 59, + 'IPv6 Prefix': 60, + 'Label': 61, + 'LAN IP': 62, + 'LAN IPv4': 63, + 'Login': 64, + 'Logout': 65, + 'Manual': 66, + 'Name': 67, + 'New password': 68, + 'No automatic DNS records. Devices you trust can add their own via RFC 2136.': 69, + 'No clients': 70, + 'No hosts available': 71, + 'No manual DNS records. Add one to get started.': 72, + 'No published ports': 73, + 'No public IPv4 addresses': 74, + 'No servers': 75, + 'No subnets': 76, + 'Not a valid IP Address': 77, + 'Number of Ports': 78, + 'Other (custom)': 79, + 'Password changed': 80, + 'Password is invalid': 81, + 'Port range runs past the maximum port (65535)': 82, + 'Protocol': 83, + 'QR': 84, + 'Reboot VPS': 85, + 'Rebooting': 86, + 'Remove': 87, + 'Rename': 88, + 'Save': 89, + 'Select device': 90, + 'Select server': 91, + 'Select Server': 92, + 'Select Subnet': 93, + 'Selected server has no IPv6 address — its subnet needs an IPv6 prefix': 94, + 'Server': 95, + 'Servers': 96, + 'Subnet': 97, + 'Subnet default': 98, + 'Success': 99, + 'System default': 100, + 'TCP/UDP': 101, + 'The device can add/update the DNS records the tunnel serves for every peer to resolve. Only enable for devices you trust.': 102, + 'The device can publish its own ports on the gateway automatically (via PCP). Only enable for devices you trust.': 103, + 'The VPS is rebooting. Please wait 1\u20132 minutes, then refresh the page.': 104, + 'TTL': 105, + 'TTL (seconds)': 106, + 'Type': 107, + 'Update Available': 108, + 'Update to': 109, + 'Value': 110, + 'Version': 111, + 'View Config': 112, + 'WAN IP': 113, + 'WAN IPv4': 114, + // Referenced via maps / ternaries / provider values (invisible to the + // literal-only check-i18n scanner) — registered so they can be translated + 'This field is required': 115, + 'Password must be at least 8 characters': 116, + 'Password cannot exceed 64 characters': 117, + 'Passwords do not match': 118, + 'IPv4': 119, + 'IPv4 + IPv6': 120, + 'Only supported for SSL/TLS services — the gateway routes by the TLS SNI, so several hostnames can share one external port. IPv4 only. Leave blank for a plain published port.': 121, + 'Publish this many consecutive ports, counting up from the external and internal ports above. Leave at 1 for a single port. SNI hostnames are not supported for ranges.': 122, + 'Hostname (optional, ipv4 only)': 123, + 'Hostname (optional)': 124, + 'Default (VPS provider)': 125, + 'Custom': 126, +} + +export default ENGLISH diff --git a/projects/start-tunnel/web/src/app/i18n/dictionaries/es.ts b/projects/start-tunnel/web/src/app/i18n/dictionaries/es.ts new file mode 100644 index 0000000000..08f86e8e0d --- /dev/null +++ b/projects/start-tunnel/web/src/app/i18n/dictionaries/es.ts @@ -0,0 +1,135 @@ +// prettier-ignore +/** + * Spanish (es_ES) UI dictionary: numeric id -> translated string. Ids are the + * join key into ./en.ts — every id there must be present here. + */ +const es: Record = { + 1: 'Subredes', + 2: 'Dispositivos', + 3: 'Puertos publicados', + 4: 'DNS', + 5: 'Ajustes', + 6: 'Contraer', + 7: 'Expandir', + 8: 'Ayuda', + 9: 'Idioma', + 10: 'Acciones', + 11: 'Añadir', + 12: 'Primero añade un dispositivo a esta subred.', + 13: 'Añadir cliente', + 14: 'Añadir registro DNS', + 15: 'Añadir etiqueta', + 16: 'Añadir puerto publicado', + 17: 'Añadir servidor', + 18: 'Añadir subred', + 19: 'La dirección no puede ser la dirección de difusión', + 20: 'La dirección no puede ser la dirección cero', + 21: 'La dirección no forma parte de', + 22: 'Permitir publicación automática', + 23: 'Permitir inyección de DNS', + 24: '¿Estás seguro?', + 25: 'Publicación automática', + 26: 'Automático', + 27: 'Cambiar contraseña', + 28: 'Cambiar contraseña', + 29: 'Cambiar a cliente', + 30: 'Cambiar a servidor', + 31: 'Buscar actualizaciones', + 32: 'Clientes', + 33: 'Confirmar nueva contraseña', + 34: 'Actual', + 35: 'personalizado', + 36: 'predeterminado', + 37: 'Eliminar', + 38: 'Dispositivo', + 39: 'Configuración del dispositivo', + 40: 'Inyección de DNS', + 41: 'Descargar', + 42: 'Editar', + 43: 'Editar dispositivo', + 44: 'Editar etiqueta', + 45: 'Editar subred', + 46: 'Activado', + 47: 'Introduce la contraseña', + 48: 'IP', + 49: 'Puerto externo', + 50: 'Archivo', + 51: 'Nombre de host', + 52: 'Redirección HTTP (80 → 443)', + 53: 'Puerto interno', + 54: 'Rango de IP', + 55: 'Versión de IP', + 56: 'Dirección IPv4', + 57: 'Rango IPv4', + 58: 'IPv6', + 59: 'Dirección IPv6', + 60: 'Prefijo IPv6', + 61: 'Etiqueta', + 62: 'IP de LAN', + 63: 'IPv4 de LAN', + 64: 'Iniciar sesión', + 65: 'Cerrar sesión', + 66: 'Manual', + 67: 'Nombre', + 68: 'Nueva contraseña', + 69: 'No hay registros DNS automáticos. Los dispositivos de confianza pueden añadir los suyos mediante RFC 2136.', + 70: 'No hay clientes', + 71: 'No hay hosts disponibles', + 72: 'No hay registros DNS manuales. Añade uno para empezar.', + 73: 'No hay puertos publicados', + 74: 'No hay direcciones IPv4 públicas', + 75: 'No hay servidores', + 76: 'No hay subredes', + 77: 'No es una dirección IP válida', + 78: 'Número de puertos', + 79: 'Otro (personalizado)', + 80: 'Contraseña cambiada', + 81: 'La contraseña no es válida', + 82: 'El rango de puertos supera el puerto máximo (65535)', + 83: 'Protocolo', + 84: 'QR', + 85: 'Reiniciar VPS', + 86: 'Reiniciando', + 87: 'Quitar', + 88: 'Renombrar', + 89: 'Guardar', + 90: 'Seleccionar dispositivo', + 91: 'Seleccionar servidor', + 92: 'Seleccionar servidor', + 93: 'Seleccionar subred', + 94: 'El servidor seleccionado no tiene dirección IPv6: su subred necesita un prefijo IPv6', + 95: 'Servidor', + 96: 'Servidores', + 97: 'Subred', + 98: 'Predeterminado de la subred', + 99: 'Éxito', + 100: 'Predeterminado del sistema', + 101: 'TCP/UDP', + 102: 'El dispositivo puede añadir o actualizar los registros DNS que el túnel sirve para que los resuelvan todos los pares. Actívalo solo para dispositivos de confianza.', + 103: 'El dispositivo puede publicar sus propios puertos en la puerta de enlace automáticamente (mediante PCP). Actívalo solo para dispositivos de confianza.', + 104: 'El VPS se está reiniciando. Espera 1–2 minutos y luego actualiza la página.', + 105: 'TTL', + 106: 'TTL (segundos)', + 107: 'Tipo', + 108: 'Actualización disponible', + 109: 'Actualizar a', + 110: 'Valor', + 111: 'Versión', + 112: 'Ver configuración', + 113: 'IP de WAN', + 114: 'IPv4 de WAN', + 115: 'Este campo es obligatorio', + 116: 'La contraseña debe tener al menos 8 caracteres', + 117: 'La contraseña no puede superar los 64 caracteres', + 118: 'Las contraseñas no coinciden', + 119: 'IPv4', + 120: 'IPv4 + IPv6', + 121: 'Solo compatible con servicios SSL/TLS: la puerta de enlace enruta según el SNI de TLS, de modo que varios nombres de host pueden compartir un mismo puerto externo. Solo IPv4. Déjalo en blanco para un puerto publicado simple.', + 122: 'Publica esta cantidad de puertos consecutivos, contando a partir de los puertos externo e interno de arriba. Déjalo en 1 para un solo puerto. Los nombres de host SNI no son compatibles con rangos.', + 123: 'Nombre de host (opcional, solo ipv4)', + 124: 'Nombre de host (opcional)', + 125: 'Predeterminado (proveedor de VPS)', + 126: 'Personalizado', +} + +export default es diff --git a/projects/start-tunnel/web/src/app/i18n/dictionaries/fr.ts b/projects/start-tunnel/web/src/app/i18n/dictionaries/fr.ts new file mode 100644 index 0000000000..85e1fbee40 --- /dev/null +++ b/projects/start-tunnel/web/src/app/i18n/dictionaries/fr.ts @@ -0,0 +1,8 @@ +// prettier-ignore +/** + * French (fr_FR) UI dictionary: numeric id -> translated string. + * Empty scaffold — populate against the ids in `./en.ts` to translate. + */ +const fr: Record = {} + +export default fr diff --git a/projects/start-tunnel/web/src/app/i18n/dictionaries/pl.ts b/projects/start-tunnel/web/src/app/i18n/dictionaries/pl.ts new file mode 100644 index 0000000000..7a6e54d4b0 --- /dev/null +++ b/projects/start-tunnel/web/src/app/i18n/dictionaries/pl.ts @@ -0,0 +1,8 @@ +// prettier-ignore +/** + * Polish (pl_PL) UI dictionary: numeric id -> translated string. + * Empty scaffold — populate against the ids in `./en.ts` to translate. + */ +const pl: Record = {} + +export default pl diff --git a/projects/start-tunnel/web/src/app/i18n/i18n.pipe.ts b/projects/start-tunnel/web/src/app/i18n/i18n.pipe.ts new file mode 100644 index 0000000000..46cbc69728 --- /dev/null +++ b/projects/start-tunnel/web/src/app/i18n/i18n.pipe.ts @@ -0,0 +1,27 @@ +import { inject, Injectable, Pipe, PipeTransform } from '@angular/core' +import { I18N } from '@start9labs/shared' +import { ENGLISH } from './dictionaries/en' +import { i18nKey } from './i18n.providers' + +/** + * Translates an English source string to the active language, falling back to + * the English key itself when no translation exists. Mirrors the shared + * i18nPipe but resolves ids against StartTunnel's own ENGLISH map (the shared + * `I18N` signal holds the active `id -> string` dictionary). + */ +@Pipe({ + name: 'i18n', + pure: false, +}) +@Injectable({ providedIn: 'root' }) +export class i18nPipe implements PipeTransform { + private readonly i18n = inject(I18N) + + transform(englishKey: i18nKey | null | undefined | ''): string { + englishKey = englishKey || ('' as i18nKey) + + const id = ENGLISH[englishKey] + + return (id !== undefined && this.i18n()?.[id]) || englishKey + } +} diff --git a/projects/start-tunnel/web/src/app/i18n/i18n.providers.ts b/projects/start-tunnel/web/src/app/i18n/i18n.providers.ts new file mode 100644 index 0000000000..cc3838414f --- /dev/null +++ b/projects/start-tunnel/web/src/app/i18n/i18n.providers.ts @@ -0,0 +1,75 @@ +import { forwardRef } from '@angular/core' +import { + I18N_LOADER, + I18N_STORAGE, + i18nService, + Languages, +} from '@start9labs/shared' +import { tuiProvide } from '@taiga-ui/cdk' +import { + TuiLanguageName, + tuiLanguageSwitcher, + TuiLanguageSwitcherService, +} from '@taiga-ui/i18n' +import { ENGLISH } from './dictionaries/en' + +/** Every English source string passed to the i18n pipe is a key of ENGLISH. */ +export type i18nKey = keyof typeof ENGLISH + +/** localStorage key holding the user's chosen POSIX locale. */ +export const LANG_STORAGE_KEY = 'start-tunnel:lang' + +/** + * StartTunnel i18n wiring. Reuses the shared `i18nService`, the `I18N` / + * `I18N_LOADER` / `I18N_STORAGE` tokens, and Taiga's language switcher — only + * the loader (pointed at StartTunnel's own dictionaries) and the storage + * strategy are local. StartTunnel has no server-side language field, so the + * choice is persisted in localStorage. Until a dictionary under `./dictionaries` + * is populated, its load resolves to an empty map and the pipe falls back to the + * English key. + */ +export const TUNNEL_I18N_PROVIDERS = [ + // Localize Taiga's own built-in widget strings (dialogs, date pickers, etc.). + tuiLanguageSwitcher(async (language: TuiLanguageName): Promise => { + switch (language) { + case 'spanish': + return import('@taiga-ui/i18n/languages/spanish') + case 'polish': + return import('@taiga-ui/i18n/languages/polish') + case 'german': + return import('@taiga-ui/i18n/languages/german') + case 'french': + return import('@taiga-ui/i18n/languages/french') + default: + return import('@taiga-ui/i18n/languages/english') + } + }), + { + provide: I18N_LOADER, + useValue: async (language: TuiLanguageName): Promise => { + switch (language) { + case 'spanish': + return import('./dictionaries/es').then(v => v.default) + case 'polish': + return import('./dictionaries/pl').then(v => v.default) + case 'german': + return import('./dictionaries/de').then(v => v.default) + case 'french': + return import('./dictionaries/fr').then(v => v.default) + default: + return null + } + }, + }, + { + provide: I18N_STORAGE, + useValue: (language: Languages): Promise => { + localStorage.setItem(LANG_STORAGE_KEY, language) + return Promise.resolve() + }, + }, + tuiProvide( + TuiLanguageSwitcherService, + forwardRef(() => i18nService), + ), +] diff --git a/projects/start-tunnel/web/src/app/i18n/validation-errors.ts b/projects/start-tunnel/web/src/app/i18n/validation-errors.ts new file mode 100644 index 0000000000..8e8e862f43 --- /dev/null +++ b/projects/start-tunnel/web/src/app/i18n/validation-errors.ts @@ -0,0 +1,79 @@ +import { computed, inject, Provider, Signal } from '@angular/core' +import { tuiValidationErrorsProvider } from '@taiga-ui/core' +import { PolymorpheusContent } from '@taiga-ui/polymorpheus' +import { i18nPipe } from './i18n.pipe' +import { i18nKey } from './i18n.providers' + +/** + * An interpolated validation message. `template` is an English string registered + * in `en.ts` containing `{placeholder}` tokens; `resolve` maps the validator's + * error context to the substitution values. + */ +export interface TranslatedTemplate { + readonly template: i18nKey + readonly resolve: (ctx: Ctx) => Record +} + +/** + * A single validation message: a plain key, a context function returning a key + * (for multi-message switches), or an interpolated `tpl(...)` template. + */ +export type TranslatedError = + | i18nKey + | ((ctx: any) => i18nKey) + | TranslatedTemplate + +export type TranslatedErrors = Record + +/** Declare an interpolated validation message. */ +export function tpl( + template: i18nKey, + resolve: (ctx: Ctx) => Record, +): TranslatedTemplate { + return { template, resolve } +} + +function isTemplate(value: TranslatedError): value is TranslatedTemplate { + return typeof value === 'object' && value !== null && 'template' in value +} + +export function fill( + text: string, + vars: Record, +): string { + return text.replace(/\{(\w+)\}/g, (_, key) => + key in vars ? String(vars[key]) : `{${key}}`, + ) +} + +/** + * Drop-in replacement for `tuiValidationErrorsProvider` that routes every + * message through the i18n pipe, so `` text is translated and + * re-translates live on language change. Plain-string entries become reactive + * `computed` signals; function/template entries translate inside Taiga's own + * error computed so they react to the language signal too. + */ +export function provideTranslatedValidationErrors( + errors: TranslatedErrors, +): Provider { + return tuiValidationErrorsProvider(() => { + const i18n = inject(i18nPipe) + const out: Record< + string, + PolymorpheusContent | Signal + > = {} + + for (const [key, value] of Object.entries(errors)) { + if (isTemplate(value)) { + out[key] = (ctx: any) => + fill(i18n.transform(value.template), value.resolve(ctx)) + } else if (typeof value === 'function') { + out[key] = (ctx: any) => i18n.transform(value(ctx)) + } else { + out[key] = computed(() => i18n.transform(value)) + } + } + + return out + }) +} diff --git a/projects/start-tunnel/web/src/app/routes/home/components/outlet.ts b/projects/start-tunnel/web/src/app/routes/home/components/outlet.ts index d853af3883..46df55106d 100644 --- a/projects/start-tunnel/web/src/app/routes/home/components/outlet.ts +++ b/projects/start-tunnel/web/src/app/routes/home/components/outlet.ts @@ -1,5 +1,6 @@ import { Component, inject, linkedSignal } from '@angular/core' import { toSignal } from '@angular/core/rxjs-interop' +import { FormsModule } from '@angular/forms' import { NavigationEnd, Router, @@ -8,8 +9,12 @@ import { TitleStrategy, } from '@angular/router' import { TUI_BREAKPOINT, TuiIcon, TuiScrollbar } from '@taiga-ui/core' +import { TuiBlock, TuiSwitch } from '@taiga-ui/kit' import { TuiNavigation } from '@taiga-ui/layout' import { filter, map } from 'rxjs' +import { Aside } from 'src/app/help/aside' +import { HELP_OPEN } from 'src/app/help/help' +import { i18nPipe } from 'src/app/i18n/i18n.pipe' import { UpdateService } from 'src/app/services/update.service' @Component({ @@ -18,6 +23,10 @@ import { UpdateService } from 'src/app/services/update.service'

StartTunnel

+
@@ -57,6 +66,7 @@ import { UpdateService } from 'src/app/services/update.service' +
`, styles: ` @@ -101,6 +111,11 @@ import { UpdateService } from 'src/app/services/update.service' margin-inline: 0.25rem 0.5rem; } + .help-toggle { + margin-inline: auto 0.5rem; + border-radius: 2rem; + } + section { display: flex; flex: 1; @@ -129,13 +144,25 @@ import { UpdateService } from 'src/app/services/update.service' } } `, - imports: [RouterOutlet, TuiNavigation, RouterLink, TuiIcon, TuiScrollbar], + imports: [ + RouterOutlet, + TuiNavigation, + RouterLink, + TuiIcon, + TuiScrollbar, + FormsModule, + TuiBlock, + TuiSwitch, + Aside, + i18nPipe, + ], }) export class Outlet { protected readonly router = inject(Router) protected readonly breakpoint = inject(TUI_BREAKPOINT) protected readonly update = inject(UpdateService) protected readonly strategy = inject(TitleStrategy) + protected readonly help = inject(HELP_OPEN) protected readonly routes = [ { name: 'Subnets', @@ -148,9 +175,9 @@ export class Outlet { link: 'devices', }, { - name: 'Port Forwards', + name: 'Published Ports', icon: '@tui.globe', - link: 'port-forwards', + link: 'published-ports', }, { name: 'DNS', diff --git a/projects/start-tunnel/web/src/app/routes/home/index.ts b/projects/start-tunnel/web/src/app/routes/home/index.ts index 9087231523..abbe72569f 100644 --- a/projects/start-tunnel/web/src/app/routes/home/index.ts +++ b/projects/start-tunnel/web/src/app/routes/home/index.ts @@ -17,9 +17,9 @@ export default [ title: 'Devices', }, { - path: 'port-forwards', - loadComponent: () => import('./routes/port-forwards'), - title: 'Port forwards', + path: 'published-ports', + loadComponent: () => import('./routes/published-ports'), + title: 'Published Ports', }, { path: 'dns', diff --git a/projects/start-tunnel/web/src/app/routes/home/routes/devices/add.ts b/projects/start-tunnel/web/src/app/routes/home/routes/devices/add.ts index 3ed2c106cf..ac32c6c228 100644 --- a/projects/start-tunnel/web/src/app/routes/home/routes/devices/add.ts +++ b/projects/start-tunnel/web/src/app/routes/home/routes/devices/add.ts @@ -32,6 +32,9 @@ import { wanLabel, } from 'src/app/routes/home/components/wan' import { ApiService } from 'src/app/services/api/api.service' +import { provideHelp } from 'src/app/help/help' +import { ModalHelp } from 'src/app/help/modal-help' +import { i18nPipe } from 'src/app/i18n/i18n.pipe' import { DEVICES_CONFIG } from './config' import { @@ -46,7 +49,7 @@ import { template: `
- + @@ -57,12 +60,12 @@ import { [stringify]="stringify" [tuiTextfieldCleaner]="false" > - + @if (mobile) { } @else { @@ -81,7 +84,7 @@ import { @if (form.controls.subnet.value?.range) { - + } @@ -97,7 +100,7 @@ import { [stringify]="stringifyWan" [tuiTextfieldCleaner]="false" > - + @if (mobile) { } @else { @@ -117,7 +120,7 @@ import { type="checkbox" formControlName="dnsInjection" /> - Allow DNS Injection + {{ 'Allow DNS Injection' | i18n }} - Allow Auto Port Forward + {{ 'Allow auto-publish' | i18n }}
- +
`, @@ -158,7 +161,10 @@ import { TuiInput, TuiChevron, TuiElasticContainer, + i18nPipe, ], + hostDirectives: [ModalHelp], + providers: [provideHelp('/devices/add')], }) export class DevicesAdd { private readonly tasks = inject(TaskService) @@ -170,6 +176,7 @@ export class DevicesAdd { injectContext>() private readonly fb = inject(NonNullableFormBuilder) + private readonly i18n = inject(i18nPipe) private readonly autoSubnet = !this.context.data.device && this.context.data.subnets().length === 1 @@ -180,13 +187,16 @@ export class DevicesAdd { name: [this.context.data.device?.name || '', Validators.required], subnet: [ this.context.data.device?.subnet ?? this.autoSubnet, - [Validators.required, subnetValidator], + [Validators.required, subnetValidator(this.i18n)], ], ip: [ this.context.data.device?.ip || (this.autoSubnet ? getIp(this.autoSubnet) : ''), this.autoSubnet - ? [Validators.required, ipInSubnetValidator(this.autoSubnet.range)] + ? [ + Validators.required, + ipInSubnetValidator(this.i18n, this.autoSubnet.range), + ] : [], ], wanIp: this.fb.control({ @@ -200,17 +210,19 @@ export class DevicesAdd { protected readonly kind: T.Tunnel.WgClientKind = this.context.data.kind ?? this.context.data.device?.kind ?? 'client' - protected readonly dnsInjectionHint = - 'The device can add/update the DNS records the tunnel serves for every peer to resolve. Only enable for devices you trust.' - protected readonly autoPortForwardHint = - 'The device can request port forwards on the gateway (via PCP). Only enable for devices you trust.' + protected readonly dnsInjectionHint = this.i18n.transform( + 'The device can add/update the DNS records the tunnel serves for every peer to resolve. Only enable for devices you trust.', + ) + protected readonly autoPortForwardHint = this.i18n.transform( + 'The device can publish its own ports on the gateway automatically (via PCP). Only enable for devices you trust.', + ) protected readonly wanItems = toWanItems(this.context.data.wanOptions) protected readonly stringify = ({ range, name }: MappedSubnet) => range ? `${name} (${range})` : '' protected readonly stringifyWan = ({ ip }: WanItem) => - wanLabel(ip, 'Subnet default', this.subnetWanIp()) + wanLabel(ip, this.i18n.transform('Subnet default'), this.subnetWanIp()) protected readonly matchWan = matchWan // The address the device inherits on "Subnet default": the selected subnet's @@ -227,7 +239,7 @@ export class DevicesAdd { this.form.controls.ip.clearValidators() this.form.controls.ip.addValidators([ Validators.required, - ipInSubnetValidator(subnet.range), + ipInSubnetValidator(this.i18n, subnet.range), ]) const ip = getIp(subnet) diff --git a/projects/start-tunnel/web/src/app/routes/home/routes/devices/config.ts b/projects/start-tunnel/web/src/app/routes/home/routes/devices/config.ts index 7210edb32e..42250758af 100644 --- a/projects/start-tunnel/web/src/app/routes/home/routes/devices/config.ts +++ b/projects/start-tunnel/web/src/app/routes/home/routes/devices/config.ts @@ -4,20 +4,23 @@ 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' +import { provideHelp } from 'src/app/help/help' +import { ModalHelp } from 'src/app/help/modal-help' +import { i18nPipe } from 'src/app/i18n/i18n.pipe' @Component({ template: `
-

Device Config

+

{{ 'Device Config' | i18n }}

@@ -41,7 +44,7 @@ import { QrCodeComponent } from 'ng-qrcode' size="s" [href]="href" > - Download + {{ 'Download' | i18n }} } @@ -55,7 +58,10 @@ import { QrCodeComponent } from 'ng-qrcode' TuiSegmented, TuiTextarea, TuiCopy, + i18nPipe, ], + hostDirectives: [ModalHelp], + providers: [provideHelp('/devices/config')], }) export class DevicesConfig { protected readonly config = diff --git a/projects/start-tunnel/web/src/app/routes/home/routes/devices/index.ts b/projects/start-tunnel/web/src/app/routes/home/routes/devices/index.ts index b9d0fd3ab0..1a5d4b19d2 100644 --- a/projects/start-tunnel/web/src/app/routes/home/routes/devices/index.ts +++ b/projects/start-tunnel/web/src/app/routes/home/routes/devices/index.ts @@ -4,6 +4,7 @@ import { FormsModule } from '@angular/forms' import { ErrorService, TaskService } from '@start9labs/shared' import { T } from '@start9labs/start-core' import { TuiResponsiveDialogService } from '@taiga-ui/addon-mobile' +import { TuiComparator, TuiTable } from '@taiga-ui/addon-table' import { TuiButton, TuiDataList, @@ -17,49 +18,48 @@ import { TuiCardLarge, TuiHeader } from '@taiga-ui/layout' import { PatchDB } from 'patch-db-client' import { filter, map } from 'rxjs' import { PlaceholderComponent } from 'src/app/routes/home/components/placeholder' -import { - defaultWanIp, - wanLabel, - wanOptions, -} from 'src/app/routes/home/components/wan' +import { defaultWanIp, wanOptions } from 'src/app/routes/home/components/wan' +import { i18nPipe } from 'src/app/i18n/i18n.pipe' import { ApiService } from 'src/app/services/api/api.service' import { TunnelData } from 'src/app/services/patch-db/data-model' import { DEVICES_ADD } from './add' import { DEVICES_CONFIG } from './config' import { deviceIpv6, MappedDevice } from './utils' +type DeviceRow = { name: string; subnet: { name: string }; ip: string } + @Component({ template: `
-

Servers

+

{{ 'Servers' | i18n }}

- +
- - - - - - - - + + + + + + + + - @for (device of servers(); track $index) { + @for (device of servers() | tuiTableSort; track $index) { - - - - + + + - - - - + + } @@ -156,33 +166,41 @@ import { deviceIpv6, MappedDevice } from './utils'
-

Clients

+

{{ 'Clients' | i18n }}

-
NameSubnetLAN IPv4DNS InjectionAuto Port ForwardWAN IPv4IPv6{{ 'Name' | i18n }}{{ 'Subnet' | i18n }}{{ 'LAN IPv4' | i18n }}{{ 'DNS Injection' | i18n }}{{ 'Auto-publish' | i18n }}{{ 'WAN IPv4' | i18n }}{{ 'IPv6' | i18n }}
{{ device.name }}{{ device.subnet.name }}{{ device.ip }} + {{ device.name }}{{ device.subnet.name }}{{ device.ip }} + {{ device.wan }}{{ device.ipv6 ?? '—' }} + + @if (device.wanIp) { + {{ device.wanIp }} + } @else if (device.inheritedWan) { + {{ 'Subnet default' | i18n }} ({{ device.inheritedWan }}) + } @else { + {{ 'Subnet default' | i18n }} + } + {{ device.ipv6 ?? '—' }} @@ -145,7 +153,9 @@ import { deviceIpv6, MappedDevice } from './utils' } @empty {
- No servers + + {{ 'No servers' | i18n }} +
+
- - - - - - + + + + + + - @for (device of clients(); track $index) { + @for (device of clients() | tuiTableSort; track $index) { - - - - - - + + + + + } @@ -255,11 +275,13 @@ import { deviceIpv6, MappedDevice } from './utils' TuiDataList, TuiLoader, TuiSwitch, + TuiTable, PlaceholderComponent, TuiSkeleton, TuiHeader, TuiIcon, TuiTitle, + i18nPipe, ], }) export default class Devices { @@ -268,6 +290,7 @@ export default class Devices { private readonly tasks = inject(TaskService) private readonly errorService = inject(ErrorService) private readonly patch = inject>(PatchDB) + private readonly i18n = inject(i18nPipe) protected readonly togglingDns = signal(null) protected readonly togglingPf = signal(null) @@ -318,7 +341,9 @@ export default class Devices { allowDnsInjection, allowAutoPortForward, wanIp, - wan: wanLabel(wanIp, 'Subnet default', subnet.wanIp ?? defaultWan), + // Raw inherited IP; the 'Subnet default' label is translated in the + // template so the column reacts to a live language switch. + inheritedWan: subnet.wanIp ?? defaultWan, ipv6: deviceIpv6(subnet.ipv6, ip), }), ), @@ -333,10 +358,28 @@ export default class Devices { this.devices()?.filter(d => d.kind === 'client'), ) + protected readonly byName: TuiComparator = (a, b) => + (a.name || '').localeCompare(b.name || '') + + protected readonly bySubnet: TuiComparator = (a, b) => + a.subnet.name.localeCompare(b.subnet.name) + + protected readonly byIp: TuiComparator = (a, b) => + this.ip4(a.ip) - this.ip4(b.ip) + + private ip4(s: string): number { + return (s.split('/')[0] ?? '') + .split('.') + .reduce((n, o) => n * 256 + Number(o), 0) + } + protected onAdd(kind: T.Tunnel.WgClientKind) { this.dialogs .open(DEVICES_ADD, { - label: kind === 'server' ? 'Add server' : 'Add client', + label: + kind === 'server' + ? this.i18n.transform('Add server') + : this.i18n.transform('Add client'), data: { kind, subnets: this.subnets, @@ -350,7 +393,7 @@ export default class Devices { protected onEdit(device: MappedDevice) { this.dialogs .open(DEVICES_ADD, { - label: 'Edit device', + label: this.i18n.transform('Edit device'), data: { device, subnets: this.subnets, @@ -373,7 +416,7 @@ export default class Devices { protected onDelete({ subnet, ip }: MappedDevice): void { this.dialogs - .open(TUI_CONFIRM, { label: 'Are you sure?' }) + .open(TUI_CONFIRM, { label: this.i18n.transform('Are you sure?') }) .pipe(filter(Boolean)) .subscribe(() => this.tasks.run( @@ -425,7 +468,7 @@ export default class Devices { kind: T.Tunnel.WgClientKind, ): void { this.dialogs - .open(TUI_CONFIRM, { label: 'Are you sure?' }) + .open(TUI_CONFIRM, { label: this.i18n.transform('Are you sure?') }) .pipe(filter(Boolean)) .subscribe(() => this.tasks.run( diff --git a/projects/start-tunnel/web/src/app/routes/home/routes/devices/utils.ts b/projects/start-tunnel/web/src/app/routes/home/routes/devices/utils.ts index af63ae8fcd..aa112b919d 100644 --- a/projects/start-tunnel/web/src/app/routes/home/routes/devices/utils.ts +++ b/projects/start-tunnel/web/src/app/routes/home/routes/devices/utils.ts @@ -2,6 +2,7 @@ import { Signal } from '@angular/core' import { AbstractControl } from '@angular/forms' import { T, utils } from '@start9labs/start-core' import { IpNet } from '@start9labs/start-core/util' +import { i18nPipe } from 'src/app/i18n/i18n.pipe' export interface MappedDevice { readonly subnet: { @@ -60,20 +61,24 @@ export interface DeviceData { readonly defaultWan: string | null } -export function subnetValidator({ value }: AbstractControl) { - return !value?.clients || getIp(value) - ? null - : { noHosts: 'No hosts available' } +export function subnetValidator(i18n: i18nPipe) { + return ({ value }: AbstractControl) => + !value?.clients || getIp(value) + ? null + : { noHosts: i18n.transform('No hosts available') } } -export const ipInSubnetValidator = (subnet: string | null = null) => { +export const ipInSubnetValidator = ( + i18n: i18nPipe, + subnet: string | null = null, +) => { const ipnet = subnet && utils.IpNet.parse(subnet) return ({ value }: AbstractControl) => { let ip: utils.IpAddress try { ip = utils.IpAddress.parse(value) } catch (e) { - return { invalidIp: 'Not a valid IP Address' } + return { invalidIp: i18n.transform('Not a valid IP Address') } } if (!ipnet) return null const zero = ipnet.zero().cmp(ip) @@ -81,10 +86,16 @@ export const ipInSubnetValidator = (subnet: string | null = null) => { return zero + broadcast === 0 ? null : zero === 0 - ? { isZeroAddr: `Address cannot be the zero address` } + ? { isZeroAddr: i18n.transform('Address cannot be the zero address') } : broadcast === 0 - ? { isBroadcastAddress: `Address cannot be the broadcast address` } - : { notInSubnet: `Address is not part of ${subnet}` } + ? { + isBroadcastAddress: i18n.transform( + 'Address cannot be the broadcast address', + ), + } + : { + notInSubnet: `${i18n.transform('Address is not part of')} ${subnet}`, + } } } diff --git a/projects/start-tunnel/web/src/app/routes/home/routes/dns/add.ts b/projects/start-tunnel/web/src/app/routes/home/routes/dns/add.ts index 77dc892658..8aa676de76 100644 --- a/projects/start-tunnel/web/src/app/routes/home/routes/dns/add.ts +++ b/projects/start-tunnel/web/src/app/routes/home/routes/dns/add.ts @@ -34,7 +34,10 @@ import { } from '@taiga-ui/kit' import { TuiForm } from '@taiga-ui/layout' import { injectContext, PolymorpheusComponent } from '@taiga-ui/polymorpheus' -import { MappedDevice } from 'src/app/routes/home/routes/port-forwards/utils' +import { provideHelp } from 'src/app/help/help' +import { ModalHelp } from 'src/app/help/modal-help' +import { i18nPipe } from 'src/app/i18n/i18n.pipe' +import { MappedDevice } from 'src/app/routes/home/routes/published-ports/utils' import { ApiService } from 'src/app/services/api/api.service' const TYPES = ['A', 'AAAA', 'CNAME', 'TXT'] as const @@ -77,12 +80,12 @@ function configure( template: `
- + - + @if (mobile) { } @else { @@ -99,19 +102,19 @@ function configure( [stringify]="stringifyDevice" [tuiTextfieldCleaner]="false" > - + @if (mobile) { } @else { } @if (!mobile) { @@ -122,7 +125,11 @@ function configure( @if (isOther()) { - + } - +
- +
`, changeDetection: ChangeDetectionStrategy.OnPush, + hostDirectives: [ModalHelp], + providers: [provideHelp('/dns/add')], imports: [ ReactiveFormsModule, TuiButton, @@ -165,11 +174,13 @@ function configure( TuiNumberFormat, TuiSelect, TuiForm, + i18nPipe, ], }) export class DnsAdd { private readonly api = inject(ApiService) private readonly tasks = inject(TaskService) + private readonly i18n = inject(i18nPipe) protected readonly mobile = inject(WA_IS_MOBILE) protected readonly context = @@ -200,7 +211,7 @@ export class DnsAdd { ) protected readonly deviceItems = computed(() => [ ...this.context.data.devices(), - OTHER, + { ...OTHER, name: this.i18n.transform('Other (custom)') }, ]) protected readonly stringifyDevice = ({ name, ip }: MappedDevice) => diff --git a/projects/start-tunnel/web/src/app/routes/home/routes/dns/index.ts b/projects/start-tunnel/web/src/app/routes/home/routes/dns/index.ts index 496c2c7cbb..4e28a81e47 100644 --- a/projects/start-tunnel/web/src/app/routes/home/routes/dns/index.ts +++ b/projects/start-tunnel/web/src/app/routes/home/routes/dns/index.ts @@ -7,7 +7,9 @@ import { } from '@angular/core' import { toSignal } from '@angular/core/rxjs-interop' import { TaskService } from '@start9labs/shared' +import { T } from '@start9labs/start-core' import { TuiResponsiveDialogService } from '@taiga-ui/addon-mobile' +import { TuiComparator, TuiTable } from '@taiga-ui/addon-table' import { TuiButton, TuiDataList, @@ -19,8 +21,9 @@ import { TUI_CONFIRM, TuiSkeleton } from '@taiga-ui/kit' import { TuiCardLarge, TuiHeader } from '@taiga-ui/layout' import { PatchDB } from 'patch-db-client' import { filter, map } from 'rxjs' +import { i18nPipe } from 'src/app/i18n/i18n.pipe' import { PlaceholderComponent } from 'src/app/routes/home/components/placeholder' -import { MappedDevice } from 'src/app/routes/home/routes/port-forwards/utils' +import { MappedDevice } from 'src/app/routes/home/routes/published-ports/utils' import { ApiService } from 'src/app/services/api/api.service' import { TunnelData } from 'src/app/services/patch-db/data-model' @@ -31,29 +34,31 @@ import { DNS_ADD } from './add'
-

Manual

+

{{ 'Manual' | i18n }}

-
NameSubnetLAN IPv4WAN IPv4IPv6{{ 'Name' | i18n }}{{ 'Subnet' | i18n }}{{ 'LAN IPv4' | i18n }}{{ 'WAN IPv4' | i18n }}{{ 'IPv6' | i18n }}
{{ device.name }}{{ device.subnet.name }}{{ device.ip }}{{ device.wan }}{{ device.ipv6 ?? '—' }} + {{ device.name }}{{ device.subnet.name }}{{ device.ip }} + @if (device.wanIp) { + {{ device.wanIp }} + } @else if (device.inheritedWan) { + {{ 'Subnet default' | i18n }} ({{ device.inheritedWan }}) + } @else { + {{ 'Subnet default' | i18n }} + } + {{ device.ipv6 ?? '—' }} @@ -232,7 +250,9 @@ import { deviceIpv6, MappedDevice } from './utils' } @empty {
- No clients + + {{ 'No clients' | i18n }} +
+
- - - - - + + + + + - @for (record of manual(); track $index) { + @for (record of manual() | tuiTableSort; track $index) { - - - - - + + + + @@ -95,31 +100,33 @@ import { DNS_ADD } from './add'
-

Automatic

+

{{ 'Automatic' | i18n }}

-
NameTypeServerTTL{{ 'Hostname' | i18n }}{{ 'Type' | i18n }}{{ 'Server' | i18n }}{{ 'TTL' | i18n }}
{{ record.name }}{{ record.type }}{{ serverDisplay(record) }}{{ record.ttl }} + {{ record.name }}{{ record.type }}{{ serverDisplay(record) }}{{ record.ttl }} @@ -83,7 +88,7 @@ import { DNS_ADD } from './add'
- No manual DNS records. Add one to get started. + {{ 'No manual DNS records. Add one to get started.' | i18n }}
+
- - - - + + + + - @for (record of automatic(); track $index) { + @for (record of automatic() | tuiTableSort; track $index) { - - - - + + + + } @empty { @@ -141,11 +148,13 @@ import { DNS_ADD } from './add' TuiCardLarge, TuiDropdown, TuiDataList, + TuiTable, PlaceholderComponent, TuiSkeleton, TuiHeader, TuiIcon, TuiTitle, + i18nPipe, ], }) export default class Dns { @@ -153,6 +162,7 @@ export default class Dns { private readonly api = inject(ApiService) private readonly tasks = inject(TaskService) private readonly patch = inject>(PatchDB) + private readonly i18n = inject(i18nPipe) protected readonly records = toSignal(this.patch.watch$('dnsRecords')) protected readonly manual = computed(() => @@ -189,6 +199,19 @@ export default class Dns { { initialValue: [] }, ) + protected readonly byName: TuiComparator = (a, b) => + (a.name || '').localeCompare(b.name || '') + protected readonly byType: TuiComparator = (a, b) => + (a.type || '').localeCompare(b.type || '') + // Sort by the rendered display so the order matches the visible "name (ip)" + // text (falls back to raw rdata for non-A/AAAA records). + protected readonly byServer: TuiComparator = ( + a, + b, + ) => this.serverDisplay(a).localeCompare(this.serverDisplay(b)) + protected readonly byTtl: TuiComparator = (a, b) => + a.ttl - b.ttl + // Only A/AAAA values are server IPs; for those, show the server's friendly // name and IP (the injecting server for automatic records, the selected one // for manual). CNAME/TXT/other rdata renders verbatim. @@ -207,7 +230,7 @@ export default class Dns { protected onAdd(): void { this.dialogs .open(DNS_ADD, { - label: 'Add DNS record', + label: this.i18n.transform('Add DNS record'), data: { devices: this.servers }, }) .subscribe() @@ -215,7 +238,7 @@ export default class Dns { protected onDelete(record: { name: string; type: string }): void { this.dialogs - .open(TUI_CONFIRM, { label: 'Are you sure?' }) + .open(TUI_CONFIRM, { label: this.i18n.transform('Are you sure?') }) .pipe(filter(Boolean)) .subscribe(() => this.tasks.run( diff --git a/projects/start-tunnel/web/src/app/routes/home/routes/port-forwards/add.ts b/projects/start-tunnel/web/src/app/routes/home/routes/published-ports/add.ts similarity index 81% rename from projects/start-tunnel/web/src/app/routes/home/routes/port-forwards/add.ts rename to projects/start-tunnel/web/src/app/routes/home/routes/published-ports/add.ts index 9dbbd58828..5309cd71fe 100644 --- a/projects/start-tunnel/web/src/app/routes/home/routes/port-forwards/add.ts +++ b/projects/start-tunnel/web/src/app/routes/home/routes/published-ports/add.ts @@ -28,9 +28,13 @@ import { } from '@taiga-ui/kit' import { TuiForm } from '@taiga-ui/layout' import { injectContext, PolymorpheusComponent } from '@taiga-ui/polymorpheus' +import { provideHelp } from 'src/app/help/help' +import { ModalHelp } from 'src/app/help/modal-help' +import { i18nPipe } from 'src/app/i18n/i18n.pipe' +import { i18nKey } from 'src/app/i18n/i18n.providers' import { ApiService } from 'src/app/services/api/api.service' -import { MappedDevice, PortForwardsData } from './utils' +import { MappedDevice, PublishedPortsData } from './utils' // A range counts up from both the external and internal port, so neither side // may run past the u16 port space. Mirrors the server-side guard in add_forward. @@ -54,7 +58,7 @@ function ipVersionRequiresGua(group: AbstractControl): ValidationErrors | null { return needsV6 && device && !device.ipv6 ? { noGua: true } : null } -const IP_VERSION: Record = { +const IP_VERSION: Record = { ipv4: 'IPv4', ipv6: 'IPv6', both: 'IPv4 + IPv6', @@ -64,12 +68,12 @@ const IP_VERSION: Record = { template: `
- + - + = { [stringify]="stringify" [tuiTextfieldCleaner]="false" > - + @if (mobile) { } @else { @@ -104,7 +108,7 @@ const IP_VERSION: Record = { - + = { - + = { [max]="65535" [tuiNumberFormat]="{ thousandSeparator: '' }" /> - + @if (form.errors?.['portRangeOverflow']) { - + }
- IP Version + {{ 'IP Version' | i18n }} = {
@if (guaError()) { } @if (form.value.ipVersion !== 'ipv6' && !isRange) { - + }
@@ -186,25 +198,29 @@ const IP_VERSION: Record = { TuiTooltip, TuiIcon, TuiInput, + i18nPipe, ], + hostDirectives: [ModalHelp], + providers: [provideHelp('/published-ports/add')], }) -export class PortForwardsAdd { +export class PublishedPortsAdd { private readonly api = inject(ApiService) private readonly tasks = inject(TaskService) + private readonly i18n = inject(i18nPipe) protected readonly hostnameHint = - 'Only supported for SSL/TLS services — the gateway routes by the TLS SNI, so several hostnames can share one external port. IPv4 only. Leave blank for a plain port forward.' + 'Only supported for SSL/TLS services — the gateway routes by the TLS SNI, so several hostnames can share one external port. IPv4 only. Leave blank for a plain published port.' protected readonly countHint = - 'Forward this many consecutive ports, counting up from the external and internal ports above. Leave at 1 for a single port. SNI hostnames are not supported for ranges.' + 'Publish this many consecutive ports, counting up from the external and internal ports above. Leave at 1 for a single port. SNI hostnames are not supported for ranges.' protected readonly mobile = inject(WA_IS_MOBILE) protected readonly context = - injectContext>() + injectContext>() protected readonly ipVersionValues = ['ipv4', 'ipv6', 'both'] as const protected readonly ipVersionLabel = (ctx: TuiContext) => - IP_VERSION[ctx.$implicit] + this.i18n.transform(IP_VERSION[ctx.$implicit]) protected readonly form = inject(NonNullableFormBuilder).group( { @@ -289,4 +305,4 @@ export class PortForwardsAdd { } } -export const PORT_FORWARDS_ADD = new PolymorpheusComponent(PortForwardsAdd) +export const PUBLISHED_PORTS_ADD = new PolymorpheusComponent(PublishedPortsAdd) diff --git a/projects/start-tunnel/web/src/app/routes/home/routes/port-forwards/edit-label.ts b/projects/start-tunnel/web/src/app/routes/home/routes/published-ports/edit-label.ts similarity index 78% rename from projects/start-tunnel/web/src/app/routes/home/routes/port-forwards/edit-label.ts rename to projects/start-tunnel/web/src/app/routes/home/routes/published-ports/edit-label.ts index f65986514c..ee8ca6c910 100644 --- a/projects/start-tunnel/web/src/app/routes/home/routes/port-forwards/edit-label.ts +++ b/projects/start-tunnel/web/src/app/routes/home/routes/published-ports/edit-label.ts @@ -9,6 +9,9 @@ import { T } from '@start9labs/start-core' import { TuiButton, TuiDialogContext, TuiError, TuiInput } from '@taiga-ui/core' import { TuiForm } from '@taiga-ui/layout' import { injectContext, PolymorpheusComponent } from '@taiga-ui/polymorpheus' +import { provideHelp } from 'src/app/help/help' +import { ModalHelp } from 'src/app/help/modal-help' +import { i18nPipe } from 'src/app/i18n/i18n.pipe' import { ApiService } from 'src/app/services/api/api.service' export interface EditLabelData { @@ -23,20 +26,29 @@ export interface EditLabelData { template: `
- +
`, - imports: [ReactiveFormsModule, TuiButton, TuiError, TuiInput, TuiForm], + imports: [ + ReactiveFormsModule, + TuiButton, + TuiError, + TuiInput, + TuiForm, + i18nPipe, + ], + hostDirectives: [ModalHelp], + providers: [provideHelp('/published-ports/edit-label')], }) -export class PortForwardsEditLabel { +export class PublishedPortsEditLabel { private readonly api = inject(ApiService) private readonly tasks = inject(TaskService) @@ -63,6 +75,6 @@ export class PortForwardsEditLabel { } } -export const PORT_FORWARDS_EDIT_LABEL = new PolymorpheusComponent( - PortForwardsEditLabel, +export const PUBLISHED_PORTS_EDIT_LABEL = new PolymorpheusComponent( + PublishedPortsEditLabel, ) diff --git a/projects/start-tunnel/web/src/app/routes/home/routes/port-forwards/index.ts b/projects/start-tunnel/web/src/app/routes/home/routes/published-ports/index.ts similarity index 67% rename from projects/start-tunnel/web/src/app/routes/home/routes/port-forwards/index.ts rename to projects/start-tunnel/web/src/app/routes/home/routes/published-ports/index.ts index a5d16e917f..b2580bea44 100644 --- a/projects/start-tunnel/web/src/app/routes/home/routes/port-forwards/index.ts +++ b/projects/start-tunnel/web/src/app/routes/home/routes/published-ports/index.ts @@ -4,6 +4,7 @@ import { FormsModule } from '@angular/forms' import { ErrorService, TaskService } from '@start9labs/shared' import { utils } from '@start9labs/start-core' import { TuiResponsiveDialogService } from '@taiga-ui/addon-mobile' +import { TuiComparator, TuiTable } from '@taiga-ui/addon-table' import { TuiButton, TuiDataList, @@ -23,9 +24,10 @@ import { TuiCardLarge, TuiHeader } from '@taiga-ui/layout' import { PatchDB } from 'patch-db-client' import { filter, map } from 'rxjs' import { PlaceholderComponent } from 'src/app/routes/home/components/placeholder' -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 { PUBLISHED_PORTS_ADD } from 'src/app/routes/home/routes/published-ports/add' +import { PUBLISHED_PORTS_EDIT_LABEL } from 'src/app/routes/home/routes/published-ports/edit-label' import { deviceIpv6 } from 'src/app/routes/home/routes/devices/utils' +import { i18nPipe } from 'src/app/i18n/i18n.pipe' import { ApiService } from 'src/app/services/api/api.service' import { TunnelData } from 'src/app/services/patch-db/data-model' @@ -36,29 +38,35 @@ import { mapForwards, mapPinholes, MappedDevice, MappedForward } from './utils'
-

Manual

+

{{ 'Manual' | i18n }}

-
NameTypeServerTTL{{ 'Hostname' | i18n }}{{ 'Type' | i18n }}{{ 'Server' | i18n }}{{ 'TTL' | i18n }}
{{ record.name }}{{ record.type }}{{ serverDisplay(record) }}{{ record.ttl }}{{ record.name }}{{ record.type }}{{ serverDisplay(record) }}{{ record.ttl }}
- No automatic DNS records. Devices you trust can add their own - via RFC 2136. + {{ + 'No automatic DNS records. Devices you trust can add their own via RFC 2136.' + | i18n + }}
+
- - - - - - - - - + + + + + + + + + - @for (forward of manual(); track $index) { + @for (forward of manual() | tuiTableSort; track $index) { - - - - - - - - - + + + + + + + @@ -131,34 +141,42 @@ import { mapForwards, mapPinholes, MappedDevice, MappedForward } from './utils'
-

Automatic

+

{{ 'Automatic' | i18n }}

-
LabelExternal IPExternal PortHostnameServerInternal PortProtocol{{ 'Label' | i18n }}{{ 'Server' | i18n }}{{ 'Hostname' | i18n }} + {{ 'External Port' | i18n }} + + {{ 'Internal Port' | i18n }} + {{ 'Protocol' | i18n }}{{ 'IP' | i18n }}
+ {{ forward.label || '—' }}{{ forward.externalip }}{{ span(forward.externalport, forward.count) }}{{ forward.sni || '—' }}{{ forward.device.name }}{{ span(forward.internalport, forward.count) }}TCP/UDP + {{ forward.label || '—' }}{{ forward.device.name }}{{ forward.sni || '—' }}{{ span(forward.externalport, forward.count) }}{{ span(forward.internalport, forward.count) }}{{ 'TCP/UDP' | i18n }}{{ forward.externalip }} @@ -119,7 +129,7 @@ import { mapForwards, mapPinholes, MappedDevice, MappedForward } from './utils'
- No port forwards + {{ 'No published ports' | i18n }}
+
- - - - - - + + + + + + - @for (forward of automatic(); track $index) { + @for (forward of automatic() | tuiTableSort; track $index) { - - - - - - + + + + + + } @empty { @@ -178,6 +196,7 @@ import { mapForwards, mapPinholes, MappedDevice, MappedForward } from './utils' FormsModule, TuiButton, TuiCardLarge, + TuiTable, TuiDropdown, TuiDataList, TuiLoader, @@ -188,14 +207,16 @@ import { mapForwards, mapPinholes, MappedDevice, MappedForward } from './utils' TuiHeader, TuiIcon, TuiTitle, + i18nPipe, ], }) -export default class PortForwards { +export default class PublishedPorts { private readonly dialogs = inject(TuiResponsiveDialogService) private readonly api = inject(ApiService) private readonly tasks = inject(TaskService) private readonly patch = inject>(PatchDB) private readonly errorService = inject(ErrorService) + private readonly i18n = inject(i18nPipe) private readonly ips = toSignal( this.patch.watch$('gateways').pipe( map(g => @@ -260,6 +281,19 @@ export default class PortForwards { protected readonly toggling = signal(null) + protected readonly byLabel: TuiComparator = (a, b) => + (a.label || '').localeCompare(b.label || '') + protected readonly byIp: TuiComparator = (a, b) => + a.externalip.localeCompare(b.externalip) + protected readonly byHostname: TuiComparator = (a, b) => + (a.sni || '').localeCompare(b.sni || '') + protected readonly byExternalPort: TuiComparator = (a, b) => + Number(a.externalport) - Number(b.externalport) + protected readonly byServer: TuiComparator = (a, b) => + a.device.name.localeCompare(b.device.name) + protected readonly byInternalPort: TuiComparator = (a, b) => + Number(a.internalport) - Number(b.internalport) + protected key(forward: MappedForward): string { return `${forward.ipVersion}:${forward.externalip}:${forward.externalport}:${forward.hostname ?? ''}` } @@ -296,8 +330,8 @@ export default class PortForwards { protected onAdd(): void { this.dialogs - .open(PORT_FORWARDS_ADD, { - label: 'Add port forward', + .open(PUBLISHED_PORTS_ADD, { + label: this.i18n.transform('Add published port'), data: { ips: this.ips, devices: this.devices }, }) .subscribe() @@ -305,8 +339,8 @@ export default class PortForwards { protected onEditLabel(forward: MappedForward): void { this.dialogs - .open(PORT_FORWARDS_EDIT_LABEL, { - label: 'Edit label', + .open(PUBLISHED_PORTS_EDIT_LABEL, { + label: this.i18n.transform('Edit label'), data: { source: `${forward.externalip}:${forward.externalport}`, label: forward.label, @@ -325,7 +359,7 @@ export default class PortForwards { protected onDelete(forward: MappedForward): void { this.dialogs - .open(TUI_CONFIRM, { label: 'Are you sure?' }) + .open(TUI_CONFIRM, { label: this.i18n.transform('Are you sure?') }) .pipe(filter(Boolean)) .subscribe(() => this.tasks.run(async () => { diff --git a/projects/start-tunnel/web/src/app/routes/home/routes/port-forwards/utils.ts b/projects/start-tunnel/web/src/app/routes/home/routes/published-ports/utils.ts similarity index 99% rename from projects/start-tunnel/web/src/app/routes/home/routes/port-forwards/utils.ts rename to projects/start-tunnel/web/src/app/routes/home/routes/published-ports/utils.ts index 701c8e02b0..2aad88d3f8 100644 --- a/projects/start-tunnel/web/src/app/routes/home/routes/port-forwards/utils.ts +++ b/projects/start-tunnel/web/src/app/routes/home/routes/published-ports/utils.ts @@ -30,7 +30,7 @@ export interface MappedForward { readonly ipVersion: IpVersion } -export interface PortForwardsData { +export interface PublishedPortsData { readonly ips: Signal readonly devices: Signal } diff --git a/projects/start-tunnel/web/src/app/routes/home/routes/settings/change-password.ts b/projects/start-tunnel/web/src/app/routes/home/routes/settings/change-password.ts index fce4cd9ada..ea414281be 100644 --- a/projects/start-tunnel/web/src/app/routes/home/routes/settings/change-password.ts +++ b/projects/start-tunnel/web/src/app/routes/home/routes/settings/change-password.ts @@ -16,19 +16,22 @@ import { TuiInput, TuiNotificationService, TuiTextfield, - tuiValidationErrorsProvider, } from '@taiga-ui/core' import { TuiButtonLoading, TuiPassword } from '@taiga-ui/kit' import { TuiForm } from '@taiga-ui/layout' import { injectContext, PolymorpheusComponent } from '@taiga-ui/polymorpheus' import { map } from 'rxjs' +import { provideHelp } from 'src/app/help/help' +import { ModalHelp } from 'src/app/help/modal-help' +import { i18nPipe } from 'src/app/i18n/i18n.pipe' +import { provideTranslatedValidationErrors } from 'src/app/i18n/validation-errors' import { ApiService } from 'src/app/services/api/api.service' @Component({ template: `
- + - + - Save + {{ 'Save' | i18n }} `, + hostDirectives: [ModalHelp], providers: [ - tuiValidationErrorsProvider({ + provideTranslatedValidationErrors({ required: 'This field is required', minlength: 'Password must be at least 8 characters', maxlength: 'Password cannot exceed 64 characters', match: 'Passwords do not match', }), + provideHelp('/settings/change-password'), ], imports: [ ReactiveFormsModule, @@ -83,6 +88,7 @@ import { ApiService } from 'src/app/services/api/api.service' TuiPassword, TuiTextfield, TuiValidator, + i18nPipe, ], }) export class ChangePasswordDialog { @@ -90,6 +96,7 @@ export class ChangePasswordDialog { private readonly api = inject(ApiService) private readonly alerts = inject(TuiNotificationService) private readonly errorService = inject(ErrorService) + private readonly i18n = inject(i18nPipe) protected readonly loading = signal(false) protected readonly form = inject(NonNullableFormBuilder).group({ @@ -125,7 +132,10 @@ export class ChangePasswordDialog { try { await this.api.setPassword({ password: this.form.getRawValue().password }) this.alerts - .open('Password changed', { label: 'Success', appearance: 'positive' }) + .open(this.i18n.transform('Password changed'), { + label: this.i18n.transform('Success'), + appearance: 'positive', + }) .subscribe() this.context.$implicit.complete() } catch (e: any) { diff --git a/projects/start-tunnel/web/src/app/routes/home/routes/settings/index.ts b/projects/start-tunnel/web/src/app/routes/home/routes/settings/index.ts index fab893e753..c2f3405c1f 100644 --- a/projects/start-tunnel/web/src/app/routes/home/routes/settings/index.ts +++ b/projects/start-tunnel/web/src/app/routes/home/routes/settings/index.ts @@ -2,20 +2,36 @@ import { Component, computed, inject, signal } from '@angular/core' import { toSignal } from '@angular/core/rxjs-interop' import { FormsModule } from '@angular/forms' import { Router } from '@angular/router' -import { ErrorService, TaskService } from '@start9labs/shared' +import { WA_IS_MOBILE } from '@ng-web-apis/platform' +import { + ErrorService, + i18nService, + Language, + LANGUAGES, + TaskService, +} from '@start9labs/shared' import { T, utils } from '@start9labs/start-core' import { TuiResponsiveDialogService } from '@taiga-ui/addon-mobile' import { TuiButton, TuiCell, TuiIcon, + TuiInput, TuiLoader, TuiTitle, } from '@taiga-ui/core' -import { TuiBadge, TuiButtonLoading, TuiSwitch } from '@taiga-ui/kit' +import { + TuiBadge, + TuiButtonLoading, + TuiChevron, + TuiDataListWrapper, + TuiSelect, + TuiSwitch, +} from '@taiga-ui/kit' import { TuiCardLarge, TuiHeader } from '@taiga-ui/layout' import { PatchDB } from 'patch-db-client' import { map } from 'rxjs' +import { i18nPipe } from 'src/app/i18n/i18n.pipe' import { ApiService } from 'src/app/services/api/api.service' import { AuthService } from 'src/app/services/auth.service' import { TunnelData } from 'src/app/services/patch-db/data-model' @@ -28,18 +44,20 @@ import { CHANGE_PASSWORD } from './change-password'
- Version + {{ 'Version' | i18n }} @if (update.hasUpdate()) { - Update Available + {{ 'Update Available' | i18n }} } - Current: {{ update.installed() ?? '—' }} + + {{ 'Current' | i18n }}: {{ update.installed() ?? '—' }} + @if (update.hasUpdate()) { } @else { }
@@ -57,13 +75,13 @@ import { CHANGE_PASSWORD } from './change-password'
-

HTTP Redirect (80 → 443)

+

{{ 'HTTP Redirect (80 → 443)' | i18n }}

External IPExternal PortHostnameServerInternal PortProtocol{{ 'Server' | i18n }}{{ 'Hostname' | i18n }} + {{ 'External Port' | i18n }} + + {{ 'Internal Port' | i18n }} + {{ 'Protocol' | i18n }}{{ 'IP' | i18n }}
{{ forward.externalip }}{{ span(forward.externalport, forward.count) }}{{ forward.sni || '—' }}{{ forward.device.name }}{{ span(forward.internalport, forward.count) }}TCP/UDP{{ forward.device.name }}{{ forward.sni || '—' }}{{ span(forward.externalport, forward.count) }}{{ span(forward.internalport, forward.count) }}{{ 'TCP/UDP' | i18n }}{{ forward.externalip }}
- No port forwards + {{ 'No published ports' | i18n }}
- - + + @@ -91,19 +109,46 @@ import { CHANGE_PASSWORD } from './change-password' } @empty { - + }
WAN IPEnabled{{ 'WAN IP' | i18n }}{{ 'Enabled' | i18n }}
No public IPv4 addresses{{ 'No public IPv4 addresses' | i18n }}
+
+ + + @if (mobile) { + + } @else { + + } + @if (!mobile) { + + } + +
`, @@ -143,11 +188,16 @@ import { CHANGE_PASSWORD } from './change-password' TuiTitle, TuiHeader, TuiIcon, + TuiInput, TuiButton, TuiButtonLoading, TuiBadge, + TuiChevron, + TuiDataListWrapper, TuiLoader, + TuiSelect, TuiSwitch, + i18nPipe, ], }) export default class Settings { @@ -158,6 +208,8 @@ export default class Settings { private readonly router = inject(Router) private readonly tasks = inject(TaskService) private readonly patch = inject>(PatchDB) + private readonly i18n = inject(i18nPipe) + private readonly i18nSvc = inject(i18nService) protected readonly update = inject(UpdateService) protected readonly checking = signal(false) @@ -165,6 +217,20 @@ export default class Settings { protected readonly restarting = signal(false) protected readonly toggling = signal(null) + protected readonly mobile = inject(WA_IS_MOBILE) + protected readonly languages = LANGUAGES + protected readonly language = signal( + LANGUAGES.find(l => l.name === this.i18nSvc.lang) ?? LANGUAGES[0]!, + ) + protected readonly stringifyLanguage = (l: Language) => l.nativeName + protected readonly matchLanguage = (a: Language, b: Language) => + a.name === b.name + + protected setLanguage(language: Language): void { + this.language.set(language) + this.i18nSvc.setLang(language.name) + } + private readonly ips = toSignal( this.patch.watch$('gateways').pipe( map(g => @@ -212,7 +278,9 @@ export default class Settings { } protected onChangePassword(): void { - this.dialogs.open(CHANGE_PASSWORD, { label: 'Change Password' }).subscribe() + this.dialogs + .open(CHANGE_PASSWORD, { label: this.i18n.transform('Change Password') }) + .subscribe() } protected async onCheckUpdate() { @@ -246,9 +314,11 @@ export default class Settings { await this.api.restart() this.dialogs .open( - 'The VPS is rebooting. Please wait 1\u20132 minutes, then refresh the page.', + this.i18n.transform( + 'The VPS is rebooting. Please wait 1\u20132 minutes, then refresh the page.', + ), { - label: 'Rebooting', + label: this.i18n.transform('Rebooting'), }, ) .subscribe() diff --git a/projects/start-tunnel/web/src/app/routes/home/routes/subnets/add.ts b/projects/start-tunnel/web/src/app/routes/home/routes/subnets/add.ts index 6b7d57e0a4..bc51fe0d87 100644 --- a/projects/start-tunnel/web/src/app/routes/home/routes/subnets/add.ts +++ b/projects/start-tunnel/web/src/app/routes/home/routes/subnets/add.ts @@ -13,6 +13,9 @@ import { TuiButton, TuiDialogContext, TuiError, TuiInput } from '@taiga-ui/core' import { TuiChevron, TuiDataListWrapper, TuiSelect } from '@taiga-ui/kit' import { TuiForm } from '@taiga-ui/layout' import { injectContext, PolymorpheusComponent } from '@taiga-ui/polymorpheus' +import { provideHelp } from 'src/app/help/help' +import { ModalHelp } from 'src/app/help/modal-help' +import { i18nPipe } from 'src/app/i18n/i18n.pipe' import { matchWan, toWanItems, @@ -21,7 +24,7 @@ import { } from 'src/app/routes/home/components/wan' import { ApiService } from 'src/app/services/api/api.service' -import { MappedDevice } from '../port-forwards/utils' +import { MappedDevice } from '../published-ports/utils' const CIDR_PATTERN = '^(?:(?:25[0-5]|2[0-4]\\d|1\\d{2}|[1-9]?\\d)\\.){3}(?:25[0-5]|2[0-4]\\d|1\\d{2}|[1-9]?\\d)/(?:[0-9]|1\\d|2[0-4])$' @@ -49,14 +52,14 @@ const MODE_LABEL: Record = { template: `
- + @if (!context.data.name) { - + @@ -67,7 +70,7 @@ const MODE_LABEL: Record = { [tuiTextfieldCleaner]="false" [stringify]="modeLabel" > - + @if (mobile) { } @else { @@ -86,12 +89,12 @@ const MODE_LABEL: Record = { [tuiTextfieldCleaner]="false" [stringify]="stringifyDevice" > - + @if (mobile) { } @else { @@ -105,13 +108,13 @@ const MODE_LABEL: Record = { } } @else { -

Add a device to this subnet first.

+

{{ 'Add a device to this subnet first.' | i18n }}

} } @case ('custom') { @for (control of servers.controls; track $index) { - + @if (servers.length > 1) { } @@ -135,7 +138,7 @@ const MODE_LABEL: Record = { iconStart="@tui.plus" (click)="addServer()" > - Add server + {{ 'Add server' | i18n }} } } @@ -147,7 +150,7 @@ const MODE_LABEL: Record = { [tuiTextfieldCleaner]="false" [stringify]="stringifyWan" > - + @if (mobile) { } @else { @@ -159,7 +162,7 @@ const MODE_LABEL: Record = { - + = {
- +
`, @@ -183,12 +188,16 @@ const MODE_LABEL: Record = { TuiForm, TuiInput, TuiSelect, + i18nPipe, ], + hostDirectives: [ModalHelp], + providers: [provideHelp('/subnets/add')], }) export class SubnetsAdd { private readonly api = inject(ApiService) private readonly tasks = inject(TaskService) private readonly fb = inject(NonNullableFormBuilder) + private readonly i18n = inject(i18nPipe) protected readonly mobile = inject(WA_IS_MOBILE) protected readonly context = injectContext>() @@ -228,10 +237,15 @@ export class SubnetsAdd { return this.form.controls.servers } - protected readonly modeLabel = (m: T.Tunnel.DnsMode) => MODE_LABEL[m] + protected readonly modeLabel = (m: T.Tunnel.DnsMode) => + this.i18n.transform(MODE_LABEL[m]) protected readonly matchWan = matchWan protected readonly stringifyWan = ({ ip }: WanItem) => - wanLabel(ip, 'System default', this.context.data.defaultWan) + wanLabel( + ip, + this.i18n.transform('System default'), + this.context.data.defaultWan, + ) protected readonly stringifyDevice = ({ ip, name }: MappedDevice) => ip ? `${name} (${ip})` : '' diff --git a/projects/start-tunnel/web/src/app/routes/home/routes/subnets/index.ts b/projects/start-tunnel/web/src/app/routes/home/routes/subnets/index.ts index 9cb9df294a..415bf1aa60 100644 --- a/projects/start-tunnel/web/src/app/routes/home/routes/subnets/index.ts +++ b/projects/start-tunnel/web/src/app/routes/home/routes/subnets/index.ts @@ -3,6 +3,7 @@ import { toSignal } from '@angular/core/rxjs-interop' import { TaskService } from '@start9labs/shared' import { T, utils } from '@start9labs/start-core' import { TuiResponsiveDialogService } from '@taiga-ui/addon-mobile' +import { TuiComparator, TuiTable } from '@taiga-ui/addon-table' import { TuiButton, TuiDataList, @@ -14,6 +15,7 @@ import { TUI_CONFIRM, TuiSkeleton } from '@taiga-ui/kit' import { TuiCardLarge, TuiHeader } from '@taiga-ui/layout' import { PatchDB } from 'patch-db-client' import { filter, map } from 'rxjs' +import { i18nPipe } from 'src/app/i18n/i18n.pipe' import { PlaceholderComponent } from 'src/app/routes/home/components/placeholder' import { defaultWanIp, @@ -30,33 +32,49 @@ import { SUBNETS_ADD } from './add'
-

Subnets

+

{{ 'Subnets' | i18n }}

- +
- - - - - - + + + + + + - @for (subnet of subnets(); track $index) { + @for (subnet of subnets() | tuiTableSort; track $index) { - - - - + + - - + + @@ -119,6 +137,8 @@ import { SUBNETS_ADD } from './add' TuiHeader, TuiIcon, TuiTitle, + TuiTable, + i18nPipe, ], }) export default class Subnets { @@ -126,9 +146,16 @@ export default class Subnets { private readonly api = inject(ApiService) private readonly tasks = inject(TaskService) private readonly patch = inject>(PatchDB) + private readonly i18n = inject(i18nPipe) protected readonly wanLabel = wanLabel + protected readonly byName: TuiComparator = (a, b) => + (a.name || '').localeCompare(b.name || '') + + protected readonly byRange: TuiComparator = (a, b) => + this.ip4(a.range) - this.ip4(b.range) + private readonly wans = toSignal( this.patch.watch$('gateways').pipe(map(wanOptions)), { initialValue: [] }, @@ -148,7 +175,13 @@ export default class Subnets { hasClients: !!Object.keys(info.clients).length, dns: info.dns, clients: info.clients, - dnsLabel: dnsLabel(info.dns, info.clients), + // Device name is data-derived (resolved here); the 'custom'/'default' + // labels are translated in the template so they react to a live + // language switch rather than being baked at data-emit time. + dnsDevice: + info.dns.type === 'device' + ? (info.clients[info.dns.ip]?.name ?? info.dns.ip) + : '', wanIp: info.wanIp, ipv6: info.ipv6, })), @@ -160,7 +193,7 @@ export default class Subnets { protected onAdd(): void { this.dialogs .open(SUBNETS_ADD, { - label: 'Add Subnet', + label: this.i18n.transform('Add Subnet'), data: { subnet: this.getNext(), mode: 'default', @@ -191,7 +224,7 @@ export default class Subnets { this.dialogs .open(SUBNETS_ADD, { - label: 'Edit Subnet', + label: this.i18n.transform('Edit Subnet'), data: { subnet: range, name, @@ -211,20 +244,23 @@ export default class Subnets { .subscribe() } - protected onDelete(index: number): void { + protected onDelete(range: string): void { this.dialogs - .open(TUI_CONFIRM, { label: 'Are you sure?' }) + .open(TUI_CONFIRM, { label: this.i18n.transform('Are you sure?') }) .pipe(filter(Boolean)) .subscribe(() => this.tasks.run( - async () => - await this.api.deleteSubnet({ - subnet: this.subnets()?.[index]?.range || '', - }), + async () => await this.api.deleteSubnet({ subnet: range }), ), ) } + private ip4(s: string): number { + return (s.split('/')[0] || '') + .split('.') + .reduce((n, o) => n * 256 + Number(o), 0) + } + private getNext(): string { const current = this.subnets()?.map(s => utils.IpNet.parse(s.range)) const suggestion = utils.IpNet.parse('10.59.0.1/24') @@ -251,21 +287,7 @@ type MappedSubnet = { hasClients: boolean dns: T.Tunnel.DnsConfig clients: T.Tunnel.WgSubnetClients - dnsLabel: string + dnsDevice: string wanIp: string | null ipv6: string | null } - -function dnsLabel( - dns: T.Tunnel.DnsConfig, - clients: T.Tunnel.WgSubnetClients, -): string { - switch (dns.type) { - case 'device': - return clients[dns.ip]?.name ?? dns.ip - case 'custom': - return 'custom' - default: - return 'default' - } -} diff --git a/projects/start-tunnel/web/src/app/routes/login/index.ts b/projects/start-tunnel/web/src/app/routes/login/index.ts index 2bbeec9824..91fbb97a87 100644 --- a/projects/start-tunnel/web/src/app/routes/login/index.ts +++ b/projects/start-tunnel/web/src/app/routes/login/index.ts @@ -3,6 +3,7 @@ import { FormsModule } from '@angular/forms' import { Router } from '@angular/router' import { TuiButton, TuiError, TuiInput } from '@taiga-ui/core' import { TuiButtonLoading } from '@taiga-ui/kit' +import { i18nPipe } from 'src/app/i18n/i18n.pipe' import { ApiService } from 'src/app/services/api/api.service' import { AuthService } from 'src/app/services/auth.service' @@ -14,7 +15,7 @@ import { AuthService } from 'src/app/services/auth.service' - Login + {{ 'Login' | i18n }} @if (error()) { - + } `, @@ -53,7 +54,14 @@ import { AuthService } from 'src/app/services/auth.service' width: 18rem; } `, - imports: [TuiButton, TuiInput, FormsModule, TuiError, TuiButtonLoading], + imports: [ + TuiButton, + TuiInput, + FormsModule, + TuiError, + TuiButtonLoading, + i18nPipe, + ], }) export default class Login { private readonly auth = inject(AuthService) diff --git a/projects/start-tunnel/web/src/styles.scss b/projects/start-tunnel/web/src/styles.scss index 80e4623de1..19e03c4133 100644 --- a/projects/start-tunnel/web/src/styles.scss +++ b/projects/start-tunnel/web/src/styles.scss @@ -128,6 +128,12 @@ tui-notification-middle { height: var(--tui-height-m); padding: 0 1rem; text-align: start; + // Taiga's tuiTh/tuiTd component `:host` styles put a full 1px border and an + // opaque background on every cell — that gridlines the table and hides our + // row striping / header band. Override both (a `.g-table` cell selector + // outspecifies `:host`) and keep only our single bottom row-line. + background: transparent; + border: none; border-bottom: 1px solid var(--tui-background-neutral-1); &:last-child { @@ -172,3 +178,81 @@ ng-component { inset-inline-start: 0; } } + +// Contextual help content (rendered from markdown in the help sidebar and the +// in-dialog help popup). The `## Title` heading is hidden — it exists only as a +// search/anchor label — and `
` render as animated accordions. +.g-help { + padding: 0 1rem 1rem; + + h2 { + display: none; + } + + h3 { + margin-block: 0.75rem 0.25rem; + font: var(--tui-typography-body-l); + font-weight: bold; + } + + p, + ul, + ol { + margin-block: 0.25rem; + } + + a { + color: var(--tui-text-action); + text-decoration: underline; + } +} + +.g-accordion, +.g-help details { + display: grid; + grid-template-rows: var(--tui-height-m) 0fr; + transition: grid-template-rows 0.3s ease-in-out; + + summary { + display: flex; + align-items: center; + justify-content: space-between; + cursor: pointer; + font: var(--tui-typography-body-m); + font-weight: bold; + list-style: none; + + --chevron: url('data:image/svg+xml;charset=UTF-8,%3Csvg%0A%20%20%20%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%0A%20%20%20%20width%3D%2224%22%0A%20%20%20%20height%3D%2224%22%0A%20%20%20%20viewBox%3D%220%200%2024%2024%22%0A%20%20%20%20fill%3D%22none%22%0A%20%20%20%20stroke%3D%22currentColor%22%0A%20%20%20%20stroke-width%3D%22calc((100vw%20-%20100vh)%20%2F%2010)%22%0A%20%20%20%20stroke-linecap%3D%22round%22%0A%20%20%20%20stroke-linejoin%3D%22round%22%0A%3E%0A%20%20%20%20%3Cpath%20vector-effect%3D%22non-scaling-stroke%22%20d%3D%22m6%209%206%206%206-6%22%20%2F%3E%0A%3C%2Fsvg%3E%0A'); + + &::after { + content: ''; + background: currentColor; + mask: var(--chevron) no-repeat center / + calc(min(1em, 100%) + 10 * var(--tui-stroke-width)) min(1em, 100%); + transition: transform 0.3s ease-in-out; + font-size: 1rem; + block-size: 1em; + inline-size: 1em; + } + } + + &::details-content { + transition: all 0.3s ease-in-out; + transition-behavior: allow-discrete; + opacity: 0; + overflow: hidden; + } + + &[open] { + grid-template-rows: var(--tui-height-m) 1fr; + + summary::after { + transform: rotate(180deg); + } + + &::details-content { + padding: 0.5rem 0; + opacity: 1; + } + } +} diff --git a/shared-libs/ts-modules/scripts/check-i18n.mjs b/shared-libs/ts-modules/scripts/check-i18n.mjs index 1d38102e0c..2b0001f0a0 100644 --- a/shared-libs/ts-modules/scripts/check-i18n.mjs +++ b/shared-libs/ts-modules/scripts/check-i18n.mjs @@ -40,12 +40,14 @@ function walk(dir, files = []) { // Projects are scattered across the monorepo: shared libs live here under // shared-libs/ts-modules, app projects live in their product dirs. +// start-tunnel and start-wrt keep their OWN local dictionaries and are checked +// by their own check-i18n scripts (check:i18n:tunnel / check:i18n:wrt), so they +// are deliberately excluded here. const scanDirs = [ 'shared', 'marketplace', '../../projects/start-os/web/ui', '../../projects/start-os/web/setup-wizard', - '../../projects/start-tunnel/web', '../../projects/brochure-marketplace', ].map(d => join(root, d)) const files = scanDirs.flatMap(d => walk(d))
NameIPv4 RangeDNSWAN IPv4IPv6 Prefix{{ 'Name' | i18n }}{{ 'IPv4 Range' | i18n }}{{ 'DNS' | i18n }}{{ 'WAN IPv4' | i18n }}{{ 'IPv6 Prefix' | i18n }}
{{ subnet.name }}{{ subnet.range }}{{ subnet.dnsLabel }} - {{ wanLabel(subnet.wanIp, 'System default', defaultWan()) }} + {{ subnet.name }}{{ subnet.range }} + @switch (subnet.dns.type) { + @case ('device') { + {{ subnet.dnsDevice }} + } + @case ('custom') { + {{ 'custom' | i18n }} + } + @default { + {{ 'default' | i18n }} + } + } {{ subnet.ipv6 ?? '—' }} + + {{ + wanLabel(subnet.wanIp, 'System default' | i18n, defaultWan()) + }} + {{ subnet.ipv6 ?? '—' }} @@ -93,7 +111,7 @@ import { SUBNETS_ADD } from './add'
- No subnets + {{ 'No subnets' | i18n }}