diff --git a/CHANGELOG.md b/CHANGELOG.md index 43680c7..888d388 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,96 @@ # Changelog +## [0.31.0](https://github.com/solisoft/soli-proxy/compare/v0.30.0...v0.31.0) (2026-09-04) + +### Security + +* **Request paths with dot segments are rejected before routing.** Rules match on the raw + path and per-route auth binds to the matched rule, so `/api/../admin/users` could pass an + open `/api/` rule and land on `/admin/users` at any backend that normalises. Literal and + `%2e`-encoded dots are caught, terminated by `/`, end of path, a `;` path parameter + (`/api/..;/admin`, as Tomcat/Jetty/Spring strip it) or a backslash (`..\`, as IIS treats + it). An encoded slash (`%2F`) anywhere is rejected as well, since a backend that decodes it + before routing would see a path the proxy never matched. Backends whose API paths carry + `%2F` as data (GitLab's `group%2Fproject`, S3-style keys) can set + `[server] allow_encoded_slash = true`; `..%2F` and `%2F..` stay rejected. +* **`docker_network` is validated.** The value went straight to `docker run --network`, so + `docker_network = "host"` bypassed the namespace denylist that only looked at + `docker_options`. `host` and `container:` are refused in every mode, the name must be + one docker accepts, and the manifest is fully validated before the network is created, so + a rejected deploy no longer leaves a tenant-named network behind. +* **The single-tenant `docker_options` denylist reads docker's syntax.** It split on `=` and + whitespace and inspected the next token, so `-v/:/host`, `--mount type=bind,source=/`, + `/./:/host`, `--pid container:x`, `--volumes-from`, `--env-file` and `--group-add` all + passed. Flags are now parsed the way docker parses them (attached shorthand, `--mount` + key=value specs), mount sources are normalised and canonicalised before the root / docker + socket check, and the namespace, volumes-from, env-file and group-add flags are on the list. +* **Multi-tenant bind mounts may only be the site directory itself, emitted canonicalised.** + A sub-path such as `/data` was validated by canonicalising it, but the tenant's raw + token reached `docker run`, which resolves the path again at mount time — and every + component under the site directory is writable by the tenant's still-running previous slot, + which could swap `data` for a symlink to `/` in between. The site directory's own path has + no tenant-writable component; it is the only permitted source, and its canonical path is + what reaches docker. +* **`PUT /api/v1/config` pairs auth hashes by matcher, not index.** A `hash: ""` entry (the + API never returns hashes) was resolved against whichever old rule sat at the same index, so + deleting or reordering rules handed a route the password of another (same username) or + rejected the change with 400 (different username). +* **Empty admin credentials count as unset.** `[admin] api_key = ""` (a templated config with + an unresolved variable) made the server log "no authentication configured" and then 401 + every request, and `ADMIN_USER="" ADMIN_PASSWORD=""` was hashed into a credential that + `Authorization: Basic Og==` satisfied. Empty strings are dropped at load time. +* **Admin mutations need `X-Requested-With`.** Any non-GET request without `X-Api-Key` must + carry an `X-Requested-With` header of any value, or it is answered 403. An HTML form cannot + set it, which is what stops a page the operator visits from driving the API with cached + Basic credentials or the open loopback default. A bare `curl -X POST` against loopback + needs `-H X-Requested-With:curl` now. + +### Changed + +* **`base64.decode` in Lua returns `nil, err` on malformed input instead of raising.** Hook + errors now fail closed (500 "script error"), so a raise on an attacker-controlled + `Authorization` header would have turned every malformed credential into a 500. Scripts + written against the old contract (`pcall(base64.decode, s)`) keep working for valid input, + but the failure branch must change to check the return value: + + ```lua + local decoded = base64.decode(token) + if not decoded then return req:deny(401, "Malformed credentials") end + ``` + + The bundled `scripts/lua/auth.lua` is updated. +* **`name` and `domain` in `app.infos` are validated in every mode.** Hostname characters + plus `_` (an existing `sites/my_app.example.com` keeps loading), a leading `_` only for + bundled apps, and `health_check` must be an absolute URL path. A directory whose manifest + fails is skipped and logged at warn level. +* **The environment allowlist reaches Docker apps too.** `HTTP(S)_PROXY`/`NO_PROXY`, + `SOLI_RELEASE_BASE_URL` and `SOLI_NO_PIN` are passed as `-e` flags into the container; + the host-path entries (`XDG_CACHE_HOME`, `SSL_CERT_FILE`, `SSL_CERT_DIR`) are native-only. + +### Fixed + +* **Apps got the proxy's `HOME`, not their own.** The proxy drops privileges to + the app's `user` but handed the child the environment variable it inherited + itself — `/root` under systemd. Every `~`-resolved path therefore pointed at a + directory the app could not read, silently breaking soli's package cache + (`~/.soli/packages`), its registry credentials and the Tailwind CLI it + downloads to `~/.soli/bin`. `HOME` is now read from the passwd entry of the + user the app actually runs as. + +### Added + +* **A short environment allowlist survives `env_clear()`.** Apps still start + with a cleared environment, but `XDG_CACHE_HOME`, `SOLI_RELEASE_BASE_URL`, + `SOLI_NO_PIN`, the `HTTP(S)_PROXY`/`NO_PROXY` family and `SSL_CERT_FILE` / + `SSL_CERT_DIR` now pass through when set on the proxy. Without them an app + behind an egress proxy could not make outbound HTTPS requests, and could not + be pointed at a shared cache. + + Together these let a Soli app pin its interpreter version + (`soli_version = "=2.0.3"` in `soli.toml`) and have the proxy start it on that + version. No proxy configuration is needed — the app already starts with its + own directory as the working directory, which is where soli looks for the pin. + ## [0.29.2](https://github.com/solisoft/soli-proxy/compare/v0.29.1...v0.29.2) (2026-07-29) Website and admin UI only — the proxy binary is unchanged from 0.29.1. diff --git a/Cargo.lock b/Cargo.lock index 4b9670a..c145140 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2680,7 +2680,7 @@ dependencies = [ [[package]] name = "soli-proxy" -version = "0.30.0" +version = "0.31.0" dependencies = [ "anyhow", "arc-swap", diff --git a/Cargo.toml b/Cargo.toml index 2c9e616..8464603 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "soli-proxy" -version = "0.30.0" +version = "0.31.0" edition = "2021" description = "A fast, configurable reverse proxy with automatic HTTPS, Lua scripting, and blue-green deployments" license = "MIT" diff --git a/README.md b/README.md index 14950a7..b074d97 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,10 @@ soli-proxy update [--reinstall] # Self-update from GitH bind = "0.0.0.0:8080" https_port = 8443 worker_threads = "auto" +# Paths with a dot segment (`/api/../admin`, `%2e%2e`, `..;`, `..\`) are answered 400 before +# any rule matches. So is an encoded slash (`%2F`) anywhere, unless the backend needs them as +# data (GitLab's `group%2Fproject`, S3-style keys); `..%2F` stays rejected either way. +allow_encoded_slash = false [tls] mode = "auto" # "auto" for dev, "letsencrypt" for production @@ -332,8 +336,63 @@ port_range_end = 30000 | `user` | string | `[apps].default_user` from `config.toml` | OS user to drop privileges to (required when running the proxy as root). | | `group` | string | `[apps].default_group` from `config.toml` | OS group to drop privileges to. | | `docker_image` | string | _none_ | If set, the app runs inside Docker using this image instead of a host process. | -| `docker_options` | string | _none_ | Extra flags appended to `docker run` (validated against an allowlist). | -| `docker_network` | string | `"soli-apps"` | Docker network the container joins (created automatically if missing). | +| `docker_options` | string | _none_ | Extra flags appended to `docker run`. Whitespace-split, no shell. Single-tenant: a denylist rejects `--privileged`, `--cap-add`, `--device`, `--security-opt`, `--userns`, `--volumes-from`, `--env-file`, `--group-add`, joining the `host` or another container's namespaces, and docker-socket / root mounts in every spelling (`-v/:/x`, `--mount type=bind,source=/`, `/./`, `/etc/..`). Multi-tenant: only the allowlist below is accepted. | +| `docker_network` | string | `"soli-apps"` | Docker network the container joins (created automatically if missing). A plain network name only: `host` and `container:` are refused in every mode, since the value goes straight to `--network`. | + +### The app's environment + +An app is started with a **cleared environment**, so nothing the proxy happens +to inherit leaks into it. The child gets: + +| Variable | Value | +|---|---| +| `PORT` | The blue/green slot's port. | +| `WORKERS` | The `workers` setting. | +| `HOME` | **The home directory of the `user` the app runs as**, read from the passwd database — not the proxy's own. | +| `PATH`, `LANG`, `TZ` | Copied from the proxy. | + +`HOME` matters more than it looks. The proxy usually runs as root and drops +privileges to the app's `user`, so handing the child the proxy's own `HOME` +(`/root` under systemd) pointed every `~`-resolved path at a directory the app +cannot read. That silently broke soli's package cache (`~/.soli/packages`), its +registry credentials, the Tailwind CLI it downloads to `~/.soli/bin`, and the +cache for [pinned interpreter versions](https://soli.solisoft.net/docs/language/modules). + +A short allowlist also survives the clear, when it is set on the proxy: + +| Variable | Why | +|---|---| +| `XDG_CACHE_HOME` | Points at a shared soli toolchain cache, so a pinned app does not download its interpreter on the server and several apps running as different users can share one. | +| `SOLI_RELEASE_BASE_URL` | An internal mirror for those downloads. | +| `SOLI_NO_PIN` | Operator override for a version pin, e.g. during an incident. | +| `HTTP_PROXY`, `HTTPS_PROXY`, `NO_PROXY` (and lowercase) | Outbound egress proxy. | +| `SSL_CERT_FILE`, `SSL_CERT_DIR` | Custom CA bundle. | + +Anything else stays cleared. Put per-app configuration in the app's own `.env`, +not in the proxy's environment. + +A Docker app gets the same treatment, minus the entries that name a host path: the proxy-family +variables, `SOLI_RELEASE_BASE_URL` and `SOLI_NO_PIN` are passed as `-e` flags into the +container, while `XDG_CACHE_HOME`, `SSL_CERT_FILE` and `SSL_CERT_DIR` are not (the container +cannot see those directories; bake a CA bundle into the image instead). + +### Pinned Soli versions + +A Soli app can pin the exact interpreter it runs on, with +`soli_version = "=2.0.3"` in its `soli.toml`. The proxy needs no configuration +for this: it starts an app with the app directory as the working directory, and +soli resolves the pin from there — the same as on a developer machine. + +Two things to get right on a server: + +- **Provision the toolchain during deployment, not at start-up.** A new instance + has 30 seconds to pass its health check. A first start after changing a pin + spends part of that window downloading, and a slow link can push it over; the + deploy then fails and succeeds on the retry, once the cache is warm. +- **Make the cache readable by the app's user.** With `HOME` now resolved + correctly this works by default, but several apps running as different users + will each download their own copy. Point `XDG_CACHE_HOME` at a shared + directory readable by all of them to avoid that. ### Variable substitution @@ -380,8 +439,32 @@ With it on: - Every container gets `--read-only`, a `noexec,nosuid` tmpfs at `/tmp`, `--cap-drop ALL`, `--security-opt no-new-privileges`, `--pids-limit 256`, plus the memory/cpu/user limits above. - These are appended **after** the app's own `docker_options`, and `docker run` honours the last - occurrence of a repeated flag, so an app cannot raise its own ceiling or run as root. -- `docker_options` is still validated (no `--privileged`, no host namespaces, no docker socket). + occurrence of a repeated flag, so an app cannot raise its own ceiling or run as root. The image + is passed after a `--` terminator, and `docker_image` must be a well-formed image reference, so + neither it nor the start script can smuggle in further flags. +- `docker_options` is validated against an **allowlist** — anything not listed fails the deploy, + naming the offending token. Every flag must carry a value (a trailing flag would swallow the + platform's hardening). Permitted: + - `-e`/`--env KEY=VALUE`, `-l`/`--label`, `--restart`, `--stop-timeout`, `--health-*` + - `-m`/`--memory`, `--cpus`, `--cpu-shares`, `--pids-limit`, `--shm-size` (the platform's + limits still win, see above) + - no `-p`/`--publish`: the proxy publishes the allocated slot port as `127.0.0.1:$PORT:$PORT` + itself, so a tenant cannot bind a host port that belongs to another tenant's slot + - `-v`/`--volume SRC:DST[:ro|rw]` and `--mount type=bind,source=SRC,target=DST[,readonly]` + only when `SRC` canonicalises (symlinks resolved) to the app's own site directory — the + directory itself, not a path inside it. Everything under the site directory is writable by + the tenant's running container, which could swap a sub-directory for a symlink between the + check and docker's own path resolution at mount time; the site directory's own path has no + tenant-writable component. The canonical path is what reaches `docker run`, never the + tenant's spelling. Named volumes, other mount types, propagation and relabel options are + rejected. +- `name` and `domain` in `app.infos` are bound to the site directory: `name` must equal it, + `domain` must be it or its `www.` twin (or empty). A tenant cannot claim another site's `Host` + or take over another app's entry; a directory whose manifest breaks the rule is skipped and + logged. Names starting with `_` are reserved for bundled apps (`_admin`) in every mode. +- `name`, `domain` and `health_check` are checked at load time in every mode: hostname + characters (plus `_`, for existing `my_app.example.com` directories) for the first two, an + absolute URL path for the third. > Docker has a long history of container escapes. This raises the cost of one; it is not a VM > boundary. For genuinely hostile code, treat it as the first step toward gVisor or Firecracker. @@ -416,9 +499,15 @@ curl -X POST http://127.0.0.1:9090/api/v1/apps/myapp.example.com/aliases \ -d '{"domain":"www.example.com"}' curl http://127.0.0.1:9090/api/v1/aliases # domain -> app -curl -X DELETE http://127.0.0.1:9090/api/v1/apps/myapp.example.com/aliases/www.example.com +curl -X DELETE http://127.0.0.1:9090/api/v1/apps/myapp.example.com/aliases/www.example.com \ + -H "X-Api-Key: $KEY" ``` +Every non-GET request must carry `X-Api-Key`, or — when no key is configured, or with Basic +auth — an `X-Requested-With` header of any value. That is the CSRF guard: an HTML form cannot +set either header, so a page the operator happens to visit cannot drive the admin API with +the browser's cached credentials or the open loopback default. + Rollback is the same POST with a different app: send `{"domain":"www.example.com"}` to the previous deployment and traffic moves back, with both processes left running. diff --git a/scripts/lua/auth.lua b/scripts/lua/auth.lua index f185be5..00f30f0 100644 --- a/scripts/lua/auth.lua +++ b/scripts/lua/auth.lua @@ -19,7 +19,12 @@ function on_request(req) return req:deny(401, "Unsupported auth scheme") end + -- base64.decode returns nil, err on malformed input; never let a bad + -- header raise past this point (an error would abort the hook). local decoded = base64.decode(encoded) + if not decoded then + return req:deny(401, "Malformed credentials") + end local user, pass = decoded:match("^([^:]+):(.+)$") if not user or not pass then return req:deny(401, "Malformed credentials") diff --git a/sites/_admin/app/views/apps/index.html.slv b/sites/_admin/app/views/apps/index.html.slv index 62cf088..039499e 100644 --- a/sites/_admin/app/views/apps/index.html.slv +++ b/sites/_admin/app/views/apps/index.html.slv @@ -50,8 +50,9 @@ function renderErrorHistory() { container.innerHTML = ''; return; } + // e.message is the API error body, which may echo tenant-controlled strings. container.innerHTML = ErrorHistory.map(function(e) { - return '
[' + e.time + '] ' + e.source + ': ' + e.message + '
'; + return '
[' + AdminAPI.esc(e.time) + '] ' + AdminAPI.esc(e.source) + ': ' + AdminAPI.esc(e.message) + '
'; }).join(''); } @@ -187,14 +188,14 @@ var AppManager = { }).join(''); return '
' + - '' + - '
' + + '
' + appsHtml + '
' + '
'; @@ -221,6 +222,31 @@ var AppManager = { AppManager.render(filtered, true); }, + // Single delegated click handler for everything rendered into #apps-grid. + // App names come from the API, so they are only ever carried in data-* + // attributes and read back via dataset — never interpolated into inline JS. + bindGridEvents: function() { + var grid = document.getElementById('apps-grid'); + if (!grid || grid._eventsBound) return; + grid._eventsBound = true; + grid.addEventListener('click', function(ev) { + var header = ev.target.closest('[data-domain-id]'); + if (header && grid.contains(header)) { + AppManager.toggleDomain(header.dataset.domainId, header); + return; + } + var btn = ev.target.closest('[data-app][data-action]'); + if (!btn || !grid.contains(btn)) return; + var name = btn.dataset.app; + var action = btn.dataset.action; + if (action === 'logs') { + AppManager.showLogs(name); + } else if (action === 'deploy' || action === 'restart' || action === 'stop') { + AppManager.action(name, action, btn); + } + }); + }, + toggleDomain: function(domainId, btn) { var content = document.getElementById(domainId); var chevron = btn.querySelector('.domain-chevron'); @@ -326,8 +352,8 @@ var AppManager = { '' + '
' + '
' + - '
-
' + - '
' + renderCpuGraph(name, currentSlot) + '
' + + '
-
' + + '
' + renderCpuGraph(name, currentSlot) + '
' + '
' + (currentSlot === 'blue' ? (blueMem ? formatBytes(blueMem) : '-') : (greenMem ? formatBytes(greenMem) : '-')) + '
' + '
' + '' + @@ -353,23 +379,24 @@ var AppManager = { '' + '
' + '
Blue' + (currentSlot === 'blue' ? ' active' : '') + '
' + - '
' + blueStatus + (bluePort ? ' :' + bluePort : '') + '
' + + '
' + AdminAPI.esc(blueStatus) + (bluePort ? ' :' + AdminAPI.esc(bluePort) : '') + '
' + '
' + '' + '
' + '' + '
' + '
Green' + (currentSlot === 'green' ? ' active' : '') + '
' + - '
' + greenStatus + (greenPort ? ' :' + greenPort : '') + '
' + + '
' + AdminAPI.esc(greenStatus) + (greenPort ? ' :' + AdminAPI.esc(greenPort) : '') + '
' + '
' + '
' + '' + '' + '
' + - '' + - '' + - '' + - '' + + // Actions are bound by the delegated click handler on #apps-grid (see bindGridEvents). + '' + + '' + + '' + + '' + '
' + ''; }, @@ -496,6 +523,7 @@ function refreshCpuData() { }).catch(function(err) { addError('refreshCpuData', err.message); AdminAPI.toast('Failed to refresh CPU data: ' + err.message, 'error'); }); } +AppManager.bindGridEvents(); AppManager.load().then(function() { refreshCpuData(); connectAppEvents(); diff --git a/sites/_admin/app/views/changelog/index.html.slv b/sites/_admin/app/views/changelog/index.html.slv index b42225b..2fa0aff 100644 --- a/sites/_admin/app/views/changelog/index.html.slv +++ b/sites/_admin/app/views/changelog/index.html.slv @@ -3,12 +3,56 @@

Release history for soli-proxy. Mirrors CHANGELOG.md in the repository.

+ +
+
+
+

v0.31.0

+ latest +
+ 2026-09-04 +
+ +
    +
  • + security +
    +
    Path traversal rejected before routing
    +

    Rules match on the raw path and per-route auth binds to the matched rule, so /api/../admin/users could pass an open /api/ rule and land on a protected route at any backend that normalises. Dot segments in every spelling (literal, %2e, ..;, ..\) and encoded slashes are answered 400. Backends that carry %2F as data can set [server] allow_encoded_slash = true.

    +
    +
  • + +
  • + security +
    +
    Multi-tenant mode for untrusted apps
    +

    [apps] multi_tenant = true treats every app as untrusted code: a docker_image is mandatory, every container gets a read-only root, dropped capabilities, no-new-privileges, pid/memory/cpu ceilings and a non-root uid that tenant config cannot weaken, and docker_options is validated against an allowlist. Bind mounts may only be the app's own site directory, passed to docker by its canonical path. docker_network may not join the host or another container in any mode, and the single-tenant denylist now reads docker's syntax (-v/:/host, --mount type=bind,source=/).

    +
    +
  • + +
  • + security +
    +
    Admin API hardening
    +

    Mutating requests without X-Api-Key must carry an X-Requested-With header, which an HTML form cannot set. Route password hashes are never returned; a re-submitted entry with an empty hash keeps the existing one, matched by the rule's matcher rather than its position so a whole-table update that deletes or reorders rules cannot hand a route another route's password. Empty admin credentials (api_key = "") count as unset. Lua hook errors fail closed with a 500.

    +
    +
  • + +
  • + fix +
    +
    Apps get their own HOME and an environment allowlist
    +

    The proxy drops privileges to the app's user but handed the child its own HOME (/root under systemd), silently breaking soli's package cache, registry credentials and the Tailwind CLI. It now comes from the passwd entry of the user the app runs as. A short allowlist (HTTP(S)_PROXY, SSL_CERT_FILE, XDG_CACHE_HOME, the soli toolchain variables) survives the cleared environment, for native and Docker apps alike.

    +
    +
  • +
+
+

v0.29.1

- latest
2026-07-28
diff --git a/sites/_admin/app/views/circuit_breaker/index.html.slv b/sites/_admin/app/views/circuit_breaker/index.html.slv index bf95206..acc09b6 100644 --- a/sites/_admin/app/views/circuit_breaker/index.html.slv +++ b/sites/_admin/app/views/circuit_breaker/index.html.slv @@ -56,7 +56,7 @@ var CBManager = { '

' + AdminAPI.esc(target) + '

' + '' + '' + - stateDisplay + + AdminAPI.esc(stateDisplay) + '' + '
' + '
' + diff --git a/sites/_admin/app/views/home/index.html.slv b/sites/_admin/app/views/home/index.html.slv index 20e6563..cdb0c14 100644 --- a/sites/_admin/app/views/home/index.html.slv +++ b/sites/_admin/app/views/home/index.html.slv @@ -187,7 +187,7 @@ Options: types[t] = (types[t] || 0) + 1; }); var html = Object.keys(types).map(function(t) { - return '' + t + ': ' + types[t] + ''; + return '' + AdminAPI.esc(t) + ': ' + types[t] + ''; }).join(''); document.getElementById('dash-route-types').innerHTML = html || 'No routes'; }).catch(function(err) { AdminAPI.toast('Failed to load routes: ' + err.message, 'error'); }); diff --git a/sites/_admin/app/views/routes/index.html.slv b/sites/_admin/app/views/routes/index.html.slv index c684abf..7cc3700 100644 --- a/sites/_admin/app/views/routes/index.html.slv +++ b/sites/_admin/app/views/routes/index.html.slv @@ -89,16 +89,29 @@ var RouteManager = { '' + scripts + '' + '' + auth + '' + '' + - '' + - '' + ''; }).join(''); }, + // Delegated click handler for the table's Edit/Delete buttons. + bindTableEvents: function() { + var tbody = document.getElementById('routes-table-body'); + tbody.addEventListener('click', function(ev) { + var btn = ev.target.closest('[data-route-index][data-route-action]'); + if (!btn || !tbody.contains(btn)) return; + var index = parseInt(btn.dataset.routeIndex, 10); + if (isNaN(index)) return; + if (btn.dataset.routeAction === 'edit') RouteManager.showEditModal(index); + else if (btn.dataset.routeAction === 'delete') RouteManager.deleteRoute(index); + }); + }, + showAddModal: function() { this._showModal('Add Route', -1, {matcher: {type: 'prefix', value: ''}, targets: [{url: '', weight: 100}], scripts: [], auth: []}); }, @@ -153,37 +166,48 @@ var RouteManager = { '
' + '
' + '
' + - '
Add users to protect this route with HTTP Basic Auth
' + + '
Add users to protect this route with HTTP Basic Auth. Existing users keep their password unless you type a new one.
' + '
' + '
' + '
' + '
' + - '' + + '' + '' + '
'; AdminAPI.showModal(html); - if (route.auth && route.auth.length) { - var container = document.getElementById('auth-users-container'); - route.auth.forEach(function(a) { - var row = document.createElement('div'); - row.className = 'auth-user-row flex gap-2 mb-2'; - row.innerHTML = '' + - '' + - ''; - container.appendChild(row); - }); - } + document.getElementById('route-save-btn').addEventListener('click', function() { + RouteManager.saveRoute(parseInt(this.dataset.routeIndex, 10)); + }); + + // Delegated remove handler for the per-user × buttons. + var container = document.getElementById('auth-users-container'); + container.addEventListener('click', function(ev) { + var btn = ev.target.closest('[data-remove-user]'); + if (btn && container.contains(btn)) btn.parentElement.remove(); + }); + + // The API never returns password hashes; existing users are listed by + // name with a blank password meaning "keep the current one". + (route.auth || []).forEach(function(a) { + RouteManager.addAuthUserRow(a.username); + }); }, - addAuthUserRow: function() { + // Append a user row. With `existingUsername` the row represents a user + // already on the route: the username is fixed and an empty password is + // submitted as hash "" (server keeps the stored hash). Without it the row + // is a new user and requires a password. + addAuthUserRow: function(existingUsername) { var container = document.getElementById('auth-users-container'); + var isExisting = typeof existingUsername === 'string' && existingUsername !== ''; var row = document.createElement('div'); row.className = 'auth-user-row flex gap-2 mb-2'; - row.innerHTML = '' + - '' + - ''; + if (isExisting) row.dataset.existing = '1'; + row.innerHTML = '' + + '' + + ''; container.appendChild(row); }, @@ -199,21 +223,34 @@ var RouteManager = { var authRows = document.querySelectorAll('.auth-user-row'); var self = this; var needsHashing = []; + var missingPassword = []; authRows.forEach(function(row, i) { var username = row.querySelector('.auth-username').value.trim(); var password = row.querySelector('.auth-password').value.trim(); - if (username && password) { - if (password.startsWith('$2')) { - auth.push({username: username, hash: password}); - } else { - needsHashing.push({row: row, username: username, password: password}); - } + var isExisting = row.dataset.existing === '1'; + if (!username) return; + if (!password) { + // Existing user with no new password: hash "" tells the server + // to keep the stored hash. A new user must have a password. + if (isExisting) auth.push({username: username, hash: ''}); + else missingPassword.push(username); + return; + } + if (password.startsWith('$2')) { + auth.push({username: username, hash: password}); + } else { + needsHashing.push({row: row, username: username, password: password}); } }); + if (missingPassword.length > 0) { + AdminAPI.toast('Password required for new user: ' + missingPassword.join(', '), 'error'); + return; + } + if (needsHashing.length > 0) { - var saveBtn = document.querySelector('#modal-container button'); + var saveBtn = document.getElementById('route-save-btn'); AdminAPI.setButtonLoading(saveBtn, true, 'Hashing passwords...'); var hashed = []; @@ -246,7 +283,7 @@ var RouteManager = { auth: auth }; - var saveBtn = document.querySelector('#modal-container button'); + var saveBtn = document.getElementById('route-save-btn'); AdminAPI.setButtonLoading(saveBtn, true, 'Saving...'); var promise = index === -1 ? AdminAPI.addRoute(route) : AdminAPI.updateRoute(index, route); @@ -272,5 +309,6 @@ var RouteManager = { } }; +RouteManager.bindTableEvents(); RouteManager.load(); diff --git a/sites/_admin/app/views/settings/index.html.slv b/sites/_admin/app/views/settings/index.html.slv index 60aeaae..5d762ce 100644 --- a/sites/_admin/app/views/settings/index.html.slv +++ b/sites/_admin/app/views/settings/index.html.slv @@ -24,7 +24,7 @@ var SettingsPage = { var sw = ThemeManager.swatch(p.id); var isActive = p.id === active; return '' + - '