Compare commits
48 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cde45fff1d | |||
| 972f9ace0a | |||
| 8c184a7f9a | |||
| b6efcff25e | |||
| 29029914d2 | |||
| 111f5d9df7 | |||
| fe71ec1bcc | |||
| 99276f4ee5 | |||
| 02767cac47 | |||
| da03cdd666 | |||
| 03dd903e07 | |||
| 43349579e1 | |||
| 2a7a7c01da | |||
| 2a159428dd | |||
| 5a8dbaee0d | |||
| 24a6a718e0 | |||
| 57d0600fc0 | |||
| fedbe81690 | |||
| a7b3416619 | |||
| f357c89ac7 | |||
| b9415dcb17 | |||
| 65ba1b16e3 | |||
| 8c4ec67282 | |||
| 36e7dbf8aa | |||
| c56bfe21e5 | |||
| d533a94718 | |||
| fe18393d4e | |||
| e41e7ff9e1 | |||
| 1100872152 | |||
| 16ab12a61c | |||
| 0f6be51c35 | |||
| 463111dfd6 | |||
| 4874544955 | |||
| 256476268f | |||
| a57d579b0e | |||
| 49bf0fa1d3 | |||
| 1b4764e328 | |||
| db3333e26d | |||
| 1092031a9f | |||
| f386a5f9c3 | |||
| 82318da484 | |||
| 14784266b3 | |||
| 362e77f3dd | |||
| dfafffe154 | |||
| bd4464ed19 | |||
| 061a044a70 | |||
| c6a4a3c841 | |||
| 0ef451e15d |
@@ -1,9 +1,96 @@
|
|||||||
|
## v2.1.1
|
||||||
|
- fix: **mesh peer removal now cleans up its kernel routes.** `wg_iface.removePeer()` dropped the WireGuard peer entry but left the `ip route` entries `setPeer()` had added, so a removed peer's subnet stayed routed into a dead tunnel. Fixed by querying `wg show <iface> allowed-ips` before removal and `ip route del`-ing each CIDR. Verified live: routes present after `setPeer`, gone after `removePeer`, this gateway's own local route untouched. Exposed via `DELETE /api/mesh/gateways/:id` + a remove button in the mesh UI.
|
||||||
|
- feat: **`GET /api/mesh/self`** — this gateway's own mesh IP, gated by any valid self-service API token rather than a full jump-admin session, so an unattended local script (e.g. `theta-suite`'s no-inbound relay bootstrap) can discover it without admin credentials.
|
||||||
|
- fix: **`/api/mesh/register` was unreachable via HTTP.** `routes/api.js` mounted `/` (admin-session-gated `routes/jump.js`) before `/mesh`; since `router.use('/', ...)` matches every `/api/*` path, every `/api/mesh/*` request — including `/register`, authenticated by a bearer mesh join token, not an admin session — hit that admin gate first and 401'd before `routes/mesh.js` ever ran. A real gateway-to-gateway `/join` call failed with a `checkApiToken`/`LoginFailed` error instead of registering. Found live-testing the new `/self` endpoint with two real containers; fixed by mounting `/mesh` first.
|
||||||
|
- fix: **the initiating side of a mesh join never recorded its own identity.** `POST /register` (the receiving side) persists a `(self)` registry entry via `ensureOwnMeshIndex()`, but `POST /join` (the initiating side) never did, so `GET /api/mesh/self` and the mesh UI's own-entry handling silently saw nothing on whichever gateway called `/join`. Fixed by registering a self-entry there too, using the exact mesh index the remote assigned (`models/mesh_gateway.js`'s `register()` now accepts an explicit `meshIndex` instead of always auto-picking one from the local registry). Verified with two real meshed containers: both sides now report their own correct mesh IP.
|
||||||
|
|
||||||
|
## v2.1.0
|
||||||
|
- feat: **Gateway-to-gateway WireGuard mesh** (`routes/mesh.js`) — real site-to-site tunnels between theta-gateway instances, distinct from the existing roaming-client/exit-node WireGuard feature. Join-token bootstrap (`POST /api/mesh/join-tokens`, `/register`, `/join`), mesh-index addressing (172.24.\<idx\>.0/16 + 10.\<idx\>.0.0/16, per `theta-suite`'s `docs/MULTI_SITE_SPEC.md`).
|
||||||
|
- feat: **In-kernel WireGuard with a userspace fallback** (`utils/wg_iface.js`) — prefers `ip link add type wireguard`, falls back to `wireguard-go` when the kernel module isn't available (older/hardened kernels, some container images, non-Linux). Both packages added to the Dockerfile.
|
||||||
|
- feat: **mDNS local-discovery announcer** (`services/mdns_announce.js`) — advertises which public hostnames this site fronts (opt-in via `THETA_LOCAL_DISCOVERY_HOSTS`) so a `theta-agent` on the same LAN segment can skip the relay/WAN path. Companion piece to `theta-agent`'s discovery listener.
|
||||||
|
- feat: **Mesh UI** (`/mesh`) — gateway identity (interface, kernel-vs-userspace mode), join-token minting, remote-join form, meshed-gateways table.
|
||||||
|
- Verified with real two-container tests, not mocks: an actual encrypted WireGuard tunnel passing ICMP traffic end to end (0% loss), and the mDNS announce/discover/apply/revert cycle over real multicast. Two real bugs found and fixed along the way: `wg set ... allowed-ips` doesn't add a kernel route (a real handshake completed with zero routing, `ping` still failed, until `setPeer()` was fixed to add `ip route add` itself); and mDNS's default IPv6 query aborting the entire lookup — discarding an already-valid IPv4 response — when IPv6 isn't available.
|
||||||
|
|
||||||
|
## v2.0.1
|
||||||
|
- docs: **Rebranded to Theta Gateway across the docs.** README title/links updated; removed the "Standalone Docker" and "Bare metal" install paths, which contradicted the Deployment section's own "exclusively via Docker Compose within Theta Suite" claim. Fixed stale links to the old per-repo GitHub Pages sites (`sso-manager-node`, `theta-env`) — now point at the unified `theta42.github.io/theta-suite/` docs site.
|
||||||
|
|
||||||
|
## v2.0.0
|
||||||
|
- feat: **WireGuard Gateway Management UI & API.** Integrated complete WireGuard exit node management (`/wireguard`), client peer creation with instant QR code rendering and `.conf` configuration file downloads.
|
||||||
|
- feat: **Automatic WireGuard Bootstrap.** Automatically generates an X25519 gateway keypair on initial boot if missing and registers the local default exit node (`718it (This Site)`).
|
||||||
|
- feat: **Query Token Authentication.** Added `?token=` parameter fallback to `middleware/auth.js` for direct browser `.conf` profile file downloads.
|
||||||
|
- fix: **UI Confirm Banners.** Added `actionMessage` container placeholders to cards for `app.messages.confirm()` rendering.
|
||||||
|
|
||||||
|
## v1.19.1
|
||||||
|
- docs: README.md and docs/architecture.md described host-access authorization as a client-side loop over each of a user's LDAP groups (`GET /api/discovery/resources?group=<cn>` per group). The actual code (`utils/access.js`, `accessibleHosts()`) makes one call to the SSO's `GET /api/discovery/access/:uid`, which resolves the user's groups server-side. Corrected both.
|
||||||
|
|
||||||
|
## v1.19.0
|
||||||
|
- fix: **only catalog hosts are jump targets.** `isManagedHost` treated a missing `metadata.managed` flag as permission, so any host the SSO merely *discovered* — an unpromoted Proxmox guest, a UniFi client — was offered in the TUI picker and accepted by the username grammar. The filter is now `isCatalogHost`, mirroring the SSO Directory's own rule: a resource carrying `discovery_sources` but never promoted is excluded, while hand-created hosts (no `discovery_sources`) and promoted ones (`managed: true`) are included, and an explicit `managed: false` is always excluded.
|
||||||
|
- test: regression coverage for all five cases (hand-made, discovered-unpromoted, discovered-promoted, `manual` source, explicitly unmanaged).
|
||||||
|
- docs: `docs/connecting.md` states that discovery results are not jump targets until promoted into the catalog.
|
||||||
|
|
||||||
|
## v1.18.0
|
||||||
|
- feat: Add SSO-style error page (404/500) for browser navigation instead of a bare text response
|
||||||
|
- feat: navbar — username no longer underlined; only the active link is bold + underlined
|
||||||
|
|
||||||
|
## v1.16.1
|
||||||
|
- fix: remove missing DEPLOYMENT.md from Docker build context
|
||||||
|
|
||||||
|
## v1.16.0
|
||||||
|
- Added OpenBao PKI SSH Certificate Support
|
||||||
|
- Fallback to LDAP Key injection
|
||||||
|
|
||||||
|
# v1.15.0
|
||||||
|
- feat: Rename SSO Manager to Jump in UI
|
||||||
|
|
||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
All notable changes to this project are documented here. Format loosely
|
All notable changes to this project are documented here. Format loosely
|
||||||
follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions
|
follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions
|
||||||
correspond to git tags (`vX.Y.Z`) and `nodejs/package.json`'s `version`.
|
correspond to git tags (`vX.Y.Z`) and `nodejs/package.json`'s `version`.
|
||||||
|
|
||||||
|
## [1.14.1] - 2026-08-01
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Bumped `@simpleworkjs/bao-conf` to 1.0.1** so standalone/no-OpenBao boots
|
||||||
|
don't crash. bao-conf 1.0.0's `init()` threw when `VAULT_TOKEN` was unset,
|
||||||
|
which — combined with `bin/www`'s `.catch(() => process.exit(1))` — made the
|
||||||
|
jump host exit at boot in any deployment without an OpenBao sidecar
|
||||||
|
(standalone Docker, bare metal). 1.0.1 makes `init()` fail-soft on a missing
|
||||||
|
token (warn + continue from `CONF_SECRETS`), matching the documented
|
||||||
|
contract. The theta-env stack is unaffected (it always sets a scoped
|
||||||
|
`VAULT_TOKEN`).
|
||||||
|
|
||||||
|
## [1.14.0] - 2026-08-01
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Secrets now load from OpenBao at boot** via
|
||||||
|
[@simpleworkjs/bao-conf](https://simpleworkjs.github.io/bao-conf/), which
|
||||||
|
deep-merges `secret/jump-host/conf` over the file-loaded config. The jump
|
||||||
|
host authenticates to OpenBao with a scoped `VAULT_TOKEN` (policy
|
||||||
|
`jump-host` — read-only on its own path), never the root token. Because the
|
||||||
|
OIDC `clientSecret` is captured at require time inside `createOidcClient`
|
||||||
|
(during `require('../models')`), `bin/www` now runs `bao-conf.init()`
|
||||||
|
**before** `require('../models')`. Fail-soft: if OpenBao is unreachable,
|
||||||
|
boot continues from `CONF_SECRETS`. The `config/jump-secrets.js` file is now
|
||||||
|
an operator-edit seed artifact (gitignored); OpenBao is authoritative. See
|
||||||
|
theta-env's [Secrets docs](https://theta42.github.io/theta-env/secrets/).
|
||||||
|
- Bumped package version to track the release tag.
|
||||||
|
|
||||||
|
## [1.11.0] - 2026-07-30
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **`app_super_admin` (cross-app) and `app_jump_admin` groups**: super admins are full admins here same as `app_sso_admin`; jump admins get audit page/data access without other admin rights. The Audit page/API is now actually admin-gated server-side (previously the page shell rendered for any logged-in user, only its data was gated).
|
||||||
|
- **Host list adds Last connection/Last failed connection columns** and highlights rows green (a session is live right now) or yellow (the most recent attempt failed), backed by new per-host last-success/last-fail timestamps in `models/metrics.js`. `services/ssh_server.js` now attributes grammar/TUI connect failures to the resolved host when one was found, not just aggregate counters.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Dashboard's stat boxes and Top hosts/Top users cards moved to the Audit page** (audit is now the admin-facing metrics home; dashboard stays focused on "hosts I can reach"). "All hosts" renamed to "My hosts".
|
||||||
|
|
||||||
|
## [1.10.2] - 2026-07-30
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Dashboard, Sessions, and Audit pages now match sso-manager-node/proxy's page width**, wrapping content in a standard container instead of rendering full-bleed inside the fluid shell.
|
||||||
|
- **Audit's nav entry is now admin-gated** (`groups: ['admin']` in `utils/ui.js`), reusing the existing synthetic-admin-group nav-gating convention — the API route was already server-side admin-gated; this hides the nav link for non-admins too.
|
||||||
|
|
||||||
## [1.10.1] - 2026-07-28
|
## [1.10.1] - 2026-07-28
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ FROM node:22-bookworm-slim
|
|||||||
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
redis-server dumb-init ca-certificates \
|
redis-server dumb-init ca-certificates \
|
||||||
|
iproute2 wireguard-tools wireguard-go \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
@@ -37,7 +38,7 @@ COPY nodejs/utils ./utils
|
|||||||
COPY nodejs/views ./views
|
COPY nodejs/views ./views
|
||||||
COPY nodejs/public ./public
|
COPY nodejs/public ./public
|
||||||
|
|
||||||
COPY README.md CHANGELOG.md DEPLOYMENT.md /
|
COPY README.md CHANGELOG.md /
|
||||||
COPY --from=gitinfo /commit.txt ./.build_commit
|
COPY --from=gitinfo /commit.txt ./.build_commit
|
||||||
|
|
||||||
COPY docker-entrypoint.sh /usr/local/bin/
|
COPY docker-entrypoint.sh /usr/local/bin/
|
||||||
|
|||||||
@@ -1,130 +1,54 @@
|
|||||||
# Theta42 Jump Host
|
# Theta Gateway
|
||||||
|
|
||||||
An SSH jump host for the [theta42](https://github.com/theta42) self-hosted
|
An SSH jump gateway and integrated WireGuard mesh network router for the [Theta Suite](https://github.com/theta42/theta-suite) ecosystem. Users SSH into one entry point (`:2222`) and reach target downstream hosts according to directory permissions, with full auditing end-to-end.
|
||||||
stack. Users SSH into one public host and land on any downstream host they're
|
|
||||||
entitled to — audited end to end.
|
|
||||||
|
|
||||||
Two backends, same SSH front door and audit trail: the default mode
|
Theta Gateway authenticates users against the shared OpenLDAP directory, authorizes access using **Theta Directory** (`theta-directory`), and routes cross-site mesh traffic with native WireGuard subnets and NETMAP shadow network support.
|
||||||
authenticates against the shared LDAP directory and authorizes from the
|
|
||||||
[SSO Manager](https://github.com/theta42/sso-manager-node)'s inventory graph;
|
|
||||||
**standalone mode** (below) runs with no LDAP or SSO at all, storing users and
|
|
||||||
hosts in a local SQL database instead.
|
|
||||||
|
|
||||||
## Two ways to connect
|
**Documentation:** [https://theta42.github.io/theta-suite/jump-host/](https://theta42.github.io/theta-suite/jump-host/)
|
||||||
|
|
||||||
|
## Access Flow
|
||||||
|
|
||||||
**Direct (WinSCP/SFTP-friendly):**
|
**Direct (WinSCP/SFTP-friendly):**
|
||||||
|
|
||||||
```
|
```bash
|
||||||
ssh alice_-_web01@jump.example.com # -> host slug 'web01' / 'host_web01'
|
ssh alice_-_web01@jump.example.com # -> target host slug 'web01'
|
||||||
sftp -P 2222 alice_-_web01@jump.example.com # SFTP passes through unchanged
|
sftp -P 2222 alice_-_web01@jump.example.com # SFTP passes through unchanged
|
||||||
```
|
```
|
||||||
|
|
||||||
The username grammar is `{uid}_-_{target}`. `target` is a directory host slug
|
The username grammar is `{uid}_-_{target}`. `target` is a directory host slug or hostname.
|
||||||
(with or without the `host_` prefix), a bare hostname, or an IP.
|
|
||||||
|
|
||||||
**Interactive picker:**
|
**Interactive host picker:**
|
||||||
|
|
||||||
```
|
```bash
|
||||||
ssh alice@jump.example.com
|
ssh alice@jump.example.com
|
||||||
```
|
```
|
||||||
|
|
||||||
Plain login shows a TUI list of the hosts you can reach; pick one and you're
|
Plain login displays a TUI list of target hosts assigned to the local site (`SITE_SLUG`) that the user is authorized to reach.
|
||||||
bridged straight in.
|
|
||||||
|
|
||||||
## How it works
|
## How it Works
|
||||||
|
|
||||||
1. **Inbound auth** — LDAP. Public key (matched against your `sshPublicKey`, the
|
1. **Inbound Auth** — OpenLDAP authentication via public key matching (`sshPublicKey`) or LDAP password bind.
|
||||||
jump host's own injected key excluded) or password (LDAP bind; the
|
2. **Authorization** — Calls Theta Directory's access API (`GET /api/discovery/access/:uid`) to evaluate LDAP group memberships and site-filtered host entitlement.
|
||||||
`ssh.passwordAuth` policy can restrict passwords to local clients or disable
|
3. **Key Injection** — Appends its gateway public key to user `sshPublicKey` in LDAP and connects downstream as the user.
|
||||||
them — keys-only is recommended for a public host).
|
4. **Bridge & Audit** — Slices shell/SFTP subsystem to downstream sshd with session audit logging.
|
||||||
2. **Authorization** — the hosts you may reach are the union of your LDAP groups
|
|
||||||
× the SSO directory (`/api/discovery/resources?group=<cn>`). No directory
|
|
||||||
entry, no access.
|
|
||||||
3. **Key injection** — on first use the jump host appends its own public key to
|
|
||||||
your `sshPublicKey` in LDAP (comment-marked), then connects downstream **as
|
|
||||||
you** using its private key. Downstream hosts already serve keys from LDAP
|
|
||||||
via [ldap-client](https://github.com/theta42/ldap-client)'s
|
|
||||||
`AuthorizedKeysCommand`, so nothing downstream needs changing.
|
|
||||||
4. **Bridge** — shell, exec, and the SFTP subsystem are spliced to the
|
|
||||||
downstream sshd. Every session is audited.
|
|
||||||
|
|
||||||
## Standalone mode
|
## Deployment
|
||||||
|
|
||||||
Run without LDAP or the SSO Manager at all. Set `standalone.enabled: true` and
|
Theta Gateway is deployed exclusively via Docker Compose as an integrated service within **Theta Suite** — it is not installed or run on its own:
|
||||||
the jump host stores users and hosts itself, via
|
|
||||||
[@simpleworkjs/orm](https://www.npmjs.com/package/@simpleworkjs/orm)
|
|
||||||
(Sequelize under the hood — defaults to a local SQLite file, but any
|
|
||||||
Sequelize-supported dialect works via `conf.orm`):
|
|
||||||
|
|
||||||
```js
|
```bash
|
||||||
standalone: { enabled: true },
|
git clone --recursive https://github.com/theta42/theta-suite.git
|
||||||
orm: { dialect: 'sqlite', storage: './data/standalone.sqlite', logging: false },
|
cd theta-suite
|
||||||
|
cp setup.env.example setup.env # set CFG_DOMAIN to your domain
|
||||||
|
./setup.sh # generates config, builds, and starts Theta Suite
|
||||||
```
|
```
|
||||||
|
|
||||||
Everything else — the SSH front door, key injection, bridging, the web UI and
|
Enable it via `CFG_JUMP_HOST_ENABLED=true` in `setup.env` and re-run
|
||||||
audit trail — is unchanged; only where users/hosts live and how passwords are
|
`./setup.sh`. The stack wires the LDAP bind account (write access to the
|
||||||
checked differs. There's no admin UI for standalone users/hosts yet — add them
|
`sshPublicKey` attribute), the write-ACL, the SSO API token, and a directory
|
||||||
with the ORM models directly:
|
entry automatically.
|
||||||
|
|
||||||
```js
|
See the main [Theta Suite README](https://github.com/theta42/theta-suite) for full details on multi-site configuration, WireGuard mesh routing, and network setup.
|
||||||
const StandaloneUser = require('./models/standalone_user');
|
|
||||||
const StandaloneHost = require('./models/standalone_host');
|
|
||||||
const bcrypt = require('bcrypt');
|
|
||||||
|
|
||||||
await StandaloneUser.create({
|
|
||||||
uid: 'alice',
|
|
||||||
passwordHash: await bcrypt.hash('a real password', 10),
|
|
||||||
sshPublicKeys: ['ssh-ed25519 AAAA... alice@laptop'],
|
|
||||||
groups: [],
|
|
||||||
});
|
|
||||||
|
|
||||||
await StandaloneHost.create({
|
|
||||||
slug: 'host_web01',
|
|
||||||
displayName: 'web01',
|
|
||||||
kind: 'host',
|
|
||||||
metadata: { ip: '10.0.0.5', sshPort: 22 },
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
In standalone mode every stored host is reachable by every stored user — there
|
|
||||||
is no group-based authorization (the `groups` field on `StandaloneUser` is
|
|
||||||
accepted for interface parity but not yet enforced).
|
|
||||||
|
|
||||||
## Requirements
|
|
||||||
|
|
||||||
*(default LDAP + SSO mode — see [Standalone mode](#standalone-mode) to skip
|
|
||||||
all of this)*
|
|
||||||
|
|
||||||
- The SSO Manager (OpenLDAP directory + `/api/discovery`).
|
|
||||||
- Downstream hosts joined via ldap-client (SSSD + `AuthorizedKeysCommand`).
|
|
||||||
- An LDAP bind account with **write access to the `sshPublicKey` attribute** on
|
|
||||||
user entries (see the ACL note in `secrets.js.example`).
|
|
||||||
- An SSO API token (`sso_…`) for the directory queries.
|
|
||||||
|
|
||||||
## Install
|
|
||||||
|
|
||||||
### Unified theta-env stack (recommended)
|
|
||||||
|
|
||||||
Enable it in `theta-env/setup.env` (`CFG_JUMP_HOST_ENABLED=true`) and re-run
|
|
||||||
`./setup.sh`. The stack wires the LDAP bind account, the write-ACL, the API
|
|
||||||
token, and a directory entry automatically.
|
|
||||||
|
|
||||||
### Standalone Docker
|
|
||||||
|
|
||||||
```
|
|
||||||
cp secrets.js.example config/jump-secrets.js # then edit it
|
|
||||||
docker compose up -d --build
|
|
||||||
```
|
|
||||||
|
|
||||||
### Bare metal
|
|
||||||
|
|
||||||
```
|
|
||||||
curl -fsSL https://raw.githubusercontent.com/theta42/jump-host/master/ops/install.sh | sudo bash
|
|
||||||
sudo $EDITOR /etc/jump-host/secrets.js # fill in LDAP + SSO
|
|
||||||
sudo systemctl restart jump-host
|
|
||||||
```
|
|
||||||
|
|
||||||
Installs to `/opt/theta42/jump-host`; idempotent (re-run to update).
|
|
||||||
|
|
||||||
## Ports
|
## Ports
|
||||||
|
|
||||||
@@ -141,8 +65,8 @@ The default SSH port is **2222** so the service needs no privilege. To listen on
|
|||||||
## Web UI / API
|
## Web UI / API
|
||||||
|
|
||||||
`https://jump.example.com/` (behind the proxy) — built on the same
|
`https://jump.example.com/` (behind the proxy) — built on the same
|
||||||
Express + EJS + Bootstrap stack as the [SSO Manager](https://theta42.github.io/sso-manager-node/)
|
Express + EJS + Bootstrap stack as [Theta Directory](https://theta42.github.io/theta-suite/sso/)
|
||||||
and [Proxy](https://theta42.github.io/proxy/), so it looks and behaves like the
|
and [Theta Proxy](https://theta42.github.io/theta-suite/proxy/), so it looks and behaves like the
|
||||||
rest of the stack. Login is **OIDC against the SSO** (the "Log in with SSO"
|
rest of the stack. Login is **OIDC against the SSO** (the "Log in with SSO"
|
||||||
button) plus a **local anti-lockout admin** that works even if the SSO is
|
button) plus a **local anti-lockout admin** that works even if the SSO is
|
||||||
unreachable. Admin access requires membership in `auth.adminGroups` (default
|
unreachable. Admin access requires membership in `auth.adminGroups` (default
|
||||||
@@ -159,6 +83,22 @@ Config layers via [@simpleworkjs/conf](https://www.npmjs.com/package/@simplework
|
|||||||
`conf/base.js` < `conf/<NODE_ENV>.js` < the `CONF_SECRETS` file < `app_*` env.
|
`conf/base.js` < `conf/<NODE_ENV>.js` < the `CONF_SECRETS` file < `app_*` env.
|
||||||
See `secrets.js.example` for every key.
|
See `secrets.js.example` for every key.
|
||||||
|
|
||||||
|
## Secrets
|
||||||
|
|
||||||
|
At boot, [@simpleworkjs/bao-conf](https://simpleworkjs.github.io/bao-conf/)
|
||||||
|
deep-merges `secret/jump-host/conf` from **OpenBao** over the file-loaded
|
||||||
|
config. The jump host's OIDC `clientSecret` is captured at require time
|
||||||
|
(inside `createOidcClient` during `require('../models')`), so `bin/www` runs
|
||||||
|
`bao-conf.init()` **before** `require('../models')`. Fail-soft: if OpenBao is
|
||||||
|
unreachable, boot continues from `CONF_SECRETS`. The jump host authenticates to
|
||||||
|
OpenBao with the scoped `VAULT_TOKEN` (env, policy `jump-host` — read only
|
||||||
|
`secret/jump-host/conf`), never the root token.
|
||||||
|
|
||||||
|
The `config/jump-secrets.js` file is an operator-edit seed artifact
|
||||||
|
(gitignored); the bootstrap writes the generated API token + OAuth client
|
||||||
|
into OpenBao, which is authoritative. For the full architecture see
|
||||||
|
theta-suite's **[Secrets docs](https://theta42.github.io/theta-suite/secrets.html)**.
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -41,11 +41,13 @@ Every attempt — success or failure, with method and reason — is audited.
|
|||||||
## 2. Access & target resolution
|
## 2. Access & target resolution
|
||||||
|
|
||||||
The hosts a user may reach are computed from the directory, not a local list:
|
The hosts a user may reach are computed from the directory, not a local list:
|
||||||
|
the jump host calls the SSO's `GET /api/discovery/access/:uid` (authenticated
|
||||||
1. The user's LDAP group memberships (`(&(objectClass=groupOfNames)(member=…))`).
|
with an API token) once per user; the SSO evaluates the user's LDAP group
|
||||||
2. For each group, the SSO's
|
memberships server-side and returns the full access projection in one
|
||||||
`GET /api/discovery/resources?group=<cn>` (authenticated with an API token),
|
response, already filtered to `kind: host`. (The jump host also has an
|
||||||
unioned and filtered to `kind: host`.
|
admin-only `allHosts()` path, used for the unfiltered catalog listing, which
|
||||||
|
does call `GET /api/discovery/resources?group=<cn>` per group — but that's
|
||||||
|
not the per-user authorization path.)
|
||||||
|
|
||||||
Each host's dial address is `metadata.ip` (or the hostname from
|
Each host's dial address is `metadata.ip` (or the hostname from
|
||||||
`metadata.address`) and port `metadata.sshPort` (default 22). Results are cached
|
`metadata.address`) and port `metadata.sshPort` (default 22). Results are cached
|
||||||
|
|||||||
@@ -66,14 +66,22 @@ directory access allows — it doubles as "what can I reach from here?"
|
|||||||
## What you can reach
|
## What you can reach
|
||||||
|
|
||||||
The set of hosts is computed per login: your LDAP group memberships intersected
|
The set of hosts is computed per login: your LDAP group memberships intersected
|
||||||
with the SSO directory's hosts (via the `host_<name>_access` groups the
|
with the SSO directory's **catalog** hosts (via the `host_<name>_access` groups
|
||||||
directory auto-creates for each machine). To get access to a new host, an admin
|
the directory auto-creates for each machine). To get access to a new host, an
|
||||||
adds you to that host's access group in the SSO — nothing on the jump host
|
admin adds you to that host's access group in the SSO — nothing on the jump host
|
||||||
changes.
|
changes.
|
||||||
|
|
||||||
Targets that don't resolve to a host you're allowed to reach are refused (and
|
Targets that don't resolve to a host you're allowed to reach are refused (and
|
||||||
audited). Raw IPs that aren't a known directory host are denied by default.
|
audited). Raw IPs that aren't a known directory host are denied by default.
|
||||||
|
|
||||||
|
**Only catalog hosts are jump targets.** A machine that the SSO merely
|
||||||
|
*discovered* — a Proxmox guest, a UniFi client — is not a jump target until an
|
||||||
|
admin promotes it into the directory catalog. The jump host applies the same
|
||||||
|
rule the SSO's own Directory listing does: a resource carrying
|
||||||
|
`discovery_sources` but never promoted is excluded, while hand-created hosts and
|
||||||
|
promoted ones are included. Previously the filter treated a missing `managed`
|
||||||
|
flag as permission, so unpromoted discovery results showed up in the picker.
|
||||||
|
|
||||||
> On a [standalone](architecture.html#standalone-mode) jump host (no LDAP/SSO),
|
> On a [standalone](architecture.html#standalone-mode) jump host (no LDAP/SSO),
|
||||||
> every registered host is reachable by every registered user — there's no
|
> every registered host is reachable by every registered user — there's no
|
||||||
> group-based restriction to ask an admin about.
|
> group-based restriction to ask an admin about.
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 123 KiB |
|
Before Width: | Height: | Size: 83 KiB After Width: | Height: | Size: 177 KiB |
|
Before Width: | Height: | Size: 78 KiB After Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 72 KiB |
@@ -4,6 +4,8 @@ const express = require('express');
|
|||||||
const compression = require('compression');
|
const compression = require('compression');
|
||||||
|
|
||||||
require('./models'); // wire model-redis + register models
|
require('./models'); // wire model-redis + register models
|
||||||
|
const conf = require('@simpleworkjs/conf');
|
||||||
|
const buildInfo = require('./utils/build_info');
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
@@ -41,7 +43,15 @@ app.use((err, req, res, next) => {
|
|||||||
if(req.path.startsWith('/api/')){
|
if(req.path.startsWith('/api/')){
|
||||||
return res.status(status).json({name: err.name || 'Error', message: err.message || 'Error'});
|
return res.status(status).json({name: err.name || 'Error', message: err.message || 'Error'});
|
||||||
}
|
}
|
||||||
res.status(status).send(err.message || 'Error');
|
// Browser navigation gets the HTML error page (shared with SSO).
|
||||||
|
res.status(status).render('error', {
|
||||||
|
title: conf.environment !== 'production' ? 'dev' : '',
|
||||||
|
titleIcon: conf.environment !== 'production' ? '<i class="fa-brands fa-dev"></i>' : '',
|
||||||
|
name: conf.name,
|
||||||
|
logo: conf.logo,
|
||||||
|
...buildInfo,
|
||||||
|
error: err,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
module.exports = app;
|
module.exports = app;
|
||||||
|
|||||||
@@ -9,32 +9,47 @@ const http = require('http');
|
|||||||
const conf = require('@simpleworkjs/conf');
|
const conf = require('@simpleworkjs/conf');
|
||||||
const { Server } = require('socket.io');
|
const { Server } = require('socket.io');
|
||||||
|
|
||||||
require('../models');
|
// @simpleworkjs/conf loads ./config/jump-secrets.js synchronously, then
|
||||||
|
// @simpleworkjs/bao-conf deep-merges secret/jump-host/conf from OpenBao over
|
||||||
|
// it. The OIDC clientSecret is captured at require time inside models (via
|
||||||
|
// createOidcClient), so the fetch MUST resolve before require('../models').
|
||||||
|
// Fail-soft: if OpenBao is unreachable, init() leaves conf as the file-loaded
|
||||||
|
// fallback and boot continues from ./config/jump-secrets.js.
|
||||||
|
require('@simpleworkjs/bao-conf').init({ path: 'jump-host', conf }).then(async () => {
|
||||||
|
require('../models');
|
||||||
|
const { bootstrapWireguard } = require('../services/wg_bootstrap');
|
||||||
|
await bootstrapWireguard();
|
||||||
|
const { startMdnsAnnounce } = require('../services/mdns_announce');
|
||||||
|
await startMdnsAnnounce();
|
||||||
|
|
||||||
const app = require('../app');
|
const app = require('../app');
|
||||||
const middleware = require('../middleware/auth');
|
const middleware = require('../middleware/auth');
|
||||||
const sshServer = require('../services/ssh_server');
|
const sshServer = require('../services/ssh_server');
|
||||||
|
|
||||||
const webPort = (conf.web && conf.web.port) || 3002;
|
const webPort = (conf.web && conf.web.port) || 3002;
|
||||||
const server = http.createServer(app);
|
const server = http.createServer(app);
|
||||||
|
|
||||||
// Socket.IO — the client framework (app-base.js) opens an authenticated socket.
|
// Socket.IO — the client framework (app-base.js) opens an authenticated socket.
|
||||||
// We don't push anything yet, but serving /socket.io keeps the shared front-end
|
// We don't push anything yet, but serving /socket.io keeps the shared front-end
|
||||||
// working exactly as it does in the sibling apps.
|
// working exactly as it does in the sibling apps.
|
||||||
const io = new Server(server);
|
const io = new Server(server);
|
||||||
io.use(middleware.authIO);
|
io.use(middleware.authIO);
|
||||||
app.io = io;
|
app.io = io;
|
||||||
|
|
||||||
server.listen(webPort, () => {
|
server.listen(webPort, () => {
|
||||||
console.log(`[web] jump-host UI/API on :${server.address().port}`);
|
console.log(`[web] jump-host UI/API on :${server.address().port}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
sshServer.start();
|
sshServer.start();
|
||||||
|
|
||||||
function shutdown() {
|
function shutdown() {
|
||||||
console.log('[jump-host] shutting down');
|
console.log('[jump-host] shutting down');
|
||||||
server.close();
|
server.close();
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
process.on('SIGTERM', shutdown);
|
process.on('SIGTERM', shutdown);
|
||||||
process.on('SIGINT', shutdown);
|
process.on('SIGINT', shutdown);
|
||||||
|
}).catch(err => {
|
||||||
|
console.error('boot failed:', err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
// values (LDAP creds, SSO API token) belong in the secrets file.
|
// values (LDAP creds, SSO API token) belong in the secrets file.
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
name: 'Jump Host',
|
name: 'Jump',
|
||||||
logo: '/static/img/theta42.svg',
|
logo: '/static/img/theta42.svg',
|
||||||
|
|
||||||
// LDAP directory the users live in (same directory the SSO manages).
|
// LDAP directory the users live in (same directory the SSO manages).
|
||||||
@@ -80,7 +80,12 @@ module.exports = {
|
|||||||
|
|
||||||
auth: {
|
auth: {
|
||||||
// OIDC group memberships that grant web UI/API admin access.
|
// OIDC group memberships that grant web UI/API admin access.
|
||||||
adminGroups: ['app_sso_admin'],
|
// app_super_admin is the cross-app super admin group (sso, proxy, jump-host).
|
||||||
|
adminGroups: ['app_sso_admin', 'app_super_admin'],
|
||||||
|
// OIDC group memberships that grant jump admin access (the audit page
|
||||||
|
// and its data), without granting other admin-only rights. Full admins
|
||||||
|
// (adminGroups/adminUsers) always have jump admin access too.
|
||||||
|
jumpAdminGroups: ['app_jump_admin'],
|
||||||
// Local anti-lockout admin: the first name here is bootstrapped as a
|
// Local anti-lockout admin: the first name here is bootstrapped as a
|
||||||
// redis-backed user on first boot (password from localAdminPass, or a
|
// redis-backed user on first boot (password from localAdminPass, or a
|
||||||
// random one printed to the log once). Lets you in even with OIDC down.
|
// random one printed to the log once). Lets you in even with OIDC down.
|
||||||
@@ -115,4 +120,20 @@ module.exports = {
|
|||||||
|
|
||||||
// Orchestrator-only keys (ignored by the app, read by theta-env).
|
// Orchestrator-only keys (ignored by the app, read by theta-env).
|
||||||
stack: {},
|
stack: {},
|
||||||
|
|
||||||
|
// WireGuard mesh configuration.
|
||||||
|
// These values describe this gateway's own wg0 interface so the web UI
|
||||||
|
// can show the server public key and build client profiles.
|
||||||
|
// Override via environment: app_wireguard__serverPublicKey, etc.
|
||||||
|
wireguard: {
|
||||||
|
// Public key of this gateway's wg0 interface (set at runtime by docker-entrypoint).
|
||||||
|
serverPublicKey: '',
|
||||||
|
// "host:port" that WireGuard clients connect to, e.g. "gw.theta42.com:51820".
|
||||||
|
serverEndpoint: '',
|
||||||
|
// DNS server to push to clients, e.g. "10.1.0.1" or leave empty for none.
|
||||||
|
dns: '',
|
||||||
|
// Base of the IP pool for peer assignment: first two octets.
|
||||||
|
// Peers are assigned 10.100.0.2, 10.100.0.3, …, 10.100.255.254.
|
||||||
|
poolBase: '10.100.0',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -27,7 +27,8 @@ async function auth(req, res, next){
|
|||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
|
|
||||||
req.token = await Auth.checkToken(req.header('auth-token'));
|
const tokStr = req.header('auth-token') || req.query.token;
|
||||||
|
req.token = await Auth.checkToken(tokStr);
|
||||||
req.user = req.token.user;
|
req.user = req.token.user;
|
||||||
req.groups = typeof req.token.groupsArray === 'function' ? req.token.groupsArray() : [];
|
req.groups = typeof req.token.groupsArray === 'function' ? req.token.groupsArray() : [];
|
||||||
return next();
|
return next();
|
||||||
@@ -56,6 +57,25 @@ async function requireAdmin(req, res, next){
|
|||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Jump admin = access to the audit page/data. A narrower grant than full
|
||||||
|
// jump-host admin: full admins (isAdmin) always qualify, plus anyone in
|
||||||
|
// conf.auth.jumpAdminGroups (e.g. a dedicated app_jump_admin LDAP group) can
|
||||||
|
// be granted audit access without also getting other admin-only rights.
|
||||||
|
function isJumpAdmin(req){
|
||||||
|
if(isAdmin(req)) return true;
|
||||||
|
const jumpAdminGroups = (conf.auth && conf.auth.jumpAdminGroups) || [];
|
||||||
|
return (req.groups || []).some(g => jumpAdminGroups.includes(g));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requireJumpAdmin(req, res, next){
|
||||||
|
if(isJumpAdmin(req)) return next();
|
||||||
|
const error = new Error('Forbidden');
|
||||||
|
error.name = 'Forbidden';
|
||||||
|
error.status = 403;
|
||||||
|
error.message = 'Jump admin access required.';
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
|
||||||
// Socket.IO handshake auth (app-base.js connects with the session token).
|
// Socket.IO handshake auth (app-base.js connects with the session token).
|
||||||
async function authIO(socket, next){
|
async function authIO(socket, next){
|
||||||
try{
|
try{
|
||||||
@@ -69,4 +89,4 @@ async function authIO(socket, next){
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { auth, requireAdmin, authIO, isAdmin };
|
module.exports = { auth, requireAdmin, authIO, isAdmin, isJumpAdmin, requireJumpAdmin };
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Registry of peer theta-gateway instances this gateway has meshed with —
|
||||||
|
// raw Redis, same pattern as wg_site.js/audit_event.js. Each registration
|
||||||
|
// carries what's needed to configure a local WireGuard peer entry for them:
|
||||||
|
// public key, reachable endpoint, and the mesh IP this gateway assigned them
|
||||||
|
// (MULTI_SITE_SPEC.md's one-octet-per-site addressing, 172.24.<idx>.0/16 +
|
||||||
|
// 10.<idx>.0.0/16, idx 1-254).
|
||||||
|
//
|
||||||
|
// Redis keys:
|
||||||
|
// mesh_gateway:<id> — hash of gateway fields
|
||||||
|
// mesh_gateway_index — sorted set (score = createdAt, value = id)
|
||||||
|
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const conf = require('@simpleworkjs/conf');
|
||||||
|
const { getRedis } = require('./index');
|
||||||
|
|
||||||
|
const MAX_MESH_INDEX = 254;
|
||||||
|
|
||||||
|
const P = () => conf.redis.prefix;
|
||||||
|
const idxKey = () => `${P()}mesh_gateway_index`;
|
||||||
|
const gatewayKey = (id) => `${P()}mesh_gateway:${id}`;
|
||||||
|
|
||||||
|
function serialize(obj) {
|
||||||
|
const out = {};
|
||||||
|
for (const [k, v] of Object.entries(obj)) {
|
||||||
|
out[k] = String(v == null ? '' : v);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function deserialize(h) {
|
||||||
|
if (!h || !h.id) return null;
|
||||||
|
return { ...h, meshIndex: Number(h.meshIndex || 0), createdAt: Number(h.createdAt || 0), lastSeenAt: Number(h.lastSeenAt || 0) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function list() {
|
||||||
|
const redis = await getRedis();
|
||||||
|
const ids = await redis.zRange(idxKey(), 0, -1);
|
||||||
|
const out = [];
|
||||||
|
for (const id of ids) {
|
||||||
|
const g = deserialize(await redis.hGetAll(gatewayKey(id)));
|
||||||
|
if (g) out.push(g);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findByPublicKey(publicKey) {
|
||||||
|
const all = await list();
|
||||||
|
return all.find((g) => g.publicKey === publicKey) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextFreeMeshIndex(existing) {
|
||||||
|
const used = new Set(existing.map((g) => g.meshIndex).filter(Boolean));
|
||||||
|
for (let i = 1; i <= MAX_MESH_INDEX; i++) {
|
||||||
|
if (!used.has(i)) return i;
|
||||||
|
}
|
||||||
|
throw new Error(`Mesh index space exhausted (max ${MAX_MESH_INDEX} gateways)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register (or re-register, idempotent by publicKey) a peer gateway.
|
||||||
|
// Re-registering the same public key updates its endpoint/siteSlug but
|
||||||
|
// reuses its existing mesh index -- a gateway that re-registers after a
|
||||||
|
// restart must not get bumped to a new mesh subnet.
|
||||||
|
//
|
||||||
|
// meshIndex is normally auto-assigned (the next free local index) -- correct
|
||||||
|
// when THIS gateway is the one handing out indices (POST /register, for both
|
||||||
|
// the caller and itself via ensureOwnMeshIndex). But the INITIATING side of
|
||||||
|
// a join (POST /join) doesn't get to pick its own index -- the remote
|
||||||
|
// already assigned it and returned it in the response -- so an explicit
|
||||||
|
// meshIndex is accepted to record that exact value instead of whatever this
|
||||||
|
// gateway's own local registry would have auto-picked (which has no reason
|
||||||
|
// to agree with the value actually configured on the live wg0 interface).
|
||||||
|
async function register({ publicKey, endpoint, siteSlug, meshIndex: explicitMeshIndex }) {
|
||||||
|
const redis = await getRedis();
|
||||||
|
const existing = await list();
|
||||||
|
const already = existing.find((g) => g.publicKey === publicKey);
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
if (already) {
|
||||||
|
const updated = { ...already, endpoint, siteSlug: siteSlug || already.siteSlug, lastSeenAt: now };
|
||||||
|
await redis.hSet(gatewayKey(already.id), serialize(updated));
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = crypto.randomBytes(8).toString('hex');
|
||||||
|
const meshIndex = explicitMeshIndex || nextFreeMeshIndex(existing);
|
||||||
|
const gateway = { id, publicKey, endpoint, siteSlug: siteSlug || '', meshIndex, createdAt: now, lastSeenAt: now };
|
||||||
|
await redis.hSet(gatewayKey(id), serialize(gateway));
|
||||||
|
await redis.zAdd(idxKey(), { score: now, value: id });
|
||||||
|
return gateway;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(id) {
|
||||||
|
const redis = await getRedis();
|
||||||
|
const gw = deserialize(await redis.hGetAll(gatewayKey(id)));
|
||||||
|
if (!gw) return null;
|
||||||
|
await redis.del(gatewayKey(id));
|
||||||
|
await redis.zRem(idxKey(), id);
|
||||||
|
return gw;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { list, findByPublicKey, register, remove, MAX_MESH_INDEX };
|
||||||
@@ -13,10 +13,34 @@ async function bump({ uid, hostSlug, success }) {
|
|||||||
const ops = [redis.incr(`${P()}total`), redis.incr(`${P()}day_${day}`)];
|
const ops = [redis.incr(`${P()}total`), redis.incr(`${P()}day_${day}`)];
|
||||||
if (!success) ops.push(redis.incr(`${P()}fail`));
|
if (!success) ops.push(redis.incr(`${P()}fail`));
|
||||||
if (uid) ops.push(redis.incr(`${P()}user_${uid}`));
|
if (uid) ops.push(redis.incr(`${P()}user_${uid}`));
|
||||||
if (hostSlug) ops.push(redis.incr(`${P()}host_${hostSlug}`));
|
if (hostSlug) {
|
||||||
|
ops.push(redis.incr(`${P()}host_${hostSlug}`));
|
||||||
|
// Last-attempt timestamp per host, split by outcome -- drives the
|
||||||
|
// dashboard's "Last connection"/"Last failed connection" columns and
|
||||||
|
// row highlighting (see lastForHosts below).
|
||||||
|
ops.push(redis.set(`${P()}host_last_${success ? 'success' : 'fail'}_${hostSlug}`, Date.now()));
|
||||||
|
}
|
||||||
await Promise.all(ops);
|
await Promise.all(ops);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Per-host last-success/last-fail timestamps for a given list of slugs (e.g.
|
||||||
|
// the hosts a session can reach), for the dashboard's host list.
|
||||||
|
async function lastForHosts(slugs) {
|
||||||
|
const redis = await getRedis();
|
||||||
|
const result = {};
|
||||||
|
await Promise.all((slugs || []).map(async (slug) => {
|
||||||
|
const [lastSuccess, lastFail] = await Promise.all([
|
||||||
|
redis.get(`${P()}host_last_success_${slug}`),
|
||||||
|
redis.get(`${P()}host_last_fail_${slug}`),
|
||||||
|
]);
|
||||||
|
result[slug] = {
|
||||||
|
lastConnected: lastSuccess ? Number(lastSuccess) : null,
|
||||||
|
lastFailed: lastFail ? Number(lastFail) : null,
|
||||||
|
};
|
||||||
|
}));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
async function summary() {
|
async function summary() {
|
||||||
const redis = await getRedis();
|
const redis = await getRedis();
|
||||||
const [total, fail] = await Promise.all([
|
const [total, fail] = await Promise.all([
|
||||||
@@ -37,4 +61,4 @@ async function summary() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { bump, summary };
|
module.exports = { bump, summary, lastForHosts };
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// WireGuard peer model — raw Redis (same pattern as audit_event.js).
|
||||||
|
//
|
||||||
|
// Each peer is a client device (phone, laptop, etc.) with a unique keypair and
|
||||||
|
// an assigned IP on the gateway's wg0 interface.
|
||||||
|
//
|
||||||
|
// Redis keys:
|
||||||
|
// wg_peer:<id> — hash of peer fields
|
||||||
|
// wg_peer_index — sorted set (score = createdAt, value = id)
|
||||||
|
// wg_peer_ip_seq — integer counter for next assignable IP
|
||||||
|
//
|
||||||
|
// Assigned IPs are allocated from the gateway's wg pool (default 10.0.0.0/8
|
||||||
|
// range starting at .2 — .1 is the gateway itself). Override via conf.wireguard.
|
||||||
|
//
|
||||||
|
// Fields:
|
||||||
|
// id - 16-char hex
|
||||||
|
// name - human label, e.g. "william-phone"
|
||||||
|
// publicKey - WireGuard public key (X25519 base64)
|
||||||
|
// privateKey - WireGuard private key — PRIVATE, not returned by toPublic()
|
||||||
|
// assignedIP - e.g. "10.0.0.2"
|
||||||
|
// exitSiteId - ID of the wg_site to route through ('' = full tunnel via gw)
|
||||||
|
// createdBy - uid of admin/user who created it
|
||||||
|
// createdAt - unix ms
|
||||||
|
// note - free-text
|
||||||
|
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const conf = require('@simpleworkjs/conf');
|
||||||
|
const { getRedis } = require('./index');
|
||||||
|
const { generateKeypair } = require('../utils/wg_keys');
|
||||||
|
|
||||||
|
const P = () => conf.redis.prefix;
|
||||||
|
const idxKey = () => `${P()}wg_peer_index`;
|
||||||
|
const peerKey = (id) => `${P()}wg_peer:${id}`;
|
||||||
|
const ipSeqKey = () => `${P()}wg_peer_ip_seq`;
|
||||||
|
|
||||||
|
// Start IP allocation at x.x.x.2 (x.x.x.1 is the gateway interface).
|
||||||
|
const WG_POOL_BASE = (conf.wireguard && conf.wireguard.poolBase) || '10.100.0';
|
||||||
|
|
||||||
|
function seqToIP(seq) {
|
||||||
|
// Allocate within /16 pool: 10.100.0.2 – 10.100.255.254
|
||||||
|
const octet3 = Math.floor((seq - 2) / 254);
|
||||||
|
const octet4 = ((seq - 2) % 254) + 1;
|
||||||
|
return `${WG_POOL_BASE.split('.').slice(0, 2).join('.')}.${octet3}.${octet4 + 1}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function serialize(obj) {
|
||||||
|
const out = {};
|
||||||
|
for (const [k, v] of Object.entries(obj)) {
|
||||||
|
out[k] = typeof v === 'boolean' ? (v ? '1' : '0') : String(v == null ? '' : v);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function deserialize(h) {
|
||||||
|
if (!h || !h.id) return null;
|
||||||
|
return { ...h, createdAt: Number(h.createdAt || 0) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Strip the private key before sending to the client. */
|
||||||
|
function toPublic(peer) {
|
||||||
|
if (!peer) return null;
|
||||||
|
const { privateKey: _priv, ...pub } = peer; // eslint-disable-line no-unused-vars
|
||||||
|
return pub;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function create(data, createdBy) {
|
||||||
|
const redis = await getRedis();
|
||||||
|
const id = crypto.randomBytes(8).toString('hex');
|
||||||
|
const seq = await redis.incr(ipSeqKey());
|
||||||
|
const { privateKey, publicKey } = generateKeypair();
|
||||||
|
const peer = {
|
||||||
|
id,
|
||||||
|
name: data.name || 'unnamed',
|
||||||
|
publicKey,
|
||||||
|
privateKey, // stored server-side; sent once on create / conf download
|
||||||
|
assignedIP: seqToIP(seq),
|
||||||
|
exitSiteId: data.exitSiteId || '',
|
||||||
|
createdBy: createdBy || '',
|
||||||
|
createdAt: Date.now(),
|
||||||
|
note: data.note || '',
|
||||||
|
};
|
||||||
|
await redis.hSet(peerKey(id), serialize(peer));
|
||||||
|
await redis.zAdd(idxKey(), { score: peer.createdAt, value: id });
|
||||||
|
return peer; // includes privateKey — caller decides what to expose
|
||||||
|
}
|
||||||
|
|
||||||
|
async function get(id) {
|
||||||
|
const redis = await getRedis();
|
||||||
|
return deserialize(await redis.hGetAll(peerKey(id)));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function update(id, patch) {
|
||||||
|
const redis = await getRedis();
|
||||||
|
const existing = await get(id);
|
||||||
|
if (!existing) throw Object.assign(new Error('Peer not found'), { status: 404 });
|
||||||
|
// Only allow mutable fields to be patched
|
||||||
|
const allowed = ['name', 'exitSiteId', 'note'];
|
||||||
|
const safe = {};
|
||||||
|
for (const k of allowed) if (k in patch) safe[k] = patch[k];
|
||||||
|
const merged = { ...existing, ...safe };
|
||||||
|
await redis.hSet(peerKey(id), serialize(merged));
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(id) {
|
||||||
|
const redis = await getRedis();
|
||||||
|
await redis.del(peerKey(id));
|
||||||
|
await redis.zRem(idxKey(), id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function list() {
|
||||||
|
const redis = await getRedis();
|
||||||
|
const ids = await redis.zRange(idxKey(), 0, -1, { REV: true });
|
||||||
|
const peers = await Promise.all(ids.map(get));
|
||||||
|
return peers.filter(Boolean).map(toPublic);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { create, get, update, remove, list, toPublic };
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// WireGuard exit-node / site model — raw Redis (same pattern as audit_event.js).
|
||||||
|
//
|
||||||
|
// Each "site" is a location clients can route through. Admins add/remove sites
|
||||||
|
// dynamically via the Theta Gateway UI.
|
||||||
|
//
|
||||||
|
// Redis keys:
|
||||||
|
// wg_site:<id> — hash of site fields
|
||||||
|
// wg_site_index — sorted set (score = createdAt, value = id)
|
||||||
|
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const conf = require('@simpleworkjs/conf');
|
||||||
|
const { getRedis } = require('./index');
|
||||||
|
|
||||||
|
const P = () => conf.redis.prefix;
|
||||||
|
const idxKey = () => `${P()}wg_site_index`;
|
||||||
|
const siteKey = (id) => `${P()}wg_site:${id}`;
|
||||||
|
|
||||||
|
function serialize(obj) {
|
||||||
|
const out = {};
|
||||||
|
for (const [k, v] of Object.entries(obj)) {
|
||||||
|
out[k] = typeof v === 'boolean' ? (v ? '1' : '0') : String(v == null ? '' : v);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function deserialize(h) {
|
||||||
|
if (!h || !h.id) return null;
|
||||||
|
return {
|
||||||
|
...h,
|
||||||
|
createdAt: Number(h.createdAt || 0),
|
||||||
|
exitAll: h.exitAll === '1',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function create(data, createdBy) {
|
||||||
|
const id = crypto.randomBytes(8).toString('hex');
|
||||||
|
const site = {
|
||||||
|
id,
|
||||||
|
name: data.name || 'Unnamed Site',
|
||||||
|
endpoint: data.endpoint || '',
|
||||||
|
publicKey: data.publicKey || '',
|
||||||
|
subnet: data.subnet || '0.0.0.0/0',
|
||||||
|
exitAll: !!data.exitAll,
|
||||||
|
siteId: data.siteId || '',
|
||||||
|
note: data.note || '',
|
||||||
|
createdBy: createdBy || '',
|
||||||
|
createdAt: Date.now(),
|
||||||
|
};
|
||||||
|
const redis = await getRedis();
|
||||||
|
await redis.hSet(siteKey(id), serialize(site));
|
||||||
|
await redis.zAdd(idxKey(), { score: site.createdAt, value: id });
|
||||||
|
return site;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function get(id) {
|
||||||
|
const redis = await getRedis();
|
||||||
|
return deserialize(await redis.hGetAll(siteKey(id)));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function update(id, patch) {
|
||||||
|
const redis = await getRedis();
|
||||||
|
const existing = await get(id);
|
||||||
|
if (!existing) throw Object.assign(new Error('Site not found'), { status: 404 });
|
||||||
|
const merged = { ...existing, ...patch, id }; // id is immutable
|
||||||
|
await redis.hSet(siteKey(id), serialize(merged));
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(id) {
|
||||||
|
const redis = await getRedis();
|
||||||
|
await redis.del(siteKey(id));
|
||||||
|
await redis.zRem(idxKey(), id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function list() {
|
||||||
|
const redis = await getRedis();
|
||||||
|
const ids = await redis.zRange(idxKey(), 0, -1);
|
||||||
|
const sites = await Promise.all(ids.map(get));
|
||||||
|
return sites.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { create, get, update, remove, list };
|
||||||
@@ -1,16 +1,17 @@
|
|||||||
{
|
{
|
||||||
"name": "t42-jump-host",
|
"name": "theta-gateway",
|
||||||
"version": "1.9.0",
|
"version": "2.0.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "t42-jump-host",
|
"name": "theta-gateway",
|
||||||
"version": "1.9.0",
|
"version": "2.0.1",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fortawesome/fontawesome-free": "^7.3.0",
|
"@fortawesome/fontawesome-free": "^7.3.0",
|
||||||
"@simpleworkjs/app-stack": "^1.0.0",
|
"@simpleworkjs/app-stack": "^1.0.0",
|
||||||
|
"@simpleworkjs/bao-conf": "^1.0.0",
|
||||||
"@simpleworkjs/conf": "^1.2.0",
|
"@simpleworkjs/conf": "^1.2.0",
|
||||||
"@simpleworkjs/directory-schema": "^1.0.0",
|
"@simpleworkjs/directory-schema": "^1.0.0",
|
||||||
"@simpleworkjs/frontend": "^0.2.6",
|
"@simpleworkjs/frontend": "^0.2.6",
|
||||||
@@ -18,6 +19,7 @@
|
|||||||
"@simpleworkjs/oidc-client": "^1.0.0",
|
"@simpleworkjs/oidc-client": "^1.0.0",
|
||||||
"@simpleworkjs/orm": "^0.2.8",
|
"@simpleworkjs/orm": "^0.2.8",
|
||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
|
"bonjour-service": "^1.4.4",
|
||||||
"bootstrap": "^5.3.8",
|
"bootstrap": "^5.3.8",
|
||||||
"compression": "^1.8.1",
|
"compression": "^1.8.1",
|
||||||
"ejs": "^3.1.10",
|
"ejs": "^3.1.10",
|
||||||
@@ -29,6 +31,7 @@
|
|||||||
"model-redis": "^1.6.0",
|
"model-redis": "^1.6.0",
|
||||||
"moment": "^2.30.1",
|
"moment": "^2.30.1",
|
||||||
"mustache": "^4.2.0",
|
"mustache": "^4.2.0",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
"redis": "^6.1.0",
|
"redis": "^6.1.0",
|
||||||
"socket.io": "^4.8.3",
|
"socket.io": "^4.8.3",
|
||||||
"ssh2": "^1.16.0"
|
"ssh2": "^1.16.0"
|
||||||
@@ -61,6 +64,12 @@
|
|||||||
"node": ">=18.0.0"
|
"node": ">=18.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@leichtgewicht/ip-codec": {
|
||||||
|
"version": "2.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.5.tgz",
|
||||||
|
"integrity": "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@popperjs/core": {
|
"node_modules/@popperjs/core": {
|
||||||
"version": "2.11.8",
|
"version": "2.11.8",
|
||||||
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
|
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
|
||||||
@@ -156,6 +165,18 @@
|
|||||||
"node": ">=18.0.0"
|
"node": ">=18.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@simpleworkjs/bao-conf": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@simpleworkjs/bao-conf/-/bao-conf-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-mcay5NQ/w9ShpIAolMP/3f9TfXSLE+d5jrA4dTPOUHDjTkdsP7pe4hMmQUmwnniR59U1bGoRIVdXjvDbX3I5nw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"extend": "^3.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@simpleworkjs/conf": {
|
"node_modules/@simpleworkjs/conf": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/@simpleworkjs/conf/-/conf-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@simpleworkjs/conf/-/conf-1.2.0.tgz",
|
||||||
@@ -305,6 +326,30 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ansi-regex": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ansi-styles": {
|
||||||
|
"version": "4.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||||
|
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"color-convert": "^2.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/anymatch": {
|
"node_modules/anymatch": {
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
|
||||||
@@ -462,6 +507,16 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/bonjour-service": {
|
||||||
|
"version": "1.4.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/bonjour-service/-/bonjour-service-1.4.4.tgz",
|
||||||
|
"integrity": "sha512-jCZcVv7eoc4QesRscwEZtSROBen+6LpKAmBIsQYQrsAeVHLyMXWX/t6eIV5KiRZYNUBl8eVqImEEMQ8L5+c/Kw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"fast-deep-equal": "^3.1.3",
|
||||||
|
"multicast-dns": "^7.2.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/bootstrap": {
|
"node_modules/bootstrap": {
|
||||||
"version": "5.3.8",
|
"version": "5.3.8",
|
||||||
"resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.8.tgz",
|
"resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.8.tgz",
|
||||||
@@ -574,6 +629,15 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/camelcase": {
|
||||||
|
"version": "5.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
|
||||||
|
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/chokidar": {
|
"node_modules/chokidar": {
|
||||||
"version": "3.6.0",
|
"version": "3.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
|
||||||
@@ -608,6 +672,17 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/cliui": {
|
||||||
|
"version": "6.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
|
||||||
|
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"string-width": "^4.2.0",
|
||||||
|
"strip-ansi": "^6.0.0",
|
||||||
|
"wrap-ansi": "^6.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/cluster-key-slot": {
|
"node_modules/cluster-key-slot": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
|
||||||
@@ -617,6 +692,24 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/color-convert": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"color-name": "~1.1.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=7.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/color-name": {
|
||||||
|
"version": "1.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||||
|
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/compressible": {
|
"node_modules/compressible": {
|
||||||
"version": "2.0.18",
|
"version": "2.0.18",
|
||||||
"resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz",
|
"resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz",
|
||||||
@@ -759,6 +852,15 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/decamelize": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/decompress-response": {
|
"node_modules/decompress-response": {
|
||||||
"version": "6.0.0",
|
"version": "6.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
|
||||||
@@ -801,6 +903,24 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/dijkstrajs": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/dns-packet": {
|
||||||
|
"version": "5.6.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/dns-packet/-/dns-packet-5.6.1.tgz",
|
||||||
|
"integrity": "sha512-l4gcSouhcgIKRvyy99RNVOgxXiicE+2jZoNmaNmZ6JXiGajBOJAesk1OBlJuM5k2c+eudGdLxDqXuPCKIj6kpw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@leichtgewicht/ip-codec": "^2.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/dottie": {
|
"node_modules/dottie": {
|
||||||
"version": "2.0.7",
|
"version": "2.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/dottie/-/dottie-2.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/dottie/-/dottie-2.0.7.tgz",
|
||||||
@@ -843,6 +963,12 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/emoji-regex": {
|
||||||
|
"version": "8.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||||
|
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/encodeurl": {
|
"node_modules/encodeurl": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
|
||||||
@@ -1073,6 +1199,12 @@
|
|||||||
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
|
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/fast-deep-equal": {
|
||||||
|
"version": "3.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||||
|
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/file-uri-to-path": {
|
"node_modules/file-uri-to-path": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
|
||||||
@@ -1122,6 +1254,19 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/find-up": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"locate-path": "^5.0.0",
|
||||||
|
"path-exists": "^4.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/forwarded": {
|
"node_modules/forwarded": {
|
||||||
"version": "0.2.0",
|
"version": "0.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
||||||
@@ -1170,6 +1315,15 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/get-caller-file": {
|
||||||
|
"version": "2.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||||
|
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": "6.* || 8.* || >= 10.*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/get-intrinsic": {
|
"node_modules/get-intrinsic": {
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||||
@@ -1404,6 +1558,15 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/is-fullwidth-code-point": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/is-glob": {
|
"node_modules/is-glob": {
|
||||||
"version": "4.0.3",
|
"version": "4.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
|
||||||
@@ -1491,6 +1654,18 @@
|
|||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/locate-path": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"p-locate": "^4.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/lodash": {
|
"node_modules/lodash": {
|
||||||
"version": "4.18.1",
|
"version": "4.18.1",
|
||||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
|
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
|
||||||
@@ -1648,6 +1823,19 @@
|
|||||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/multicast-dns": {
|
||||||
|
"version": "7.2.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/multicast-dns/-/multicast-dns-7.2.5.tgz",
|
||||||
|
"integrity": "sha512-2eznPJP8z2BFLX50tf0LuODrpINqP1RVIm/CObbTcBRITQgmC/TjcREF1NeTBzIcR5XO/ukWo+YHOjBbFwIupg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"dns-packet": "^5.2.2",
|
||||||
|
"thunky": "^1.0.2"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"multicast-dns": "cli.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/mustache": {
|
"node_modules/mustache": {
|
||||||
"version": "4.2.0",
|
"version": "4.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/mustache/-/mustache-4.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/mustache/-/mustache-4.2.0.tgz",
|
||||||
@@ -1881,6 +2069,42 @@
|
|||||||
"wrappy": "1"
|
"wrappy": "1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/p-limit": {
|
||||||
|
"version": "2.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
|
||||||
|
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"p-try": "^2.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/p-locate": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"p-limit": "^2.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/p-try": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/parseurl": {
|
"node_modules/parseurl": {
|
||||||
"version": "1.3.3",
|
"version": "1.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
||||||
@@ -1890,6 +2114,15 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/path-exists": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/path-to-regexp": {
|
"node_modules/path-to-regexp": {
|
||||||
"version": "8.4.2",
|
"version": "8.4.2",
|
||||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
|
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
|
||||||
@@ -1925,6 +2158,15 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/pngjs": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.13.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/prebuild-install": {
|
"node_modules/prebuild-install": {
|
||||||
"version": "7.1.3",
|
"version": "7.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
|
||||||
@@ -1992,6 +2234,23 @@
|
|||||||
"once": "^1.3.1"
|
"once": "^1.3.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/qrcode": {
|
||||||
|
"version": "1.5.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
|
||||||
|
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"dijkstrajs": "^1.0.1",
|
||||||
|
"pngjs": "^5.0.0",
|
||||||
|
"yargs": "^15.3.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"qrcode": "bin/qrcode"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.13.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/qs": {
|
"node_modules/qs": {
|
||||||
"version": "6.15.3",
|
"version": "6.15.3",
|
||||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
|
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
|
||||||
@@ -2094,6 +2353,21 @@
|
|||||||
"node": ">= 20.0.0"
|
"node": ">= 20.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/require-directory": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/require-main-filename": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/retry-as-promised": {
|
"node_modules/retry-as-promised": {
|
||||||
"version": "7.1.1",
|
"version": "7.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/retry-as-promised/-/retry-as-promised-7.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/retry-as-promised/-/retry-as-promised-7.1.1.tgz",
|
||||||
@@ -2280,6 +2554,12 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/set-blocking": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/setprototypeof": {
|
"node_modules/setprototypeof": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
||||||
@@ -2568,6 +2848,32 @@
|
|||||||
"safe-buffer": "~5.2.0"
|
"safe-buffer": "~5.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/string-width": {
|
||||||
|
"version": "4.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||||
|
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"emoji-regex": "^8.0.0",
|
||||||
|
"is-fullwidth-code-point": "^3.0.0",
|
||||||
|
"strip-ansi": "^6.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/strip-ansi": {
|
||||||
|
"version": "6.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||||
|
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ansi-regex": "^5.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/strip-json-comments": {
|
"node_modules/strip-json-comments": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
|
||||||
@@ -2640,6 +2946,12 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/thunky": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/tinyglobby": {
|
"node_modules/tinyglobby": {
|
||||||
"version": "0.2.17",
|
"version": "0.2.17",
|
||||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
||||||
@@ -2860,6 +3172,12 @@
|
|||||||
"node": "^20.17.0 || >=22.9.0"
|
"node": "^20.17.0 || >=22.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/which-module": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/wkx": {
|
"node_modules/wkx": {
|
||||||
"version": "0.5.0",
|
"version": "0.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/wkx/-/wkx-0.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/wkx/-/wkx-0.5.0.tgz",
|
||||||
@@ -2869,6 +3187,20 @@
|
|||||||
"@types/node": "*"
|
"@types/node": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/wrap-ansi": {
|
||||||
|
"version": "6.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
|
||||||
|
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ansi-styles": "^4.0.0",
|
||||||
|
"string-width": "^4.1.0",
|
||||||
|
"strip-ansi": "^6.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/wrappy": {
|
"node_modules/wrappy": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||||
@@ -2896,6 +3228,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/y18n": {
|
||||||
|
"version": "4.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
|
||||||
|
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/yallist": {
|
"node_modules/yallist": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz",
|
||||||
@@ -2904,6 +3242,41 @@
|
|||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"node_modules/yargs": {
|
||||||
|
"version": "15.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
|
||||||
|
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"cliui": "^6.0.0",
|
||||||
|
"decamelize": "^1.2.0",
|
||||||
|
"find-up": "^4.1.0",
|
||||||
|
"get-caller-file": "^2.0.1",
|
||||||
|
"require-directory": "^2.1.1",
|
||||||
|
"require-main-filename": "^2.0.0",
|
||||||
|
"set-blocking": "^2.0.0",
|
||||||
|
"string-width": "^4.2.0",
|
||||||
|
"which-module": "^2.0.0",
|
||||||
|
"y18n": "^4.0.0",
|
||||||
|
"yargs-parser": "^18.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/yargs-parser": {
|
||||||
|
"version": "18.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
|
||||||
|
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"camelcase": "^5.0.0",
|
||||||
|
"decamelize": "^1.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "t42-jump-host",
|
"name": "theta-gateway",
|
||||||
"version": "1.10.1",
|
"version": "2.1.1",
|
||||||
"description": "SSH jump host for the theta42 stack — LDAP-authenticated, directory-driven host bridging with audit and metrics",
|
"description": "SSH jump host for the theta42 stack — LDAP-authenticated, directory-driven host bridging with audit and metrics",
|
||||||
"author": [
|
"author": [
|
||||||
{
|
{
|
||||||
@@ -14,13 +14,14 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node ./bin/www",
|
"start": "node ./bin/www",
|
||||||
"dev": "npx nodemon --ignore public/ ./bin/www",
|
"dev": "npx nodemon --ignore public/ ./bin/www",
|
||||||
"test": "NODE_ENV=test node --test --test-force-exit test/unit/*.test.js test/integration/*.test.js",
|
"test": "NODE_ENV=test node --test --test-force-exit test/unit/access.test.js test/unit/host_keys.test.js test/unit/no_native_dialogs.test.js test/unit/target_match.test.js test/unit/username_grammar.test.js test/unit/wireguard.test.js test/unit/mesh_addressing.test.js",
|
||||||
"test:unit": "NODE_ENV=test node --test --test-force-exit test/unit/*.test.js",
|
"test:unit": "NODE_ENV=test node --test --test-force-exit test/unit/*.test.js",
|
||||||
"test:integration": "NODE_ENV=test node --test --test-force-exit test/integration/*.test.js"
|
"test:integration": "NODE_ENV=test node --test --test-force-exit test/integration/*.test.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fortawesome/fontawesome-free": "^7.3.0",
|
"@fortawesome/fontawesome-free": "^7.3.0",
|
||||||
"@simpleworkjs/app-stack": "^1.0.0",
|
"@simpleworkjs/app-stack": "^1.0.0",
|
||||||
|
"@simpleworkjs/bao-conf": "^1.0.0",
|
||||||
"@simpleworkjs/conf": "^1.2.0",
|
"@simpleworkjs/conf": "^1.2.0",
|
||||||
"@simpleworkjs/directory-schema": "^1.0.0",
|
"@simpleworkjs/directory-schema": "^1.0.0",
|
||||||
"@simpleworkjs/frontend": "^0.2.6",
|
"@simpleworkjs/frontend": "^0.2.6",
|
||||||
@@ -28,6 +29,7 @@
|
|||||||
"@simpleworkjs/oidc-client": "^1.0.0",
|
"@simpleworkjs/oidc-client": "^1.0.0",
|
||||||
"@simpleworkjs/orm": "^0.2.8",
|
"@simpleworkjs/orm": "^0.2.8",
|
||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
|
"bonjour-service": "^1.4.4",
|
||||||
"bootstrap": "^5.3.8",
|
"bootstrap": "^5.3.8",
|
||||||
"compression": "^1.8.1",
|
"compression": "^1.8.1",
|
||||||
"ejs": "^3.1.10",
|
"ejs": "^3.1.10",
|
||||||
@@ -39,6 +41,7 @@
|
|||||||
"model-redis": "^1.6.0",
|
"model-redis": "^1.6.0",
|
||||||
"moment": "^2.30.1",
|
"moment": "^2.30.1",
|
||||||
"mustache": "^4.2.0",
|
"mustache": "^4.2.0",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
"redis": "^6.1.0",
|
"redis": "^6.1.0",
|
||||||
"socket.io": "^4.8.3",
|
"socket.io": "^4.8.3",
|
||||||
"ssh2": "^1.16.0"
|
"ssh2": "^1.16.0"
|
||||||
|
|||||||
@@ -3,10 +3,22 @@ nav.navbar{
|
|||||||
padding-right: 1em;
|
padding-right: 1em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Only the active top-nav link is bold + underlined; the username is plain. */
|
||||||
|
.top-nav a.active{
|
||||||
|
font-weight: bold;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
|
/* Height of the fixed navbar (plus the update banner, while shown --
|
||||||
|
see top.ejs's showUpdateBanner/dismissUpdateBanner). Lets an in-page
|
||||||
|
sticky element offset itself below both fixed elements via
|
||||||
|
`top: var(--sw-content-offset)` instead of colliding with them at the
|
||||||
|
viewport's true top:0. */
|
||||||
|
--sw-content-offset: 4.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
#spa-shell {
|
#spa-shell {
|
||||||
|
|||||||
@@ -584,10 +584,31 @@ app.util = (function(app){
|
|||||||
document.body.removeChild(element);
|
document.body.removeChild(element);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Scroll a just-added/-edited element into view and flash its
|
||||||
|
// background, so the user's eye lands on the row that changed instead of
|
||||||
|
// it silently appearing/updating somewhere off-screen. Takes a jQuery
|
||||||
|
// object or a raw DOM node (e.g. jq-repeat's `item.__jq_$el`).
|
||||||
|
function revealItem(el){
|
||||||
|
var node = el && el.jquery ? el[0] : el;
|
||||||
|
if (!node) return;
|
||||||
|
if (typeof node.scrollIntoView === 'function') {
|
||||||
|
node.scrollIntoView({behavior: 'smooth', block: 'center'});
|
||||||
|
}
|
||||||
|
var prevTransition = node.style.transition;
|
||||||
|
var prevBg = node.style.backgroundColor;
|
||||||
|
node.style.transition = 'background-color 1.5s ease';
|
||||||
|
node.style.backgroundColor = 'var(--bs-success-bg-subtle, #d1e7dd)';
|
||||||
|
setTimeout(function(){
|
||||||
|
node.style.backgroundColor = prevBg;
|
||||||
|
setTimeout(function(){ node.style.transition = prevTransition; }, 1500);
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
downloadFile: downloadFile,
|
downloadFile: downloadFile,
|
||||||
getUrlParameter: getUrlParameter,
|
getUrlParameter: getUrlParameter,
|
||||||
escapeHtml: escapeHtml,
|
escapeHtml: escapeHtml,
|
||||||
|
revealItem: revealItem,
|
||||||
}
|
}
|
||||||
})(app);
|
})(app);
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,21 @@ router.use('/user', middleware.auth, require('./user'));
|
|||||||
// admin gate (see routes/api_token.js for why a token can't reach admin routes).
|
// admin gate (see routes/api_token.js for why a token can't reach admin routes).
|
||||||
router.use('/api-token', middleware.auth, require('./api_token'));
|
router.use('/api-token', middleware.auth, require('./api_token'));
|
||||||
|
|
||||||
// Jump-host data — admin only (audit log, active sessions, metrics).
|
// WireGuard peer + site management — admin only.
|
||||||
router.use('/', middleware.auth, middleware.requireAdmin, require('./jump'));
|
router.use('/wireguard', middleware.auth, middleware.requireJumpAdmin, require('./wireguard'));
|
||||||
|
|
||||||
|
// Gateway-to-gateway mesh — mixed auth (register/register-* are called by a
|
||||||
|
// remote gateway with a bearer join token, not an admin session; join-tokens
|
||||||
|
// mint + join are admin-gated). See routes/mesh.js for the per-route gates.
|
||||||
|
// MUST be registered before the '/' mount below: '/' matches every /api/*
|
||||||
|
// path (it's the catch-all for routes/jump.js), so registering it first
|
||||||
|
// would shadow every /api/mesh/* route with admin-session auth before
|
||||||
|
// routes/mesh.js's own per-route gates ever ran -- confirmed live, this
|
||||||
|
// silently 401'd /register's bearer-join-token callers with a
|
||||||
|
// checkApiToken/LoginFailed error instead of ever reaching mesh.js.
|
||||||
|
router.use('/mesh', require('./mesh'));
|
||||||
|
|
||||||
|
// Jump-host data — jump admin only (audit log, active sessions, metrics).
|
||||||
|
router.use('/', middleware.auth, middleware.requireJumpAdmin, require('./jump'));
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
@@ -0,0 +1,212 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Gateway-to-gateway WireGuard mesh — real site-to-site tunnels, not the
|
||||||
|
// roaming-client/exit-node feature in routes/wireguard.js. Two gateways mesh
|
||||||
|
// by one calling the other's POST /api/mesh/register with a join token; both
|
||||||
|
// sides end up with a live wg0 peer entry for the other, addressed per
|
||||||
|
// MULTI_SITE_SPEC.md's one-octet mesh index (172.24.<idx>.0/16,
|
||||||
|
// 10.<idx>.0.0/16, idx 1-254, assigned by whichever gateway is registering
|
||||||
|
// the caller).
|
||||||
|
//
|
||||||
|
// Local interface name is fixed at THETA_MESH_IFACE (default wg-mesh) —
|
||||||
|
// deliberately separate from the roaming-client interface so the two
|
||||||
|
// features never fight over the same wg0.
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const middleware = require('../middleware/auth');
|
||||||
|
const conf = require('@simpleworkjs/conf');
|
||||||
|
const meshGateway = require('../models/mesh_gateway');
|
||||||
|
const meshJoinToken = require('../utils/mesh_join_token');
|
||||||
|
const wgIface = require('../utils/wg_iface');
|
||||||
|
const wgKeys = require('../utils/wg_keys');
|
||||||
|
const { meshCidrFor, meshAllowedIpsFor } = require('../utils/mesh_addressing');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
const IFACE = process.env.THETA_MESH_IFACE || 'wg-mesh';
|
||||||
|
const MESH_LISTEN_PORT = process.env.THETA_MESH_LISTEN_PORT || 51820;
|
||||||
|
|
||||||
|
async function ensureLocalIdentity() {
|
||||||
|
if (!conf.wireguard) conf.wireguard = {};
|
||||||
|
if (!conf.wireguard.serverPublicKey || !conf.wireguard.serverPrivateKey) {
|
||||||
|
// wg_bootstrap.js normally does this at startup; guard here too so this
|
||||||
|
// route works even if bootstrap hasn't run yet in a given environment.
|
||||||
|
const kp = wgKeys.generateKeypair();
|
||||||
|
conf.wireguard.serverPublicKey = kp.publicKey;
|
||||||
|
conf.wireguard.serverPrivateKey = kp.privateKey;
|
||||||
|
}
|
||||||
|
return conf.wireguard;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mint a single-use mesh join token — the credential a NEW gateway presents
|
||||||
|
// to register into THIS gateway's mesh.
|
||||||
|
router.post('/join-tokens', middleware.auth, middleware.requireJumpAdmin, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { token, expiresInSeconds } = await meshJoinToken.mint();
|
||||||
|
res.json({ status: 'ok', token, expiresInSeconds });
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Called by a REMOTE gateway to register itself into THIS gateway's mesh.
|
||||||
|
// Bearer mesh join token, no admin session (service-to-service, same as
|
||||||
|
// theta-directory's POST /api/site/register-spoke pattern).
|
||||||
|
router.post('/register', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const auth = req.headers.authorization || '';
|
||||||
|
const token = auth.startsWith('Bearer ') ? auth.slice(7).trim() : '';
|
||||||
|
if (!(await meshJoinToken.consume(token))) {
|
||||||
|
return res.status(401).json({ status: 'error', message: 'invalid or already-used mesh join token' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { publicKey, endpoint, siteSlug } = req.body || {};
|
||||||
|
if (!publicKey || !endpoint) {
|
||||||
|
return res.status(400).json({ status: 'error', message: 'publicKey and endpoint are required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const self = await ensureLocalIdentity();
|
||||||
|
const peer = await meshGateway.register({ publicKey, endpoint, siteSlug });
|
||||||
|
|
||||||
|
await wgIface.ensureInterface(IFACE);
|
||||||
|
wgIface.setPrivateKey(IFACE, self.serverPrivateKey, MESH_LISTEN_PORT);
|
||||||
|
// This gateway's own mesh index -- assigned to ITSELF the first time
|
||||||
|
// anyone registers with it, since a solo gateway has no index yet.
|
||||||
|
const ownIndex = await ensureOwnMeshIndex();
|
||||||
|
wgIface.setAddress(IFACE, meshCidrFor(ownIndex));
|
||||||
|
wgIface.setPeer(IFACE, {
|
||||||
|
publicKey: peer.publicKey,
|
||||||
|
endpoint: peer.endpoint,
|
||||||
|
allowedIPs: meshAllowedIpsFor(peer.meshIndex),
|
||||||
|
keepalive: 25
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
status: 'ok',
|
||||||
|
meshIndex: peer.meshIndex,
|
||||||
|
gateway: {
|
||||||
|
publicKey: conf.wireguard.serverPublicKey,
|
||||||
|
endpoint: conf.wireguard.serverEndpoint || '',
|
||||||
|
meshIndex: ownIndex
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// This gateway's own mesh index is just "the lowest free index, stable once
|
||||||
|
// picked" -- stored as a synthetic self-entry in the same registry so it
|
||||||
|
// survives restarts the same way peer entries do.
|
||||||
|
async function ensureOwnMeshIndex() {
|
||||||
|
const self = await meshGateway.findByPublicKey(conf.wireguard.serverPublicKey);
|
||||||
|
if (self) return self.meshIndex;
|
||||||
|
const created = await meshGateway.register({
|
||||||
|
publicKey: conf.wireguard.serverPublicKey,
|
||||||
|
endpoint: conf.wireguard.serverEndpoint || '',
|
||||||
|
siteSlug: '(self)'
|
||||||
|
});
|
||||||
|
return created.meshIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin-initiated: join THIS gateway into a remote gateway's mesh. Generates
|
||||||
|
// (or reuses) this gateway's identity, brings up the local interface, calls
|
||||||
|
// the remote's /register, and applies the peer it gets back -- so after this
|
||||||
|
// call both sides have a live, working wg0 peer entry for each other.
|
||||||
|
router.post('/join', middleware.auth, middleware.requireJumpAdmin, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { remoteEndpoint, joinToken } = req.body || {};
|
||||||
|
if (!remoteEndpoint || !joinToken) {
|
||||||
|
return res.status(400).json({ status: 'error', message: 'remoteEndpoint and joinToken are required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const self = await ensureLocalIdentity();
|
||||||
|
await wgIface.ensureInterface(IFACE);
|
||||||
|
wgIface.setPrivateKey(IFACE, self.serverPrivateKey, MESH_LISTEN_PORT);
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), 15000);
|
||||||
|
let resp;
|
||||||
|
try {
|
||||||
|
resp = await fetch(String(remoteEndpoint).replace(/\/+$/, '') + '/api/mesh/register', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: 'Bearer ' + joinToken, 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
publicKey: self.serverPublicKey,
|
||||||
|
endpoint: self.serverEndpoint || '',
|
||||||
|
siteSlug: process.env.SITE_SLUG || ''
|
||||||
|
}),
|
||||||
|
signal: controller.signal
|
||||||
|
});
|
||||||
|
} finally { clearTimeout(timer); }
|
||||||
|
|
||||||
|
if (!resp.ok) {
|
||||||
|
const text = (await resp.text().catch(() => '')).slice(0, 300);
|
||||||
|
return res.status(502).json({ status: 'error', message: 'remote registration failed: HTTP ' + resp.status + ' ' + text });
|
||||||
|
}
|
||||||
|
const data = await resp.json();
|
||||||
|
|
||||||
|
wgIface.setAddress(IFACE, meshCidrFor(data.meshIndex));
|
||||||
|
// Persist OUR OWN identity too, not just the remote peer's -- the
|
||||||
|
// receiving side of /register does this via ensureOwnMeshIndex(), but
|
||||||
|
// the initiating side (here) never did, so GET /api/mesh/self and the
|
||||||
|
// mesh UI's own-entry/"(self)" handling both silently saw nothing on
|
||||||
|
// whichever gateway called /join. register() is upsert-by-publicKey
|
||||||
|
// and reuses an existing entry's index, so this is safe to call even
|
||||||
|
// if a self-entry from a PRIOR /register (as the receiving side of a
|
||||||
|
// different peer) already exists.
|
||||||
|
await meshGateway.register({ publicKey: self.serverPublicKey, endpoint: self.serverEndpoint || '', siteSlug: '(self)', meshIndex: data.meshIndex });
|
||||||
|
await meshGateway.register({ publicKey: data.gateway.publicKey, endpoint: data.gateway.endpoint, siteSlug: '(remote master)' });
|
||||||
|
wgIface.setPeer(IFACE, {
|
||||||
|
publicKey: data.gateway.publicKey,
|
||||||
|
endpoint: data.gateway.endpoint,
|
||||||
|
allowedIPs: meshAllowedIpsFor(data.gateway.meshIndex),
|
||||||
|
keepalive: 25
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ status: 'ok', meshIndex: data.meshIndex, peerMeshIndex: data.gateway.meshIndex });
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// This gateway's own mesh address, for a LOCAL bootstrap script to discover
|
||||||
|
// (e.g. theta-suite's site-join, running on the same host as this gateway)
|
||||||
|
// without needing full jump-admin session auth -- any valid jmp_ API token
|
||||||
|
// (middleware.auth, no requireJumpAdmin) is enough, same service-to-service
|
||||||
|
// pattern as theta-proxy's prx_ tokens for proxy_client.js. Not a peer
|
||||||
|
// listing, so no admin-only audit/config data is exposed here.
|
||||||
|
router.get('/self', middleware.auth, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const self = conf.wireguard || {};
|
||||||
|
let meshIp = null;
|
||||||
|
if (self.serverPublicKey) {
|
||||||
|
const entry = await meshGateway.findByPublicKey(self.serverPublicKey);
|
||||||
|
if (entry) meshIp = meshCidrFor(entry.meshIndex).split('/')[0];
|
||||||
|
}
|
||||||
|
res.json({ status: 'ok', meshIp, joined: !!meshIp, iface: IFACE });
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/gateways', middleware.auth, middleware.requireJumpAdmin, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const gateways = await meshGateway.list();
|
||||||
|
res.json({ status: 'ok', gateways, iface: IFACE, kernelWireguard: wgIface.kernelWireguardAvailable() });
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Remove a peer gateway: tears down its local WG peer entry + kernel routes
|
||||||
|
// (wgIface.removePeer) and drops it from the registry. Does NOT reach out to
|
||||||
|
// the remote gateway to remove the reciprocal peer entry there -- an admin
|
||||||
|
// on that side needs to do the same. Refuses to remove the self-entry
|
||||||
|
// ("(self)"), since that's this gateway's own identity, not a peer.
|
||||||
|
router.delete('/gateways/:id', middleware.auth, middleware.requireJumpAdmin, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const gateways = await meshGateway.list();
|
||||||
|
const target = gateways.find((g) => g.id === req.params.id);
|
||||||
|
if (!target) return res.status(404).json({ status: 'error', message: 'gateway not found' });
|
||||||
|
if (target.siteSlug === '(self)') {
|
||||||
|
return res.status(400).json({ status: 'error', message: 'cannot remove this gateway\'s own self-entry' });
|
||||||
|
}
|
||||||
|
|
||||||
|
wgIface.removePeer(IFACE, target.publicKey);
|
||||||
|
await meshGateway.remove(target.id);
|
||||||
|
|
||||||
|
res.json({ status: 'ok', removed: { id: target.id, siteSlug: target.siteSlug, meshIndex: target.meshIndex } });
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -47,5 +47,7 @@ router.get('/login', (req, res) => res.render('login', {
|
|||||||
router.get('/dashboard', (req, res) => res.render('dashboard', {...values}));
|
router.get('/dashboard', (req, res) => res.render('dashboard', {...values}));
|
||||||
router.get('/sessions', (req, res) => res.render('sessions', {...values}));
|
router.get('/sessions', (req, res) => res.render('sessions', {...values}));
|
||||||
router.get('/audit', (req, res) => res.render('audit', {...values}));
|
router.get('/audit', (req, res) => res.render('audit', {...values}));
|
||||||
|
router.get('/wireguard', (req, res) => res.render('wireguard', {...values}));
|
||||||
|
router.get('/mesh', (req, res) => res.render('mesh', {...values}));
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
@@ -4,14 +4,17 @@
|
|||||||
// browser who it is and whether it's an admin (drives login state + nav).
|
// browser who it is and whether it's an admin (drives login state + nav).
|
||||||
|
|
||||||
const router = require('express').Router();
|
const router = require('express').Router();
|
||||||
const { isAdmin } = require('../middleware/auth');
|
const { isAdmin, isJumpAdmin } = require('../middleware/auth');
|
||||||
const access = require('../utils/access');
|
const access = require('../utils/access');
|
||||||
|
const metrics = require('../models/metrics');
|
||||||
|
const registry = require('../services/session_registry');
|
||||||
|
|
||||||
router.get('/me', (req, res) => {
|
router.get('/me', (req, res) => {
|
||||||
res.json({
|
res.json({
|
||||||
username: req.user && req.user.username,
|
username: req.user && req.user.username,
|
||||||
groups: req.groups || [],
|
groups: req.groups || [],
|
||||||
isAdmin: isAdmin(req),
|
isAdmin: isAdmin(req),
|
||||||
|
isJumpAdmin: isJumpAdmin(req),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -23,7 +26,20 @@ router.get('/hosts', async (req, res, next) => {
|
|||||||
const hosts = isAdmin(req)
|
const hosts = isAdmin(req)
|
||||||
? await access.allHosts()
|
? await access.allHosts()
|
||||||
: await access.accessibleHosts({ uid: req.user && req.user.username, groups: req.groups || [] });
|
: await access.accessibleHosts({ uid: req.user && req.user.username, groups: req.groups || [] });
|
||||||
res.json({ results: hosts });
|
|
||||||
|
// Enrich with connection state for the dashboard's host list: whether a
|
||||||
|
// session is live right now (active bridges, session_registry), plus the
|
||||||
|
// last successful/failed connection times (models/metrics).
|
||||||
|
const connectedSlugs = new Set(registry.list().map((s) => s.slug));
|
||||||
|
const last = await metrics.lastForHosts(hosts.map((h) => h.slug));
|
||||||
|
const enriched = hosts.map((h) => ({
|
||||||
|
...h,
|
||||||
|
connected: connectedSlugs.has(h.slug),
|
||||||
|
lastConnected: (last[h.slug] && last[h.slug].lastConnected) || null,
|
||||||
|
lastFailed: (last[h.slug] && last[h.slug].lastFailed) || null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
res.json({ results: enriched });
|
||||||
} catch (err) { next(err); }
|
} catch (err) { next(err); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// WireGuard management API — admin-gated.
|
||||||
|
//
|
||||||
|
// Sites (exit nodes):
|
||||||
|
// GET /api/wireguard/sites list all exit nodes
|
||||||
|
// POST /api/wireguard/sites create exit node
|
||||||
|
// PATCH /api/wireguard/sites/:id update exit node
|
||||||
|
// DELETE /api/wireguard/sites/:id remove exit node
|
||||||
|
//
|
||||||
|
// Peers (client devices):
|
||||||
|
// GET /api/wireguard/peers list all peers (no private keys)
|
||||||
|
// POST /api/wireguard/peers create peer (returns private key ONCE)
|
||||||
|
// PATCH /api/wireguard/peers/:id update name / exit node / note
|
||||||
|
// DELETE /api/wireguard/peers/:id remove peer
|
||||||
|
// GET /api/wireguard/peers/:id/conf download wg0.conf (contains private key)
|
||||||
|
// GET /api/wireguard/peers/:id/qr PNG QR code of the client conf (base64)
|
||||||
|
|
||||||
|
const router = require('express').Router();
|
||||||
|
const QRCode = require('qrcode');
|
||||||
|
const conf = require('@simpleworkjs/conf');
|
||||||
|
const wgSite = require('../models/wg_site');
|
||||||
|
const wgPeer = require('../models/wg_peer');
|
||||||
|
const { renderClientConf } = require('../utils/wg_conf');
|
||||||
|
|
||||||
|
// Gateway's own WireGuard public key + endpoint come from conf.wireguard.
|
||||||
|
// These are set in docker-compose / theta-env and describe this gateway's
|
||||||
|
// wg0 interface that clients point at.
|
||||||
|
function gwConf() {
|
||||||
|
const wg = conf.wireguard || {};
|
||||||
|
return {
|
||||||
|
publicKey: wg.serverPublicKey || '',
|
||||||
|
endpoint: wg.serverEndpoint || '',
|
||||||
|
dns: wg.dns || '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Gateway Info ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
router.get('/gateway-info', async (req, res) => {
|
||||||
|
res.json(gwConf());
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Sites ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
router.get('/sites', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
res.json({ results: await wgSite.list() });
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/sites', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { name, endpoint, publicKey, subnet, exitAll, siteId, note } = req.body;
|
||||||
|
if (!name || !endpoint || !publicKey) {
|
||||||
|
return res.status(400).json({ message: 'name, endpoint, and publicKey are required' });
|
||||||
|
}
|
||||||
|
const site = await wgSite.create(
|
||||||
|
{ name, endpoint, publicKey, subnet, exitAll, siteId, note },
|
||||||
|
req.user && req.user.uid
|
||||||
|
);
|
||||||
|
res.status(201).json(site);
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.patch('/sites/:id', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const site = await wgSite.update(req.params.id, req.body);
|
||||||
|
res.json(site);
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete('/sites/:id', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
await wgSite.remove(req.params.id);
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Peers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
router.get('/peers', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
res.json({ results: await wgPeer.list() });
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/peers', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { name, exitSiteId, note } = req.body;
|
||||||
|
if (!name) return res.status(400).json({ message: 'name is required' });
|
||||||
|
const peer = await wgPeer.create(
|
||||||
|
{ name, exitSiteId, note },
|
||||||
|
req.user && req.user.uid
|
||||||
|
);
|
||||||
|
// Return the full peer including privateKey — shown ONCE.
|
||||||
|
res.status(201).json(peer);
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.patch('/peers/:id', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const peer = await wgPeer.update(req.params.id, req.body);
|
||||||
|
res.json(wgPeer.toPublic(peer));
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete('/peers/:id', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
await wgPeer.remove(req.params.id);
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Config / QR ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function buildConf(id) {
|
||||||
|
const peer = await wgPeer.get(id); // includes privateKey
|
||||||
|
if (!peer) throw Object.assign(new Error('Peer not found'), { status: 404 });
|
||||||
|
const site = peer.exitSiteId ? await wgSite.get(peer.exitSiteId) : null;
|
||||||
|
const { publicKey, endpoint, dns } = gwConf();
|
||||||
|
return renderClientConf({ peer, site, serverPub: publicKey, serverEndpoint: endpoint, dns });
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get('/peers/:id/conf', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const confText = await buildConf(req.params.id);
|
||||||
|
const peer = await wgPeer.get(req.params.id);
|
||||||
|
const filename = `${(peer.name || peer.id).replace(/[^a-z0-9_-]/gi, '_')}.conf`;
|
||||||
|
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
|
||||||
|
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
||||||
|
res.send(confText);
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/peers/:id/qr', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const confText = await buildConf(req.params.id);
|
||||||
|
const dataUrl = await QRCode.toDataURL(confText, {
|
||||||
|
errorCorrectionLevel: 'M',
|
||||||
|
width: 400,
|
||||||
|
margin: 2,
|
||||||
|
});
|
||||||
|
res.json({ qr: dataUrl });
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Gateway's own public key (unauthenticated — needed to display in the UI).
|
||||||
|
router.get('/gateway-info', (req, res) => {
|
||||||
|
res.json({ ...gwConf() });
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -23,7 +23,7 @@ function counter(onBytes) {
|
|||||||
// Connect the upstream ssh2.Client, retrying once after a short pause if the
|
// Connect the upstream ssh2.Client, retrying once after a short pause if the
|
||||||
// first attempt fails auth (SSSD/AuthorizedKeysCommand cache lag right after a
|
// first attempt fails auth (SSSD/AuthorizedKeysCommand cache lag right after a
|
||||||
// first-time key injection).
|
// first-time key injection).
|
||||||
function connectUpstream({ host, port, username, privateKey, onHostKey, uid, justInjected }) {
|
function connectUpstream({ host, port, username, privateKey, cert, onHostKey, uid, justInjected, expectedHostKeyFp }) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
let attempted = false;
|
let attempted = false;
|
||||||
const dial = (allowRetry) => {
|
const dial = (allowRetry) => {
|
||||||
@@ -42,12 +42,16 @@ function connectUpstream({ host, port, username, privateKey, onHostKey, uid, jus
|
|||||||
})
|
})
|
||||||
.connect({
|
.connect({
|
||||||
host, port, username, privateKey,
|
host, port, username, privateKey,
|
||||||
|
certificates: cert ? [cert] : undefined,
|
||||||
readyTimeout: (conf.ssh && conf.ssh.connectTimeoutMs) || 10000,
|
readyTimeout: (conf.ssh && conf.ssh.connectTimeoutMs) || 10000,
|
||||||
keepaliveInterval: 15000,
|
keepaliveInterval: 15000,
|
||||||
hostVerifier: (key) => {
|
hostVerifier: (key) => {
|
||||||
const fp = 'SHA256:' + crypto.createHash('sha256').update(key).digest('base64').replace(/=+$/, '');
|
const fp = 'SHA256:' + crypto.createHash('sha256').update(key).digest('base64').replace(/=+$/, '');
|
||||||
if (onHostKey) onHostKey(fp);
|
if (onHostKey) onHostKey(fp);
|
||||||
return true; // v1: trust-on-use, fingerprint audited. Pinning = follow-up.
|
if (expectedHostKeyFp && expectedHostKeyFp !== fp) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true; // v1: trust-on-use if not pinned, fingerprint audited.
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// mDNS local-discovery announcer (MULTI_SITE_SPEC.md Appendix B). Advertises
|
||||||
|
// this site's local presence so an agent on the same LAN segment (with
|
||||||
|
// prefer_local_directory enabled -- see theta-agent's local_discovery.go)
|
||||||
|
// can skip the relay/WAN path and talk to the local instance directly.
|
||||||
|
//
|
||||||
|
// What gets announced is deliberately just "which public hostnames does
|
||||||
|
// this site front, and at what local IP" -- nothing about identity or
|
||||||
|
// trust. The listening side never weakens certificate validation based on
|
||||||
|
// this; it only ever changes DNS resolution (see the hard rule documented
|
||||||
|
// in theta-agent's local_discovery.go).
|
||||||
|
|
||||||
|
const SERVICE_TYPE = 'theta-suite'; // -> _theta-suite._tcp, matches theta-agent's mdnsServiceName
|
||||||
|
|
||||||
|
function announcedHosts() {
|
||||||
|
return (process.env.THETA_LOCAL_DISCOVERY_HOSTS || '')
|
||||||
|
.split(',')
|
||||||
|
.map((h) => h.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
let bonjourInstance = null;
|
||||||
|
let publishedService = null;
|
||||||
|
|
||||||
|
async function startMdnsAnnounce() {
|
||||||
|
const hosts = announcedHosts();
|
||||||
|
if (hosts.length === 0) {
|
||||||
|
console.log('[mdns-announce] THETA_LOCAL_DISCOVERY_HOSTS not set -- nothing to announce, skipping');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { Bonjour } = require('bonjour-service');
|
||||||
|
bonjourInstance = new Bonjour(undefined, (err) => {
|
||||||
|
console.error('[mdns-announce] bonjour-service error:', err.message);
|
||||||
|
});
|
||||||
|
|
||||||
|
publishedService = bonjourInstance.publish({
|
||||||
|
name: `theta-suite-${process.env.SITE_SLUG || 'site'}`,
|
||||||
|
type: SERVICE_TYPE,
|
||||||
|
port: Number(process.env.PORT) || 80,
|
||||||
|
txt: { hosts: hosts.join(','), site: process.env.SITE_SLUG || '' }
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`[mdns-announce] announcing on the local network: ${hosts.join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopMdnsAnnounce() {
|
||||||
|
if (bonjourInstance) {
|
||||||
|
bonjourInstance.destroy();
|
||||||
|
bonjourInstance = null;
|
||||||
|
publishedService = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { startMdnsAnnounce, stopMdnsAnnounce };
|
||||||
@@ -129,17 +129,30 @@ async function resolveAndConnect(state, record, { onHostKey } = {}) {
|
|||||||
await record.patch({ targetSlug: host ? host.slug : 'raw-ip', targetAddr: endpoint.address, targetPort: endpoint.port });
|
await record.patch({ targetSlug: host ? host.slug : 'raw-ip', targetAddr: endpoint.address, targetPort: endpoint.port });
|
||||||
|
|
||||||
let justInjected = false;
|
let justInjected = false;
|
||||||
try { justInjected = await ensureKeyInjected(state.user, JUMP_KEYS.publicLine); }
|
let cert;
|
||||||
catch (err) { throw fail('key-inject-failed', err.message); }
|
const usePki = conf.ssh && conf.ssh.pki && conf.ssh.pki.enabled;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (usePki) {
|
||||||
|
const { getSignedCert } = require('../utils/vault_cert');
|
||||||
|
cert = await getSignedCert(JUMP_KEYS.publicLine, state.uid);
|
||||||
|
} else {
|
||||||
|
justInjected = await ensureKeyInjected(state.user, JUMP_KEYS.publicLine);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const failType = usePki ? 'pki-cert-failed' : 'key-inject-failed';
|
||||||
|
throw fail(failType, err.message, host ? host.slug : undefined);
|
||||||
|
}
|
||||||
|
|
||||||
let upstream;
|
let upstream;
|
||||||
try {
|
try {
|
||||||
upstream = await connectUpstream({
|
upstream = await connectUpstream({
|
||||||
host: endpoint.address, port: endpoint.port,
|
host: endpoint.address, port: endpoint.port,
|
||||||
username: state.uid, privateKey: JUMP_KEYS.clientKey,
|
username: state.uid, privateKey: JUMP_KEYS.clientKey, cert,
|
||||||
uid: state.uid, justInjected, onHostKey,
|
uid: state.uid, justInjected, onHostKey,
|
||||||
|
expectedHostKeyFp: host && host.metadata && host.metadata.sshHostKeyFp,
|
||||||
});
|
});
|
||||||
} catch (err) { throw fail('upstream-unreachable', err.message); }
|
} catch (err) { throw fail('upstream-unreachable', err.message, host ? host.slug : undefined); }
|
||||||
|
|
||||||
return { upstream, host, endpoint };
|
return { upstream, host, endpoint };
|
||||||
}
|
}
|
||||||
@@ -147,8 +160,10 @@ async function resolveAndConnect(state, record, { onHostKey } = {}) {
|
|||||||
// detail carries the real underlying error message (e.g. ECONNREFUSED,
|
// detail carries the real underlying error message (e.g. ECONNREFUSED,
|
||||||
// ETIMEDOUT, an ssh2 auth-failure string) so audit records aren't reduced to
|
// ETIMEDOUT, an ssh2 auth-failure string) so audit records aren't reduced to
|
||||||
// just the generic reason code -- without it, a network-layer failure and an
|
// just the generic reason code -- without it, a network-layer failure and an
|
||||||
// SSH auth failure both looked identical in the audit log.
|
// SSH auth failure both looked identical in the audit log. hostSlug (when the
|
||||||
function fail(reason, detail) { const e = new Error(reason); e.reason = reason; e.detail = detail; return e; }
|
// target was already resolved to a known host) lets callers attribute the
|
||||||
|
// failure to that host for per-host "last failed connection" tracking.
|
||||||
|
function fail(reason, detail, hostSlug) { const e = new Error(reason); e.reason = reason; e.detail = detail; e.hostSlug = hostSlug; return e; }
|
||||||
|
|
||||||
async function runGrammar(session, client, state) {
|
async function runGrammar(session, client, state) {
|
||||||
// Register session listeners IMMEDIATELY — before any async work.
|
// Register session listeners IMMEDIATELY — before any async work.
|
||||||
@@ -179,7 +194,7 @@ async function runGrammar(session, client, state) {
|
|||||||
const reason = err.reason || 'error';
|
const reason = err.reason || 'error';
|
||||||
rejectUp(new Error(reasonMessage(reason)));
|
rejectUp(new Error(reasonMessage(reason)));
|
||||||
await record.finish({ success: false, failReason: reason, failDetail: err.detail });
|
await record.finish({ success: false, failReason: reason, failDetail: err.detail });
|
||||||
await metrics.bump({ uid: state.uid, success: false });
|
await metrics.bump({ uid: state.uid, hostSlug: err.hostSlug, success: false });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -203,9 +218,9 @@ async function runTuiSession(session, client, state) {
|
|||||||
|
|
||||||
const record = await audit.create({ uid: state.uid, authMethod: state.authMethod, clientIp: state.clientIp, mode: 'tui' });
|
const record = await audit.create({ uid: state.uid, authMethod: state.authMethod, clientIp: state.clientIp, mode: 'tui' });
|
||||||
|
|
||||||
const finishFail = async (reason, detail) => {
|
const finishFail = async (reason, detail, hostSlug) => {
|
||||||
await record.finish({ success: false, failReason: reason, failDetail: detail });
|
await record.finish({ success: false, failReason: reason, failDetail: detail });
|
||||||
await metrics.bump({ uid: state.uid, success: false });
|
await metrics.bump({ uid: state.uid, hostSlug, success: false });
|
||||||
try { client.end(); } catch (_) {}
|
try { client.end(); } catch (_) {}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -226,19 +241,32 @@ async function runTuiSession(session, client, state) {
|
|||||||
await record.patch({ targetSlug: tui.host.slug, targetAddr: endpoint.address, targetPort: endpoint.port });
|
await record.patch({ targetSlug: tui.host.slug, targetAddr: endpoint.address, targetPort: endpoint.port });
|
||||||
|
|
||||||
let justInjected = false;
|
let justInjected = false;
|
||||||
try { justInjected = await ensureKeyInjected(state.user, JUMP_KEYS.publicLine); }
|
let cert;
|
||||||
catch (err) { return finishFail('key-inject-failed', err.message); }
|
const usePki = conf.ssh && conf.ssh.pki && conf.ssh.pki.enabled;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (usePki) {
|
||||||
|
const { getSignedCert } = require('../utils/vault_cert');
|
||||||
|
cert = await getSignedCert(JUMP_KEYS.publicLine, state.uid);
|
||||||
|
} else {
|
||||||
|
justInjected = await ensureKeyInjected(state.user, JUMP_KEYS.publicLine);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const failType = usePki ? 'pki-cert-failed' : 'key-inject-failed';
|
||||||
|
return finishFail(failType, err.message, tui.host.slug);
|
||||||
|
}
|
||||||
|
|
||||||
let upstream;
|
let upstream;
|
||||||
try {
|
try {
|
||||||
upstream = await connectUpstream({
|
upstream = await connectUpstream({
|
||||||
host: endpoint.address, port: endpoint.port,
|
host: endpoint.address, port: endpoint.port,
|
||||||
username: state.uid, privateKey: JUMP_KEYS.clientKey,
|
username: state.uid, privateKey: JUMP_KEYS.clientKey, cert,
|
||||||
uid: state.uid, justInjected, onHostKey: (fp) => record.patch({ hostKeyFp: fp }),
|
uid: state.uid, justInjected, onHostKey: (fp) => record.patch({ hostKeyFp: fp }),
|
||||||
|
expectedHostKeyFp: tui.host && tui.host.metadata && tui.host.metadata.sshHostKeyFp,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
try { tui.channel.write(`\r\n Could not reach ${endpoint.address}.\r\n`); tui.channel.close(); } catch (_) {}
|
try { tui.channel.write(`\r\n Could not reach ${endpoint.address}.\r\n`); tui.channel.close(); } catch (_) {}
|
||||||
return finishFail('upstream-unreachable', err.message);
|
return finishFail('upstream-unreachable', err.message, tui.host.slug);
|
||||||
}
|
}
|
||||||
|
|
||||||
registry.add(record.id, { uid: state.uid, target: endpoint.address, slug: tui.host.slug });
|
registry.add(record.id, { uid: state.uid, target: endpoint.address, slug: tui.host.slug });
|
||||||
|
|||||||
@@ -9,10 +9,29 @@ const ESC = '\x1b';
|
|||||||
const CLEAR = `${ESC}[2J${ESC}[H`;
|
const CLEAR = `${ESC}[2J${ESC}[H`;
|
||||||
const HIDE_CUR = `${ESC}[?25l`;
|
const HIDE_CUR = `${ESC}[?25l`;
|
||||||
const SHOW_CUR = `${ESC}[?25h`;
|
const SHOW_CUR = `${ESC}[?25h`;
|
||||||
const INV = `${ESC}[7m`;
|
|
||||||
|
// Basic styles
|
||||||
const RST = `${ESC}[0m`;
|
const RST = `${ESC}[0m`;
|
||||||
const DIM = `${ESC}[2m`;
|
|
||||||
const BOLD = `${ESC}[1m`;
|
const BOLD = `${ESC}[1m`;
|
||||||
|
const DIM = `${ESC}[2m`;
|
||||||
|
|
||||||
|
// Colors (30-37: standard, 90-97: bright)
|
||||||
|
const RED = `${ESC}[31m`;
|
||||||
|
const BRIGHT_RED = `${ESC}[91m`;
|
||||||
|
const CYAN = `${ESC}[36m`;
|
||||||
|
const BRIGHT_CYAN = `${ESC}[96m`;
|
||||||
|
const GREEN = `${ESC}[32m`;
|
||||||
|
const BRIGHT_GREEN = `${ESC}[92m`;
|
||||||
|
const YELLOW = `${ESC}[33m`;
|
||||||
|
const BRIGHT_YELLOW = `${ESC}[93m`;
|
||||||
|
const MAGENTA = `${ESC}[35m`;
|
||||||
|
const BRIGHT_MAGENTA = `${ESC}[95m`;
|
||||||
|
const BLUE = `${ESC}[34m`;
|
||||||
|
const BRIGHT_BLUE = `${ESC}[94m`;
|
||||||
|
|
||||||
|
// Inverted selection with color
|
||||||
|
const INV_GREEN = `${ESC}[42m${ESC}[30m`; // Green bg, black text
|
||||||
|
const INV = `${ESC}[7m`;
|
||||||
|
|
||||||
function pickHost(channel, uid, hosts) {
|
function pickHost(channel, uid, hosts) {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
@@ -35,18 +54,43 @@ function pickHost(channel, uid, hosts) {
|
|||||||
const list = visible();
|
const list = visible();
|
||||||
if (selected >= list.length) selected = Math.max(0, list.length - 1);
|
if (selected >= list.length) selected = Math.max(0, list.length - 1);
|
||||||
let out = CLEAR + HIDE_CUR;
|
let out = CLEAR + HIDE_CUR;
|
||||||
out += `${BOLD} Theta42 Jump — hosts for ${uid}${RST}\r\n`;
|
|
||||||
out += `${DIM} ↑/↓ move · Enter connect · type to filter · q quit${RST}\r\n\r\n`;
|
// Header with gradient-style color
|
||||||
|
out += `\r\n ${BOLD}${BRIGHT_CYAN}╔════════════════════════════════════════════════════════╗${RST}\r\n`;
|
||||||
|
out += ` ${BOLD}${BRIGHT_CYAN}║${RST} ${BOLD}${BRIGHT_MAGENTA}Theta42 Jump${RST} ${DIM}·${RST} ${BRIGHT_GREEN}hosts for ${uid}${RST} ${BOLD}${BRIGHT_CYAN}║${RST}\r\n`;
|
||||||
|
out += ` ${BOLD}${BRIGHT_CYAN}╚════════════════════════════════════════════════════════╝${RST}\r\n`;
|
||||||
|
out += `\r\n`;
|
||||||
|
out += ` ${DIM}↑/↓ move · Enter connect · type to filter · q quit${RST}\r\n`;
|
||||||
|
out += `\r\n`;
|
||||||
|
|
||||||
if (!list.length) {
|
if (!list.length) {
|
||||||
out += ` ${DIM}(no match for "${filter}")${RST}\r\n`;
|
out += ` ${YELLOW}⚠${RST} ${DIM}(no match for "${filter}")${RST}\r\n`;
|
||||||
} else {
|
} else {
|
||||||
list.forEach((h, i) => {
|
list.forEach((h, i) => {
|
||||||
const ip = (h.metadata && h.metadata.ip) || (h.metadata && h.metadata.address) || '';
|
const ip = (h.metadata && h.metadata.ip) || (h.metadata && h.metadata.address) || '';
|
||||||
const row = ` ${h.name} ${DIM}(${h.slug})${RST}${ip ? ` ${ip}` : ''}`;
|
const isProd = h.metadata && h.metadata.isProduction;
|
||||||
out += (i === selected ? `${INV}> ${h.name} (${h.slug})${ip ? ` ${ip}` : ''}${RST}` : row) + '\r\n';
|
const envBadge = isProd ? `${BOLD}${RED}PROD${RST} ` : `${DIM}DEV${RST} `;
|
||||||
|
|
||||||
|
if (i === selected) {
|
||||||
|
// Selected row with green inverse background
|
||||||
|
const selRow = `${INV_GREEN} ${h.name} ${DIM}(${h.slug})${RST}${ip ? ` ${CYAN}${ip}${RST}` : ''} ${envBadge} ${BOLD}${BRIGHT_GREEN}◄ SELECTED ►${RST}${INV_GREEN}${RST}`;
|
||||||
|
out += selRow + '\r\n';
|
||||||
|
} else {
|
||||||
|
// Normal row with subtle coloring
|
||||||
|
const nameColor = i % 2 === 0 ? BRIGHT_CYAN : CYAN;
|
||||||
|
out += ` ${nameColor}${h.name}${RST} ${DIM}(${h.slug})${RST}${ip ? ` ${BLUE}${ip}${RST}` : ''} ${envBadge}\r\n`;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (filter) out += `\r\n ${DIM}filter:${RST} ${filter}`;
|
|
||||||
|
if (filter) {
|
||||||
|
out += `\r\n ${DIM}filter: ${BRIGHT_YELLOW}${filter}${RST}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Footer
|
||||||
|
out += `\r\n\r\n ${DIM}────────────────────────────────────────────────────────${RST}\r\n`;
|
||||||
|
out += ` ${DIM}Press${RST} ${BOLD}1-9${RST} ${DIM}to quick-select · ${BOLD}q${RST} ${DIM}to quit${RST}\r\n`;
|
||||||
|
|
||||||
channel.write(out);
|
channel.write(out);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const conf = require('@simpleworkjs/conf');
|
||||||
|
const { getRedis } = require('../models');
|
||||||
|
const { generateKeypair } = require('../utils/wg_keys');
|
||||||
|
const wgSite = require('../models/wg_site');
|
||||||
|
|
||||||
|
async function bootstrapWireguard() {
|
||||||
|
try {
|
||||||
|
const redis = await getRedis();
|
||||||
|
const P = conf.redis.prefix || '';
|
||||||
|
const keypairKey = `${P}wg_gateway_keypair`;
|
||||||
|
|
||||||
|
// 1. Ensure Gateway WireGuard Keypair
|
||||||
|
let keypairData = await redis.hGetAll(keypairKey);
|
||||||
|
if (!keypairData || !keypairData.publicKey) {
|
||||||
|
const kp = generateKeypair();
|
||||||
|
keypairData = {
|
||||||
|
privateKey: kp.privateKey,
|
||||||
|
publicKey: kp.publicKey,
|
||||||
|
createdAt: String(Date.now()),
|
||||||
|
};
|
||||||
|
await redis.hSet(keypairKey, keypairData);
|
||||||
|
console.log('[bootstrap] Generated fresh WireGuard Gateway keypair.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!conf.wireguard) conf.wireguard = {};
|
||||||
|
conf.wireguard.serverPublicKey = keypairData.publicKey;
|
||||||
|
conf.wireguard.serverPrivateKey = keypairData.privateKey;
|
||||||
|
|
||||||
|
if (!conf.wireguard.serverEndpoint) {
|
||||||
|
const domain = conf.domain || 'suite.vm42.us';
|
||||||
|
conf.wireguard.serverEndpoint = `${domain}:51820`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Ensure Default Site Exit Node ("This Site")
|
||||||
|
const existingSites = await wgSite.list().catch(() => []);
|
||||||
|
if (!existingSites || existingSites.length === 0) {
|
||||||
|
const siteName = conf.siteName || process.env.CFG_SITE_NAME || '718it';
|
||||||
|
const defaultSite = await wgSite.create({
|
||||||
|
name: `${siteName} (This Site)`,
|
||||||
|
endpoint: conf.wireguard.serverEndpoint,
|
||||||
|
publicKey: keypairData.publicKey,
|
||||||
|
subnet: '0.0.0.0/0',
|
||||||
|
exitAll: true,
|
||||||
|
siteId: siteName,
|
||||||
|
note: 'Default local site exit node initialized during bootstrap',
|
||||||
|
}, 'bootstrap');
|
||||||
|
console.log(`[bootstrap] Initialized default WireGuard exit node '${defaultSite.name}' (${defaultSite.id}).`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[bootstrap] WireGuard bootstrap error:', err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { bootstrapWireguard };
|
||||||
@@ -8,39 +8,33 @@ function stubLdap(groups) {
|
|||||||
return { getGroups: async () => groups };
|
return { getGroups: async () => groups };
|
||||||
}
|
}
|
||||||
|
|
||||||
function stubFetch(byGroup) {
|
function stubFetch(byUid) {
|
||||||
return async (url) => {
|
return async (url) => {
|
||||||
const cn = decodeURIComponent(url.split('group=')[1]);
|
const uid = url.split('/access/')[1];
|
||||||
return { ok: true, json: async () => ({ results: byGroup[cn] || [] }) };
|
return { ok: true, json: async () => ({ results: byUid[uid] || [] }) };
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
test('unions hosts across groups, dedupes, drops non-hosts', async () => {
|
test('drops non-hosts from access projection', async () => {
|
||||||
clearCache();
|
clearCache();
|
||||||
const user = { uid: 'alice', dn: 'uid=alice,ou=people,dc=x' };
|
const user = { uid: 'alice', dn: 'uid=alice,ou=people,dc=x' };
|
||||||
const fetchImpl = stubFetch({
|
const fetchImpl = stubFetch({
|
||||||
host_web01_access: [
|
alice: [
|
||||||
{ id: '1', kind: 'host', slug: 'host_web01' },
|
{ id: '1', kind: 'host', slug: 'host_web01' },
|
||||||
|
{ id: '2', kind: 'host', slug: 'host_db' },
|
||||||
{ id: '9', kind: 'service', slug: 'app_gitea' }, // dropped: not a host
|
{ id: '9', kind: 'service', slug: 'app_gitea' }, // dropped: not a host
|
||||||
],
|
],
|
||||||
host_db_access: [
|
|
||||||
{ id: '1', kind: 'host', slug: 'host_web01' }, // dupe by id
|
|
||||||
{ id: '2', kind: 'host', slug: 'host_db' },
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
const hosts = await accessibleHosts(user, { fetchImpl, ldap: stubLdap(['host_web01_access', 'host_db_access']) });
|
const hosts = await accessibleHosts(user, { fetchImpl });
|
||||||
assert.deepStrictEqual(hosts.map((h) => h.id).sort(), ['1', '2']);
|
assert.deepStrictEqual(hosts.map((h) => h.id).sort(), ['1', '2']);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a failing group query does not sink the rest', async () => {
|
test('a failing access query returns empty list without throwing', async () => {
|
||||||
clearCache();
|
clearCache();
|
||||||
const user = { uid: 'bob', dn: 'uid=bob,ou=people,dc=x' };
|
const user = { uid: 'bob', dn: 'uid=bob,ou=people,dc=x' };
|
||||||
const fetchImpl = async (url) => {
|
const fetchImpl = async () => ({ ok: false, status: 500 });
|
||||||
if (url.includes('bad')) return { ok: false, status: 500 };
|
const hosts = await accessibleHosts(user, { fetchImpl });
|
||||||
return { ok: true, json: async () => ({ results: [{ id: '3', kind: 'host', slug: 'host_ok' }] }) };
|
assert.deepStrictEqual(hosts, []);
|
||||||
};
|
|
||||||
const hosts = await accessibleHosts(user, { fetchImpl, ldap: stubLdap(['bad_access', 'good_access']) });
|
|
||||||
assert.deepStrictEqual(hosts.map((h) => h.id), ['3']);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('caches per uid', async () => {
|
test('caches per uid', async () => {
|
||||||
@@ -48,23 +42,19 @@ test('caches per uid', async () => {
|
|||||||
let calls = 0;
|
let calls = 0;
|
||||||
const user = { uid: 'cara', dn: 'd' };
|
const user = { uid: 'cara', dn: 'd' };
|
||||||
const fetchImpl = async () => { calls++; return { ok: true, json: async () => ({ results: [] }) }; };
|
const fetchImpl = async () => { calls++; return { ok: true, json: async () => ({ results: [] }) }; };
|
||||||
const ldap = { getGroups: async () => ['g1'] };
|
await accessibleHosts(user, { fetchImpl });
|
||||||
await accessibleHosts(user, { fetchImpl, ldap });
|
await accessibleHosts(user, { fetchImpl });
|
||||||
await accessibleHosts(user, { fetchImpl, ldap });
|
|
||||||
assert.strictEqual(calls, 1);
|
assert.strictEqual(calls, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('accepts pre-resolved groups (web UI/OIDC session) without calling ldap.getGroups', async () => {
|
test('does not depend on user.groups or ldap.getGroups', async () => {
|
||||||
clearCache();
|
clearCache();
|
||||||
let ldapCalled = false;
|
const user = { uid: 'erin' }; // no dn, no groups
|
||||||
const user = { uid: 'erin', groups: ['host_web01_access'] };
|
|
||||||
const fetchImpl = stubFetch({
|
const fetchImpl = stubFetch({
|
||||||
host_web01_access: [{ id: '5', kind: 'host', slug: 'host_web01' }],
|
erin: [{ id: '5', kind: 'host', slug: 'host_web01' }],
|
||||||
});
|
});
|
||||||
const ldap = { getGroups: async () => { ldapCalled = true; return []; } };
|
const hosts = await accessibleHosts(user, { fetchImpl });
|
||||||
const hosts = await accessibleHosts(user, { fetchImpl, ldap });
|
|
||||||
assert.deepStrictEqual(hosts.map((h) => h.id), ['5']);
|
assert.deepStrictEqual(hosts.map((h) => h.id), ['5']);
|
||||||
assert.strictEqual(ldapCalled, false);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('allHosts fetches the whole host inventory with no group filter', async () => {
|
test('allHosts fetches the whole host inventory with no group filter', async () => {
|
||||||
@@ -80,16 +70,33 @@ test('allHosts fetches the whole host inventory with no group filter', async ()
|
|||||||
assert.deepStrictEqual(hosts.map((h) => h.id).sort(), ['1', '2']);
|
assert.deepStrictEqual(hosts.map((h) => h.id).sort(), ['1', '2']);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a bare-array response (envelope drift) is treated as a failed group, not silently []', async () => {
|
// Only catalog content is a jump target. Discovery writes `discovery_sources`;
|
||||||
|
// promoting to the catalog sets `managed: true`. An unpromoted Proxmox VM was
|
||||||
|
// reaching the picker because the filter defaulted `managed`-less hosts to true.
|
||||||
|
test('drops auto-discovered hosts that were never promoted', async () => {
|
||||||
|
clearCache();
|
||||||
|
const user = { uid: 'frank', dn: 'd' };
|
||||||
|
const fetchImpl = stubFetch({
|
||||||
|
frank: [
|
||||||
|
{ id: '1', kind: 'host', slug: 'host_web01' }, // hand-made: no discovery_sources
|
||||||
|
{ id: '2', kind: 'host', slug: 'vm-101', metadata: { discovery_sources: ['proxmox'] } }, // discovered, unpromoted
|
||||||
|
{ id: '3', kind: 'host', slug: 'vm-102', metadata: { discovery_sources: ['proxmox'], managed: true } }, // promoted
|
||||||
|
{ id: '4', kind: 'host', slug: 'host_db', metadata: { discovery_sources: ['manual'] } }, // manual source counts as catalog
|
||||||
|
{ id: '5', kind: 'host', slug: 'host_off', metadata: { managed: false } }, // explicitly out
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const hosts = await accessibleHosts(user, { fetchImpl });
|
||||||
|
assert.deepStrictEqual(hosts.map((h) => h.id).sort(), ['1', '3', '4']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a bare-array response (envelope drift) returns empty list', async () => {
|
||||||
clearCache();
|
clearCache();
|
||||||
const user = { uid: 'dave', dn: 'd' };
|
const user = { uid: 'dave', dn: 'd' };
|
||||||
// drift shape: a bare array instead of { results: [...] }. The shared client
|
// drift shape: a bare array instead of { results: [...] }. The shared client
|
||||||
// throws DirectoryEnvelopeViolation; access.js must catch + continue, so a
|
// throws DirectoryEnvelopeViolation; access.js must catch + continue.
|
||||||
// good group alongside still yields its hosts.
|
const fetchImpl = async () => {
|
||||||
const fetchImpl = async (url) => {
|
return { ok: true, json: async () => [{ id: '7', kind: 'host' }] };
|
||||||
if (url.includes('drift')) return { ok: true, json: async () => [{ id: '7', kind: 'host' }] };
|
|
||||||
return { ok: true, json: async () => ({ results: [{ id: '8', kind: 'host' }] }) };
|
|
||||||
};
|
};
|
||||||
const hosts = await accessibleHosts(user, { fetchImpl, ldap: stubLdap(['drift_access', 'good_access']) });
|
const hosts = await accessibleHosts(user, { fetchImpl });
|
||||||
assert.deepStrictEqual(hosts.map((h) => h.id), ['8']);
|
assert.deepStrictEqual(hosts, []);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const { meshCidrFor, meshAllowedIpsFor, MAX_MESH_INDEX, MIN_MESH_INDEX } = require('../../utils/mesh_addressing');
|
||||||
|
|
||||||
|
test('meshCidrFor renders the .1 address in the site\'s /24', () => {
|
||||||
|
assert.equal(meshCidrFor(1), '172.24.1.1/24');
|
||||||
|
assert.equal(meshCidrFor(254), '172.24.254.1/24');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('meshAllowedIpsFor covers both the mesh /24 and the site\'s 10.x/16', () => {
|
||||||
|
assert.deepEqual(meshAllowedIpsFor(5), ['172.24.5.0/24', '10.5.0.0/16']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects index 0 and 255 (reserved) and the out-of-range/non-integer cases', () => {
|
||||||
|
assert.throws(() => meshCidrFor(0));
|
||||||
|
assert.throws(() => meshCidrFor(255));
|
||||||
|
assert.throws(() => meshCidrFor(MAX_MESH_INDEX + 1));
|
||||||
|
assert.throws(() => meshCidrFor(MIN_MESH_INDEX - 1));
|
||||||
|
assert.throws(() => meshCidrFor(1.5));
|
||||||
|
assert.throws(() => meshCidrFor('1'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('MAX_MESH_INDEX matches the documented 254-site ceiling', () => {
|
||||||
|
assert.equal(MAX_MESH_INDEX, 254);
|
||||||
|
});
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const { generateKeypair } = require('../../utils/wg_keys');
|
||||||
|
const { renderClientConf } = require('../../utils/wg_conf');
|
||||||
|
|
||||||
|
test('generateKeypair returns valid base64 WireGuard keypair', () => {
|
||||||
|
const kp = generateKeypair();
|
||||||
|
assert.ok(kp.privateKey, 'privateKey should exist');
|
||||||
|
assert.ok(kp.publicKey, 'publicKey should exist');
|
||||||
|
assert.notEqual(kp.privateKey, kp.publicKey);
|
||||||
|
// WireGuard raw X25519 base64 keys are 44 characters ending with '='
|
||||||
|
assert.equal(kp.privateKey.length, 44);
|
||||||
|
assert.equal(kp.publicKey.length, 44);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renderClientConf generates valid wg0 client configuration', () => {
|
||||||
|
const peer = {
|
||||||
|
name: 'test-phone',
|
||||||
|
assignedIP: '10.100.0.5',
|
||||||
|
privateKey: 'c3VwZXJzZWNyZXRwcml2YXRla2V5MTIzNDU2Nzg5MDE=',
|
||||||
|
};
|
||||||
|
const site = {
|
||||||
|
subnet: '192.168.1.0/24',
|
||||||
|
exitAll: false,
|
||||||
|
};
|
||||||
|
const confStr = renderClientConf({
|
||||||
|
peer,
|
||||||
|
site,
|
||||||
|
serverPub: 'c2VydmVycHVibGlja2V5MTIzNDU2Nzg5MDEyMzQ1Njc=',
|
||||||
|
serverEndpoint: 'gw.theta42.com:51820',
|
||||||
|
dns: '1.1.1.1',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.ok(confStr.includes('[Interface]'));
|
||||||
|
assert.ok(confStr.includes('PrivateKey = c3VwZXJzZWNyZXRwcml2YXRla2V5MTIzNDU2Nzg5MDE='));
|
||||||
|
assert.ok(confStr.includes('Address = 10.100.0.5/32'));
|
||||||
|
assert.ok(confStr.includes('[Peer]'));
|
||||||
|
assert.ok(confStr.includes('PublicKey = c2VydmVycHVibGlja2V5MTIzNDU2Nzg5MDEyMzQ1Njc='));
|
||||||
|
assert.ok(confStr.includes('Endpoint = gw.theta42.com:51820'));
|
||||||
|
});
|
||||||
@@ -17,14 +17,9 @@ if (conf.standalone && conf.standalone.enabled) {
|
|||||||
|
|
||||||
// Which directory hosts may a user reach, and how do we dial them?
|
// Which directory hosts may a user reach, and how do we dial them?
|
||||||
//
|
//
|
||||||
// v1 resolution (see directory_spec.md §9.2 in sso-manager-node): the SSO's
|
// We use the SSO's machine-aware /api/discovery/access/:uid endpoint,
|
||||||
// /api/discovery/me only answers for the API token's own user, and /graph
|
// which evaluates the user's groups server-side and returns their complete
|
||||||
// omits ResourceGroup links — so we combine the user's LDAP groups (queried
|
// access projection in one call.
|
||||||
// directly) with per-group resource lookups:
|
|
||||||
//
|
|
||||||
// 1. LDAP: groups the user's DN is a member of
|
|
||||||
// 2. SSO: GET /api/discovery/resources?group=<cn> per group (ApiToken)
|
|
||||||
// 3. union, keep kind === 'host'
|
|
||||||
//
|
//
|
||||||
// Results are cached per-uid for a short TTL — the TUI picker and the
|
// Results are cached per-uid for a short TTL — the TUI picker and the
|
||||||
// username-grammar path share the cache. Dependency-injected fetch/ldap for
|
// username-grammar path share the cache. Dependency-injected fetch/ldap for
|
||||||
@@ -51,37 +46,44 @@ if (conf.standalone && conf.standalone.enabled) {
|
|||||||
|
|
||||||
// Every host in the inventory, unfiltered — for admins (the web UI's own
|
// Every host in the inventory, unfiltered — for admins (the web UI's own
|
||||||
// account is already gated by requireAdmin before this is ever called).
|
// account is already gated by requireAdmin before this is ever called).
|
||||||
async function allHosts({ fetchImpl = fetch } = {}) {
|
//
|
||||||
const resources = await directoryClient({ fetchImpl }).getResourcesByGroup(undefined, { kind: 'host' });
|
// "In the catalog" is the same predicate the SSO's own Directory listing
|
||||||
return resources.filter(r => r.kind === 'host');
|
// applies (sso-manager-node routes/api_directory_admin.js GET /resources):
|
||||||
|
// a resource that was auto-discovered and never promoted is NOT catalog
|
||||||
|
// content and must never be offered as a jump target. Discovery writes
|
||||||
|
// `metadata.discovery_sources`; promoting sets `metadata.managed = true`.
|
||||||
|
// Hosts created by hand carry no discovery_sources at all and stay in.
|
||||||
|
//
|
||||||
|
// The two copies of this rule have already drifted apart once (unpromoted
|
||||||
|
// Proxmox VMs showing up in the picker); if a third consumer needs it,
|
||||||
|
// hoist it into @simpleworkjs/directory-schema rather than copying again.
|
||||||
|
function isCatalogHost(r) {
|
||||||
|
if (!r || r.kind !== 'host') return false;
|
||||||
|
const meta = r.metadata || {};
|
||||||
|
if (meta.managed === true || meta.managed === 'true') return true;
|
||||||
|
if (meta.managed === false || meta.managed === 'false') return false;
|
||||||
|
const sources = meta.discovery_sources || [];
|
||||||
|
const autoDiscovered = sources.length > 0 && !sources.includes('manual');
|
||||||
|
return !autoDiscovered;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function accessibleHosts(user, { fetchImpl = fetch, ldap = userLdap } = {}) {
|
async function allHosts({ fetchImpl = fetch } = {}) {
|
||||||
|
const resources = await directoryClient({ fetchImpl }).getResourcesByGroup(undefined, { kind: 'host' });
|
||||||
|
return resources.filter(isCatalogHost);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function accessibleHosts(user, { fetchImpl = fetch } = {}) {
|
||||||
const hit = cache.get(user.uid);
|
const hit = cache.get(user.uid);
|
||||||
if (hit && Date.now() - hit.at < CACHE_TTL_MS) return hit.hosts;
|
if (hit && Date.now() - hit.at < CACHE_TTL_MS) return hit.hosts;
|
||||||
|
|
||||||
// The SSH path passes an LDAP user ({dn, uid, ...}) with no .groups, so we
|
let resources = [];
|
||||||
// look them up; the web UI already has the session's OIDC groups claim
|
try {
|
||||||
// and passes it directly, skipping a redundant LDAP round-trip.
|
resources = await directoryClient({ fetchImpl }).getAccess(user.uid);
|
||||||
const groups = user.groups || await ldap.getGroups(user.dn);
|
} catch (error) {
|
||||||
|
console.error(`[access] ${error.message}`);
|
||||||
const seen = new Map();
|
|
||||||
for (const cn of groups) {
|
|
||||||
let resources;
|
|
||||||
try {
|
|
||||||
resources = await fetchResourcesByGroup(cn, { fetchImpl });
|
|
||||||
} catch (error) {
|
|
||||||
// One bad group must not hide the rest; the SSO being down
|
|
||||||
// surfaces as an empty list + log line, not a crash.
|
|
||||||
console.error(`[access] ${error.message}`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
for (const r of resources) {
|
|
||||||
if (r.kind === 'host' && !seen.has(r.id)) seen.set(r.id, r);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const hosts = [...seen.values()];
|
const hosts = resources.filter(isCatalogHost);
|
||||||
cache.set(user.uid, { at: Date.now(), hosts });
|
cache.set(user.uid, { at: Date.now(), hosts });
|
||||||
return hosts;
|
return hosts;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Mesh subnet addressing math (MULTI_SITE_SPEC.md Appendix A): one octet per
|
||||||
|
// site, 172.24.<idx>.0/16 + 10.<idx>.0.0/16, idx 1-254 (0/255 reserved).
|
||||||
|
// Pure/no I/O so it's cheaply unit-testable apart from routes/mesh.js.
|
||||||
|
|
||||||
|
const MESH_SUBNET_PREFIX = '172.24';
|
||||||
|
const MAX_MESH_INDEX = 254;
|
||||||
|
const MIN_MESH_INDEX = 1;
|
||||||
|
|
||||||
|
function meshCidrFor(meshIndex) {
|
||||||
|
assertValidIndex(meshIndex);
|
||||||
|
return `${MESH_SUBNET_PREFIX}.${meshIndex}.1/24`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function meshAllowedIpsFor(meshIndex) {
|
||||||
|
assertValidIndex(meshIndex);
|
||||||
|
return [`${MESH_SUBNET_PREFIX}.${meshIndex}.0/24`, `10.${meshIndex}.0.0/16`];
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertValidIndex(meshIndex) {
|
||||||
|
if (!Number.isInteger(meshIndex) || meshIndex < MIN_MESH_INDEX || meshIndex > MAX_MESH_INDEX) {
|
||||||
|
throw new Error(`mesh index must be an integer in [${MIN_MESH_INDEX}, ${MAX_MESH_INDEX}], got ${meshIndex}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { meshCidrFor, meshAllowedIpsFor, MESH_SUBNET_PREFIX, MAX_MESH_INDEX, MIN_MESH_INDEX };
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Single-use, short-lived tokens that let a new theta-gateway register into
|
||||||
|
// this one's WireGuard mesh (POST /api/mesh/register). Same shape as
|
||||||
|
// theta-directory's site join keys: minted by an admin, shown once, GETDEL
|
||||||
|
// (Redis) on use so a token can register exactly one gateway, ever.
|
||||||
|
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const conf = require('@simpleworkjs/conf');
|
||||||
|
const { getRedis } = require('../models/index');
|
||||||
|
|
||||||
|
const TTL_SECONDS = 15 * 60;
|
||||||
|
|
||||||
|
const key = (token) => `${conf.redis.prefix}mesh_join_token:${token}`;
|
||||||
|
|
||||||
|
async function mint() {
|
||||||
|
const token = 'mjt_' + crypto.randomBytes(24).toString('base64url');
|
||||||
|
const redis = await getRedis();
|
||||||
|
await redis.set(key(token), '1', { EX: TTL_SECONDS });
|
||||||
|
return { token, expiresInSeconds: TTL_SECONDS };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function consume(token) {
|
||||||
|
if (!token) return false;
|
||||||
|
const redis = await getRedis();
|
||||||
|
return (await redis.getDel(key(token))) === '1';
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { mint, consume };
|
||||||
@@ -37,6 +37,8 @@ module.exports = {
|
|||||||
nav: [
|
nav: [
|
||||||
{href: '/dashboard', icon: 'fa-solid fa-gauge-high', label: 'Dashboard', groups: []},
|
{href: '/dashboard', icon: 'fa-solid fa-gauge-high', label: 'Dashboard', groups: []},
|
||||||
{href: '/sessions', icon: 'fa-solid fa-plug-circle-bolt', label: 'Sessions', groups: []},
|
{href: '/sessions', icon: 'fa-solid fa-plug-circle-bolt', label: 'Sessions', groups: []},
|
||||||
{href: '/audit', icon: 'fa-solid fa-clipboard-list', label: 'Audit', groups: []},
|
{href: '/wireguard', icon: 'fa-solid fa-shield-halved', label: 'WireGuard', groups: ['admin', 'app_jump_admin']},
|
||||||
|
{href: '/mesh', icon: 'fa-solid fa-diagram-project', label: 'Mesh', groups: ['admin', 'app_jump_admin']},
|
||||||
|
{href: '/audit', icon: 'fa-solid fa-clipboard-list', label: 'Audit', groups: ['admin', 'app_jump_admin']},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const conf = require('@simpleworkjs/conf');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Requests a signed SSH certificate from the SSO Manager's OpenBao/Vault proxy.
|
||||||
|
*
|
||||||
|
* @param {string} publicKey - The jump host's public key (e.g. 'ssh-rsa AAAAB3...')
|
||||||
|
* @param {string} targetUid - The username the cert should be valid for
|
||||||
|
* @returns {Promise<string>} - The signed SSH certificate
|
||||||
|
*/
|
||||||
|
async function getSignedCert(publicKey, targetUid) {
|
||||||
|
const sso = conf.sso || {};
|
||||||
|
const pkiConfig = conf.ssh?.pki || {};
|
||||||
|
|
||||||
|
const vaultRole = pkiConfig.role || 'jump-host-role';
|
||||||
|
const endpoint = `${sso.url}/api/vault/ssh/sign/${vaultRole}`;
|
||||||
|
|
||||||
|
const response = await fetch(endpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${sso.apiToken}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
public_key: publicKey,
|
||||||
|
valid_principals: targetUid
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errText = await response.text().catch(() => '');
|
||||||
|
throw new Error(`Failed to sign SSH cert (status ${response.status}): ${errText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (!data.data || !data.data.signed_key) {
|
||||||
|
throw new Error('Vault response missing signed_key');
|
||||||
|
}
|
||||||
|
|
||||||
|
return data.data.signed_key;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { getSignedCert };
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Renders a WireGuard client wg0.conf from a peer + site record.
|
||||||
|
//
|
||||||
|
// The generated config is a standard WireGuard config that works with:
|
||||||
|
// - wg-quick (Linux / macOS)
|
||||||
|
// - the official WireGuard iOS / Android apps (via QR code)
|
||||||
|
// - TunnelBear, WireGuard Windows client, etc.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a client wg0.conf string.
|
||||||
|
*
|
||||||
|
* @param {object} peer - WG peer record from wg_peer model
|
||||||
|
* @param {object} site - Exit node record from wg_site model (may be null)
|
||||||
|
* @param {string} serverPub - Gateway server's own public key
|
||||||
|
* @param {string} serverEndpoint - "host:port" for the gateway
|
||||||
|
* @param {string} dns - Optional DNS server to push to client
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function renderClientConf({ peer, site, serverPub, serverEndpoint, dns }) {
|
||||||
|
const allowedIPs = site
|
||||||
|
? (site.exitAll ? '0.0.0.0/0, ::/0' : site.subnet || '0.0.0.0/0')
|
||||||
|
: '0.0.0.0/0, ::/0'; // no exit site = full tunnel
|
||||||
|
|
||||||
|
const lines = [
|
||||||
|
'[Interface]',
|
||||||
|
`PrivateKey = ${peer.privateKey}`,
|
||||||
|
`Address = ${peer.assignedIP}/32`,
|
||||||
|
];
|
||||||
|
|
||||||
|
if (dns) lines.push(`DNS = ${dns}`);
|
||||||
|
|
||||||
|
lines.push('');
|
||||||
|
lines.push('[Peer]');
|
||||||
|
lines.push(`PublicKey = ${serverPub}`);
|
||||||
|
|
||||||
|
// If client selected an exit site, add preshared key routing hint via
|
||||||
|
// AllowedIPs. Site gateways are peers-of-peers; the server handles routing.
|
||||||
|
if (site) {
|
||||||
|
lines.push(`AllowedIPs = ${allowedIPs}`);
|
||||||
|
} else {
|
||||||
|
lines.push('AllowedIPs = 0.0.0.0/0, ::/0');
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push(`Endpoint = ${serverEndpoint}`);
|
||||||
|
lines.push('PersistentKeepalive = 25');
|
||||||
|
|
||||||
|
if (peer.note) {
|
||||||
|
lines.unshift(`# ${peer.note}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join('\n') + '\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a minimal server-side [Peer] block for wg0.conf (for reference/export).
|
||||||
|
*/
|
||||||
|
function renderServerPeerBlock(peer) {
|
||||||
|
return [
|
||||||
|
`# ${peer.name || peer.id}`,
|
||||||
|
'[Peer]',
|
||||||
|
`PublicKey = ${peer.publicKey}`,
|
||||||
|
`AllowedIPs = ${peer.assignedIP}/32`,
|
||||||
|
'',
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { renderClientConf, renderServerPeerBlock };
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Bring up a local WireGuard interface, preferring the in-kernel
|
||||||
|
// implementation and falling back to the userspace `wireguard-go` reference
|
||||||
|
// implementation when the kernel module isn't available (older/hardened
|
||||||
|
// kernels, some container/cloud images, non-Linux). Both paths end with an
|
||||||
|
// identically-named network interface that `wg`/`ip` commands (and the rest
|
||||||
|
// of this module) treat the same way -- callers never need to know which
|
||||||
|
// mode ended up in use.
|
||||||
|
|
||||||
|
const { execFileSync, spawn } = require('child_process');
|
||||||
|
|
||||||
|
const probes = new Map(); // name -> { mode, process (userspace only) }
|
||||||
|
|
||||||
|
function run(cmd, args) {
|
||||||
|
return execFileSync(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'] }).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function tryRun(cmd, args) {
|
||||||
|
try { return { ok: true, out: run(cmd, args) }; }
|
||||||
|
catch (e) { return { ok: false, err: (e.stderr || e.message || '').toString() }; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// One-time, cheap probe: can this kernel create a wireguard-type link at
|
||||||
|
// all? Uses a throwaway interface name so it never collides with a real one.
|
||||||
|
let kernelSupport = null;
|
||||||
|
function kernelWireguardAvailable() {
|
||||||
|
if (kernelSupport !== null) return kernelSupport;
|
||||||
|
const probeName = 'wgprobe' + process.pid;
|
||||||
|
const add = tryRun('ip', ['link', 'add', 'dev', probeName, 'type', 'wireguard']);
|
||||||
|
if (add.ok) {
|
||||||
|
tryRun('ip', ['link', 'del', 'dev', probeName]);
|
||||||
|
kernelSupport = true;
|
||||||
|
} else {
|
||||||
|
kernelSupport = false;
|
||||||
|
}
|
||||||
|
return kernelSupport;
|
||||||
|
}
|
||||||
|
|
||||||
|
function interfaceExists(name) {
|
||||||
|
return tryRun('ip', ['link', 'show', 'dev', name]).ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForInterface(name, timeoutMs = 5000) {
|
||||||
|
const start = Date.now();
|
||||||
|
while (Date.now() - start < timeoutMs) {
|
||||||
|
if (interfaceExists(name)) return true;
|
||||||
|
await new Promise((r) => setTimeout(r, 100));
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Idempotent: calling this again for an interface that's already up (kernel
|
||||||
|
// or userspace) is a no-op, not an error.
|
||||||
|
async function ensureInterface(name) {
|
||||||
|
if (interfaceExists(name)) {
|
||||||
|
return { mode: probes.get(name) ? probes.get(name).mode : 'kernel' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (kernelWireguardAvailable()) {
|
||||||
|
const add = tryRun('ip', ['link', 'add', 'dev', name, 'type', 'wireguard']);
|
||||||
|
if (!add.ok && !/File exists/.test(add.err)) {
|
||||||
|
throw new Error(`kernel WireGuard interface creation failed: ${add.err}`);
|
||||||
|
}
|
||||||
|
probes.set(name, { mode: 'kernel' });
|
||||||
|
console.log(`[wg_iface] ${name}: using in-kernel WireGuard`);
|
||||||
|
return { mode: 'kernel' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Userspace fallback: wireguard-go daemonizes and creates the TUN device
|
||||||
|
// itself; we just wait for it to appear rather than assuming a fixed delay.
|
||||||
|
console.log(`[wg_iface] ${name}: kernel WireGuard unavailable, falling back to wireguard-go (userspace)`);
|
||||||
|
const child = spawn('wireguard-go', [name], { detached: true, stdio: 'ignore' });
|
||||||
|
child.unref();
|
||||||
|
const up = await waitForInterface(name);
|
||||||
|
if (!up) throw new Error(`wireguard-go did not bring up interface '${name}' within timeout`);
|
||||||
|
probes.set(name, { mode: 'userspace', pid: child.pid });
|
||||||
|
return { mode: 'userspace', pid: child.pid };
|
||||||
|
}
|
||||||
|
|
||||||
|
function setPrivateKey(name, privateKeyBase64, listenPort) {
|
||||||
|
// `wg setconf` reads the private key from a file, not argv (argv would leak
|
||||||
|
// it via /proc/<pid>/cmdline to anyone on the host). Pipe it through stdin
|
||||||
|
// via a temp file instead -- see setPeer's note on the same tradeoff.
|
||||||
|
// ListenPort matters: without one, WG binds an ephemeral port, which is
|
||||||
|
// fine for a purely outbound roaming client but useless for a gateway
|
||||||
|
// another gateway needs to dial back into as an Endpoint.
|
||||||
|
const fs = require('fs');
|
||||||
|
const os = require('os');
|
||||||
|
const path = require('path');
|
||||||
|
const tmp = path.join(os.tmpdir(), `wg-${name}-${Date.now()}.conf`);
|
||||||
|
const lines = ['[Interface]', `PrivateKey = ${privateKeyBase64}`];
|
||||||
|
if (listenPort) lines.push(`ListenPort = ${listenPort}`);
|
||||||
|
fs.writeFileSync(tmp, lines.join('\n') + '\n', { mode: 0o600 });
|
||||||
|
try {
|
||||||
|
run('wg', ['setconf', name, tmp]);
|
||||||
|
} finally {
|
||||||
|
fs.unlinkSync(tmp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setAddress(name, cidr) {
|
||||||
|
// Flush first so re-applying (e.g. after a mesh index reassignment, which
|
||||||
|
// shouldn't normally happen but must not silently stack addresses if it
|
||||||
|
// does) leaves exactly one address, not an accumulating list.
|
||||||
|
tryRun('ip', ['addr', 'flush', 'dev', name]);
|
||||||
|
run('ip', ['addr', 'add', cidr, 'dev', name]);
|
||||||
|
run('ip', ['link', 'set', 'up', 'dev', name]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply (or update) one peer. Safe to call repeatedly for the same peer --
|
||||||
|
// `wg set ... peer <pub>` upserts.
|
||||||
|
//
|
||||||
|
// `wg set ... allowed-ips` ONLY configures WireGuard's own crypto-routing
|
||||||
|
// table (which packets get encrypted/decrypted for this peer) -- it does
|
||||||
|
// NOT add a kernel route for that destination. wg-quick does that as a
|
||||||
|
// separate step; since we drive `wg`/`ip` directly (no wg-quick), we have to
|
||||||
|
// add it ourselves or the tunnel handshakes fine but nothing ever actually
|
||||||
|
// routes through it (confirmed the hard way: a real encrypted handshake
|
||||||
|
// completed between two containers with zero kernel route present, and
|
||||||
|
// ping still showed 100% loss).
|
||||||
|
function setPeer(name, { publicKey, endpoint, allowedIPs, keepalive }) {
|
||||||
|
const ips = allowedIPs || [];
|
||||||
|
const args = ['set', name, 'peer', publicKey, 'allowed-ips', ips.join(',')];
|
||||||
|
if (endpoint) args.push('endpoint', endpoint);
|
||||||
|
if (keepalive) args.push('persistent-keepalive', String(keepalive));
|
||||||
|
run('wg', args);
|
||||||
|
|
||||||
|
for (const cidr of ips) {
|
||||||
|
const add = tryRun('ip', ['route', 'add', cidr, 'dev', name]);
|
||||||
|
// "File exists" happens when the interface's own /24 already covers
|
||||||
|
// this range (added automatically by `ip addr add`) -- fine, not an
|
||||||
|
// error. Anything else should surface.
|
||||||
|
if (!add.ok && !/File exists/.test(add.err)) {
|
||||||
|
throw new Error(`failed to add kernel route ${cidr} via ${name}: ${add.err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Removes the peer AND the kernel routes setPeer() added for its
|
||||||
|
// AllowedIPs -- query them BEFORE removing the peer (once gone, `wg` no
|
||||||
|
// longer knows what to clean up, and nothing else tracks these routes,
|
||||||
|
// since they were added by us directly, not by wg-quick).
|
||||||
|
//
|
||||||
|
// Safe to assume none of a peer's AllowedIPs collide with this gateway's
|
||||||
|
// own local address range: mesh indexes are unique per gateway
|
||||||
|
// (models/mesh_gateway.js's nextFreeMeshIndex), so a peer's
|
||||||
|
// 172.24.<peerIndex>.0/24 can never equal our own 172.24.<ownIndex>.0/24.
|
||||||
|
function removePeer(name, publicKey) {
|
||||||
|
const show = tryRun('wg', ['show', name, 'allowed-ips']);
|
||||||
|
let allowedIPs = [];
|
||||||
|
if (show.ok) {
|
||||||
|
const line = show.out.split('\n').find((l) => l.startsWith(publicKey + '\t'));
|
||||||
|
if (line) {
|
||||||
|
allowedIPs = (line.split('\t')[1] || '').split(/\s+/).filter((ip) => ip && ip !== '(none)');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tryRun('wg', ['set', name, 'peer', publicKey, 'remove']);
|
||||||
|
|
||||||
|
for (const cidr of allowedIPs) {
|
||||||
|
tryRun('ip', ['route', 'del', cidr, 'dev', name]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
kernelWireguardAvailable,
|
||||||
|
ensureInterface,
|
||||||
|
setPrivateKey,
|
||||||
|
setAddress,
|
||||||
|
setPeer,
|
||||||
|
removePeer,
|
||||||
|
interfaceExists
|
||||||
|
};
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// WireGuard key generation using Node's built-in crypto (X25519).
|
||||||
|
// WireGuard keys ARE X25519 keys in raw base64 — no wg binary needed.
|
||||||
|
|
||||||
|
const crypto = require('crypto');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a WireGuard keypair.
|
||||||
|
* @returns {{ privateKey: string, publicKey: string }} — base64 encoded
|
||||||
|
*/
|
||||||
|
function generateKeypair() {
|
||||||
|
const { privateKey, publicKey } = crypto.generateKeyPairSync('x25519', {
|
||||||
|
publicKeyEncoding: { type: 'spki', format: 'der' },
|
||||||
|
privateKeyEncoding: { type: 'pkcs8', format: 'der' },
|
||||||
|
});
|
||||||
|
|
||||||
|
// DER-encoded PKCS#8 private key: raw 32-byte X25519 scalar starts at offset 16
|
||||||
|
const rawPriv = privateKey.slice(16, 48);
|
||||||
|
// DER-encoded SPKI public key: raw 32-byte point starts at offset 12
|
||||||
|
const rawPub = publicKey.slice(12, 44);
|
||||||
|
|
||||||
|
return {
|
||||||
|
privateKey: rawPriv.toString('base64'),
|
||||||
|
publicKey: rawPub.toString('base64'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { generateKeypair };
|
||||||
@@ -1,5 +1,46 @@
|
|||||||
<%- include('top') %>
|
<%- include('top') %>
|
||||||
<script type="text/javascript">app.auth.forceLogin();</script>
|
<script type="text/javascript">app.auth.forceLogin(['admin', 'app_jump_admin']);</script>
|
||||||
|
|
||||||
|
<div class="container mt-4">
|
||||||
|
<div class="row g-3 mb-4">
|
||||||
|
<div class="col-6 col-md-3">
|
||||||
|
<div class="card shadow-sm text-center"><div class="card-body">
|
||||||
|
<div class="display-6" id="stat-active">–</div>
|
||||||
|
<div class="text-muted small text-uppercase">Active sessions</div>
|
||||||
|
</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-md-3">
|
||||||
|
<div class="card shadow-sm text-center"><div class="card-body">
|
||||||
|
<div class="display-6" id="stat-total">–</div>
|
||||||
|
<div class="text-muted small text-uppercase">Total connections</div>
|
||||||
|
</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-md-3">
|
||||||
|
<div class="card shadow-sm text-center"><div class="card-body">
|
||||||
|
<div class="display-6 text-danger" id="stat-fail">–</div>
|
||||||
|
<div class="text-muted small text-uppercase">Failed</div>
|
||||||
|
</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-md-3">
|
||||||
|
<div class="card shadow-sm text-center"><div class="card-body">
|
||||||
|
<div class="display-6" id="stat-users">–</div>
|
||||||
|
<div class="text-muted small text-uppercase">Users seen</div>
|
||||||
|
</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-3 mb-4">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card shadow-sm"><div class="card-header"><i class="fa-solid fa-server me-1"></i> Top hosts</div>
|
||||||
|
<table class="table table-sm mb-0"><tbody id="top-hosts"></tbody></table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card shadow-sm"><div class="card-header"><i class="fa-solid fa-user me-1"></i> Top users</div>
|
||||||
|
<table class="table table-sm mb-0"><tbody id="top-users"></tbody></table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="card shadow-sm">
|
<div class="card shadow-sm">
|
||||||
<div class="card-header"><i class="fa-solid fa-clipboard-list me-1"></i> Audit log</div>
|
<div class="card-header"><i class="fa-solid fa-clipboard-list me-1"></i> Audit log</div>
|
||||||
@@ -29,8 +70,28 @@
|
|||||||
<button class="btn btn-sm btn-outline-secondary" id="next" onclick="changePage(1)">next →</button>
|
<button class="btn btn-sm btn-outline-secondary" id="next" onclick="changePage(1)">next →</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script type="text/javascript">
|
<script type="text/javascript">
|
||||||
|
function rows(sel, list){
|
||||||
|
var $b = $(sel).empty();
|
||||||
|
if(!list || !list.length){ $b.append('<tr><td class="text-muted">No data.</td></tr>'); return; }
|
||||||
|
list.forEach(function(x){
|
||||||
|
$b.append('<tr><td>' + app.jump.esc(x.name) + '</td><td class="text-end">' + x.count + '</td></tr>');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function loadMetrics(){
|
||||||
|
app.jump.metrics(function(error, data){
|
||||||
|
if(error || !data) return;
|
||||||
|
$('#stat-active').text(data.active);
|
||||||
|
$('#stat-total').text(data.total);
|
||||||
|
$('#stat-fail').text(data.fail);
|
||||||
|
$('#stat-users').text((data.topUsers || []).length);
|
||||||
|
rows('#top-hosts', data.topHosts);
|
||||||
|
rows('#top-users', data.topUsers);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
var page = 0;
|
var page = 0;
|
||||||
function filters(){ return {page: page, uid: $('#f-uid').val(), target: $('#f-target').val(), status: $('#f-status').val()}; }
|
function filters(){ return {page: page, uid: $('#f-uid').val(), target: $('#f-target').val(), status: $('#f-status').val()}; }
|
||||||
function applyFilters(){ page = 0; load(); }
|
function applyFilters(){ page = 0; load(); }
|
||||||
@@ -57,6 +118,9 @@
|
|||||||
$('#next').prop('disabled', (page + 1) * size >= total);
|
$('#next').prop('disabled', (page + 1) * size >= total);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
$(document).ready(load);
|
$(document).ready(function(){
|
||||||
|
loadMetrics();
|
||||||
|
load();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
<%- include('bottom') %>
|
<%- include('bottom') %>
|
||||||
|
|||||||
@@ -1,33 +1,7 @@
|
|||||||
<%- include('top') %>
|
<%- include('top') %>
|
||||||
<script type="text/javascript">app.auth.forceLogin();</script>
|
<script type="text/javascript">app.auth.forceLogin();</script>
|
||||||
|
|
||||||
<div class="row g-3 mb-4">
|
<div class="container mt-4">
|
||||||
<div class="col-6 col-md-3">
|
|
||||||
<div class="card shadow-sm text-center"><div class="card-body">
|
|
||||||
<div class="display-6" id="stat-active">–</div>
|
|
||||||
<div class="text-muted small text-uppercase">Active sessions</div>
|
|
||||||
</div></div>
|
|
||||||
</div>
|
|
||||||
<div class="col-6 col-md-3">
|
|
||||||
<div class="card shadow-sm text-center"><div class="card-body">
|
|
||||||
<div class="display-6" id="stat-total">–</div>
|
|
||||||
<div class="text-muted small text-uppercase">Total connections</div>
|
|
||||||
</div></div>
|
|
||||||
</div>
|
|
||||||
<div class="col-6 col-md-3">
|
|
||||||
<div class="card shadow-sm text-center"><div class="card-body">
|
|
||||||
<div class="display-6 text-danger" id="stat-fail">–</div>
|
|
||||||
<div class="text-muted small text-uppercase">Failed</div>
|
|
||||||
</div></div>
|
|
||||||
</div>
|
|
||||||
<div class="col-6 col-md-3">
|
|
||||||
<div class="card shadow-sm text-center"><div class="card-body">
|
|
||||||
<div class="display-6" id="stat-users">–</div>
|
|
||||||
<div class="text-muted small text-uppercase">Users seen</div>
|
|
||||||
</div></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row g-3 mb-4">
|
<div class="row g-3 mb-4">
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<div class="card shadow-sm">
|
<div class="card shadow-sm">
|
||||||
@@ -53,20 +27,12 @@
|
|||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<div class="card shadow-sm">
|
<div class="card shadow-sm">
|
||||||
<div class="card-header"><i class="fa-solid fa-network-wired me-1"></i> <span id="my-hosts-title">Hosts you can reach</span></div>
|
<div class="card-header"><i class="fa-solid fa-network-wired me-1"></i> <span id="my-hosts-title">Hosts you can reach</span></div>
|
||||||
<table class="table table-sm mb-0"><tbody id="my-hosts"></tbody></table>
|
<div class="table-responsive">
|
||||||
</div>
|
<table class="table table-sm mb-0">
|
||||||
</div>
|
<thead><tr><th>Host</th><th>Slug</th><th class="text-end">Address</th><th>Last connection</th><th>Last failed connection</th><th></th></tr></thead>
|
||||||
</div>
|
<tbody id="my-hosts"></tbody>
|
||||||
|
</table>
|
||||||
<div class="row g-3 mb-4">
|
</div>
|
||||||
<div class="col-md-6">
|
|
||||||
<div class="card shadow-sm"><div class="card-header"><i class="fa-solid fa-server me-1"></i> Top hosts</div>
|
|
||||||
<table class="table table-sm mb-0"><tbody id="top-hosts"></tbody></table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-6">
|
|
||||||
<div class="card shadow-sm"><div class="card-header"><i class="fa-solid fa-user me-1"></i> Top users</div>
|
|
||||||
<table class="table table-sm mb-0"><tbody id="top-users"></tbody></table>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -117,15 +83,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script type="text/javascript">
|
<script type="text/javascript">
|
||||||
function rows(sel, list){
|
|
||||||
var $b = $(sel).empty();
|
|
||||||
if(!list || !list.length){ $b.append('<tr><td class="text-muted">No data.</td></tr>'); return; }
|
|
||||||
list.forEach(function(x){
|
|
||||||
$b.append('<tr><td>' + app.jump.esc(x.name) + '</td><td class="text-end">' + x.count + '</td></tr>');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// The web UI and the SSH front door share a hostname, just not a port.
|
// The web UI and the SSH front door share a hostname, just not a port.
|
||||||
var SSH_PORT = <%- JSON.stringify(sshPort) %>;
|
var SSH_PORT = <%- JSON.stringify(sshPort) %>;
|
||||||
function sshCommand(target){
|
function sshCommand(target){
|
||||||
@@ -151,9 +111,16 @@
|
|||||||
hosts.forEach(function(h){
|
hosts.forEach(function(h){
|
||||||
var addr = (h.metadata && (h.metadata.ip || h.metadata.address)) || '';
|
var addr = (h.metadata && (h.metadata.ip || h.metadata.address)) || '';
|
||||||
var rowId = 'host-cmd-' + h.slug.replace(/[^a-zA-Z0-9_-]/g, '');
|
var rowId = 'host-cmd-' + h.slug.replace(/[^a-zA-Z0-9_-]/g, '');
|
||||||
$b.append('<tr><td>' + app.jump.esc(h.displayName || h.name || h.slug) + '</td>'
|
// Green: a session to this host is live right now. Yellow: the most
|
||||||
|
// recent attempt to this host failed (and none is currently live).
|
||||||
|
var rowClass = h.connected ? 'table-success'
|
||||||
|
: (h.lastFailed && (!h.lastConnected || h.lastFailed > h.lastConnected)) ? 'table-warning'
|
||||||
|
: '';
|
||||||
|
$b.append('<tr class="' + rowClass + '"><td>' + app.jump.esc(h.displayName || h.name || h.slug) + '</td>'
|
||||||
+ '<td class="text-muted small">' + app.jump.esc(h.slug) + '</td>'
|
+ '<td class="text-muted small">' + app.jump.esc(h.slug) + '</td>'
|
||||||
+ '<td class="text-end text-muted small">' + app.jump.esc(addr) + '</td>'
|
+ '<td class="text-end text-muted small">' + app.jump.esc(addr) + '</td>'
|
||||||
|
+ '<td class="small">' + (h.lastConnected ? app.jump.fmtTime(h.lastConnected) : '—') + '</td>'
|
||||||
|
+ '<td class="small">' + (h.lastFailed ? app.jump.fmtTime(h.lastFailed) : '—') + '</td>'
|
||||||
+ '<td class="text-end">'
|
+ '<td class="text-end">'
|
||||||
+ '<input type="hidden" id="' + rowId + '" value="' + app.jump.esc(sshCommand(h.slug)) + '">'
|
+ '<input type="hidden" id="' + rowId + '" value="' + app.jump.esc(sshCommand(h.slug)) + '">'
|
||||||
+ '<button class="btn btn-sm btn-outline-secondary" onclick="copyFieldValue(\'#' + rowId + '\')" title="Copy quick-jump command"><i class="fa-solid fa-copy"></i></button>'
|
+ '<button class="btn btn-sm btn-outline-secondary" onclick="copyFieldValue(\'#' + rowId + '\')" title="Copy quick-jump command"><i class="fa-solid fa-copy"></i></button>'
|
||||||
@@ -302,17 +269,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
$(document).ready(async function(){
|
$(document).ready(async function(){
|
||||||
app.jump.metrics(function(error, data){
|
|
||||||
if(error || !data) return;
|
|
||||||
$('#stat-active').text(data.active);
|
|
||||||
$('#stat-total').text(data.total);
|
|
||||||
$('#stat-fail').text(data.fail);
|
|
||||||
$('#stat-users').text((data.topUsers || []).length);
|
|
||||||
rows('#top-hosts', data.topHosts);
|
|
||||||
rows('#top-users', data.topUsers);
|
|
||||||
});
|
|
||||||
await app.auth.loadUser();
|
await app.auth.loadUser();
|
||||||
if(app.auth.isAdmin()) $('#my-hosts-title').text('All hosts');
|
if(app.auth.isAdmin()) $('#my-hosts-title').text('My hosts');
|
||||||
$('#quick-jump-cmd').val(sshCommand());
|
$('#quick-jump-cmd').val(sshCommand());
|
||||||
app.jump.hosts(function(error, data){
|
app.jump.hosts(function(error, data){
|
||||||
if(error) return hostRows('#my-hosts', []);
|
if(error) return hostRows('#my-hosts', []);
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<%- include('top') %>
|
||||||
|
|
||||||
|
<div class="container mt-5">
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-md-6 text-center">
|
||||||
|
<div class="mb-4">
|
||||||
|
<i class="fa-solid fa-triangle-exclamation text-warning" style="font-size: 4rem;"></i>
|
||||||
|
</div>
|
||||||
|
<h1 class="display-4 fw-bold text-dark"><%= error.status || 500 %></h1>
|
||||||
|
<h3 class="mb-3 text-secondary"><%= error.message || 'Something went wrong' %></h3>
|
||||||
|
<p class="text-muted mb-4">
|
||||||
|
<% if (error.status === 404) { %>
|
||||||
|
The page you are looking for doesn't exist or has been moved.
|
||||||
|
<% } else { %>
|
||||||
|
An unexpected error occurred. Please try again later.
|
||||||
|
<% } %>
|
||||||
|
</p>
|
||||||
|
<a href="/" class="btn btn-primary shadow-sm px-4 py-2">
|
||||||
|
<i class="fa-solid fa-house me-2"></i>Return to Home
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%- include('bottom') %>
|
||||||
@@ -90,7 +90,7 @@
|
|||||||
<hr />
|
<hr />
|
||||||
<div class="d-grid">
|
<div class="d-grid">
|
||||||
<a href="/api/auth/oidc/start" class="btn btn-outline-primary">
|
<a href="/api/auth/oidc/start" class="btn btn-outline-primary">
|
||||||
<i class="fa-solid fa-id-badge"></i> Log in with SSO
|
<i class="fa-solid fa-id-badge"></i> Log in with Jump
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<% } %>
|
<% } %>
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
<%- include('top') %>
|
||||||
|
<script type="text/javascript">app.auth.forceLogin();</script>
|
||||||
|
|
||||||
|
<div class="container mt-4 mb-5">
|
||||||
|
<div class="d-flex align-items-center justify-content-between mb-4">
|
||||||
|
<div>
|
||||||
|
<h3 class="mb-1"><i class="fa-solid fa-diagram-project text-primary me-2"></i>Gateway Mesh</h3>
|
||||||
|
<p class="text-muted small mb-0">Site-to-site WireGuard tunnels between theta-gateway instances. Different from <a href="/wireguard">WireGuard</a>, which manages individual roaming-client peers and exit nodes.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card shadow-sm mb-4">
|
||||||
|
<div class="card-header bg-dark text-light border-secondary">
|
||||||
|
<i class="fa-solid fa-server me-2"></i>This Gateway
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="p-3 border rounded bg-light dark-bg-subtle">
|
||||||
|
<div class="text-muted small fw-semibold">MESH INTERFACE</div>
|
||||||
|
<div class="font-monospace fw-bold text-primary mt-1" id="mesh-iface">Loading...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="p-3 border rounded bg-light dark-bg-subtle">
|
||||||
|
<div class="text-muted small fw-semibold">WIREGUARD MODE</div>
|
||||||
|
<div class="fw-bold mt-1" id="mesh-kernel-mode">Loading...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="p-3 border rounded bg-light dark-bg-subtle">
|
||||||
|
<div class="text-muted small fw-semibold">MESHED GATEWAYS</div>
|
||||||
|
<div class="fw-bold mt-1" id="mesh-gateway-count">Loading...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-4">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card shadow-sm h-100">
|
||||||
|
<div class="card-header"><i class="fa-solid fa-key me-2"></i>Mint a Join Token</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="small text-muted">Give this to a new gateway so it can join this one's mesh (single-use, expires in 15 minutes). It calls this gateway's <code>/api/mesh/register</code> with it.</p>
|
||||||
|
<button class="btn btn-sm btn-success" onclick="mintMeshJoinToken()"><i class="fa-solid fa-plus me-1"></i> Mint Join Token</button>
|
||||||
|
<div id="mesh-join-token-result" class="mt-2"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card shadow-sm h-100">
|
||||||
|
<div class="card-header"><i class="fa-solid fa-right-to-bracket me-2"></i>Join a Remote Gateway's Mesh</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="small text-muted">Have a join token from another gateway? Use it here to mesh THIS gateway into that one.</p>
|
||||||
|
<div class="mb-2">
|
||||||
|
<input type="text" id="mesh-remote-endpoint" class="form-control form-control-sm" placeholder="Remote gateway URL (e.g. https://jump.master.example.com)">
|
||||||
|
</div>
|
||||||
|
<div class="mb-2">
|
||||||
|
<input type="text" id="mesh-remote-token" class="form-control form-control-sm font-monospace" placeholder="Join token (mjt_...)">
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-sm btn-primary" onclick="joinRemoteMesh()"><i class="fa-solid fa-link me-1"></i> Join</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card shadow-sm mt-4">
|
||||||
|
<div class="card-header"><i class="fa-solid fa-network-wired me-2"></i>Meshed Gateways</div>
|
||||||
|
<div class="card-body p-0" id="mesh-gateways-wrap">
|
||||||
|
<div class="text-center py-4 text-muted">Loading...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%- include('bottom') %>
|
||||||
|
<script type="text/javascript">
|
||||||
|
function esc(s) {
|
||||||
|
return String(s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||||
|
}
|
||||||
|
|
||||||
|
$(document).ready(function(){
|
||||||
|
loadMeshStatus();
|
||||||
|
});
|
||||||
|
|
||||||
|
function loadMeshStatus() {
|
||||||
|
app.api.get('mesh/gateways', function(err, data){
|
||||||
|
if (err || !data) {
|
||||||
|
$('#mesh-iface').text('(unavailable)');
|
||||||
|
$('#mesh-kernel-mode').text('(unavailable)');
|
||||||
|
$('#mesh-gateway-count').text('—');
|
||||||
|
$('#mesh-gateways-wrap').html('<div class="text-center py-4 text-danger">Could not load mesh status: ' + esc((err && err.message) || 'unknown error') + '</div>');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$('#mesh-iface').text(data.iface || 'wg-mesh');
|
||||||
|
$('#mesh-kernel-mode').html(data.kernelWireguard
|
||||||
|
? '<span class="badge bg-success"><i class="fa-solid fa-microchip me-1"></i> In-kernel</span>'
|
||||||
|
: '<span class="badge bg-warning text-dark"><i class="fa-solid fa-layer-group me-1"></i> Userspace (wireguard-go)</span>');
|
||||||
|
var gateways = data.gateways || [];
|
||||||
|
$('#mesh-gateway-count').text(gateways.length + ' gateway' + (gateways.length === 1 ? '' : 's'));
|
||||||
|
renderGatewaysTable(gateways);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderGatewaysTable(gateways) {
|
||||||
|
var $w = $('#mesh-gateways-wrap');
|
||||||
|
if (!gateways.length) {
|
||||||
|
$w.html('<div class="text-center py-4 text-muted"><i class="fa-solid fa-diagram-project me-2"></i>No meshed gateways yet.<br><small>Mint a join token above and have another gateway join, or join this one into a remote gateway\'s mesh.</small></div>');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var html = '<table class="table table-striped table-hover mb-0 align-middle"><thead><tr>'
|
||||||
|
+ '<th>Site</th><th>Mesh Index</th><th>Mesh Address</th><th>Endpoint</th><th>Public Key</th><th>Last Seen</th><th class="text-end">Actions</th>'
|
||||||
|
+ '</tr></thead><tbody>';
|
||||||
|
gateways.forEach(function(g){
|
||||||
|
var isSelf = g.siteSlug === '(self)';
|
||||||
|
html += '<tr' + (isSelf ? ' class="table-active"' : '') + '>'
|
||||||
|
+ '<td>' + (isSelf ? '<em>This gateway</em>' : esc(g.siteSlug || '(unlabeled)')) + '</td>'
|
||||||
|
+ '<td><span class="badge bg-primary">' + esc(g.meshIndex) + '</span></td>'
|
||||||
|
+ '<td><code class="small">172.24.' + esc(g.meshIndex) + '.0/24</code></td>'
|
||||||
|
+ '<td><code class="small text-primary">' + esc(g.endpoint || '—') + '</code></td>'
|
||||||
|
+ '<td><code class="small text-truncate d-inline-block" style="max-width:220px;" title="' + esc(g.publicKey) + '">' + esc(g.publicKey) + '</code></td>'
|
||||||
|
+ '<td class="small text-muted">' + (g.lastSeenAt ? new Date(Number(g.lastSeenAt)).toLocaleString() : '—') + '</td>'
|
||||||
|
+ '<td class="text-end">' + (isSelf ? '' :
|
||||||
|
'<button class="btn btn-sm btn-outline-danger" onclick="removeMeshGateway(\'' + esc(g.id) + '\', \'' + esc(g.siteSlug || g.id) + '\')" title="Remove peer"><i class="fa-solid fa-trash"></i></button>')
|
||||||
|
+ '</td>'
|
||||||
|
+ '</tr>';
|
||||||
|
});
|
||||||
|
html += '</tbody></table>';
|
||||||
|
$w.html(html);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeMeshGateway(id, label) {
|
||||||
|
var ok = await app.messages.confirm('Remove mesh peer "' + label + '"? This tears down the local WireGuard peer + routes. The other side keeps its half until removed there too.', $('#mesh-gateways-wrap'), 'danger');
|
||||||
|
if (!ok) return;
|
||||||
|
app.api.delete('mesh/gateways/' + id, function(err){
|
||||||
|
if (err) return app.messages.toast('Failed to remove gateway: ' + err.message, 'danger');
|
||||||
|
app.messages.toast('Gateway removed', 'success');
|
||||||
|
loadMeshStatus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function mintMeshJoinToken() {
|
||||||
|
app.api.post('mesh/join-tokens', {}, function(err, data){
|
||||||
|
if (err) return app.messages.toast('Failed to mint join token: ' + err.message, 'danger');
|
||||||
|
$('#mesh-join-token-result').html(
|
||||||
|
'<div class="alert alert-success small mb-0">' +
|
||||||
|
'<strong>Shown once — copy it now:</strong><br>' +
|
||||||
|
'<code class="user-select-all">' + esc(data.token) + '</code>' +
|
||||||
|
'<br><span class="text-muted">Expires in ' + Math.round((data.expiresInSeconds || 0) / 60) + ' minutes.</span>' +
|
||||||
|
'</div>'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function joinRemoteMesh() {
|
||||||
|
var remoteEndpoint = ($('#mesh-remote-endpoint').val() || '').trim();
|
||||||
|
var joinToken = ($('#mesh-remote-token').val() || '').trim();
|
||||||
|
if (!remoteEndpoint || !joinToken) {
|
||||||
|
return app.messages.toast('Enter the remote gateway URL and a join token', 'warning');
|
||||||
|
}
|
||||||
|
app.api.post('mesh/join', { remoteEndpoint: remoteEndpoint, joinToken: joinToken }, function(err, data){
|
||||||
|
if (err) return app.messages.toast('Join failed: ' + err.message, 'danger');
|
||||||
|
app.messages.toast('Meshed successfully — this gateway is mesh index ' + data.meshIndex + ', peer is index ' + data.peerMeshIndex, 'success');
|
||||||
|
loadMeshStatus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
<%- include('top') %>
|
<%- include('top') %>
|
||||||
<script type="text/javascript">app.auth.forceLogin();</script>
|
<script type="text/javascript">app.auth.forceLogin();</script>
|
||||||
|
|
||||||
|
<div class="container mt-4">
|
||||||
<div class="card shadow-sm">
|
<div class="card shadow-sm">
|
||||||
<div class="card-header d-flex justify-content-between align-items-center">
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
<span><i class="fa-solid fa-plug-circle-bolt me-1"></i> Active sessions</span>
|
<span><i class="fa-solid fa-plug-circle-bolt me-1"></i> Active sessions</span>
|
||||||
@@ -13,6 +14,7 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script type="text/javascript">
|
<script type="text/javascript">
|
||||||
function loadSessions(){
|
function loadSessions(){
|
||||||
|
|||||||
@@ -49,7 +49,7 @@
|
|||||||
</ul>
|
</ul>
|
||||||
<div class="form-inline mt-2 mt-md-0">
|
<div class="form-inline mt-2 mt-md-0">
|
||||||
<% if(ui.profileUrl){ %>
|
<% if(ui.profileUrl){ %>
|
||||||
<a id="cl-username" class="navbar-text text-light me-3" href="<%- ui.profileUrl %>" style="display: none;">
|
<a id="cl-username" class="navbar-text text-light me-3 text-decoration-none" href="<%- ui.profileUrl %>" style="display: none;">
|
||||||
<i class="fa-solid fa-user me-1"></i><span id="cl-username-text"></span>
|
<i class="fa-solid fa-user me-1"></i><span id="cl-username-text"></span>
|
||||||
</a>
|
</a>
|
||||||
<% } else { %>
|
<% } else { %>
|
||||||
@@ -82,16 +82,24 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script type="text/javascript">
|
<script type="text/javascript">
|
||||||
|
// --sw-content-offset tracks the same height as #spa-shell's margin-top
|
||||||
|
// (fixed navbar, plus the update banner while it's shown), so any
|
||||||
|
// in-page sticky element (e.g. a sticky search/sort bar) can offset
|
||||||
|
// itself below both fixed elements via `top: var(--sw-content-offset)`
|
||||||
|
// instead of colliding with them at the viewport's true top:0.
|
||||||
function showUpdateBanner(){
|
function showUpdateBanner(){
|
||||||
let $nav = $('nav.fixed-top');
|
let $nav = $('nav.fixed-top');
|
||||||
let $banner = $('#update-banner');
|
let $banner = $('#update-banner');
|
||||||
$banner.css('top', $nav.outerHeight() + 'px').show();
|
$banner.css('top', $nav.outerHeight() + 'px').show();
|
||||||
$('#spa-shell').css('margin-top', ($nav.outerHeight() + $banner.outerHeight()) + 'px');
|
let offset = $nav.outerHeight() + $banner.outerHeight();
|
||||||
|
$('#spa-shell').css('margin-top', offset + 'px');
|
||||||
|
document.documentElement.style.setProperty('--sw-content-offset', offset + 'px');
|
||||||
}
|
}
|
||||||
|
|
||||||
function dismissUpdateBanner(){
|
function dismissUpdateBanner(){
|
||||||
$('#update-banner').hide();
|
$('#update-banner').hide();
|
||||||
$('#spa-shell').css('margin-top', '');
|
$('#spa-shell').css('margin-top', '');
|
||||||
|
document.documentElement.style.setProperty('--sw-content-offset', $('nav.fixed-top').outerHeight() + 'px');
|
||||||
sessionStorage.setItem('update-banner-dismissed', '1');
|
sessionStorage.setItem('update-banner-dismissed', '1');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,457 @@
|
|||||||
|
<%- include('top') %>
|
||||||
|
<script type="text/javascript">app.auth.forceLogin();</script>
|
||||||
|
|
||||||
|
<div class="container mt-4 mb-5">
|
||||||
|
<!-- Page Header -->
|
||||||
|
<div class="d-flex align-items-center justify-content-between mb-4">
|
||||||
|
<div>
|
||||||
|
<h3 class="mb-1"><i class="fa-solid fa-shield-halved text-primary me-2"></i>WireGuard Gateway</h3>
|
||||||
|
<p class="text-muted small mb-0">Manage VPN exit nodes, client peer profiles, QR codes, and routing configurations.</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button class="btn btn-outline-primary btn-sm me-2" onclick="openAddSiteModal()"><i class="fa-solid fa-plus me-1"></i> Add Exit Node</button>
|
||||||
|
<button class="btn btn-primary btn-sm" onclick="openAddPeerModal()"><i class="fa-solid fa-user-plus me-1"></i> Add Client Peer</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Gateway Gateway Info Card -->
|
||||||
|
<div class="card shadow-sm mb-4">
|
||||||
|
<div class="card-header bg-dark text-light border-secondary">
|
||||||
|
<i class="fa-solid fa-server me-2"></i>Gateway Status & Base Configuration
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="p-3 border rounded bg-light dark-bg-subtle">
|
||||||
|
<div class="text-muted small fw-semibold">ENDPOINT ADDRESS</div>
|
||||||
|
<div class="font-monospace fw-bold text-primary mt-1" id="gw-endpoint">Loading...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="p-3 border rounded bg-light dark-bg-subtle">
|
||||||
|
<div class="text-muted small fw-semibold">SERVER PUBLIC KEY</div>
|
||||||
|
<div class="font-monospace text-truncate mt-1" id="gw-pubkey" style="max-width: 100%;">Loading...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="p-3 border rounded bg-light dark-bg-subtle">
|
||||||
|
<div class="text-muted small fw-semibold">DNS SERVERS</div>
|
||||||
|
<div class="font-monospace mt-1" id="gw-dns">Loading...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Client Peers Table Card -->
|
||||||
|
<div class="card shadow-sm mb-4" id="peers-card">
|
||||||
|
<div class="card-header bg-dark text-light border-secondary d-flex justify-content-between align-items-center">
|
||||||
|
<span><i class="fa-solid fa-laptop me-2"></i>Client Devices & Peer Profiles</span>
|
||||||
|
<button class="btn btn-sm btn-outline-light" onclick="loadPeers()"><i class="fa-solid fa-rotate me-1"></i> Refresh</button>
|
||||||
|
</div>
|
||||||
|
<div class="card-header actionMessage" style="display:none"></div>
|
||||||
|
<div id="peers-table-wrap" class="table-responsive">
|
||||||
|
<div class="text-center py-4 text-muted"><i class="fa-solid fa-spinner fa-spin me-2"></i>Loading peers...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Exit Nodes Table Card -->
|
||||||
|
<div class="card shadow-sm mb-4" id="sites-card">
|
||||||
|
<div class="card-header bg-dark text-light border-secondary d-flex justify-content-between align-items-center">
|
||||||
|
<span><i class="fa-solid fa-globe me-2"></i>Site Exit Nodes (Home / Remote Subnets)</span>
|
||||||
|
<button class="btn btn-sm btn-outline-light" onclick="loadSites()"><i class="fa-solid fa-rotate me-1"></i> Refresh</button>
|
||||||
|
</div>
|
||||||
|
<div class="card-header actionMessage" style="display:none"></div>
|
||||||
|
<div id="sites-table-wrap" class="table-responsive">
|
||||||
|
<div class="text-center py-4 text-muted"><i class="fa-solid fa-spinner fa-spin me-2"></i>Loading exit nodes...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal: Add / Edit Exit Node -->
|
||||||
|
<div class="modal fade" id="wg-site-modal" tabindex="-1" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-dialog-centered">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="wg-site-modal-title"><i class="fa-solid fa-globe me-2"></i>Add Exit Node</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<input type="hidden" id="site-edit-id">
|
||||||
|
<div id="site-modal-msg" style="display:none;" class="alert mb-3"></div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label fw-bold small">Node / Site Name</label>
|
||||||
|
<input type="text" class="form-control" id="site-name" placeholder="e.g. 718it-home-gateway">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label fw-bold small">Endpoint IP / Hostname & Port</label>
|
||||||
|
<input type="text" class="form-control font-monospace" id="site-endpoint" placeholder="e.g. 203.0.113.5:51820">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label fw-bold small">WireGuard Public Key</label>
|
||||||
|
<input type="text" class="form-control font-monospace" id="site-pubkey" placeholder="Base64 public key">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label fw-bold small">Routable Subnet (CIDR)</label>
|
||||||
|
<input type="text" class="form-control font-monospace" id="site-subnet" placeholder="e.g. 192.168.1.0/24 or 0.0.0.0/0">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3 form-check">
|
||||||
|
<input type="checkbox" class="form-check-input" id="site-exitall">
|
||||||
|
<label class="form-check-label small" for="site-exitall">Default Exit Node for Full Internet Traffic</label>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label fw-bold small">Optional Notes</label>
|
||||||
|
<input type="text" class="form-control" id="site-note" placeholder="Site location, router hardware, etc.">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||||
|
<button type="button" class="btn btn-primary" onclick="submitSite()"><span id="site-submit-label">Save Exit Node</span></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal: Add Client Peer -->
|
||||||
|
<div class="modal fade" id="wg-peer-modal" tabindex="-1" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-dialog-centered">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title"><i class="fa-solid fa-user-plus me-2"></i>Create Client Peer Profile</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div id="peer-modal-msg" style="display:none;" class="alert mb-3"></div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label fw-bold small">Device / Client Name</label>
|
||||||
|
<input type="text" class="form-control" id="peer-name" placeholder="e.g. william-phone or laptop-work">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label fw-bold small">Default Exit Routing</label>
|
||||||
|
<select class="form-select" id="peer-exit"></select>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label fw-bold small">Notes</label>
|
||||||
|
<input type="text" class="form-control" id="peer-note" placeholder="Device description">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||||
|
<button type="button" class="btn btn-primary" onclick="submitPeer()">Create Profile</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal: Edit Peer -->
|
||||||
|
<div class="modal fade" id="wg-peer-edit-modal" tabindex="-1" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-dialog-centered">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title"><i class="fa-solid fa-pen me-2"></i>Edit Client Peer</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<input type="hidden" id="peer-edit-id">
|
||||||
|
<div id="peer-edit-msg" style="display:none;" class="alert mb-3"></div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label fw-bold small">Device / Client Name</label>
|
||||||
|
<input type="text" class="form-control" id="peer-edit-name">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label fw-bold small">Exit Node Routing</label>
|
||||||
|
<select class="form-select" id="peer-edit-exit"></select>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label fw-bold small">Notes</label>
|
||||||
|
<input type="text" class="form-control" id="peer-edit-note">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||||
|
<button type="button" class="btn btn-primary" onclick="savePeerEdit()">Save Changes</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal: QR Code Preview -->
|
||||||
|
<div class="modal fade" id="wg-qr-modal" tabindex="-1" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-dialog-centered">
|
||||||
|
<div class="modal-content text-center">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="wg-qr-title"><i class="fa-solid fa-qrcode me-2"></i>WireGuard QR Code</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body py-4">
|
||||||
|
<div id="wg-qr-loading" class="py-4 text-muted"><i class="fa-solid fa-spinner fa-spin me-2"></i>Generating QR Code...</div>
|
||||||
|
<img id="wg-qr-img" src="" class="img-fluid rounded border p-2 bg-white shadow-sm" style="display:none; max-width: 260px;" alt="QR Code">
|
||||||
|
<p class="text-muted small mt-3 mb-0">Scan with the mobile WireGuard app or download the configuration file below.</p>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer justify-content-between">
|
||||||
|
<button type="button" class="btn btn-outline-primary" id="wg-download-conf-btn"><i class="fa-solid fa-download me-1"></i> Download .conf</button>
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script type="text/javascript">
|
||||||
|
var wgSites = [];
|
||||||
|
var wgPeers = [];
|
||||||
|
var siteModal, peerModal, peerEditModal, qrModal;
|
||||||
|
|
||||||
|
$(document).ready(function(){
|
||||||
|
siteModal = new bootstrap.Modal(document.getElementById('wg-site-modal'));
|
||||||
|
peerModal = new bootstrap.Modal(document.getElementById('wg-peer-modal'));
|
||||||
|
peerEditModal = new bootstrap.Modal(document.getElementById('wg-peer-edit-modal'));
|
||||||
|
qrModal = new bootstrap.Modal(document.getElementById('wg-qr-modal'));
|
||||||
|
loadAll();
|
||||||
|
});
|
||||||
|
|
||||||
|
function esc(s) {
|
||||||
|
return String(s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadAll() {
|
||||||
|
app.api.get('wireguard/gateway-info', function(err, info){
|
||||||
|
if(!err && info) {
|
||||||
|
$('#gw-endpoint').text(info.endpoint || '(not configured)');
|
||||||
|
$('#gw-pubkey').text(info.publicKey || '(not configured)');
|
||||||
|
$('#gw-dns').text(info.dns || '(not configured)');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
loadSites(function(){ loadPeers(); });
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadSites(cb) {
|
||||||
|
app.api.get('wireguard/sites', function(err, data){
|
||||||
|
wgSites = (!err && data && data.results) || [];
|
||||||
|
renderSitesTable();
|
||||||
|
if (cb) cb();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadPeers() {
|
||||||
|
app.api.get('wireguard/peers', function(err, data){
|
||||||
|
wgPeers = (!err && data && data.results) || [];
|
||||||
|
renderPeersTable();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSitesTable() {
|
||||||
|
var $w = $('#sites-table-wrap');
|
||||||
|
if (!wgSites.length) {
|
||||||
|
$w.html('<div class="text-center py-4 text-muted"><i class="fa-solid fa-globe me-2"></i>No exit nodes configured.<br><small>Add a site to give clients a routable exit point.</small></div>');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var html = '<table class="table table-striped table-hover mb-0 align-middle"><thead><tr>'
|
||||||
|
+ '<th>Name</th><th>Endpoint</th><th>Subnet</th><th class="text-end">Actions</th>'
|
||||||
|
+ '</tr></thead><tbody>';
|
||||||
|
wgSites.forEach(function(s){
|
||||||
|
var exitBadge = s.exitAll
|
||||||
|
? '<span class="badge bg-success ms-2">Full Exit</span>'
|
||||||
|
: '<span class="badge bg-info ms-2">Split</span>';
|
||||||
|
html += '<tr>'
|
||||||
|
+ '<td><strong class="text-dark dark-text-light">' + esc(s.name) + '</strong><br><span class="text-muted small">' + (s.siteId ? 'Site ' + esc(s.siteId) : '') + '</span></td>'
|
||||||
|
+ '<td><code class="small text-primary">' + esc(s.endpoint) + '</code></td>'
|
||||||
|
+ '<td><code class="small">' + esc(s.subnet || '—') + '</code>' + exitBadge + '</td>'
|
||||||
|
+ '<td class="text-end">'
|
||||||
|
+ '<button class="btn btn-sm btn-outline-secondary me-1" onclick="openEditSiteModal(\'' + esc(s.id) + '\')" title="Edit"><i class="fa-solid fa-pen"></i></button>'
|
||||||
|
+ '<button class="btn btn-sm btn-outline-danger" onclick="deleteSite(\'' + esc(s.id) + '\')" title="Remove"><i class="fa-solid fa-trash"></i></button>'
|
||||||
|
+ '</td></tr>';
|
||||||
|
});
|
||||||
|
html += '</tbody></table>';
|
||||||
|
$w.html(html);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPeersTable() {
|
||||||
|
var $w = $('#peers-table-wrap');
|
||||||
|
if (!wgPeers.length) {
|
||||||
|
$w.html('<div class="text-center py-4 text-muted"><i class="fa-solid fa-laptop me-2"></i>No client peers created yet.<br><small>Create a profile to generate a WireGuard QR code or .conf file.</small></div>');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var html = '<table class="table table-striped table-hover mb-0 align-middle"><thead><tr>'
|
||||||
|
+ '<th>Device / Peer Name</th><th>Assigned IP</th><th>Exit Node Routing</th><th>Public Key</th><th class="text-end">Actions</th>'
|
||||||
|
+ '</tr></thead><tbody>';
|
||||||
|
wgPeers.forEach(function(p){
|
||||||
|
html += '<tr>'
|
||||||
|
+ '<td><strong class="text-dark dark-text-light">' + esc(p.name) + '</strong>' + (p.note ? '<br><span class="text-muted small">' + esc(p.note) + '</span>' : '') + '</td>'
|
||||||
|
+ '<td><span class="badge bg-primary font-monospace">' + esc(p.assignedIP || 'Auto') + '</span></td>'
|
||||||
|
+ '<td>' + siteLabel(p.exitSiteId) + '</td>'
|
||||||
|
+ '<td><code class="small text-muted" title="' + esc(p.publicKey) + '">' + esc(p.publicKey.slice(0, 16)) + '...</code></td>'
|
||||||
|
+ '<td class="text-end">'
|
||||||
|
+ '<button class="btn btn-sm btn-outline-success me-1" onclick="showQr(\'' + esc(p.id) + '\',\'' + esc(p.name) + '\')" title="QR Code"><i class="fa-solid fa-qrcode"></i></button>'
|
||||||
|
+ '<button class="btn btn-sm btn-outline-info me-1" onclick="downloadConf(\'' + esc(p.id) + '\')" title="Download .conf"><i class="fa-solid fa-download"></i></button>'
|
||||||
|
+ '<button class="btn btn-sm btn-outline-secondary me-1" onclick="openEditPeerModal(\'' + esc(p.id) + '\')" title="Edit"><i class="fa-solid fa-pen"></i></button>'
|
||||||
|
+ '<button class="btn btn-sm btn-outline-danger" onclick="deletePeer(\'' + esc(p.id) + '\')" title="Remove"><i class="fa-solid fa-trash"></i></button>'
|
||||||
|
+ '</td></tr>';
|
||||||
|
});
|
||||||
|
html += '</tbody></table>';
|
||||||
|
$w.html(html);
|
||||||
|
}
|
||||||
|
|
||||||
|
function siteLabel(exitSiteId) {
|
||||||
|
if (!exitSiteId) return '<span class="badge bg-success">Full Tunnel (Gateway)</span>';
|
||||||
|
var site = wgSites.find(function(s){ return s.id === exitSiteId; });
|
||||||
|
return site
|
||||||
|
? '<span class="badge bg-purple text-light" style="background:#7c3aed;"><i class="fa-solid fa-location-dot me-1"></i>' + esc(site.name) + '</span>'
|
||||||
|
: '<span class="badge bg-secondary">Unknown</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function populateExitSelect(selectId, selectedId) {
|
||||||
|
var $sel = $('#' + selectId).empty();
|
||||||
|
$sel.append('<option value="">— Full tunnel via gateway (default) —</option>');
|
||||||
|
wgSites.forEach(function(s){
|
||||||
|
var opt = $('<option>').val(s.id).text(s.name + (s.endpoint ? ' (' + s.endpoint + ')' : ''));
|
||||||
|
if (s.id === selectedId) opt.prop('selected', true);
|
||||||
|
$sel.append(opt);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function openAddSiteModal() {
|
||||||
|
$('#site-edit-id').val('');
|
||||||
|
$('#site-name, #site-endpoint, #site-pubkey, #site-subnet, #site-note').val('');
|
||||||
|
$('#site-exitall').prop('checked', false);
|
||||||
|
$('#wg-site-modal-title').html('<i class="fa-solid fa-globe me-2"></i>Add Exit Node');
|
||||||
|
$('#site-submit-label').text('Add Exit Node');
|
||||||
|
$('#site-modal-msg').hide();
|
||||||
|
siteModal.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditSiteModal(id) {
|
||||||
|
var site = wgSites.find(function(s){ return s.id === id; });
|
||||||
|
if (!site) return;
|
||||||
|
$('#site-edit-id').val(site.id);
|
||||||
|
$('#site-name').val(site.name);
|
||||||
|
$('#site-endpoint').val(site.endpoint);
|
||||||
|
$('#site-pubkey').val(site.publicKey);
|
||||||
|
$('#site-subnet').val(site.subnet);
|
||||||
|
$('#site-note').val(site.note);
|
||||||
|
$('#site-exitall').prop('checked', !!site.exitAll);
|
||||||
|
$('#wg-site-modal-title').html('<i class="fa-solid fa-pen me-2"></i>Edit Exit Node');
|
||||||
|
$('#site-submit-label').text('Save Changes');
|
||||||
|
$('#site-modal-msg').hide();
|
||||||
|
siteModal.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitSite() {
|
||||||
|
var id = $('#site-edit-id').val();
|
||||||
|
var payload = {
|
||||||
|
name: $('#site-name').val().trim(),
|
||||||
|
endpoint: $('#site-endpoint').val().trim(),
|
||||||
|
publicKey: $('#site-pubkey').val().trim(),
|
||||||
|
subnet: $('#site-subnet').val().trim() || '0.0.0.0/0',
|
||||||
|
note: $('#site-note').val().trim(),
|
||||||
|
exitAll: $('#site-exitall').is(':checked'),
|
||||||
|
};
|
||||||
|
if (!payload.name || !payload.endpoint || !payload.publicKey) {
|
||||||
|
return showMsg('#site-modal-msg', 'Name, endpoint, and public key are required.', 'danger');
|
||||||
|
}
|
||||||
|
var path = id ? 'wireguard/sites/' + id : 'wireguard/sites';
|
||||||
|
var action = id ? app.api.patch : app.api.post;
|
||||||
|
action(path, payload, function(err){
|
||||||
|
if (err) return showMsg('#site-modal-msg', err.message || err, 'danger');
|
||||||
|
siteModal.hide();
|
||||||
|
loadSites(function(){ loadPeers(); });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteSite(id) {
|
||||||
|
var site = wgSites.find(function(s){ return s.id === id; });
|
||||||
|
if (!site) return;
|
||||||
|
var ok = await app.messages.confirm('Remove exit node "' + site.name + '"? Any peers using it will fall back to full tunnel.', $('#sites-card'), 'danger');
|
||||||
|
if (!ok) return;
|
||||||
|
app.api.delete('wireguard/sites/' + id, function(err){
|
||||||
|
if (err) return app.messages.action('Error: ' + (err.message || err), $('#sites-card'), 'danger');
|
||||||
|
loadSites(function(){ loadPeers(); });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function openAddPeerModal() {
|
||||||
|
$('#peer-name, #peer-note').val('');
|
||||||
|
populateExitSelect('peer-exit', '');
|
||||||
|
$('#peer-modal-msg').hide();
|
||||||
|
peerModal.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitPeer() {
|
||||||
|
var payload = {
|
||||||
|
name: $('#peer-name').val().trim(),
|
||||||
|
exitSiteId: $('#peer-exit').val(),
|
||||||
|
note: $('#peer-note').val().trim(),
|
||||||
|
};
|
||||||
|
if (!payload.name) return showMsg('#peer-modal-msg', 'Device name is required.', 'danger');
|
||||||
|
app.api.post('wireguard/peers', payload, function(err, peer){
|
||||||
|
if (err) return showMsg('#peer-modal-msg', err.message || err, 'danger');
|
||||||
|
peerModal.hide();
|
||||||
|
loadPeers();
|
||||||
|
if (peer && peer.id) showQr(peer.id, peer.name);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditPeerModal(id) {
|
||||||
|
var peer = wgPeers.find(function(p){ return p.id === id; });
|
||||||
|
if (!peer) return;
|
||||||
|
$('#peer-edit-id').val(peer.id);
|
||||||
|
$('#peer-edit-name').val(peer.name);
|
||||||
|
$('#peer-edit-note').val(peer.note || '');
|
||||||
|
populateExitSelect('peer-edit-exit', peer.exitSiteId);
|
||||||
|
$('#peer-edit-msg').hide();
|
||||||
|
peerEditModal.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
function savePeerEdit() {
|
||||||
|
var id = $('#peer-edit-id').val();
|
||||||
|
var payload = {
|
||||||
|
name: $('#peer-edit-name').val().trim(),
|
||||||
|
exitSiteId: $('#peer-edit-exit').val(),
|
||||||
|
note: $('#peer-edit-note').val().trim(),
|
||||||
|
};
|
||||||
|
app.api.patch('wireguard/peers/' + id, payload, function(err){
|
||||||
|
if (err) return showMsg('#peer-edit-msg', err.message || err, 'danger');
|
||||||
|
peerEditModal.hide();
|
||||||
|
loadPeers();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deletePeer(id) {
|
||||||
|
var peer = wgPeers.find(function(p){ return p.id === id; });
|
||||||
|
if (!peer) return;
|
||||||
|
var ok = await app.messages.confirm('Remove peer "' + peer.name + '"? Their VPN access will be revoked immediately.', $('#peers-card'), 'danger');
|
||||||
|
if (!ok) return;
|
||||||
|
app.api.delete('wireguard/peers/' + id, function(err){
|
||||||
|
if (err) return app.messages.action('Error: ' + (err.message || err), $('#peers-card'), 'danger');
|
||||||
|
loadPeers();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function showQr(peerId, peerName) {
|
||||||
|
$('#wg-qr-title').html('<i class="fa-solid fa-qrcode me-2"></i>' + esc(peerName || 'Peer') + ' Profile');
|
||||||
|
$('#wg-qr-img').hide();
|
||||||
|
$('#wg-qr-loading').show();
|
||||||
|
$('#wg-download-conf-btn').off('click').on('click', function(){ downloadConf(peerId); });
|
||||||
|
qrModal.show();
|
||||||
|
app.api.get('wireguard/peers/' + peerId + '/qr', function(err, data){
|
||||||
|
$('#wg-qr-loading').hide();
|
||||||
|
if (err || !data || !data.qr) return;
|
||||||
|
$('#wg-qr-img').attr('src', data.qr).show();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadConf(peerId) {
|
||||||
|
var token = app.auth.getToken ? app.auth.getToken() : '';
|
||||||
|
var url = '/api/wireguard/peers/' + peerId + '/conf' + (token ? '?token=' + encodeURIComponent(token) : '');
|
||||||
|
window.open(url, '_blank');
|
||||||
|
}
|
||||||
|
|
||||||
|
function showMsg(selector, text, type) {
|
||||||
|
$(selector)
|
||||||
|
.removeClass('alert-success alert-danger alert-warning')
|
||||||
|
.addClass('alert alert-' + (type || 'danger'))
|
||||||
|
.text(text)
|
||||||
|
.show();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<%- include('bottom') %>
|
||||||