Skip to content

Fix/gsas server per user - #10

Merged
Adam Corrao (AdamCorrao) merged 4 commits into
NSLS2:mainfrom
AdamCorrao:fix/gsas-server-per-user
Oct 10, 2026
Merged

Adam Corrao (AdamCorrao) merged 4 commits into
NSLS2:mainfrom
AdamCorrao:fix/gsas-server-per-user

Conversation

@AdamCorrao

@AdamCorrao Adam Corrao (AdamCorrao) commented Oct 10, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Makes the persistent GSAS-II server safe on machines with more than one user. Each user gets their own server, and only that user can submit jobs to it or receive results from it. The server listens on a free loopback port. Requests and responses are signed with a secret that only its owner can read, and that secret never travels over the wire. This PR also adds POWDERLINE_NO_SERVER, an environment variable that switches the server off.

Problem

Up to v0.1.1 the server behaved as if the machine had a single user:

  • Its state files (powderline_gsas_server.{port,pid,log}) had fixed names in the system temp directory.
  • It listened on a fixed port (19471).
  • It accepted requests without any authentication, and wrote output to whatever output_dir the request named, as the user who started it.

With several users on one machine, this caused four problems:

  1. Jobs ran as the wrong user. One user's client could find another user's server and run its job there. Output files were then owned by the other user, and could land in places the submitting user can't write to.
  2. Any local user could write files as the server's owner. Anyone could send a request to a running server with an output_dir inside the owner's directories.
  3. Users collided. They competed for the same port and the same temp-file names.
  4. No way to turn the server off. --no-server only applies to the CLI. powderline.run() still auto-started a server.

Changes

  • Per-user state directory
    • On Linux/macOS: $XDG_RUNTIME_DIR/powderline if that is a real directory owned by the user, otherwise <tempdir>/powderline-<uid>.
    • It is created with mode 0700 and refused if it is a symlink, owned by someone else, or accessible to group/other users. It is also refused if the temp directory would let another user replace it: writable by others without the sticky bit, or owned by another user. On POSIX, files inside are opened relative to a re-verified, no-follow directory handle.
    • On Windows: %LOCALAPPDATA%\powderline, protected by the user-profile ACL.
    • It holds server.lock, server.pid, server.log and server.json ({pid, port, token}), all mode 0600. server.json is written atomically.
  • Signed requests and signed responses
    • At each start the server generates a secret with secrets.token_urlsafe(32) and binds 127.0.0.1 on a free port (or POWDERLINE_SERVER_PORT if set).
    • The secret is never sent over the wire. Each request carries a fresh nonce, a timestamp and an HMAC-SHA256 over the method, path, nonce, timestamp and body.
    • An ASGI middleware wraps the whole app. It rejects unsigned, wrongly signed, stale or replayed requests with 401 on every route (404/405/422 included), before reading the body, and it signs every response over the nonce, status and body. Each nonce is reserved atomically, so even two concurrent copies of one signed request run at most once.
    • The client discards any response without a valid signature. So a process that takes over the port of a crashed server learns nothing it can reuse, and cannot fake a result. Clients also ignore an endpoint whose server process has exited.
    • The interactive docs and OpenAPI routes are off, and client requests ignore HTTP proxy settings.
  • Single instance per user. gsas-server start holds a kernel lock (flock / msvcrt.locking) on server.lock for the server's lifetime. The OS releases it when the process exits, however it exits. Concurrent auto-starts therefore never create duplicate servers, and a leftover PID file or recycled PID never makes a dead server look alive to status/stop. gsas-server stop removes the files of a server that could not clean up after itself (e.g. on Windows, where stopping terminates the process). It does this while briefly holding the lock, so a live server's files are never touched.
  • POWDERLINE_NO_SERVER=1|true|yes|on
    • execution_mode='auto' (the CLI default) runs in-process and never contacts or starts a server.
    • execution_mode='server' and --use-server fail with a clear error.
    • gsas-server start and gsas-server restart refuse to run.
    • It is enforced in GSASClient, so it covers run(), the CLI, mp-simulate and direct GSASClient use.

Why signed messages over loopback TCP rather than a Unix domain socket: a socket file in a 0700 directory (or a named pipe) would give OS-level peer authentication. But it needs separate POSIX and Windows code paths. Signing requests and responses gives mutual authentication on loopback TCP with one code path on every platform, and it doesn't rely on whoever is listening being trustworthy.

Behaviour change: POWDERLINE_SERVER_PORT now only fixes the server's port. Clients always find the port in the user's state directory.

Upgrading: a v0.1.1 server that is still running is never used by the new client, but gsas-server status/stop can't see it either. Stop it once with pkill -u "$USER" -f gsas_server.py; Windows instructions are in TROUBLESHOOTING. Old servers were often auto-started, so you may not know one is running.

Unchanged: auto-start, reusing a running server, the output-visibility guard and the in-process fallback.

Tests

  • Linux: full suite 578 passed, 4 skipped, against 492 passed / 3 skipped on main. The extra skip is a Windows-only test.
  • Windows 11 (win-64, Python 3.13), at the commit before the last one: full suite 556 passed, 23 skipped, 0 failed. The last commit adds only the atomic nonce re-check and the stale-file cleanup in stop, which use the same lock primitives verified on Windows; its tests pass on Linux. The skips are POSIX-only tests; the Windows-only test ran and passed. The server tests passed in 3 of 3 repeat runs. Also checked by hand on Windows:
    • Server: the server started on a random loopback port. The state folder is only accessible to the user, SYSTEM and Administrators (inherited profile ACL). A second start is refused.
    • Results: LaB6 gives Rwp 6.527% through the server and in-process, with byte-identical refined_parameters.csv.
    • Authentication: /health, /docs and /openapi.json return 401 without a signature, and also with the real token sent as a bearer token.
    • Lifecycle: after a forced kill, status reports "not running" and the next run auto-starts a fresh server. stop and restart work.
    • Start lock: four simultaneous starts leave exactly one server.
    • Off switch: POWDERLINE_NO_SERVER works (1 and on).
    • Pinned port: honoured, and another socket cannot bind it (SO_EXCLUSIVEADDRUSE).
  • pixi run docs passes with -W.
  • New unit tests:
    • State-directory checks: symlink, foreign owner, group/other access, a symlinked XDG_RUNTIME_DIR, a shared temp directory without the sticky bit (refused), a sticky one (accepted), a temp directory owned by another user, a directory swapped for a symlink after the check, a directory that can't be created, and distinct paths per uid.
    • Signing on the ASGI app: unsigned requests, a bare bearer token, a wrong key, stale and future timestamps, a tampered body, the wrong path, malformed nonces, non-ASCII signatures and replays all get 401, with no job run. That includes two copies of one request sent concurrently. Signed requests are accepted and every response is signed, including 404/405/422. Oversized bodies are refused.
    • Client identity: unsigned, wrongly signed or replayed responses are rejected. An impostor that signs with any secret it was sent is rejected. A forged success is never reported. Endpoints of dead servers are not probed. The token never appears in any request.
    • The start lock: claim, refusal while held, replacement of stale files, release, and stale-file cleanup after stop that never touches a live server. Also a real 8-process concurrent start with stale files present, which must produce exactly one holder.
    • POWDERLINE_NO_SERVER for auto mode, server mode, auto-start, direct probes, start/restart, run(), and the kicker --use-server exit code.
  • Mutation-checked: each of the following makes at least one test fail:
    • sending the token,
    • skipping response verification,
    • dropping the dead-PID check,
    • dropping replay protection, or the atomic re-check of the nonce,
    • following a symlinked XDG_RUNTIME_DIR,
    • dropping the sticky check,
    • dropping O_NOFOLLOW,
    • removing the POWDERLINE_NO_SERVER check from probes,
    • removing the lock probe.
  • Integration tests start a real server in an isolated private state directory. They cover:
    • rejection of unsigned and wrongly signed requests;
    • file permissions;
    • the start lock held by a live server;
    • a client with another user's (forged) endpoint;
    • a full LaB6 refinement through the server.
  • Isolation: an autouse fixture keeps every test away from the developer's real state directory.
  • Checked by hand:
    • With a real server killed by SIGKILL and a listener squatting its port, the squatter receives no secret and no job, whether the stale endpoint names a dead PID or a recycled live one.
    • A server started after the crash takes over cleanly.
    • LaB6 through the server gives Rwp 6.527%, the same as in-process.
  • Not yet run on macOS. Its code paths are the POSIX ones tested on Linux. Its per-user $TMPDIR is owned by the user, so the parent check accepts it.

Docs

  • Updated README, TROUBLESHOOTING (including the upgrade note), DEVELOPMENT, the integration guide and the cross-platform guide.
  • New register entry KI-13 (implemented).
  • KI-11 updated. Its split-discovery problem is resolved; the one remaining case stays open: a server whose state directory is deleted while it runs can only be stopped by hand.
  • Version bumped to 0.1.2 in pixi.toml, CITATION.cff and src/powderline/__init__.py.

Assisted-by: claude-code:claude-opus-5-5
Assisted-by: claude-code:claude-haiku-4-5

The server kept machine-global state (fixed file names in the system temp
dir), listened on a fixed port and accepted unauthenticated requests. On a
host with several users, one user's client could find and use another
user's server, so jobs ran, and files were written, under the wrong
identity; any local user could also submit a job writing into another
user's directories. Users also collided on the port and the shared files,
and nothing could stop powderline.run() from auto-starting a server.

Server state (PID, log, and server.json with port + token, mode 0600) now
lives in a per-user directory: $XDG_RUNTIME_DIR/powderline when it is ours,
else <tempdir>/powderline-<uid> on POSIX (0700; refused if a symlink,
foreign-owned or group/other-accessible), %LOCALAPPDATA%\powderline on
Windows. The server binds 127.0.0.1 on an ephemeral port (or
POWDERLINE_SERVER_PORT) and requires a per-start bearer token on every
route. /health returns an HMAC of a client nonce, so a client only uses a
server that holds its token; client requests bypass HTTP proxies. The PID
file is claimed with O_EXCL before the GSAS-II import, the start lock the
fixed port used to provide.

POWDERLINE_NO_SERVER=1|true|yes disables the server: auto runs in-process
without probing or starting one, server mode and --use-server fail with a
clear error, gsas-server start|restart refuse. Enforced in GSASClient, so
run(), the CLI and mp-simulate all honour it.

Docs updated (README, TROUBLESHOOTING, DEVELOPMENT, integration and
cross-platform guides); KI-13 records the issue and fix, KI-11 notes the
new state layout. kicker.py: run() docstring, CLI epilog and --use-server
check; code hash updated.

Assisted-by: claude-code:claude-opus-5-5
Patch release for the per-user, token-authenticated GSAS-II server and
the POWDERLINE_NO_SERVER switch. Same three files as the 0.1.1 bump; tag
v0.1.2 on the merge commit.

Assisted-by: claude-code:claude-opus-5-5
Copilot AI balanced review requested due to automatic review settings October 10, 2026 03:16

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The identity probe discloses its token to unverified listeners, and the PID-file lock permits concurrent starters.

5 open findings
What changed in this PR

Makes the persistent GSAS-II server per-user, authenticated, dynamically ported, and optionally disabled.

Changes:

  • Adds private per-user state, bearer authentication, identity checks, and startup locking.
  • Adds POWDERLINE_NO_SERVER behavior across API and CLI paths.
  • Expands tests/documentation and bumps the release to 0.1.2.
File Description
src/​powderline/​gsas_server.py Implements server security and lifecycle changes.
src/​powderline/​gsas_client.py Adds endpoint discovery, authentication, and opt-out behavior.
src/​powderline/​kicker.py Documents and enforces CLI opt-out behavior.
src/​powderline/​_code_hash.json Updates the verified kicker hash.
src/​powderline/​__init__.py Bumps package version.
tests/​test_gsas_server_unit.py Tests state security, authentication, locking, and opt-out behavior.
tests/​test_gsas_server_http.py Tests an isolated authenticated server.
tests/​test_gsas_client_visibility.py Tests server identity and client opt-out behavior.
tests/​test_api.py Tests public API behavior and authenticated retries.
README.md Documents per-user servers and opt-out configuration.
docs/​TROUBLESHOOTING.md Updates server troubleshooting guidance.
docs/​known_issues.md Records the multi-user security remediation.
docs/​integration-guide.md Documents integration behavior.
docs/​DEVELOPMENT.md Documents server architecture and operation.
docs/​cross-platform-guide.md Documents platform-specific state locations.
pixi.toml Bumps project version.
CITATION.cff Bumps citation version.

🧠 Review effort: Balanced


💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/powderline/gsas_client.py Outdated
Comment thread src/powderline/gsas_server.py Outdated
Comment thread src/powderline/gsas_server.py Outdated
Comment thread README.md Outdated
Comment thread docs/TROUBLESHOOTING.md Outdated
Review of the per-user server change found that the /health identity check
could be defeated. The client sent its bearer token to whatever listened on
the recorded port, and the expected proof was an HMAC keyed by that same
token. A process that took over the port of a crashed server (whose endpoint
file survives SIGKILL/OOM) therefore received the token, answered with a
valid proof, received the recipe, and — if it could write to the output
directory — made the client report a forged success.

The token now never leaves the owner's processes. Every request carries a
fresh nonce, a timestamp and an HMAC-SHA256 over method, path, nonce,
timestamp and body. A pure ASGI middleware wrapping the whole app rejects
unsigned, wrongly signed, stale or replayed requests on every route
(including 404/405/422) before reading the body, and signs every response
over the nonce, status and body. The client discards unsigned responses,
which also closes the gap between the health probe and the submission.
Clients additionally ignore an endpoint whose server process is gone.

The PID-file start lock let several concurrent starters win whenever a
stale PID file existed: each deleted the file by name, including a fresh
claim. It is replaced by a kernel-held lock (flock / msvcrt.locking) on
server.lock, kept for the server's lifetime and released by the OS on exit.
"Running" now means holding that lock, so leftover PID files and recycled
PIDs no longer fool status/stop.

The state directory is hardened further: XDG_RUNTIME_DIR is only trusted
if it is a real directory (lstat), a temp directory that would let other
users replace ours (world/group-writable without the sticky bit, or owned
by another user) is refused, and on POSIX state files are opened relative
to an O_NOFOLLOW, fstat-verified directory handle. The log is now 0600.

Also: POWDERLINE_NO_SERVER accepts "on" and a direct is_server_available()
call honours it; tests no longer reach the developer's real state
directory (autouse conftest fixture); new tests cover impostor listeners,
forged results, replay, a concurrent-start race and the --use-server exit.
Docs now describe the auto/server-mode distinction accurately, add an
upgrade note for still-running v0.1.1 servers, and KI-11/KI-13 are updated.

Assisted-by: claude-code:claude-opus-5-5
Copilot AI balanced review requested due to automatic review settings October 10, 2026 16:16

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment thread src/powderline/gsas_server.py
The replay check ran before the request body was awaited, but the nonce was
recorded only after signature verification. Two concurrent copies of the
same signed request could therefore both pass the check while suspended
in receive() and both run. The nonce is now re-checked and recorded with no
await in between, so exactly one copy proceeds; a test drives two copies
through the middleware concurrently.

On Windows, `gsas-server stop` terminates the process (TerminateProcess),
so the server never removes its PID and endpoint files. They were harmless
(the lock decides whether a server runs, and clients ignore endpoints of
dead processes) but confusing. stop now removes them after the server is
gone, while briefly holding the start lock, which proves no server is
running or starting, so a live server's files are never touched.

Assisted-by: claude-code:claude-opus-5-5
Copilot AI balanced review requested due to automatic review settings October 10, 2026 17:01

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Unauthenticated bodies are buffered before verification, and stale-server cleanup can erase a replacement server’s state.

0 open findings

1 resolved since last review
Previously missed (4)

In code that hasn't changed since last review

Medium severity Authenticate request bodies before buffering to prevent resource exhaustion

src/​powderline/​gsas_server.py:385

The request HMAC is not checked until the complete body has been buffered. Any local user can therefore send a plausible nonce/timestamp/signature and slowly stream up to 64 MiB per connection, consuming unbounded connections and memory before receiving 401; this also contradicts the documentation that wrongly signed requests are rejected before reading the body. Sign a declared body digest so the header can be authenticated first, enforce a body-read timeout/size limit, and then verify the streamed body against that digest.

Medium severity Prevent cleanup from deleting replacement server discovery files

src/​powderline/​gsas_server.py:585

Cleanup unlinks whichever PID and endpoint files currently exist at the pathname, not necessarily the files associated with _lock_fd. If the runtime directory is deleted while this server runs, a replacement server can recreate it and acquire a new lock inode; when the old server later exits, this cleanup deletes the replacement server's discovery files. Only unlink when the current PID file still contains this process's PID (or otherwise bind cleanup to the original directory/instance).

Low severity Correct PR description to document HMAC authentication

src/​powderline/​gsas_server.py:88

The PR description says every route uses Authorization: Bearer <token>, but this protocol uses nonce/timestamp/HMAC headers and the new tests explicitly reject bearer authentication. Update the PR description so reviewers and release notes describe the protocol actually implemented.

Low severity Correct PR description to document the actual startup locking design

src/​powderline/​gsas_server.py:529

The PR description says startup claims the PID file atomically with O_EXCL, whereas the implementation opens server.lock normally and relies on a kernel flock/msvcrt.locking lock. The repository documentation matches this implementation, so update the PR description's start-lock section to avoid documenting a different lifecycle design.

🧠 Review effort: Balanced

@AdamCorrao Adam Corrao (AdamCorrao) left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Linux and Windows testing of data security compliant server / client setup done. Merge approved.

@AdamCorrao
Adam Corrao (AdamCorrao) merged commit b38c73e into NSLS2:main Oct 10, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants