Compare commits

..
Author SHA1 Message Date
BaaRai ce279d77d3 Merge pull request #151 from BaaRai/release/v0.5.1
release: v0.5.1
2026-11-05 18:17:19 +01:00
BaaRai 41e10456d7 release: v0.5.1 2026-11-05 18:09:19 +01:00
BaaRai 43424404ee Merge pull request #150 from BaaRai/fix/login-rate-limit-hardening
fix(auth): rate-limit login per IP, trusted proxies, 12-character passwords
2026-11-05 18:08:11 +01:00
BaaRai cb13a22673 ci: retrigger after PR head sync 2026-11-05 17:30:24 +01:00
BaaRai 8beb9c50d6 fix(ci): drop unused variable in reset-password.sh (SC2034) 2026-11-05 17:11:41 +01:00
BaaRai 4be2fc5c9a docs, ci: proxy setup for host and Docker, shellcheck reset-password.sh
- README: ARTEX_TRUSTED_PROXIES for nginx and ARTEX on the host
  (127.0.0.1) and for nginx on the host with ARTEX in Docker (the bridge
  gateway, found with docker network inspect).
- CI shell job: run shellcheck on reset-password.sh too.
2026-11-05 17:06:10 +01:00
BaaRai d0d153b768 fix(auth): trusted proxies for the login rate limit, 12 characters in reset-password.sh
- ARTEX_TRUSTED_PROXIES (comma-separated CIDRs, empty by default). When
  the TCP peer is listed, the login limit reads X-Forwarded-For from the
  right and keys on the first hop that is not a trusted proxy; otherwise
  the header is ignored. Without it every client behind a reverse proxy
  shares one bucket and an attacker can block the admin. Passed through
  by docker-compose.yml and listed in .env.example.
- README: nginx example sends X-Forwarded-For; document the variable;
  the binary's default listen address is 127.0.0.1:8787.
- reset-password.sh refuses passwords under 12 characters, counted in
  characters like the server.
2026-11-05 16:57:48 +01:00
BaaRai 2419038884 fix(auth): rate-limit login per IP, 12-character passwords, loopback default
- POST /api/auth/login: 10 attempts per minute per TCP peer IP
  (X-Forwarded-For ignored), then 429 with Retry-After. The block starts
  at 1 minute and doubles on each attempt while blocked and on each new
  block, capped at 15 minutes. A successful login clears the IP. No
  per-account lockout. In-memory, idle entries swept after 15 minutes,
  at most 10,000 IPs.
- New passwords (init, change-password, /setup page) need 12 characters.
  Login does not check length, so existing 8-character passwords work.
- The binary's -addr default is 127.0.0.1:8787. The Docker image passes
  -addr :8787 explicitly and is unchanged.
- GET /api/health returns {"ok":true} without a valid token; with one it
  still returns service and version for the web UI top bar.
2026-11-05 16:48:27 +01:00
BaaRai e6e9b2f978 Add Trendshift badge to README
Added a Trendshift badge to the README.
2026-11-04 21:29:45 +01:00
BaaRai 248d9d9673 Merge pull request #149 from BaaRai/fix/jwt-key-volume
fix(auth): keep the JWT signing key in a dedicated volume
2026-11-04 09:57:52 +01:00
BaaRai 65bdc5d73c fix(auth): keep the JWT signing key in a dedicated volume
The server wrote jwt.key to the executable's directory, /app in the
image, so it lived in the container's writable layer and every container
rebuild generated a new key and signed all users out.

- ARTEX_JWT_KEY_DIR selects the key directory (default: config.BaseDir(),
  unchanged for non-Docker installs). docker-compose.yml sets it to
  /app/keys and bind-mounts ./keys there; the Dockerfile declares the
  volume. The key stays out of data/, which the file manager browses.
- loadOrCreateJWTKey creates the directory (0700). When keyDir/jwt.key is
  missing it migrates dataDir/jwt.key, then BaseDir()/jwt.key (mode 0600,
  old file removed), otherwise generates a new key. An existing key is
  never overwritten; a short one is now a startup error.
- .gitignore and .dockerignore exclude keys/.
- README and CHANGELOG updated; the 0.5.0 known issue is removed.
2026-11-04 09:18:11 +01:00
BaaRai fc35dddee8 release: v0.5.0 (#148)
Move the Unreleased entries into a 0.5.0 section and add the changes merged since v0.4.0 (#128, #136-#147), with known issues.
2026-11-04 01:26:24 +01:00
BaaRai 668e099dd8 fix(docker): build the image from source so a fresh clone builds (#147)
The Dockerfile copied a pre-built binary from dist/<arch>/artex into the
runtime image, but dist/ is git-ignored, so a fresh `git clone` has no such
file and `docker compose up -d --build` fails with
"/dist/amd64/artex: not found".

Build the binary from source in the image instead: a Node 22 stage runs the
frontend static export, a Go 1.26 stage runs build.sh with the same flags as
the release binaries job (CGO-free cross-compile, -trimpath, pinned version),
and the runtime stage copies the compiled binary. No build-context binary is
needed, so a clean clone builds.

Drop the now-obsolete dist/ warning in install.sh.
2026-11-04 01:06:59 +01:00
BaaRai 04777513b6 ci: give the race run its own database
The -race step reused the job's ARTEX_PG_DSN, so it ran against the database the plain test step had just populated. The db tests are not idempotent across runs and failed on duplicate llm_profiles names, turning main red right after #144 merged.
2026-11-03 23:58:15 +01:00
BaaRai 90e7b3ef55 Merge pull request #146 from BaaRai/fix/apply-new-options
fix(security): apply New options so the secret scrub actually runs
2026-11-03 23:52:28 +01:00
BaaRai b100848ebf Merge pull request #144 from BaaRai/fix/ci-race-shellcheck
ci: add go test -race and shellcheck
2026-11-03 23:49:40 +01:00
BaaRai 0093781c8b Merge pull request #142 from BaaRai/fix/llm-stored-key-endpoint
fix(llm): send a saved API key only to the endpoint it was saved for
2026-11-03 23:49:33 +01:00
BaaRai e94471609f Merge pull request #141 from BaaRai/fix/config-load-errors
fix(config): fail on malformed config.json; escape values in install.sh
2026-11-03 23:49:26 +01:00
BaaRai 77e2c5f9fa Merge pull request #145 from BaaRai/fix/go-1.26.10-xnet
build: go 1.26.10 and golang.org/x/net v0.62.0
2026-11-03 23:49:20 +01:00
BaaRai 41fe40af07 Merge pull request #143 from BaaRai/fix/node-22
fix(docker): install Node 22 in the runtime image
2026-11-03 23:49:03 +01:00
BaaRai 0d9602dc56 Merge pull request #140 from BaaRai/fix/llm-provider-env-case
fix(agent): match ARTEX_LLM_PROVIDER case-insensitively
2026-11-03 23:48:56 +01:00
BaaRai 5e872ccc15 Merge pull request #139 from BaaRai/fix/backup-keep-zero
fix(backup): reject --keep 0
2026-11-03 23:48:49 +01:00
BaaRai d3c4d21086 Merge pull request #138 from BaaRai/fix/gitignore-backups
fix(gitignore): ignore backups/ again
2026-11-03 23:48:42 +01:00
BaaRai 69831f5cdf fix(security): apply New options so the secret scrub actually runs
New accepted opts ...Option but never called them, so scrubSecrets stayed
false in the binary and ScrubSecretEnv was unreachable: the protection merged
in #136 never engaged. The unit test passed because it called the scrub
function directly instead of going through New.

Apply the options in the constructor and add a test that builds a Server
through New with WithSecretScrub and asserts the secrets are gone. Verified to
fail without the loop.
2026-11-03 23:47:49 +01:00
GRAB Racem 3322ecd6a6 ci: run shellcheck at warning severity
The first run reported only info-level notes (SC2015 in update.sh, SC2012 and
SC2086 on the intentional glob in backup.sh), which made the job fail.
2026-11-03 23:36:00 +01:00
GRAB Racem 7b2a5fb05e build: go 1.26.10, golang.org/x/net v0.62.0
Raises the go directive from 1.26.3 to 1.26.10, the newest 1.26 patch release
(CI installs it from go.mod; release.yml's "1.26" already resolves to it; the
Dockerfile ships a prebuilt binary and has no Go image). golang.org/x/net goes
from v0.58.0 to v0.62.0, the latest; go mod tidy raises x/crypto, x/text,
x/sys, x/sync, x/mod and x/tools to the versions it requires.
2026-11-03 23:34:39 +01:00
GRAB Racem 2d373ae77a ci: add a race-detector step and shellcheck for the install scripts
go test -race runs on every package that passes under the race detector
locally (all but ./server, which needs a -race check against postgres
first). A new shell job runs shellcheck on install.sh, update.sh, start.sh and
scripts/backup.sh.
2026-11-03 23:30:08 +01:00
GRAB Racem fc08133ab2 fix(docker): install Node 22 in the runtime image
The image installed Node 20 from NodeSource, which is end-of-life. CI and the
release workflow already build the frontend with Node 22; the runtime image
(Playwright MCP/CLI, npx-based MCP servers) now uses the same major.
2026-11-03 23:26:50 +01:00
GRAB Racem 334f6fdddc fix(llm): send a saved API key only to the endpoint it was saved for
POST /api/llm/test and POST /api/llm/models take provider and base_url from
the form but fell back to the stored profile key (and, for the test, the
global key) when the form had none. Any base_url typed into the form, or
supplied by a crafted request, received the saved key.

The saved key is now used only when the request's provider and base URL
normalize to the ones it was saved with (sameLLMEndpoint, built on
agent.ConfigFrom). Otherwise the key must be entered in the form, and the
error says why.
2026-11-03 23:25:47 +01:00
GRAB Racem a6e3fd0a2e fix(agent): match ARTEX_LLM_PROVIDER case-insensitively
FromEnv compared the raw value, so "Ollama" or "OpenAI" matched no case and
fell through to the anthropic default. With ARTEX_LLM_PROVIDER=Ollama and an
ANTHROPIC_API_KEY set, the Anthropic key was sent to the Ollama base URL.
2026-11-03 23:20:21 +01:00
GRAB Racem ddddddd1f3 fix(backup): reject --keep 0 so the new dump is never pruned
With --keep 0, prune ran tail -n +1 over the newest-first listing and deleted
every dump, including the one written seconds earlier, then reported success.
The check also rejects leading zeros, which the $((KEEP + 1)) arithmetic would
parse as octal (08 and 09 abort the script after the dump).
2026-11-03 23:18:45 +01:00
GRAB Racem f3f9b8d4fd fix(gitignore): split the merged bench/backups line so backups/ is ignored
Commit 2c8359f appended backups/ to the /Dockerfile.bench.v2 line, which had no
trailing newline. The result, /Dockerfile.bench.v2backups/, matched neither file,
so dumps written by scripts/backup.sh showed up as untracked and could be
committed by accident.
2026-11-03 23:17:56 +01:00
26 changed files with 1262 additions and 97 deletions
+1
View File
@@ -5,6 +5,7 @@ web/out
server/webui/dist
/artex
data
keys
*.db
transcripts
bench/kv
+3
View File
@@ -3,6 +3,9 @@
# Address the web UI is published on. 127.0.0.1 keeps it host-local; set 0.0.0.0
# to reach it from another machine or from Windows when ARTEX runs inside WSL.
ARTEX_BIND_ADDR=127.0.0.1
# Reverse proxies (comma-separated CIDRs) whose X-Forwarded-For the login rate
# limit trusts. Empty: the header is ignored. See "Reverse proxy deployment" in README.md.
ARTEX_TRUSTED_PROXIES=
POSTGRES_USER=artex
POSTGRES_PASSWORD=change-me-please
POSTGRES_DB=artex
+20
View File
@@ -44,6 +44,26 @@ jobs:
# as rotating failures in the agent package). Serial package execution
# keeps the database state deterministic.
run: go test -p 1 ./...
- name: go test -race
# Race detector on the packages that pass under -race today. ./server is
# left out until it has been checked with -race against postgres.
# Its own database: the plain run above leaves rows behind and the db tests
# are not idempotent across runs (duplicate llm_profiles names).
env:
ARTEX_PG_DSN: postgres://artex:testpass@localhost:5432/artextest_race?sslmode=disable
run: >-
go test -race -p 1 ./agent ./cmd/artex ./config ./db ./evidence ./guard
./intercept ./llmpool ./llmrec ./mcphttp ./notify ./sidequestion ./traffic
shell:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# shellcheck is preinstalled on the ubuntu runners. -S warning: the scripts
# currently have only info-level notes (intentional A && B || C chains in
# update.sh, an unquoted glob in backup.sh's prune), so fail on warnings.
- run: shellcheck -S warning install.sh update.sh start.sh scripts/backup.sh reset-password.sh
web:
runs-on: ubuntu-latest
+5 -1
View File
@@ -47,6 +47,7 @@ Thumbs.db
# JWT signing key (generated at runtime, stored in the project root, never committed)
/jwt.key
/keys/
# local environment generated by Docker deployment (contains passwords/keys, not committed)
.env
@@ -57,7 +58,10 @@ Thumbs.db
# bench benchmark related (local artifacts, not committed)
/bench/
/Dockerfile.bench
/Dockerfile.bench.v2backups/
/Dockerfile.bench.v2
# scripts/backup.sh output (database dumps, data archives)
backups/
# posture probe raw output
posture-runs/
+63 -5
View File
@@ -2,20 +2,76 @@
All notable changes to this project are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [Unreleased]
## [0.5.1] - 2026-11-05
Login rate limiting per client IP with trusted-proxy support, a 12-character minimum for new passwords, a loopback-only default for the binary, and the JWT signing key in its own Docker volume.
### Changed
- **The image is built from the checkout instead of pulled**: `docker-compose.yml` no longer references the `autumn27/artex` namespace, whose repository is gone and whose name its holder could republish at any time. `install.sh` and `update.sh` build the image locally.
- **`update.sh` refuses to rebuild stale code**: when `git pull --ff-only` fails on a diverged clone (including every clone made before the 2026-10-23 history rewrite), it now stops with recovery commands instead of warning, rebuilding the old tree and printing "Build complete".
- **The binary listens on `127.0.0.1:8787` by default**: the `-addr` default was `:8787`, every interface. Running `artex` or `./start.sh` without `-addr` now accepts connections from the local machine only; pass `-addr :8787` to listen on every interface. Docker is unchanged: the image starts the server with `-addr :8787` inside the container, and `docker-compose.yml` already publishes it on `127.0.0.1` by default (`ARTEX_BIND_ADDR`).
- **New passwords need at least 12 characters** (was 8), at first-run setup (`POST /api/auth/init` and the `/setup` page) and in `POST /api/auth/change-password`. Login does not check length, so a password set under the old minimum keeps working until it is changed. `reset-password.sh` enforces the same minimum.
### Fixed
- **Ollama base URL on the environment path**: `ARTEX_LLM_BASE_URL=http://host:11434` is normalized to the `/v1` root the same way the saved-profile path already was, so a LAN Ollama server configured through the environment no longer answers 404 on chat.
- **The JWT signing key survives container rebuilds**: the server wrote `jwt.key` to the executable's directory, `/app` in the image, so the key lived in the container's writable layer and recreating the container (for example `docker compose up -d --build`) generated a new key and signed every user out. The key directory is now set by `ARTEX_JWT_KEY_DIR` (default: the executable's directory, unchanged for non-Docker installs). `docker-compose.yml` sets it to `/app/keys` and bind-mounts `./keys` there, outside `./data`, which the file manager browses. On start, when `<key dir>/jwt.key` is missing, a key at `data/jwt.key`, then at the executable's directory, is copied there with mode 0600 and the old file is removed; otherwise a new key is generated. An existing key is never overwritten. Upgrading a Docker install signs every user out once, because the old key is in the container being replaced; to keep sessions, copy it out before recreating the container:
```bash
mkdir -p keys
docker cp artex-artex-1:/app/jwt.key ./keys/jwt.key
```
### Security
- **Login rate limit**: `POST /api/auth/login` allows 10 attempts per minute per client IP. The next attempt gets 429 with a `Retry-After` header (seconds) and a JSON error, and the IP is blocked for 1 minute. Each attempt while blocked, and each later block, doubles the wait, up to 15 minutes. A successful login clears the IP's count. There is no per-account lockout, so failing logins cannot lock the admin out from other addresses. The state is in memory: a restart clears it, an IP idle for 15 minutes is dropped, and at most 10,000 IPs are tracked (the least recently seen is dropped first). The IP is the TCP peer address, and `X-Forwarded-For` is ignored, unless the peer is listed in the new `ARTEX_TRUSTED_PROXIES` (comma-separated CIDRs, empty by default, passed through by `docker-compose.yml`). For a listed peer the header is read from the right and the first address that is not a trusted proxy is the client; entries the client added further left are never used. Behind a reverse proxy, set it: otherwise every client has the proxy's address and shares one budget, and an attacker failing logins through the proxy blocks the real users, admin included, for up to 15 minutes. The README nginx example now sends `X-Forwarded-For`.
- **`GET /api/health` hides the version from anonymous callers**: without a valid token it answers `{"ok":true}` only. With a token (the web UI sends one for the version in its top bar) the answer is unchanged: `ok`, `service` and `version`.
## [0.5.0] - 2026-11-04
Ollama support, a setup token for the first-run claim, secret scrubbing for child processes, a Docker image that builds from a fresh clone, Go 1.26.10 and Node 22, and race and shellcheck runs in CI. Entries marked "direct commit" landed on `main` without a pull request.
### Added
- **Ollama support**: an `ollama` LLM profile format for a local or LAN Ollama server. Keyless by design: no API key is needed, `OPENAI_API_KEY` is never inherited, the base URL defaults to `http://127.0.0.1:11434/v1` and is normalized to the `/v1` root, and the profile works in the failover pool, task LLM chains, model listing and connection tests. Contributed by @glauberportella (#128).
- **Setup token for first-run setup**: while no admin password is set, the server generates a random 128-bit one-time token at startup and prints it to its log (`docker compose logs artex | grep "Setup token"`). The `/setup` page has a field for it, `POST /api/auth/init` requires it (`setup_token`, compared in constant time, 403 when missing or wrong), and it is cleared once the password is stored (#137).
- **`ARTEX_BIND_ADDR`**: the address `docker-compose.yml` publishes the web UI on, default `127.0.0.1` (#137).
### Security
- **First-run claim**: before, anyone who reached port 8787 on a fresh install before the operator could set the admin password. Init now needs the setup token above, and Docker installs publish 8787 on `127.0.0.1` by default; set `ARTEX_BIND_ADDR=0.0.0.0` in `.env`, or use a reverse proxy or SSH tunnel, for remote access (#137).
- **Startup secrets removed from the environment**: `ARTEX_PG_DSN`, `ANTHROPIC_API_KEY` and `OPENAI_API_KEY` are unset inside `server.New` after they are read and before the MCP discovery goroutine and the task resume loop start, so agent shells, MCP stdio servers and custom tools spawned afterwards no longer inherit them (#136). The binary passes the option that enables this, but `New` did not apply its options, so the scrub from #136 never ran until #146 fixed it (#146). See Known issues for what this does not cover.
- **Saved API keys go only to their own endpoint**: `POST /api/llm/test` and `POST /api/llm/models` used a saved key (the profile's, or the global one) for any `base_url` in the request body, so a crafted request could send it to another host. A saved key is now sent only when the request's provider and normalized base URL match the ones it was saved for; otherwise the request must carry `api_key` (#142).
- **`ARTEX_LLM_PROVIDER` is matched case-insensitively**: `Ollama` or a value with surrounding spaces fell through to the Anthropic default and sent `ANTHROPIC_API_KEY` to the configured base URL (#140).
### Fixed
- **Ollama base URL on the environment path**: `ARTEX_LLM_BASE_URL=http://host:11434` is normalized to the `/v1` root the same way the saved-profile path already was, so a LAN Ollama server configured through the environment no longer answers 404 on chat (direct commit 1c35c9c).
- **Malformed `config.json` is an error**: `config.Load` used to ignore a parse error and treat the file as missing, which surfaced as a misleading "no database config found" or a silently ignored `skill_dir`. A read or parse error is now returned with the file path and startup exits (#141).
- **`install.sh` escapes what it writes**: the local install writes `config.json` string values through a JSON escaper and requires a numeric port; the Docker install escapes `\`, `|` and `&` in the Postgres password and API key before the `.env` substitution (#141).
- **`scripts/backup.sh --keep 0` is rejected**: it used to delete every dump, including the one just written, and still report success. Values with a leading zero are rejected too (#139).
- **`backups/` is ignored again**: a missing newline had merged it into the previous `.gitignore` line, so database dumps showed up as untracked files (#138).
### Build and CI
- **The image is built from the checkout instead of pulled**: `docker-compose.yml` no longer references the `autumn27/artex` namespace, whose repository is gone and whose name its holder could republish at any time. `install.sh` and `update.sh` build the image locally (direct commit 1ed154e).
- **A fresh clone builds the image**: the Dockerfile copied a prebuilt `dist/<arch>/artex` that a clean checkout does not have. It is now a multi-stage build: a Node 22 stage runs the frontend static export, a Go 1.26 stage runs `build.sh` with the release binaries job's flags (CGO-free cross-compile, `-trimpath`, version stamp), and the runtime stage copies the result (#147).
- **`update.sh` refuses to rebuild stale code**: when `git pull --ff-only` fails on a diverged clone (including every clone made before the 2026-10-23 history rewrite), it now stops with recovery commands instead of warning, rebuilding the old tree and printing "Build complete" (direct commit 1ed154e).
- **CI runs the race detector and shellcheck**: `go test -race -p 1` over every package with tests except `./server`, and `shellcheck -S warning` on `install.sh`, `update.sh`, `start.sh` and `scripts/backup.sh` (#144). The race run uses its own database (direct commit 0477751).
### Upgrades
- **Go 1.26.10** in `go.mod` (was 1.26.3), and **`golang.org/x/net` v0.62.0** (was v0.58.0), which raised x/crypto to 0.58.0, x/text to 0.43.0, x/sys to 0.49.0, x/sync to 0.24.0, x/mod to 0.41.0 and x/tools to 0.51.0 (#145).
- **Node 22 in the runtime image**: NodeSource `setup_22.x` instead of the end-of-life `setup_20.x`, matching CI and the release workflow (#143).
### Known issues
- **The secret scrub has limits**: `os.Unsetenv` changes only the Go runtime's copy, so a process running as the same user can still read `/proc/<pid>/environ` of the server (and, in Docker, of PID 1, `start.sh`). Other variables that may carry credentials, such as `ARTEX_LLM_PROXY`, are not scrubbed.
- **The LLM test and model-list endpoints still take `proxy` from the request**, so a saved key can travel through a request-supplied proxy when the base URL is plain HTTP.
- **Postgres passwords with URL-special characters** (`@`, `/`, `#`, `?`) break the `ARTEX_PG_DSN` that `docker-compose.yml` builds by interpolation, and Compose interpolates `$` in `.env`.
- **The build context includes local secrets**: `.dockerignore` does not exclude `.env`, `config.json` or `jwt.key`, so the build stage's `COPY . .` puts them in a build-stage layer of the local builder cache. They are not copied into the final image.
- **Image supply chain**: the recon tools are downloaded at pinned versions but not checksum-verified, sqlmap tracks its default branch, and the Playwright packages are installed at `@latest`. Draft #127 proposes checksum verification.
- **No Docker image is published for this release**: the release workflow's `docker` job only runs when `DOCKERHUB_ENABLED` is set, and it still downloads prebuilt binaries into `dist/` that the Dockerfile no longer uses. Build the image from the checkout with `docker compose up -d --build`.
- **CI gaps**: `./server` is not run under `-race`; there is no `govulncheck` step and no `docker build` job. Four tests skip themselves (fresh-database or timing failures, see #9).
- **Reported and not fixed in this release**: `list_task_credentials` emits an invalid `"required": [""]` schema that strict LLM providers reject (#130), and the MCP Streamable HTTP client fails against servers that only support protocol version 2025-11-25 (#131; fix proposed in #132).
## [0.4.0] - 2026-10-23
@@ -934,7 +990,9 @@ First release of this fork. The engine underneath is still upstream's, but this
- [@Autumn-27](https://github.com/Autumn-27)
- [@neouks](https://github.com/neouks)
[Unreleased]: https://github.com/BaaRai/ARTEX/compare/v0.4.0...HEAD
[Unreleased]: https://github.com/BaaRai/ARTEX/compare/v0.5.1...HEAD
[0.5.1]: https://github.com/BaaRai/ARTEX/compare/v0.5.0...v0.5.1
[0.5.0]: https://github.com/BaaRai/ARTEX/compare/v0.4.0...v0.5.0
[0.4.0]: https://github.com/BaaRai/ARTEX/compare/v0.3.15...v0.4.0
[0.3.10]: https://github.com/Autumn-27/ARTEX/compare/v0.3.9...v0.3.10
[0.3.9]: https://github.com/Autumn-27/ARTEX/compare/v0.3.8...v0.3.9
+51 -17
View File
@@ -1,24 +1,58 @@
# syntax=docker/dockerfile:1
#
# Runtime image (no compilation inside the image): install common tools only and ship
# a **pre-built Linux single binary**. The binary is cross-compiled by the CI binaries
# job (pure Go, no QEMU) and placed at dist/<TARGETARCH>/artex in the build context.
# This way multi-arch builds only need to emulate the apt layer for arm64 instead of
# emulating the Next/Go compilation, which is much faster.
# Multi-stage build: `docker compose up -d --build` (or `docker build .`) produces
# the complete image from source. No prebuilt binary in the build context is needed,
# so a fresh `git clone` builds — dist/ is git-ignored and is no longer required.
#
# To build the image manually, prepare the binary yourself first:
# cd web && npm run build:static && cd ..
# cp -r web/out server/webui/dist
# CGO_ENABLED=0 GOARCH=amd64 go build -tags embedui -o dist/amd64/artex ./cmd/artex
# docker build -t artex:local .
# frontend (web) -> Go binary with embedded UI (build) -> runtime image
#
# The Go binary is produced by build.sh with the same flags the release workflow's
# binaries job uses, so the image binary and the published release binary come from
# one source.
# ---------------------------------------------------------------- frontend ---
# Pinned to the Node 22 line the CI/release workflows use. On the build platform
# only; the frontend static export is architecture-independent.
FROM --platform=$BUILDPLATFORM node:22-bookworm-slim AS web
WORKDIR /src/web
COPY web/package.json web/package-lock.json ./
RUN npm ci --ignore-scripts
COPY web/ ./
RUN npm run build:static
# ------------------------------------------------------------- Go binary ---
# Pinned to the Go 1.26 line (matches go.mod and the release binaries job). The
# binary is CGO-free, so this stage runs on the build platform and cross-compiles
# to $TARGETARCH without QEMU.
FROM --platform=$BUILDPLATFORM golang:1.26-bookworm AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# Reuse the frontend built above; build.sh skips its own frontend step.
COPY --from=web /src/web/out ./server/webui/dist
ARG ARTEX_VERSION=dev
ARG TARGETOS
ARG TARGETARCH
# Same flags as the release binaries job (ARTEX_COMPRESS=0, -trimpath,
# -X main.version), driven through build.sh so there is one build path.
RUN ARTEX_SKIP_FRONTEND=1 \
ARTEX_SKIP_NPM_CI=1 \
ARTEX_COMPRESS=0 \
ARTEX_OUTPUT=/out/artex \
ARTEX_BUILD_VERSION="${ARTEX_VERSION}" \
bash ./build.sh --target "${TARGETOS:-linux}/${TARGETARCH:-amd64}"
# ---------------------------------------------------------------- runtime ---
FROM python:3.12-slim-bookworm
ARG TARGETARCH
# Common tools: ripgrep / curl / vim, plus a set of recon staples (add/remove as needed).
# Node 20.x comes from NodeSource: bookworm's apt nodejs is 18, but Playwright requires >=20.
# Node 22.x comes from NodeSource: bookworm's apt nodejs is 18, Playwright requires >=20,
# and 20 is end-of-life. 22 matches the CI and release workflows.
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates ripgrep curl wget vim git jq unzip \
dnsutils iputils-ping netcat-openbsd inetutils-telnet whois nmap \
&& curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
&& curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
&& apt-get install -y --no-install-recommends nodejs \
&& rm -rf /var/lib/apt/lists/*
# Web-recon toolbox (drost-community take 1, #100): pinned release binaries installed
@@ -83,16 +117,16 @@ RUN npm install -g @playwright/mcp@latest @playwright/cli@latest playwright@late
&& playwright install --with-deps chromium \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# pre-built binary for the matching architecture (dist/amd64/artex or dist/arm64/artex)
COPY dist/${TARGETARCH}/artex /app/artex
# Binary compiled from source in the build stage above.
COPY --from=build /out/artex /app/artex
# Daemon startup script: relaunches the process after a crash and forwards SIGTERM
# to artex — docker stop only signals PID 1; without forwarding, artex never receives it,
# cannot shut down gracefully, and is hard-killed by SIGKILL after 10 seconds.
COPY start.sh /app/start.sh
RUN chmod +x /app/artex /app/start.sh
RUN chmod +x /app/artex /app/start.sh && mkdir -p /app/keys
COPY skills/ /app/skills/
# persistence point for data/ (SQLite + jwt.key)
VOLUME ["/app/data"]
# persistence points: data/ (SQLite, task files) and keys/ (jwt.key, via ARTEX_JWT_KEY_DIR)
VOLUME ["/app/data", "/app/keys"]
EXPOSE 8787 8788
ENTRYPOINT ["/app/start.sh"]
CMD ["-addr", ":8787", "-proxy", ":8788"]
+12 -2
View File
@@ -9,6 +9,8 @@ AI autonomous penetration testing system (Go backend + Next.js frontend)
</div>
<a href="https://trendshift.io/repositories/316805?utm_source=trendshift-badge&amp;utm_medium=badge&amp;utm_campaign=badge-trendshift-316805" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/trendshift/repositories/316805/daily?language=Go" alt="BaaRai%2FARTEX | Trendshift" width="250" height="55"/></a>
---
## Screenshots
@@ -180,7 +182,7 @@ Daily cron example (03:20, operator crontab):
## Updating and upgrading
> Upgrading only swaps the program and never touches data: the Postgres volume `pgdata`, `./data` (jwt.key / SQLite, etc.), and `./skills` are all preserved. **No manual database migration is needed**: `artex` idempotently re-runs `schema.sql` on every start (with `ADD COLUMN` / `CREATE INDEX IF NOT EXISTS`), i.e. "restart equals migrate". It is still recommended to back up `./data` and the database before upgrading.
> Upgrading only swaps the program and never touches data: the Postgres volume `pgdata`, `./data` (SQLite, task files, etc.), `./keys` (jwt.key), and `./skills` are all preserved. **No manual database migration is needed**: `artex` idempotently re-runs `schema.sql` on every start (with `ADD COLUMN` / `CREATE INDEX IF NOT EXISTS`), i.e. "restart equals migrate". It is still recommended to back up `./data` and the database before upgrading.
### Upgrading from a clone made before 2026-10-23
@@ -261,7 +263,7 @@ Optional: `ARTEX_LLM_PROVIDER` (anthropic | openai | openai-responses | ollama)
### Reverse proxy deployment (HTTPS / only exposing 443)
The frontend and API/SSE are served by the same backend port (default `:8787`), and the realtime activity stream uses the **same-origin** address by default, so **there is no need to configure `NEXT_PUBLIC_SSE_BASE`**: just expose 443 publicly and keep 8787 on the internal network.
The frontend and API/SSE are served by the same backend port (8787; the binary listens on `127.0.0.1:8787` by default, the Docker container on `:8787` published on `127.0.0.1`), and the realtime activity stream uses the **same-origin** address by default, so **there is no need to configure `NEXT_PUBLIC_SSE_BASE`**: just expose 443 publicly and keep 8787 on the internal network.
SSE is a long-lived connection with continuous pushes, so the reverse proxy **must disable buffering**, otherwise the browser connects but never receives events (the activity stream spins forever). Nginx example:
@@ -275,6 +277,7 @@ server {
proxy_pass http://127.0.0.1:8787;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# SSE essentials: buffering off, long timeouts, HTTP/1.1
proxy_buffering off;
@@ -286,6 +289,13 @@ server {
}
```
**Login rate limit behind a proxy**: login allows 10 attempts per minute per client IP. Behind a proxy every request comes from the proxy's address, so without further setup all users share one budget and an attacker who fails 10 logins blocks everyone, admin included, for up to 15 minutes. To avoid that, keep the `X-Forwarded-For` line above and set `ARTEX_TRUSTED_PROXIES` (comma-separated CIDRs, empty by default) to the address ARTEX sees the proxy connect from:
- **nginx and ARTEX both on the host** (binary, `./start.sh`): the peer is `127.0.0.1`. Start ARTEX with `ARTEX_TRUSTED_PROXIES=127.0.0.1/32`.
- **nginx on the host, ARTEX in Docker** (`docker-compose.yml`): the connection reaches the container from the Docker bridge gateway (a `172.x` address), not from `127.0.0.1`. Find it with `docker network inspect artex_default --format '{{(index .IPAM.Config 0).Gateway}}'` (the network is `<project directory>_default`; `docker network ls` lists it), put `ARTEX_TRUSTED_PROXIES=<gateway>/32` in `.env`, and run `docker compose up -d`.
For a peer in that list, ARTEX reads `X-Forwarded-For` from the right and takes the first address that is not a trusted proxy; from any other peer the header is ignored. Trust only addresses of proxies you run: a trusted peer can claim any client address.
> Only when SSE needs a different origin from the page (e.g. a dedicated subdomain) should you set `NEXT_PUBLIC_SSE_BASE` **at build time** (this variable is baked into the static bundle during `next build`; setting it at container runtime has no effect).
---
+3 -1
View File
@@ -203,7 +203,9 @@ func compactionConfig(windowTokens int) *compaction.Config {
// ARTEX_LLM_PROXY = proxy URL (optional; http/https/socks5)
// ANTHROPIC_API_KEY / OPENAI_API_KEY = credentials (unused for ollama)
func FromEnv() (Config, bool) {
prov := os.Getenv("ARTEX_LLM_PROVIDER")
// Case- and space-insensitive: "Ollama" used to miss every case below and
// fall through to anthropic, sending ANTHROPIC_API_KEY to the Ollama base URL.
prov := strings.ToLower(strings.TrimSpace(os.Getenv("ARTEX_LLM_PROVIDER")))
anthKey := os.Getenv("ANTHROPIC_API_KEY")
oaiKey := os.Getenv("OPENAI_API_KEY")
+37
View File
@@ -0,0 +1,37 @@
package agent
import (
"testing"
"github.com/Autumn-27/norma/llm"
)
// ARTEX_LLM_PROVIDER is matched regardless of case and surrounding spaces. A
// miss used to fall through to anthropic: "Ollama" then sent ANTHROPIC_API_KEY
// to the Ollama base URL.
func TestFromEnvProviderIsCaseInsensitive(t *testing.T) {
t.Setenv("ANTHROPIC_API_KEY", "sk-ant-secret")
t.Setenv("OPENAI_API_KEY", "sk-oai")
t.Setenv("ARTEX_LLM_BASE_URL", "")
t.Setenv("ARTEX_LLM_MODEL", "")
for _, tc := range []struct {
env string
want llm.Format
key string
}{
{"Ollama", FormatOllama, ""},
{" OLLAMA ", FormatOllama, ""},
{"OpenAI", llm.FormatOpenAI, "sk-oai"},
{"OpenAI-Responses", llm.FormatOpenAIResponses, "sk-oai"},
{"Anthropic", llm.FormatAnthropic, "sk-ant-secret"},
} {
t.Setenv("ARTEX_LLM_PROVIDER", tc.env)
c, ok := FromEnv()
if !ok {
t.Fatalf("%q: FromEnv returned ok=false", tc.env)
}
if c.Format != tc.want || c.APIKey != tc.key {
t.Fatalf("%q: format=%v key=%q, want format=%v key=%q", tc.env, c.Format, c.APIKey, tc.want, tc.key)
}
}
}
+22 -2
View File
@@ -13,6 +13,7 @@ import (
"os/signal"
"path/filepath"
"runtime"
"strings"
"syscall"
"time"
@@ -49,7 +50,7 @@ func main() {
func run() int {
var (
addr = flag.String("addr", ":8787", "HTTP listen address")
addr = flag.String("addr", "127.0.0.1:8787", "HTTP listen address")
dataDir = flag.String("data", filepath.Join(config.BaseDir(), "data"), "data directory for SQLite stores (default: data/ next to the executable)")
proxy = flag.String("proxy", "127.0.0.1:8788", "traffic recording proxy address (empty to disable)")
)
@@ -97,7 +98,26 @@ func run() int {
skillDir = abs
}
log.Printf("[config] skill directory: %s", skillDir)
srv := server.New(ctx, mgr, skillDir, *dataDir, config.BaseDir(), server.WithSecretScrub())
// JWT signing key directory: ARTEX_JWT_KEY_DIR (the Docker image points it at
// the /app/keys volume) or the base dir. Never the browsable data dir.
keyDir := strings.TrimSpace(os.Getenv("ARTEX_JWT_KEY_DIR"))
if keyDir == "" {
keyDir = config.BaseDir()
}
if abs, err := filepath.Abs(keyDir); err == nil {
keyDir = abs
}
log.Printf("[config] JWT key directory: %s", keyDir)
// Proxies whose X-Forwarded-For the login rate limit reads; empty trusts none.
trusted, err := server.ParseTrustedProxies(os.Getenv("ARTEX_TRUSTED_PROXIES"))
if err != nil {
log.Printf("[config] ARTEX_TRUSTED_PROXIES: %v", err)
return 1
}
if len(trusted) > 0 {
log.Printf("[config] trusted proxies: %v", trusted)
}
srv := server.New(ctx, mgr, skillDir, *dataDir, keyDir, server.WithSecretScrub(), server.WithTrustedProxies(trusted))
httpSrv := &http.Server{
Addr: *addr,
Handler: srv.Handler(),
+6 -1
View File
@@ -34,13 +34,18 @@ services:
ARTEX_LLM_MODEL: ${ARTEX_LLM_MODEL:-}
ARTEX_LLM_BASE_URL: ${ARTEX_LLM_BASE_URL:-}
ARTEX_LLM_PROXY: ${ARTEX_LLM_PROXY:-}
ARTEX_JWT_KEY_DIR: /app/keys
ARTEX_TRUSTED_PROXIES: ${ARTEX_TRUSTED_PROXIES:-}
ports:
- "${ARTEX_BIND_ADDR:-127.0.0.1}:8787:8787"
- "127.0.0.1:8788:8788"
volumes:
# Bind the data directory (jwt.key, SQLite, etc.) to data/ in the project root,
# Bind the data directory (SQLite, task files, etc.) to data/ in the project root,
# so it is easy to inspect/back up on the host and survives container rebuilds.
- ./data:/app/data
# JWT signing key (jwt.key), kept out of data/, which the file manager browses.
# Persisting it keeps users signed in across container rebuilds.
- ./keys:/app/keys
# Mount skills/ from the project root into the container (overriding the image's built-in /app/skills);
# skill changes require no image rebuild — the container reads the host copy.
- ./skills:/app/skills
+8 -8
View File
@@ -1,6 +1,6 @@
module github.com/Autumn-27/artex
go 1.26.3
go 1.26.10
require (
github.com/Autumn-27/norma v0.4.3
@@ -11,9 +11,9 @@ require (
github.com/lqqyt2423/go-mitmproxy v1.9.2
github.com/miekg/dns v1.1.72
github.com/projectdiscovery/dnsx v1.2.3
golang.org/x/crypto v0.55.0
golang.org/x/net v0.58.0
golang.org/x/text v0.41.0
golang.org/x/crypto v0.58.0
golang.org/x/net v0.62.0
golang.org/x/text v0.43.0
modernc.org/sqlite v1.52.0
)
@@ -51,10 +51,10 @@ require (
go.uber.org/atomic v1.11.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8 // indirect
golang.org/x/mod v0.40.0 // indirect
golang.org/x/sync v0.22.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/tools v0.49.0 // indirect
golang.org/x/mod v0.41.0 // indirect
golang.org/x/sync v0.24.0 // indirect
golang.org/x/sys v0.49.0 // indirect
golang.org/x/tools v0.51.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.72.3 // indirect
modernc.org/mathutil v1.7.1 // indirect
+14 -14
View File
@@ -106,23 +106,23 @@ go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54=
golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w=
golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8 h1:yqrTHse8TCMW1M1ZCP+VAR/l0kKxwaAIqN/il7x4voA=
golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8/go.mod h1:tujkw807nyEEAamNbDrEGzRav+ilXA7PCRAd6xsmwiU=
golang.org/x/mod v0.40.0 h1:hUv+3cXcdRHz08UmSiOob7sadHig73uo5bkXxQ/tvUs=
golang.org/x/mod v0.40.0/go.mod h1:0/weTWkPWGBikyTWAX3dkjVztMmBA5hM0DH6BElSupE=
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=
golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o=
golang.org/x/net v0.62.0 h1:18ELalfM5+XNRwzM2nYqXsZQ9ByS1uhj2O/U7+5jqRY=
golang.org/x/net v0.62.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI=
golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA=
golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI=
golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo=
golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc=
golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s=
golang.org/x/tools v0.51.0 h1:k4Xc/1Om9jwkBJBo4NVLMSARBoWtK10mx+W5BnXCeAI=
golang.org/x/tools v0.51.0/go.mod h1:9eEncMayCV6zRMGhR5eZEC2iBx98qWcF1HZ9Z7wJOoA=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
-3
View File
@@ -56,9 +56,6 @@ install_docker(){
info "reusing existing .env"
fi
info "building the image from this checkout and starting..."
if [ ! -f "dist/amd64/artex" ] && [ ! -f "dist/arm64/artex" ]; then
warn "no prebuilt binary under dist/ - build it first (frontend, then go build -tags embedui); see the Dockerfile header"
fi
docker compose up -d --build
ok "started → http://localhost:8787"
info "view logs: docker compose logs -f artex"
+6 -2
View File
@@ -14,7 +14,7 @@
#
# Usage examples:
# ./reset-password.sh # local, auto-reads config.json/env, prompts for the new password
# ./reset-password.sh -p 'NewPass!' # local, new password given directly
# ./reset-password.sh -p 'New-Passphrase!' # local, new password given directly (at least 12 characters)
# ./reset-password.sh --dsn postgres://u:p@h:5432/artex
# ./reset-password.sh -H 127.0.0.1 -P 5433 -U autopentest -W pass -d artex
# ./reset-password.sh -m docker # docker deployment (reads POSTGRES_* from .env)
@@ -95,7 +95,7 @@ PY
}
apply_config_fields() {
local line key val
local key val
while IFS=$'\t' read -r key val; do
[[ -z "$key" ]] && continue
case "$key" in
@@ -130,6 +130,10 @@ if [[ -z "$NEWPASS" ]]; then
[[ "$NEWPASS" == "$NEWPASS2" ]] || die "the two entries do not match"
fi
[[ -n "$NEWPASS" ]] || die "password cannot be empty"
# Same minimum as the server (minPasswordRunes in server/auth.go), counted in
# characters like the server: UTF-8 bytes minus continuation bytes, whatever the locale.
NEWPASS_CHARS=$(printf '%s' "$NEWPASS" | LC_ALL=C tr -d '\200-\277' | wc -c)
(( NEWPASS_CHARS >= 12 )) || die "password must be at least 12 characters"
# Hand the password to psql via environment variable (read with \getenv, never in argv/ps)
export ARTEX_RESET_NEWPASS="$NEWPASS"
+3 -1
View File
@@ -31,7 +31,9 @@ while [ $# -gt 0 ]; do
*) echo "unknown option: $1" >&2; usage >&2; exit 2 ;;
esac
done
case "$KEEP" in (*[!0-9]*|'') echo "--keep must be a positive integer" >&2; exit 2 ;; esac
# 0* rejects 0, which would make prune delete the dump this run just wrote, and
# leading zeros, which $((KEEP + 1)) would read as octal.
case "$KEEP" in (*[!0-9]*|''|0*) echo "--keep must be a positive integer" >&2; exit 2 ;; esac
# Compose reads .env for credentials; source it for the dump command too.
if [ -f .env ]; then
+1 -1
View File
@@ -58,7 +58,7 @@ Standing rules, enforced by the runner:
cd scripts/benchmark
# against a locally deployed ARTEX, with an independent judge endpoint
ARTEX_TOKEN=<jwt or mint via ARTEX_JWT_KEY=/app/jwt.key copy> \
ARTEX_TOKEN=<jwt or mint via ARTEX_JWT_KEY=/app/keys/jwt.key copy> \
JUDGE_BASE_URL=https://api.example.com JUDGE_MODEL=<other-model> JUDGE_API_KEY=... \
python3 run_bench.py --corpus juice-shop --subject-model glm-5.3
```
+109 -20
View File
@@ -10,12 +10,15 @@ import (
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"unicode/utf8"
"github.com/golang-jwt/jwt/v5"
"golang.org/x/crypto/bcrypt"
"github.com/Autumn-27/artex/config"
)
const (
@@ -27,7 +30,9 @@ const (
// The lower bound matches the setup page's frontend validation — validation that
// lives only in the frontend is as good as none: hitting the API directly bypasses
// it. The upper bound is bcrypt's hard limit: past 72 bytes GenerateFromPassword returns ErrPasswordTooLong; rejecting early beats a cryptic "password encryption failed".
minPasswordRunes = 8
// Both apply to new passwords only (init, change-password); login never checks
// length, so passwords set under the old 8-character minimum still log in.
minPasswordRunes = 12
maxPasswordBytes = 72
)
@@ -47,28 +52,38 @@ func validatePassword(pw string) string {
return ""
}
// jwtLegacyBaseDir is the second legacy key location (the executable's directory,
// where releases before ARTEX_JWT_KEY_DIR wrote the key). A var so tests can point it
// at a temp dir.
var jwtLegacyBaseDir = config.BaseDir
// loadOrCreateJWTKey reads the 32-byte signing key from keyDir/jwt.key. keyDir is
// the project base dir (next to the executable), NOT the browsable workspace root
// (dataDir) — the signing key must never be listable/downloadable via the file
// manager. Legacy installs kept it at dataDir/jwt.key; if present there and not yet
// at the new location, it is migrated (key preserved, so sessions stay valid) and
// the old file removed so it disappears from the workspace. On first run a random
// key is generated and persisted.
// ARTEX_JWT_KEY_DIR (the /app/keys volume in Docker) or the project base dir, NOT
// the browsable workspace root (dataDir) — the signing key must never be
// listable/downloadable via the file manager. When keyDir/jwt.key is missing, a key
// at dataDir/jwt.key, then at BaseDir()/jwt.key, is migrated (key preserved, so
// sessions stay valid) and the old file removed. Otherwise a random key is
// generated. An existing key file is never overwritten.
func loadOrCreateJWTKey(keyDir, dataDir string) ([]byte, error) {
if err := os.MkdirAll(keyDir, 0o700); err != nil {
return nil, fmt.Errorf("create jwt key dir: %w", err)
}
path := filepath.Join(keyDir, jwtKeyFilename)
// one-time migration out of the old in-workspace location.
if legacy := filepath.Join(dataDir, jwtKeyFilename); legacy != path {
if _, err := os.Stat(path); os.IsNotExist(err) {
if data, rerr := os.ReadFile(legacy); rerr == nil {
if werr := os.WriteFile(path, data, 0o600); werr == nil {
_ = os.Remove(legacy)
log.Printf("[auth] JWT key migrated from %s to %s (moved out of the browsable workspace)", legacy, path)
}
}
if _, err := os.Stat(path); os.IsNotExist(err) {
if err := migrateJWTKey(path, dataDir); err != nil {
return nil, err
}
}
if data, err := os.ReadFile(path); err == nil && len(strings.TrimSpace(string(data))) >= 32 {
return []byte(strings.TrimSpace(string(data))), nil
data, err := os.ReadFile(path)
if err == nil {
key := strings.TrimSpace(string(data))
if len(key) < 32 {
return nil, fmt.Errorf("jwt key %s is shorter than 32 bytes; fix or remove it (it is never overwritten)", path)
}
return []byte(key), nil
}
if !os.IsNotExist(err) {
return nil, fmt.Errorf("read jwt key: %w", err)
}
buf := make([]byte, 32)
for i := range buf {
@@ -78,13 +93,71 @@ func loadOrCreateJWTKey(keyDir, dataDir string) ([]byte, error) {
}
buf[i] = keyChars[n.Int64()]
}
if err := os.WriteFile(path, buf, 0600); err != nil {
if err := writeNewJWTKey(path, buf); err != nil {
return nil, fmt.Errorf("write jwt key: %w", err)
}
log.Printf("[auth] new JWT key written to %s", path)
return buf, nil
}
// migrateJWTKey copies the first legacy key found (dataDir, then BaseDir) to dst
// and removes the old file. Candidates equal to dst are skipped.
func migrateJWTKey(dst, dataDir string) error {
for _, src := range []string{
filepath.Join(dataDir, jwtKeyFilename),
filepath.Join(jwtLegacyBaseDir(), jwtKeyFilename),
} {
if samePath(src, dst) {
continue
}
data, err := os.ReadFile(src)
if os.IsNotExist(err) {
continue
}
if err != nil {
log.Printf("[auth] cannot read legacy JWT key %s: %v", src, err)
continue
}
if err := writeNewJWTKey(dst, data); err != nil {
return fmt.Errorf("migrate jwt key from %s: %w", src, err)
}
if err := os.Remove(src); err != nil {
log.Printf("[auth] JWT key copied to %s but the old file %s could not be removed: %v", dst, src, err)
}
log.Printf("[auth] JWT key migrated from %s to %s", src, dst)
return nil
}
return nil
}
// writeNewJWTKey creates path with mode 0600, failing if it already exists.
func writeNewJWTKey(path string, data []byte) error {
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
if err != nil {
return err
}
if _, err := f.Write(data); err != nil {
f.Close()
_ = os.Remove(path)
return err
}
if err := f.Close(); err != nil {
_ = os.Remove(path)
return err
}
return nil
}
func samePath(a, b string) bool {
if absA, err := filepath.Abs(a); err == nil {
a = absA
}
if absB, err := filepath.Abs(b); err == nil {
b = absB
}
return a == b
}
// signJWT issues a 7-day HS256 token for user ARTEX.
func signJWT(key []byte) (string, error) {
return jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.RegisteredClaims{
@@ -302,6 +375,21 @@ func (s *Server) authLogin(w http.ResponseWriter, r *http.Request) {
if pg == nil {
return
}
s.login(w, r, pg.GetSetting)
}
// login is authLogin past the data-source check; getSetting is pg.GetSetting,
// a parameter so tests can run it without PostgreSQL. Every attempt counts
// against the caller's IP (s.loginLimit, keyed by clientIP) and a successful
// one clears it. There is no per-account lockout and no password length check here.
func (s *Server) login(w http.ResponseWriter, r *http.Request, getSetting func(string) (string, bool, error)) {
ip := clientIP(r, s.trustedProxies)
if wait := s.loginLimit.allow(ip); wait > 0 {
secs := int((wait + time.Second - 1) / time.Second)
w.Header().Set("Retry-After", strconv.Itoa(secs))
writeErr(w, 429, fmt.Sprintf("Too many login attempts, try again in %d seconds", secs))
return
}
var req struct {
Username string `json:"username"`
Password string `json:"password"`
@@ -314,7 +402,7 @@ func (s *Server) authLogin(w http.ResponseWriter, r *http.Request) {
writeErr(w, 401, "Incorrect username or password")
return
}
hash, ok, err := pg.GetSetting(authPassKey)
hash, ok, err := getSetting(authPassKey)
if err != nil {
writeErr(w, 503, errDataSourceUnavailable)
return
@@ -327,6 +415,7 @@ func (s *Server) authLogin(w http.ResponseWriter, r *http.Request) {
writeErr(w, 401, "Incorrect username or password")
return
}
s.loginLimit.reset(ip)
tok, err := signJWT(s.jwtKey)
if err != nil {
writeErr(w, 500, "token generation failed")
+183 -4
View File
@@ -3,10 +3,188 @@ package server
import (
"encoding/json"
"net/http/httptest"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
// jwtKeyDirs returns fresh key, data and legacy base dirs and points
// jwtLegacyBaseDir at the latter for the duration of the test. keyDir does not
// exist yet, so loadOrCreateJWTKey has to create it.
func jwtKeyDirs(t *testing.T) (keyDir, dataDir, baseDir string) {
t.Helper()
root := t.TempDir()
keyDir = filepath.Join(root, "keys")
dataDir = filepath.Join(root, "data")
baseDir = filepath.Join(root, "base")
for _, d := range []string{dataDir, baseDir} {
if err := os.MkdirAll(d, 0o755); err != nil {
t.Fatal(err)
}
}
old := jwtLegacyBaseDir
jwtLegacyBaseDir = func() string { return baseDir }
t.Cleanup(func() { jwtLegacyBaseDir = old })
return keyDir, dataDir, baseDir
}
// assertKeyFileMode checks for 0600. Windows has no Unix permission bits (Go
// reports 0666 or 0444), so the check is skipped there.
func assertKeyFileMode(t *testing.T, path string) {
t.Helper()
fi, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
if runtime.GOOS == "windows" {
t.Logf("mode check skipped on windows (got %v)", fi.Mode().Perm())
return
}
if got := fi.Mode().Perm(); got != 0o600 {
t.Fatalf("%s mode=%v want 0600", path, got)
}
}
func assertMissing(t *testing.T, path string) {
t.Helper()
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Fatalf("%s should not exist (stat err=%v)", path, err)
}
}
func TestLoadOrCreateJWTKeyMigrates(t *testing.T) {
const legacyKey = "legacy-key-0123456789abcdefghijkl"
for _, src := range []string{"data", "base"} {
t.Run(src, func(t *testing.T) {
keyDir, dataDir, baseDir := jwtKeyDirs(t)
legacy := filepath.Join(dataDir, jwtKeyFilename)
if src == "base" {
legacy = filepath.Join(baseDir, jwtKeyFilename)
}
if err := os.WriteFile(legacy, []byte(legacyKey+"\n"), 0o644); err != nil {
t.Fatal(err)
}
key, err := loadOrCreateJWTKey(keyDir, dataDir)
if err != nil {
t.Fatal(err)
}
if string(key) != legacyKey {
t.Fatalf("key=%q want the migrated %q", key, legacyKey)
}
path := filepath.Join(keyDir, jwtKeyFilename)
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if string(data) != legacyKey+"\n" {
t.Fatalf("migrated file content=%q, want the legacy bytes unchanged", data)
}
assertKeyFileMode(t, path)
assertMissing(t, legacy)
})
}
}
func TestLoadOrCreateJWTKeyMigratesDataDirFirst(t *testing.T) {
keyDir, dataDir, baseDir := jwtKeyDirs(t)
dataKey := "data-key-0123456789abcdefghijklmn"
baseKey := "base-key-0123456789abcdefghijklmn"
if err := os.WriteFile(filepath.Join(dataDir, jwtKeyFilename), []byte(dataKey), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(baseDir, jwtKeyFilename), []byte(baseKey), 0o600); err != nil {
t.Fatal(err)
}
key, err := loadOrCreateJWTKey(keyDir, dataDir)
if err != nil {
t.Fatal(err)
}
if string(key) != dataKey {
t.Fatalf("key=%q want the dataDir key", key)
}
assertMissing(t, filepath.Join(dataDir, jwtKeyFilename))
}
func TestLoadOrCreateJWTKeyGenerates(t *testing.T) {
keyDir, dataDir, _ := jwtKeyDirs(t)
key, err := loadOrCreateJWTKey(keyDir, dataDir)
if err != nil {
t.Fatal(err)
}
if len(key) != 32 {
t.Fatalf("len(key)=%d want 32", len(key))
}
path := filepath.Join(keyDir, jwtKeyFilename)
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if len(data) != 32 || string(data) != string(key) {
t.Fatalf("file holds %d bytes, want the 32 returned", len(data))
}
assertKeyFileMode(t, path)
assertMissing(t, filepath.Join(dataDir, jwtKeyFilename))
again, err := loadOrCreateJWTKey(keyDir, dataDir)
if err != nil {
t.Fatal(err)
}
if string(again) != string(key) {
t.Fatal("second load returned a different key")
}
}
func TestLoadOrCreateJWTKeyNeverOverwrites(t *testing.T) {
keyDir, dataDir, baseDir := jwtKeyDirs(t)
if err := os.MkdirAll(keyDir, 0o700); err != nil {
t.Fatal(err)
}
path := filepath.Join(keyDir, jwtKeyFilename)
current := "current-key-0123456789abcdefghijk"
if err := os.WriteFile(path, []byte(current), 0o600); err != nil {
t.Fatal(err)
}
// legacy keys must not replace the current one, and are left alone.
legacyData := filepath.Join(dataDir, jwtKeyFilename)
legacyBase := filepath.Join(baseDir, jwtKeyFilename)
for _, p := range []string{legacyData, legacyBase} {
if err := os.WriteFile(p, []byte("legacy-key-0123456789abcdefghijkl"), 0o600); err != nil {
t.Fatal(err)
}
}
key, err := loadOrCreateJWTKey(keyDir, dataDir)
if err != nil {
t.Fatal(err)
}
if string(key) != current {
t.Fatalf("key=%q want the existing %q", key, current)
}
for _, p := range []string{legacyData, legacyBase} {
if _, err := os.Stat(p); err != nil {
t.Fatalf("legacy key %s should be untouched: %v", p, err)
}
}
// a short (invalid) key is an error, not a reason to replace it.
short := "too-short"
if err := os.WriteFile(path, []byte(short), 0o600); err != nil {
t.Fatal(err)
}
if _, err := loadOrCreateJWTKey(keyDir, dataDir); err == nil {
t.Fatal("want an error for a short key")
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if string(data) != short {
t.Fatalf("short key was overwritten with %d bytes", len(data))
}
}
// GetSetting distinguishes "key absent" from "read failed" only through its error:
// both return an empty value. If a password handler mistakes the error for "no
// password set yet", a database blip opens the init door — authInit would let an
@@ -124,10 +302,11 @@ func TestValidatePassword(t *testing.T) {
wantErr bool
}{
{"empty", "", true},
{"seven chars", "1234567", true},
{"eight chars", "12345678", false},
{"eight multibyte chars counted as runes not bytes", strings.Repeat("€", 8), false},
{"three chars reach 9 bytes but only 3 runes", strings.Repeat("€", 3), true},
{"eight chars, the old minimum", "12345678", true},
{"eleven chars", "12345678901", true},
{"twelve chars", "123456789012", false},
{"twelve multibyte chars counted as runes not bytes", strings.Repeat("€", 12), false},
{"four chars reach 12 bytes but only 4 runes", strings.Repeat("€", 4), true},
{"72 bytes", strings.Repeat("a", 72), false},
{"73 bytes exceeds the bcrypt cap", strings.Repeat("a", 73), true},
} {
+29
View File
@@ -1,6 +1,8 @@
package server
import (
"context"
"os"
"os/exec"
"strings"
"testing"
@@ -34,3 +36,30 @@ func TestScrubSecretEnvHidesSecretsFromChildren(t *testing.T) {
t.Errorf("non-secret variable did not reach the child process")
}
}
// New must apply the options it receives. An earlier version accepted
// ...Option and never called them, so the binary kept the secrets in the
// environment while the unit test above still passed.
func TestNewAppliesSecretScrubOption(t *testing.T) {
// t.Setenv restores both after the test: the scrub unsets them for the rest
// of this process, and later tests still need the DSN.
if dsn := os.Getenv("ARTEX_PG_DSN"); dsn != "" {
t.Setenv("ARTEX_PG_DSN", dsn)
}
t.Setenv("ANTHROPIC_API_KEY", "scrub-probe")
t.Setenv("OPENAI_API_KEY", "scrub-probe")
m, err := NewManager(t.TempDir(), "")
if err != nil {
t.Skipf("no manager: %v", err)
}
t.Cleanup(func() { m.Close() })
ctx, cancel := context.WithCancel(context.Background())
cancel()
_ = New(ctx, m, t.TempDir(), t.TempDir(), t.TempDir(), WithSecretScrub())
if os.Getenv("ANTHROPIC_API_KEY") != "" || os.Getenv("OPENAI_API_KEY") != "" {
t.Fatal("New did not apply WithSecretScrub: the secrets are still in the environment")
}
}
+127
View File
@@ -0,0 +1,127 @@
package server
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"github.com/Autumn-27/artex/agent"
"github.com/Autumn-27/artex/db"
)
func TestSameLLMEndpoint(t *testing.T) {
for _, tc := range []struct {
provA, baseA, provB, baseB string
want bool
}{
{"anthropic", "https://gw.example", "anthropic", "https://gw.example/", true},
{"anthropic", "https://gw.example/v1/messages", "anthropic", "https://gw.example", true},
{"", "", "anthropic", "", true}, // empty provider defaults to anthropic
{"openai", " https://gw.example/v1/chat/completions ", "openai", "https://gw.example/v1", true},
{"anthropic", "https://evil.example", "anthropic", "https://gw.example", false},
{"anthropic", "", "anthropic", "https://gw.example", false}, // canonical host != saved host
{"openai", "", "anthropic", "", false}, // different canonical hosts
{"openai", "https://gw.example/v1", "openai-responses", "https://gw.example/v1", false},
} {
if got := sameLLMEndpoint(tc.provA, tc.baseA, tc.provB, tc.baseB); got != tc.want {
t.Errorf("sameLLMEndpoint(%q,%q,%q,%q)=%v want %v", tc.provA, tc.baseA, tc.provB, tc.baseB, got, tc.want)
}
}
}
// keyRecorder is a fake Anthropic endpoint that records the x-api-key it receives.
func keyRecorder(t *testing.T) (*httptest.Server, *atomic.Int32, *atomic.Value) {
var hits atomic.Int32
var key atomic.Value
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hits.Add(1)
key.Store(r.Header.Get("x-api-key"))
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"id":"m","type":"message","role":"assistant","model":"m","content":[{"type":"text","text":"OK"}],"stop_reason":"end_turn","usage":{"input_tokens":1,"output_tokens":1},"data":[]}`))
}))
t.Cleanup(srv.Close)
return srv, &hits, &key
}
func postJSON(t *testing.T, h http.HandlerFunc, path string, body map[string]any) map[string]any {
t.Helper()
b, _ := json.Marshal(body)
req := httptest.NewRequest(http.MethodPost, path, bytes.NewReader(b))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
h(rec, req)
var resp map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &resp)
return resp
}
// The global key must not follow a base_url typed into the test form.
func TestLLMTestGlobalKeyOnlyForGlobalEndpoint(t *testing.T) {
saved, savedHits, savedKey := keyRecorder(t)
other, otherHits, _ := keyRecorder(t)
s := &Server{}
s.llmCfg = agent.ConfigFrom("anthropic", "m", saved.URL, "global-secret", "")
resp := postJSON(t, s.testLLM, "/api/llm/test", map[string]any{
"provider": "anthropic", "model": "m", "base_url": other.URL, "api_key": "", "streaming": false,
})
if otherHits.Load() != 0 || resp["ok"] != false {
t.Fatalf("global key went to a form-supplied host: hits=%d resp=%v", otherHits.Load(), resp)
}
postJSON(t, s.testLLM, "/api/llm/test", map[string]any{
"provider": "anthropic", "model": "m", "base_url": saved.URL + "/", "api_key": "", "streaming": false,
})
if k, _ := savedKey.Load().(string); savedHits.Load() != 1 || k != "global-secret" {
t.Fatalf("global key not used for its own endpoint: hits=%d key=%q", savedHits.Load(), k)
}
}
// A stored profile's key goes only to that profile's base URL, for both the
// connection test and the model listing.
func TestStoredProfileKeyOnlyForStoredEndpoint(t *testing.T) {
m, err := NewManager(t.TempDir(), "")
if err != nil {
t.Skipf("postgres unavailable (%v)", err)
}
t.Cleanup(func() { m.Close() })
ctx, cancel := context.WithCancel(context.Background())
cancel()
s := New(ctx, m, t.TempDir(), t.TempDir(), t.TempDir())
s.cfgMu.Lock()
s.llmCfg = agent.Config{} // no global key to fall back on
s.cfgMu.Unlock()
saved, savedHits, savedKey := keyRecorder(t)
other, otherHits, _ := keyRecorder(t)
pid, err := m.pg.SaveProfile(&db.LLMProfile{
Name: "key-endpoint", Format: "anthropic", Model: "m", BaseURL: saved.URL, APIKey: "profile-secret",
})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _, _ = m.pg.Exec("DELETE FROM llm_profiles WHERE id=$1", pid) })
for _, ep := range []struct {
path string
h http.HandlerFunc
}{{"/api/llm/test", s.testLLM}, {"/api/llm/models", s.pgListModels}} {
resp := postJSON(t, ep.h, ep.path, map[string]any{
"provider": "anthropic", "model": "m", "base_url": other.URL, "api_key": "", "profile_id": pid, "streaming": false,
})
if otherHits.Load() != 0 || resp["ok"] != false {
t.Fatalf("%s: stored key went to a form-supplied host: hits=%d resp=%v", ep.path, otherHits.Load(), resp)
}
before := savedHits.Load()
postJSON(t, ep.h, ep.path, map[string]any{
"provider": "anthropic", "model": "m", "base_url": saved.URL, "api_key": "", "profile_id": pid, "streaming": false,
})
if k, _ := savedKey.Load().(string); savedHits.Load() == before || k != "profile-secret" {
t.Fatalf("%s: stored key not used for its own endpoint: key=%q", ep.path, k)
}
}
}
+191
View File
@@ -0,0 +1,191 @@
package server
import (
"fmt"
"net"
"net/http"
"net/netip"
"strings"
"sync"
"time"
)
// Login attempts are limited per client IP, never per account: a per-account
// lockout would let anyone lock the admin out by failing on purpose.
const (
loginMaxAttempts = 10 // attempts allowed per IP per window
loginWindow = time.Minute // the counting window
loginBaseBlock = time.Minute // length of the first block
loginMaxBlock = 15 * time.Minute // cap on the block; an entry idle this long is dropped
loginSweepEvery = time.Minute // how often stale entries are swept
loginMaxEntries = 10000 // hard cap on tracked IPs
)
type loginEntry struct {
attempts int
windowStart time.Time
blockedUntil time.Time
block time.Duration // current block length; doubles on every new block or blocked attempt
lastSeen time.Time
}
// loginLimiter counts login attempts per IP. Past loginMaxAttempts in one
// loginWindow the IP is blocked for loginBaseBlock; every attempt while blocked,
// and every later block, doubles the block up to loginMaxBlock. State is in
// memory only and swept of idle entries, so a restart clears it.
type loginLimiter struct {
mu sync.Mutex
now func() time.Time
entries map[string]*loginEntry
lastSweep time.Time
}
func newLoginLimiter(now func() time.Time) *loginLimiter {
return &loginLimiter{now: now, entries: map[string]*loginEntry{}}
}
// allow records one attempt from ip. It returns 0 when the attempt may go on,
// otherwise how long ip must wait.
func (l *loginLimiter) allow(ip string) time.Duration {
l.mu.Lock()
defer l.mu.Unlock()
now := l.now()
l.sweep(now)
e := l.entries[ip]
if e == nil {
if len(l.entries) >= loginMaxEntries {
l.evictOldest()
}
e = &loginEntry{windowStart: now}
l.entries[ip] = e
}
e.lastSeen = now
if now.Before(e.blockedUntil) {
e.block = min(2*e.block, loginMaxBlock)
e.blockedUntil = now.Add(e.block)
return e.block
}
if now.Sub(e.windowStart) >= loginWindow {
e.attempts, e.windowStart = 0, now
}
if e.attempts >= loginMaxAttempts {
if e.block == 0 {
e.block = loginBaseBlock
} else {
e.block = min(2*e.block, loginMaxBlock)
}
e.blockedUntil = now.Add(e.block)
e.attempts, e.windowStart = 0, now
return e.block
}
e.attempts++
return 0
}
// reset forgets ip, after a successful login. A blocked IP never reaches the
// password check, so this only runs outside a block.
func (l *loginLimiter) reset(ip string) {
l.mu.Lock()
defer l.mu.Unlock()
delete(l.entries, ip)
}
// sweep drops entries idle for loginMaxBlock, at most once per loginSweepEvery.
// Such an entry is never blocked: a block ends at most loginMaxBlock after the
// attempt that set it.
func (l *loginLimiter) sweep(now time.Time) {
if now.Sub(l.lastSweep) < loginSweepEvery {
return
}
l.lastSweep = now
for ip, e := range l.entries {
if now.Sub(e.lastSeen) >= loginMaxBlock {
delete(l.entries, ip)
}
}
}
// evictOldest drops the least recently seen entry to stay under loginMaxEntries.
func (l *loginLimiter) evictOldest() {
var oldest string
var at time.Time
for ip, e := range l.entries {
if oldest == "" || e.lastSeen.Before(at) {
oldest, at = ip, e.lastSeen
}
}
delete(l.entries, oldest)
}
// peerIP is the host part of the TCP peer address.
func peerIP(r *http.Request) string {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
return host
}
// clientIP is the address login attempts are counted against. It is the TCP
// peer unless the peer is in trusted: then X-Forwarded-For is read from the
// right, past trusted hops, and the first untrusted hop is the client. Entries
// left of that hop come from the client itself and are never used, so a client
// cannot pick its own bucket. With trusted empty the header is ignored.
func clientIP(r *http.Request, trusted []netip.Prefix) string {
peer := peerIP(r)
addr, err := netip.ParseAddr(peer)
if err != nil || !isTrusted(addr, trusted) {
return peer
}
hops := strings.Split(strings.Join(r.Header.Values("X-Forwarded-For"), ","), ",")
for i := len(hops) - 1; i >= 0; i-- {
hop, err := netip.ParseAddr(strings.TrimSpace(hops[i]))
if err != nil {
break // keep the last address a trusted proxy reported
}
addr = hop
if !isTrusted(addr, trusted) {
break
}
}
return addr.Unmap().WithZone("").String()
}
func isTrusted(a netip.Addr, trusted []netip.Prefix) bool {
a = a.Unmap().WithZone("")
for _, p := range trusted {
if p.Contains(a) {
return true
}
}
return false
}
// ParseTrustedProxies parses ARTEX_TRUSTED_PROXIES: comma-separated CIDRs (a
// bare address means that one host). Empty means no trusted proxy.
func ParseTrustedProxies(s string) ([]netip.Prefix, error) {
var out []netip.Prefix
for _, f := range strings.Split(s, ",") {
f = strings.TrimSpace(f)
if f == "" {
continue
}
if p, err := netip.ParsePrefix(f); err == nil {
out = append(out, p.Masked())
continue
}
a, err := netip.ParseAddr(f)
if err != nil || a.Zone() != "" {
return nil, fmt.Errorf("%q is not a CIDR or an IP address", f)
}
a = a.Unmap()
out = append(out, netip.PrefixFrom(a, a.BitLen()))
}
return out, nil
}
// WithTrustedProxies sets the proxies whose X-Forwarded-For the login rate
// limit reads (see clientIP).
func WithTrustedProxies(p []netip.Prefix) Option {
return func(s *Server) { s.trustedProxies = p }
}
+299
View File
@@ -0,0 +1,299 @@
package server
import (
"encoding/json"
"fmt"
"net/http/httptest"
"net/netip"
"strings"
"testing"
"time"
"golang.org/x/crypto/bcrypt"
)
type fakeClock struct{ t time.Time }
func (c *fakeClock) now() time.Time { return c.t }
func (c *fakeClock) advance(d time.Duration) { c.t = c.t.Add(d) }
func newFakeClock() *fakeClock { return &fakeClock{t: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)} }
// oldPassword is 8 characters: valid under the old minimum, too short for a new password now.
const oldPassword = "abcd1234"
// loginServer returns a Server whose login reads a stored hash of oldPassword
// from a stub instead of PostgreSQL.
func loginServer(t *testing.T, clk *fakeClock) (*Server, func(remote, password string, hdr map[string]string) *httptest.ResponseRecorder) {
t.Helper()
hash, err := bcrypt.GenerateFromPassword([]byte(oldPassword), bcrypt.MinCost)
if err != nil {
t.Fatal(err)
}
getSetting := func(key string) (string, bool, error) {
if key != authPassKey {
return "", false, nil
}
return string(hash), true, nil
}
s := &Server{jwtKey: []byte("0123456789abcdef0123456789abcdef"), loginLimit: newLoginLimiter(clk.now)}
post := func(remote, password string, hdr map[string]string) *httptest.ResponseRecorder {
body := fmt.Sprintf(`{"username":"ARTEX","password":%q}`, password)
r := httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(body))
r.RemoteAddr = remote
for k, v := range hdr {
r.Header.Set(k, v)
}
w := httptest.NewRecorder()
s.login(w, r, getSetting)
return w
}
return s, post
}
func failN(t *testing.T, post func(string, string, map[string]string) *httptest.ResponseRecorder, remote string, n int) {
t.Helper()
for i := 0; i < n; i++ {
if w := post(remote, "wrong-password", nil); w.Code != 401 {
t.Fatalf("attempt %d from %s: status=%d want 401; body=%s", i+1, remote, w.Code, w.Body.String())
}
}
}
func TestLoginRateLimitReturns429WithRetryAfter(t *testing.T) {
clk := newFakeClock()
_, post := loginServer(t, clk)
const ip = "192.0.2.10:40000"
failN(t, post, ip, loginMaxAttempts)
w := post(ip, oldPassword, nil) // even the right password is refused while blocked
if w.Code != 429 {
t.Fatalf("status=%d want 429; body=%s", w.Code, w.Body.String())
}
if got := w.Header().Get("Retry-After"); got != "60" {
t.Fatalf("Retry-After=%q want 60", got)
}
var body struct {
Error string `json:"error"`
Token string `json:"token"`
}
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil || body.Error == "" || body.Token != "" {
t.Fatalf("want a JSON error body without a token, got %s (err=%v)", w.Body.String(), err)
}
// Each attempt while blocked doubles the wait, capped at loginMaxBlock.
for _, want := range []string{"120", "240", "480", "900", "900"} {
if got := post(ip, "wrong-password", nil).Header().Get("Retry-After"); got != want {
t.Fatalf("Retry-After=%q want %s", got, want)
}
}
// Once the block runs out the IP gets a fresh window.
clk.advance(loginMaxBlock)
if w := post(ip, oldPassword, nil); w.Code != 200 {
t.Fatalf("after the block: status=%d want 200; body=%s", w.Code, w.Body.String())
}
}
func TestLoginSuccessResetsCounter(t *testing.T) {
clk := newFakeClock()
_, post := loginServer(t, clk)
const ip = "192.0.2.11:40000"
failN(t, post, ip, loginMaxAttempts-1)
if w := post(ip, oldPassword, nil); w.Code != 200 {
t.Fatalf("status=%d want 200; body=%s", w.Code, w.Body.String())
}
// The counter starts over: a full budget of failures before the next block.
failN(t, post, ip, loginMaxAttempts)
if w := post(ip, "wrong-password", nil); w.Code != 429 {
t.Fatalf("status=%d want 429", w.Code)
}
}
func TestLoginRateLimitIsPerIP(t *testing.T) {
clk := newFakeClock()
_, post := loginServer(t, clk)
failN(t, post, "192.0.2.12:40000", loginMaxAttempts)
if w := post("192.0.2.12:40001", "wrong-password", nil); w.Code != 429 {
t.Fatalf("same IP, other port: status=%d want 429", w.Code)
}
if w := post("198.51.100.7:40000", "wrong-password", nil); w.Code != 401 {
t.Fatalf("other IP: status=%d want 401", w.Code)
}
if w := post("[2001:db8::1]:40000", oldPassword, nil); w.Code != 200 {
t.Fatalf("other IP: status=%d want 200", w.Code)
}
}
func TestLoginRateLimitIgnoresForwardedFor(t *testing.T) {
clk := newFakeClock()
_, post := loginServer(t, clk)
const blocked = "192.0.2.13"
failN(t, post, blocked+":40000", loginMaxAttempts)
// A blocked peer cannot escape by claiming another address.
for _, h := range []string{"X-Forwarded-For", "X-Real-IP"} {
if w := post(blocked+":40000", oldPassword, map[string]string{h: "203.0.113.9"}); w.Code != 429 {
t.Fatalf("%s from blocked peer: status=%d want 429", h, w.Code)
}
}
// And another peer naming the blocked address is not blocked.
if w := post("198.51.100.8:40000", oldPassword, map[string]string{"X-Forwarded-For": blocked}); w.Code != 200 {
t.Fatalf("X-Forwarded-For naming the blocked IP: status=%d want 200", w.Code)
}
}
func mustTrusted(t *testing.T, s string) []netip.Prefix {
t.Helper()
p, err := ParseTrustedProxies(s)
if err != nil {
t.Fatal(err)
}
return p
}
func TestParseTrustedProxies(t *testing.T) {
if p, err := ParseTrustedProxies(" "); err != nil || len(p) != 0 {
t.Fatalf("empty: %v, %v; want no proxies", p, err)
}
p, err := ParseTrustedProxies(" 127.0.0.1/32, 10.1.2.3/8 ,::1")
if err != nil {
t.Fatal(err)
}
if got := fmt.Sprint(p); got != "[127.0.0.1/32 10.0.0.0/8 ::1/128]" {
t.Fatalf("got %s", got)
}
for _, bad := range []string{"proxy.local", "10.0.0.0/33", "127.0.0.1,nope"} {
if _, err := ParseTrustedProxies(bad); err == nil {
t.Fatalf("%q: want an error", bad)
}
}
}
func TestClientIP(t *testing.T) {
trusted := mustTrusted(t, "127.0.0.1/32,10.0.0.0/8")
for _, tc := range []struct {
name, remote string
xff []string
trusted []netip.Prefix
want string
}{
{"direct client, no proxies configured", "198.51.100.9:5000", nil, nil, "198.51.100.9"},
{"direct client, proxies configured", "198.51.100.9:5000", nil, trusted, "198.51.100.9"},
{"spoofed header, no proxies configured", "198.51.100.9:5000", []string{"203.0.113.1"}, nil, "198.51.100.9"},
{"spoofed header from an untrusted peer", "198.51.100.9:5000", []string{"203.0.113.1"}, trusted, "198.51.100.9"},
{"spoofed header claiming a trusted address", "198.51.100.9:5000", []string{"127.0.0.1"}, trusted, "198.51.100.9"},
{"one trusted proxy", "127.0.0.1:5000", []string{"198.51.100.7"}, trusted, "198.51.100.7"},
{"client-supplied entries left of the real hop", "127.0.0.1:5000", []string{"203.0.113.1, 10.0.0.9, 198.51.100.7"}, trusted, "198.51.100.7"},
{"chain of trusted proxies", "127.0.0.1:5000", []string{"203.0.113.1, 198.51.100.7, 10.0.0.5"}, trusted, "198.51.100.7"},
{"chain split over header lines", "127.0.0.1:5000", []string{"203.0.113.1", "198.51.100.7, 10.0.0.5"}, trusted, "198.51.100.7"},
{"trusted proxy without the header", "127.0.0.1:5000", nil, trusted, "127.0.0.1"},
{"unparsable hop stops the walk", "127.0.0.1:5000", []string{"198.51.100.7, garbage, 10.0.0.5"}, trusted, "10.0.0.5"},
{"IPv6 client behind the proxy", "127.0.0.1:5000", []string{"2001:db8::7"}, trusted, "2001:db8::7"},
} {
t.Run(tc.name, func(t *testing.T) {
r := httptest.NewRequest("GET", "/", nil)
r.RemoteAddr = tc.remote
for _, v := range tc.xff {
r.Header.Add("X-Forwarded-For", v)
}
if got := clientIP(r, tc.trusted); got != tc.want {
t.Fatalf("clientIP=%s want %s", got, tc.want)
}
})
}
}
// Behind a trusted reverse proxy every request arrives from the proxy's address;
// the limit must still apply per real client.
func TestLoginRateLimitBehindTrustedProxy(t *testing.T) {
clk := newFakeClock()
s, post := loginServer(t, clk)
s.trustedProxies = mustTrusted(t, "127.0.0.1/32")
const proxy = "127.0.0.1:50000"
via := func(xff string) map[string]string { return map[string]string{"X-Forwarded-For": xff} }
for i := 0; i < loginMaxAttempts; i++ {
if w := post(proxy, "wrong-password", via("198.51.100.20")); w.Code != 401 {
t.Fatalf("attempt %d: status=%d want 401", i+1, w.Code)
}
}
if w := post(proxy, oldPassword, via("198.51.100.20")); w.Code != 429 {
t.Fatalf("attacker: status=%d want 429", w.Code)
}
// What nginx's $proxy_add_x_forwarded_for sends when the attacker adds its own header.
if w := post(proxy, oldPassword, via("203.0.113.50, 198.51.100.20")); w.Code != 429 {
t.Fatalf("attacker with a forged entry: status=%d want 429", w.Code)
}
// Another user behind the same proxy is not affected.
if w := post(proxy, oldPassword, via("198.51.100.21")); w.Code != 200 {
t.Fatalf("other user via the proxy: status=%d want 200", w.Code)
}
}
// The 12-character minimum applies to new passwords only. A hash stored under the
// old 8-character minimum must still log in.
func TestEightCharPasswordLogsInButIsRejectedAsNew(t *testing.T) {
if n := len([]rune(oldPassword)); n != 8 {
t.Fatalf("oldPassword has %d characters, want 8", n)
}
_, post := loginServer(t, newFakeClock())
w := post("192.0.2.14:40000", oldPassword, nil)
if w.Code != 200 || !strings.Contains(w.Body.String(), `"token"`) {
t.Fatalf("login with an existing 8-character password: status=%d body=%s", w.Code, w.Body.String())
}
// authInit and authChangePassword both refuse a new password through validatePassword.
if msg := validatePassword(oldPassword); msg == "" {
t.Fatal("validatePassword accepted an 8-character new password")
}
}
func TestLoginLimiterSweepsIdleEntries(t *testing.T) {
clk := newFakeClock()
l := newLoginLimiter(clk.now)
l.allow("192.0.2.20")
clk.advance(loginMaxBlock - time.Second)
l.allow("192.0.2.21") // sweeps, but .20 is not idle long enough yet
if len(l.entries) != 2 {
t.Fatalf("entries=%d want 2 before either is idle for %v", len(l.entries), loginMaxBlock)
}
clk.advance(loginSweepEvery)
l.allow("192.0.2.22") // next sweep drops .20
if _, ok := l.entries["192.0.2.20"]; ok || len(l.entries) != 2 {
t.Fatalf("idle entry not swept: %d entries", len(l.entries))
}
}
func TestLoginLimiterCapsEntries(t *testing.T) {
l := newLoginLimiter(newFakeClock().now)
for i := 0; i <= loginMaxEntries; i++ {
l.allow(fmt.Sprintf("10.%d.%d.%d", i>>16&255, i>>8&255, i&255))
}
if len(l.entries) != loginMaxEntries {
t.Fatalf("entries=%d want the cap %d", len(l.entries), loginMaxEntries)
}
}
func TestHealthHidesVersionWithoutToken(t *testing.T) {
key := []byte("0123456789abcdef0123456789abcdef")
s := &Server{jwtKey: key}
w := httptest.NewRecorder()
s.health(w, httptest.NewRequest("GET", "/api/health", nil))
if got := strings.TrimSpace(w.Body.String()); w.Code != 200 || got != `{"ok":true}` {
t.Fatalf("no token: status=%d body=%s want {\"ok\":true}", w.Code, got)
}
tok, err := signJWT(key)
if err != nil {
t.Fatal(err)
}
r := httptest.NewRequest("GET", "/api/health", nil)
r.Header.Set("Authorization", "Bearer "+tok)
w = httptest.NewRecorder()
s.health(w, r)
var body map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil || body["version"] != BuildVersion || body["service"] != "artex" {
t.Fatalf("valid token: body=%s want service and version", w.Body.String())
}
}
+52 -8
View File
@@ -9,6 +9,7 @@ import (
"log"
"net"
"net/http"
"net/netip"
"net/url"
"os"
"path/filepath"
@@ -44,13 +45,16 @@ type Server struct {
ctx context.Context
skillDir string // root directory for skill subdirectories
jwtKey []byte // HS256 signing key loaded from / generated into dataDir/jwt.key
jwtKey []byte // HS256 signing key loaded from / generated into keyDir/jwt.key
scrubSecrets bool // drop ARTEX_PG_DSN and the API keys from the environment once read (binary only)
setupMu sync.Mutex
setupToken string // one-time token POST /api/auth/init requires; empty when none is active
loginLimit *loginLimiter // per-IP attempt limit on POST /api/auth/login
trustedProxies []netip.Prefix // ARTEX_TRUSTED_PROXIES; see clientIP
// concMu serializes concurrency-cap decisions (admission + reconcile) so a
// scheduler tick and an HTTP settings change / task creation can't both count
// free slots off the same snapshot and over-promote past the limit.
@@ -155,7 +159,11 @@ func New(ctx context.Context, m *Manager, skillDir string, dataDir string, keyDi
triggerActive: map[string]int{}, triggerCfg: map[string]triggerBehavior{},
profChatAgents: map[int64]*agent.ChatAgent{},
provByProfile: map[int64]*provEntry{}, llmHealth: newLLMHealthRegistry(m.pg),
taskAgents: map[string]*taskAgentBundle{}, archiveWake: make(chan struct{}, 1)}
taskAgents: map[string]*taskAgentBundle{}, archiveWake: make(chan struct{}, 1),
loginLimit: newLoginLimiter(time.Now)}
for _, o := range opts {
o(s)
}
s.initSetupToken()
s.initSideQuestions()
// Circuit-breaker thresholds/cooldowns are hot parameters on the failure path; push the global retry policy to the Registry once at startup,
@@ -961,7 +969,14 @@ func (s *Server) Handler() http.Handler {
// --- handlers ---
// health answers without a token (requireAuth exempts it), but only a caller
// with a valid token gets the service name and version; the web UI's top bar
// reads the version, and sends its token.
func (s *Server) health(w http.ResponseWriter, r *http.Request) {
if !verifyJWT(extractToken(r), s.jwtKey) {
writeJSON(w, 200, map[string]any{"ok": true})
return
}
writeJSON(w, 200, map[string]any{"ok": true, "service": "artex", "version": BuildVersion})
}
@@ -1466,19 +1481,38 @@ func (s *Server) testLLM(w http.ResponseWriter, r *http.Request) {
if cfg.NeedsAPIKey() {
// API key resolution precedence: form input > the named profile's stored key > the global config's key.
// A stored profile's key is never sent back to the browser, so the form is empty when testing a stored profile and the key comes from the DB.
// A saved key is only sent to the endpoint it was saved for: base_url and
// provider come from the form, so without this check the test button sends
// the saved key to any host typed into the form.
withheld := false
if stored != nil && cfg.APIKey == "" {
// Trim like ConfigFrom does for form values: a key stored with
// surrounding whitespace runs fine in conversations (the runtime
// path re-trims) but would be sent raw here and fail auth.
cfg.APIKey = strings.TrimSpace(stored.APIKey)
if sameLLMEndpoint(req.Provider, req.BaseURL, stored.Format, stored.BaseURL) {
// Trim like ConfigFrom does for form values: a key stored with
// surrounding whitespace runs fine in conversations (the runtime
// path re-trims) but would be sent raw here and fail auth.
cfg.APIKey = strings.TrimSpace(stored.APIKey)
} else {
withheld = true
}
}
if cfg.APIKey == "" {
s.cfgMu.Lock()
cfg.APIKey = s.llmCfg.APIKey
global := s.llmCfg
s.cfgMu.Unlock()
if global.APIKey != "" {
if sameLLMEndpoint(req.Provider, req.BaseURL, global.Provider(), global.BaseURL) {
cfg.APIKey = global.APIKey
} else {
withheld = true
}
}
}
if cfg.APIKey == "" {
writeJSON(w, 200, map[string]any{"ok": false, "error": "no API key provided"})
msg := "no API key provided"
if withheld {
msg = "no API key provided: the saved key is only sent to the endpoint it was saved for; enter the key to test a different base URL or provider"
}
writeJSON(w, 200, map[string]any{"ok": false, "error": msg})
return
}
}
@@ -1496,6 +1530,16 @@ func (s *Server) testLLM(w http.ResponseWriter, r *http.Request) {
})
}
// sameLLMEndpoint reports whether two provider/base URL pairs resolve to the same
// endpoint after ConfigFrom's normalization (trimmed, endpoint suffix stripped;
// an empty base URL means the provider's canonical host). A saved API key may only
// be sent when the request's endpoint matches the one it was saved for.
func sameLLMEndpoint(provA, baseA, provB, baseB string) bool {
a := agent.ConfigFrom(provA, "", baseA, "", "")
b := agent.ConfigFrom(provB, "", baseB, "", "")
return a.Format == b.Format && a.BaseURL == b.BaseURL
}
// truncateReply clips a connection-test reply for display. A model told to answer
// "OK" can still ramble (or think out loud); the UI only needs enough to show it
// really said something. Rune-based so multibyte text never splits mid-character.
+13 -3
View File
@@ -2053,17 +2053,27 @@ func (s *Server) pgListModels(w http.ResponseWriter, r *http.Request) {
writeErr(w, 400, err.Error())
return
}
// Resolve API key: form input > profile stored key.
// Resolve API key: form input > profile stored key. The stored key is only
// sent to the endpoint it was saved for, since base_url comes from the form.
apiKey := strings.TrimSpace(req.APIKey)
withheld := false
if apiKey == "" && req.ProfileID != nil {
if p, err := s.m.pg.ProfileByID(*req.ProfileID); err == nil && p != nil {
apiKey = p.APIKey
if sameLLMEndpoint(req.Provider, req.BaseURL, p.Format, p.BaseURL) {
apiKey = p.APIKey
} else {
withheld = p.APIKey != ""
}
}
}
provider := strings.TrimSpace(req.Provider)
// Ollama serves its model list keyless on the local server: no key gate.
if apiKey == "" && provider != "ollama" {
writeJSON(w, 200, map[string]any{"ok": false, "error": "no API Key provided"})
msg := "no API Key provided"
if withheld {
msg = "no API Key provided: the saved key is only sent to the endpoint it was saved for; enter the key to list models from a different base URL or provider"
}
writeJSON(w, 200, map[string]any{"ok": false, "error": msg})
return
}
+4 -4
View File
@@ -41,8 +41,8 @@ export default function SetupPage() {
setError("The two passwords do not match");
return;
}
if (password.length < 8) {
setError("Password must be at least 8 characters");
if (password.length < 12) {
setError("Password must be at least 12 characters");
return;
}
setLoading(true);
@@ -81,7 +81,7 @@ export default function SetupPage() {
<p className="mx-auto max-w-xl text-muted-foreground">
{unavailable
? "The backend or database is temporarily unavailable. To avoid overwriting a password already set on this instance, setup is temporarily closed — restore the service and try again."
: "First time using ARTEX? Enter the setup token from the server log, then set a login password for the account (at least 8 characters)"}
: "First time using ARTEX? Enter the setup token from the server log, then set a login password for the account (at least 12 characters)"}
</p>
</div>
{unavailable ? (
@@ -111,7 +111,7 @@ export default function SetupPage() {
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="At least 8 characters"
placeholder="At least 12 characters"
autoComplete="new-password"
/>
</div>