From 11d1937de6c2932c1df3d965fc89242aebe5a4ea Mon Sep 17 00:00:00 2001 From: Ben Date: Sat, 29 Aug 2026 09:45:25 -0600 Subject: [PATCH 01/14] Add command-free first-run WebUI setup --- README.md | 41 ++-- .../control/ControlApplication.java | 10 +- .../control/auth/CredentialStore.java | 125 ++++++++++++ .../control/http/ControlHttpServer.java | 115 ++++++++++- src/main/resources/web/app.css | 6 +- src/main/resources/web/app.js | 180 ++++++++++++++++-- src/main/resources/web/index.html | 50 ++++- .../control/auth/CredentialStoreTest.java | 27 +++ .../control/http/ControlHttpServerTest.java | 47 +++++ 9 files changed, 563 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index 619b78ef..21957194 100644 --- a/README.md +++ b/README.md @@ -21,9 +21,12 @@ require a separate admin credential. Browser management uses a distinct owner-ch stored by Control: `data/credentials.json` contains SHA-256 verifiers for 256-bit random tokens and a salted, 600,000-iteration PBKDF2-HMAC-SHA256 WebUI password verifier. -Control binds to `127.0.0.1` by default. A non-loopback bind is refused until an admin token or WebUI password exists. Authentication -does not encrypt traffic: use HTTPS or a trusted private tunnel/network when traffic can cross an untrusted network. Do -not publish the HTTP service directly to the Internet. +Control binds to `127.0.0.1` by default. On first start it creates a permission-restricted +`data/web-setup-code.txt` containing a one-time 256-bit setup code; `credentials.json` stores only its SHA-256 verifier. +The WebUI consumes that code when the owner creates the first password and immediately deletes the raw-code file. +A non-loopback first-run listener exposes no management data without that code, but authentication does not encrypt +traffic: use HTTPS or a trusted private tunnel/network whenever traffic can cross an untrusted network. Do not publish +the HTTP service directly to the Internet. ## Requirements, build, and run @@ -40,7 +43,7 @@ report `development` rather than maintaining a second version literal. | Variable | Default | Constraint | | --- | --- | --- | -| `CONTROL_HOST` | `127.0.0.1` | Valid bind host; non-loopback requires an admin token or WebUI password | +| `CONTROL_HOST` | `127.0.0.1` | Valid bind host; use `0.0.0.0` inside a container only behind an allocated port and trusted HTTPS/private proxy | | `CONTROL_PORT` | `8080` | `0`–`65535`; `0` selects a free port | | `CONTROL_DATA_DIR` | `data` | Writable directory for identity and credential verifiers | | `CONTROL_OFFLINE_TIMEOUT_SECONDS` | `90` | `1`–`3600` | @@ -58,14 +61,14 @@ indefinitely. Claimed configuration tasks include an `attemptId`. Nodes must echo it in the result so an expired and reissued lease cannot be completed by a stale execution from the same node session. -Set the WebUI password from an interactive console, then open `http://127.0.0.1:8080/`: +Open the WebUI after the first start, read the one-time value from `data/web-setup-code.txt` with the server file +manager, and create the WebUI password in the browser. No server command is required. The setup code is shown only in that +permission-restricted file, is rate-limited at the HTTP boundary, becomes invalid as soon as setup completes, and is +deleted after the password verifier is committed. -```shell -java -jar target/votingplugin-control-0.1.0-SNAPSHOT-all.jar web-password data -``` - -The password is read twice without echo and never accepted as a command-line argument. The static shell is public on the -Control listener, but topology and configuration operations require either the admin bearer token (API automation) or a +The legacy `web-password` owner command remains available for offline recovery and rotation. It reads the password twice +without echo and never accepts it as a command-line argument. The static shell is public on the Control listener, but +topology, enrollment, and configuration operations require either the admin bearer token (API automation) or a password-authenticated browser session. Browser sessions are bounded to 100, expire after 30 minutes idle or 8 hours absolute, use an HttpOnly/SameSite=Strict cookie, require a per-session CSRF token for writes, and are invalidated immediately by password rotation. Cookies gain the `Secure` flag when Control is served through HTTPS. Static assets use a @@ -73,8 +76,12 @@ restrictive Content Security Policy and the browser calls the same `/api/v1` end ## Enrollment -Owner commands modify the configured data directory and print a new secret once. Stop shell history capture or otherwise -handle the output as a password. +After signing in, use **Node enrollment** in the WebUI to create or rotate a credential for an exact node ID. Copy the +one-time value into that node's configured `control-credential.txt` with the server file manager, then reload or restart +the node. The same page lists enrolled IDs and can revoke them. This is the normal command-free setup path. + +Owner commands remain available for offline recovery and automation. They modify the configured data directory and print +a new secret once. Stop shell history capture or otherwise handle the output as a password. ```shell # Create or rotate a credential bound to proxy-a @@ -116,9 +123,14 @@ All errors have the stable form: | --- | --- | --- | --- | | `GET` | `/`, `/app.js`, `/app.css` | none | Minimal local WebUI static assets | | `GET` | `/api/v1/health` | none | Health plus Control identity/application/protocol version | +| `GET` | `/api/v1/auth/setup` | none | Report whether one-time first-run password setup is required | +| `POST` | `/api/v1/auth/setup` | one-time file-held setup code | Atomically create the first WebUI password, consume the code, and create a browser session | | `POST` | `/api/v1/auth/login` | WebUI password | Create a bounded browser session and return its CSRF token | | `GET` | `/api/v1/auth/session` | WebUI session | Restore the in-memory CSRF token after a page refresh; invalid/absent sessions return `204` | | `POST` | `/api/v1/auth/logout` | WebUI session + CSRF | Revoke the current browser session and expire its cookie | +| `GET` | `/api/v1/enrollments` | admin or WebUI session | List enrolled node IDs without exposing verifiers | +| `POST` | `/api/v1/enrollments` | admin or WebUI session + CSRF | Create/rotate a node-bound credential and return it once | +| `DELETE` | `/api/v1/enrollments/{nodeId}` | admin or WebUI session + CSRF | Revoke the node credential | | `POST` | `/api/v1/nodes/register` | node | Idempotently create/replace one enrolled proxy or Bukkit session | | `PUT` | `/api/v1/nodes/{nodeId}/heartbeat` | matching node | Refresh liveness and replace capability advertisement | | `PUT` | `/api/v1/nodes/{nodeId}/presence` | matching node | Replace that session's backend snapshot | @@ -180,7 +192,8 @@ They are not misleadingly used by these simple HTTP resource endpoints. - `409 UNSUPPORTED_PROTOCOL` / `INCOMPATIBLE_CAPABILITIES`: upgrade the older side before retrying. - Node stays offline: verify its connector is enabled, the node ID matches enrollment, and heartbeat timeouts permit the configured interval. -- LAN startup is refused: create the admin token or WebUI password first and protect transport with HTTPS/private tunneling. +- Container/LAN access: bind `0.0.0.0` inside the container rather than the VM's address, allocate the port, and place it + behind trusted HTTPS/private routing. Complete the one-time browser setup using the code file before normal sign-in. - Corrupt `instance-id` or credential data fails closed; restore a known backup or deliberately recreate the affected file and re-enroll nodes. diff --git a/src/main/java/com/bencodez/votingplugin/control/ControlApplication.java b/src/main/java/com/bencodez/votingplugin/control/ControlApplication.java index ea420547..89c0ddbc 100644 --- a/src/main/java/com/bencodez/votingplugin/control/ControlApplication.java +++ b/src/main/java/com/bencodez/votingplugin/control/ControlApplication.java @@ -127,9 +127,15 @@ private static Path ownerDataDirectory(String explicit, Map envi static void runServer(Map environment) throws Exception { Configuration configuration = Configuration.from(environment); CredentialStore credentials = new CredentialStore(configuration.dataDirectory()); + Path setupCodeFile = credentials.ensureWebSetupCode(); if (!configuration.address().getAddress().isLoopbackAddress() - && !credentials.hasAdmin() && !credentials.hasWebPassword()) { - throw new IllegalArgumentException("An admin token or WebUI password is required for non-loopback binding"); + && !credentials.hasAdmin() && !credentials.hasWebPassword() && setupCodeFile == null) { + throw new IllegalArgumentException("An admin token, WebUI password, or first-run setup code is required " + + "for non-loopback binding"); + } + if (setupCodeFile != null) { + System.out.println("First-run WebUI setup is required. Enter the code stored in " + + setupCodeFile.toAbsolutePath().normalize()); } // JDK HttpServer honors these bounds for stalled request and response bodies. System.setProperty("sun.net.httpserver.maxReqTime", Integer.toString(configuration.requestTimeoutSeconds())); diff --git a/src/main/java/com/bencodez/votingplugin/control/auth/CredentialStore.java b/src/main/java/com/bencodez/votingplugin/control/auth/CredentialStore.java index 9c7c6192..c7685479 100644 --- a/src/main/java/com/bencodez/votingplugin/control/auth/CredentialStore.java +++ b/src/main/java/com/bencodez/votingplugin/control/auth/CredentialStore.java @@ -36,6 +36,8 @@ public final class CredentialStore { private static final int WEB_PASSWORD_ITERATIONS = 600_000; private static final int WEB_PASSWORD_BYTES = 32; private static final int WEB_PASSWORD_MINIMUM = 12; + private static final int MAX_SETUP_CODE_BYTES = 128; + private static final String WEB_SETUP_CODE_FILE = "web-setup-code.txt"; private static final byte[] DUMMY_PASSWORD_SALT = new byte[16]; private static final byte[] DUMMY_PASSWORD_HASH = new byte[WEB_PASSWORD_BYTES]; private static final byte[] DUMMY_HASH = new byte[32]; @@ -99,13 +101,83 @@ public void setWebPassword(char[] password) throws IOException { data.webPasswordSalt = Base64.getEncoder().encodeToString(salt); data.webPasswordHash = Base64.getEncoder().encodeToString(verifier); data.webPasswordIterations = WEB_PASSWORD_ITERATIONS; + data.webSetupHash = null; }); + Files.deleteIfExists(setupCodeFile()); } public boolean hasWebPassword() throws IOException { return passwordRevision(read()) != null; } + /** + * Creates a one-time first-run setup code when no WebUI password exists. + * The raw code is stored only in a permission-restricted file for the server + * owner; credentials.json retains only its SHA-256 verifier. + */ + public Path ensureWebSetupCode() throws IOException { + Path setupFile = setupCodeFile(); + if (hasWebPassword()) { + Files.deleteIfExists(setupFile); + return null; + } + + String existing = readSetupCode(setupFile); + StoreData current = read(); + if (existing != null && constantTimeMatches(current.webSetupHash, existing)) { + return setupFile; + } + + String code = token("vpctl_setup_"); + mutate(data -> { + if (passwordRevision(data) == null) { + data.webSetupHash = hashHex(code); + } + }); + if (hasWebPassword()) { + Files.deleteIfExists(setupFile); + return null; + } + writeSetupCode(setupFile, code); + if (hasWebPassword()) { + Files.deleteIfExists(setupFile); + return null; + } + return setupFile; + } + + /** Atomically consumes the first-run setup code and installs the WebUI password. */ + public boolean completeWebSetup(String setupCode, char[] password) throws IOException { + String boundedCode = setupCode != null && setupCode.length() <= MAX_SETUP_CODE_BYTES ? setupCode : null; + StoreData current = read(); + if (passwordRevision(current) != null || !constantTimeMatches(current.webSetupHash, boundedCode)) { + return false; + } + + validateWebPassword(password); + byte[] salt = new byte[16]; + random.nextBytes(salt); + byte[] verifier = derivePassword(password, salt, WEB_PASSWORD_ITERATIONS); + boolean[] completed = {false}; + mutate(data -> { + if (passwordRevision(data) == null && constantTimeMatches(data.webSetupHash, boundedCode)) { + data.webPasswordSalt = Base64.getEncoder().encodeToString(salt); + data.webPasswordHash = Base64.getEncoder().encodeToString(verifier); + data.webPasswordIterations = WEB_PASSWORD_ITERATIONS; + data.webSetupHash = null; + completed[0] = true; + } + }); + if (completed[0]) { + Files.deleteIfExists(setupCodeFile()); + } + return completed[0]; + } + + public java.util.List enrolledNodeIds() throws IOException { + return read().nodeHashes.keySet().stream().sorted().toList(); + } + /** Opaque verifier revision used to invalidate sessions immediately after password rotation. */ public String webPasswordRevision() { try { @@ -263,6 +335,58 @@ private synchronized void mutate(Mutation mutation) throws IOException { } } + private Path setupCodeFile() { + return directory.resolve(WEB_SETUP_CODE_FILE); + } + + private String readSetupCode(Path setupFile) throws IOException { + if (!Files.exists(setupFile, LinkOption.NOFOLLOW_LINKS)) { + return null; + } + if (!Files.isRegularFile(setupFile, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(setupFile) + || Files.size(setupFile) > MAX_SETUP_CODE_BYTES) { + throw new IOException("Web setup code file is unsafe"); + } + ByteBuffer buffer = ByteBuffer.allocate(MAX_SETUP_CODE_BYTES + 1); + try (SeekableByteChannel channel = Files.newByteChannel(setupFile, + Set.of(StandardOpenOption.READ, LinkOption.NOFOLLOW_LINKS))) { + while (channel.read(buffer) >= 0 && buffer.hasRemaining()) { } + } + if (!buffer.hasRemaining()) { + throw new IOException("Web setup code file changed while it was read"); + } + buffer.flip(); + String value = StandardCharsets.UTF_8.decode(buffer).toString().trim(); + return value.isEmpty() ? null : value; + } + + private void writeSetupCode(Path setupFile, String code) throws IOException { + Path temporary = Files.createTempFile(directory, "web-setup-", ".tmp"); + try { + try { + Files.setPosixFilePermissions(temporary, Set.of( + java.nio.file.attribute.PosixFilePermission.OWNER_READ, + java.nio.file.attribute.PosixFilePermission.OWNER_WRITE)); + } catch (UnsupportedOperationException ignored) { + // The platform does not expose POSIX permissions. + } + Files.writeString(temporary, code + System.lineSeparator(), StandardCharsets.UTF_8, + StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.WRITE); + try (FileChannel channel = FileChannel.open(temporary, StandardOpenOption.WRITE)) { + channel.force(true); + } + try { + Files.move(temporary, setupFile, StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING); + } catch (java.nio.file.AtomicMoveNotSupportedException e) { + Files.move(temporary, setupFile, StandardCopyOption.REPLACE_EXISTING); + } + DurableFiles.forceDirectory(directory); + } finally { + Files.deleteIfExists(temporary); + } + } + private String token(String prefix) { byte[] bytes = new byte[32]; random.nextBytes(bytes); @@ -319,6 +443,7 @@ private static final class StoreData { public String webPasswordSalt; public String webPasswordHash; public int webPasswordIterations; + public String webSetupHash; public Map nodeHashes = new HashMap<>(); } } diff --git a/src/main/java/com/bencodez/votingplugin/control/http/ControlHttpServer.java b/src/main/java/com/bencodez/votingplugin/control/http/ControlHttpServer.java index c9a9041d..82df7c93 100644 --- a/src/main/java/com/bencodez/votingplugin/control/http/ControlHttpServer.java +++ b/src/main/java/com/bencodez/votingplugin/control/http/ControlHttpServer.java @@ -64,6 +64,8 @@ public final class ControlHttpServer implements AutoCloseable { private static final String AUTH_LOGIN = "/api/v1/auth/login"; private static final String AUTH_SESSION = "/api/v1/auth/session"; private static final String AUTH_LOGOUT = "/api/v1/auth/logout"; + private static final String AUTH_SETUP = "/api/v1/auth/setup"; + private static final String ENROLLMENTS = "/api/v1/enrollments"; private static final String SESSION_COOKIE = "vpctl_session"; private static final Map WEB_RESOURCES = Map.of( "/", new WebResource("/web/index.html", "text/html; charset=utf-8"), @@ -222,7 +224,7 @@ private void handle(HttpExchange exchange) throws IOException { case "NODE_NOT_FOUND", "OPERATION_NOT_FOUND" -> 404; case "UNSUPPORTED_PROTOCOL", "INCOMPATIBLE_CAPABILITIES", "SESSION_MISMATCH", "PREVIEW_INCOMPLETE", "APPROVAL_REQUIRED", "NODE_UNAVAILABLE", "OPERATION_LIMIT", - "REGISTRY_LIMIT", "TASK_NOT_CLAIMED", "TASK_LEASE_EXPIRED" -> 409; + "REGISTRY_LIMIT", "TASK_NOT_CLAIMED", "TASK_LEASE_EXPIRED", "SETUP_COMPLETE" -> 409; case "UNSUPPORTED_MEDIA_TYPE" -> 415; default -> 400; }; @@ -262,6 +264,42 @@ private void route(HttpExchange exchange) throws IOException { send(exchange, 200, health); return; } + if (AUTH_SETUP.equals(path)) { + if ("GET".equals(exchange.getRequestMethod())) { + send(exchange, 200, Map.of("required", !credentials.hasWebPassword(), + "codeFile", "web-setup-code.txt")); + return; + } + if (!"POST".equals(exchange.getRequestMethod())) { + exchange.getResponseHeaders().set("Allow", "GET, POST"); + error(exchange, 405, "METHOD_NOT_ALLOWED", "Method is not allowed", + List.of("allowed=GET", "allowed=POST")); + throw new ResponseCompleteException(); + } + if (credentials.hasWebPassword()) { + throw new ValidationException("SETUP_COMPLETE", "First-run WebUI setup is already complete", + List.of()); + } + SetupRequest request = read(exchange, SetupRequest.class); + requireRequest(request); + String client = passwordClient(exchange); + if (!passwordFailureLimiter.allowAttempt(client) || !passwordAdmission.acquire(client)) { + throw new AuthenticationException(true); + } + try { + passwordExecutor.execute(() -> { + try { + handleWebSetup(exchange, request, client); + } finally { + passwordAdmission.release(client); + } + }); + } catch (RejectedExecutionException e) { + passwordAdmission.release(client); + throw new AuthenticationException(true); + } + throw new DeferredResponseException(); + } if (AUTH_LOGIN.equals(path)) { requireMethod(exchange, "POST"); PasswordRequest request = read(exchange, PasswordRequest.class); @@ -306,6 +344,36 @@ private void route(HttpExchange exchange) throws IOException { send(exchange, 200, Map.of("loggedOut", true)); return; } + if (ENROLLMENTS.equals(path)) { + if ("GET".equals(exchange.getRequestMethod())) { + authenticateAdmin(exchange, false); + send(exchange, 200, Map.of("nodeIds", credentials.enrolledNodeIds())); + return; + } + if ("POST".equals(exchange.getRequestMethod())) { + authenticateAdmin(exchange, true); + EnrollmentRequest request = read(exchange, EnrollmentRequest.class); + requireRequest(request); + String credential = credentials.rotateNode(request.nodeId()); + send(exchange, 201, Map.of("nodeId", request.nodeId(), "credential", credential)); + return; + } + exchange.getResponseHeaders().set("Allow", "GET, POST"); + error(exchange, 405, "METHOD_NOT_ALLOWED", "Method is not allowed", + List.of("allowed=GET", "allowed=POST")); + throw new ResponseCompleteException(); + } + if (path != null && path.startsWith(ENROLLMENTS + "/")) { + String remainder = path.substring((ENROLLMENTS + "/").length()); + if (!remainder.contains("/")) { + requireMethod(exchange, "DELETE"); + authenticateAdmin(exchange, true); + String nodeId = decodePathSegment(remainder); + credentials.revokeNode(nodeId); + send(exchange, 200, Map.of("nodeId", nodeId, "revoked", true)); + return; + } + } if (NODES.equals(path)) { requireMethod(exchange, "GET"); authenticateAdmin(exchange, false); @@ -408,6 +476,49 @@ private void route(HttpExchange exchange) throws IOException { error(exchange, 404, "NOT_FOUND", "Endpoint not found", List.of()); } + private void handleWebSetup(HttpExchange exchange, SetupRequest request, String client) { + char[] password = request.password() == null ? null : request.password().toCharArray(); + try { + if (!credentials.completeWebSetup(request.setupCode(), password)) { + passwordFailureLimiter.recordFailure(client); + authenticationFailed(); + } + passwordFailureLimiter.clear(client); + String revision = credentials.webPasswordRevision(); + if (revision == null) { + throw new IOException("WebUI password verifier was not created"); + } + webSessions.remove(cookie(exchange, SESSION_COOKIE)); + WebSessionStore.Session session = webSessions.create(revision); + setSessionCookie(exchange, session.id(), false); + send(exchange, 200, Map.of("csrfToken", session.csrfToken(), "setupComplete", true)); + } catch (AuthenticationException e) { + exchange.getResponseHeaders().set("WWW-Authenticate", "Bearer"); + try { + error(exchange, e.rateLimited ? 429 : 401, + e.rateLimited ? "AUTH_RATE_LIMITED" : "UNAUTHORIZED", + e.rateLimited ? "Too many authentication failures" : "Authentication failed", List.of()); + } catch (IOException ignored) { + // The client disconnected while first-run setup was completing. + } + } catch (IllegalArgumentException e) { + try { + error(exchange, 400, "VALIDATION_ERROR", "Request validation failed", List.of()); + } catch (IOException ignored) { + // The client disconnected while first-run setup was completing. + } + } catch (IOException | RuntimeException e) { + try { + error(exchange, 500, "INTERNAL_ERROR", "Request could not be completed", List.of()); + } catch (IOException ignored) { + // The client disconnected while first-run setup was completing. + } + } finally { + if (password != null) java.util.Arrays.fill(password, '\0'); + exchange.close(); + } + } + private void handlePasswordLogin(HttpExchange exchange, PasswordRequest request, String client) { try { String credentialRevision = credentials.authenticateWebPassword(request.password()); @@ -735,6 +846,8 @@ private static final class DeferredResponseException extends RuntimeException { private record WebResource(String classpath, String contentType) { } private record PasswordRequest(String password) { } + private record SetupRequest(String setupCode, String password) { } + private record EnrollmentRequest(String nodeId) { } record BackendPage(List items, int backendItemsReturned, boolean backendItemsTruncated) { } static final class AuthFailureLimiter { diff --git a/src/main/resources/web/app.css b/src/main/resources/web/app.css index 0ba87c4b..43cf8c75 100644 --- a/src/main/resources/web/app.css +++ b/src/main/resources/web/app.css @@ -20,7 +20,11 @@ label { display: block; margin-bottom: 8px; font-weight: 700; } input, textarea, select { flex: 1; min-width: 0; padding: 12px 14px; border: 1px solid #38506f; border-radius: 10px; background: #07101d; color: inherit; } button { padding: 12px 16px; border: 0; border-radius: 10px; background: #3187ee; color: white; font-weight: 700; cursor: pointer; } button:disabled { opacity: .5; cursor: not-allowed; } -#message { min-height: 1.5em; margin: 14px 0 0; color: #aabbd0; } +#message, #setup-message, #enrollment-message { min-height: 1.5em; margin: 14px 0 0; color: #aabbd0; } +#setup-form > input { width: 100%; margin-bottom: 14px; } +#enrollment-credential { font-family: ui-monospace, SFMono-Regular, Consolas, monospace; } +.enrollment-item { flex-direction: row; align-items: center; justify-content: space-between; gap: 12px; } +.enrollment-item button { padding: 8px 12px; background: #8f3550; } .nodes { display: grid; grid-template-columns: repeat(auto-fit, minmax(270px, 1fr)); gap: 16px; } .nodes.empty { display: block; padding: 28px; border: 1px dashed #38506f; border-radius: 12px; color: #aabbd0; text-align: center; } .node { padding: 18px; border: 1px solid #38506f; border-radius: 12px; background: #0b192b; } diff --git a/src/main/resources/web/app.js b/src/main/resources/web/app.js index 98b2a6c8..e413aae6 100644 --- a/src/main/resources/web/app.js +++ b/src/main/resources/web/app.js @@ -1,6 +1,13 @@ 'use strict'; const health = document.querySelector('#health'); +const setupCard = document.querySelector('#setup-card'); +const setupForm = document.querySelector('#setup-form'); +const setupCode = document.querySelector('#setup-code'); +const setupPassword = document.querySelector('#setup-password'); +const setupConfirmPassword = document.querySelector('#setup-confirm-password'); +const setupMessage = document.querySelector('#setup-message'); +const authCard = document.querySelector('#auth-card'); const form = document.querySelector('#auth-form'); const passwordInput = document.querySelector('#password'); const loginButton = form.querySelector('button[type="submit"]'); @@ -50,6 +57,13 @@ const quickPartyOnline = document.querySelector('#quick-party-online'); const previewQuickSetup = document.querySelector('#preview-quick-setup'); const applyQuickSetup = document.querySelector('#apply-quick-setup'); const quickOperationStatus = document.querySelector('#quick-operation-status'); +const enrollmentCard = document.querySelector('#enrollment-card'); +const enrollmentForm = document.querySelector('#enrollment-form'); +const enrollmentNodeId = document.querySelector('#enrollment-node-id'); +const enrollmentCredential = document.querySelector('#enrollment-credential'); +const enrollmentList = document.querySelector('#enrollment-list'); +const enrollmentMessage = document.querySelector('#enrollment-message'); +const refreshEnrollments = document.querySelector('#refresh-enrollments'); const PAGE_SIZE = 100; let authenticated = false; let csrfToken = ''; @@ -63,6 +77,8 @@ let nodePlugins = new Map(); let inputGeneration = 0; let authenticationGeneration = 0; let loginInFlight = false; +let setupRequired = false; +let enrollmentInFlight = false; let configurationOperationsInFlight = 0; function text(element, value) { @@ -83,6 +99,42 @@ async function loadHealth() { } } +async function loadSetupState() { + try { + const response = await fetch('/api/v1/auth/setup', {cache: 'no-store'}); + if (!response.ok) throw new Error('First-run setup status is unavailable.'); + const body = await response.json(); + setupRequired = Boolean(body.required); + setupCard.hidden = !setupRequired; + authCard.hidden = setupRequired; + if (setupRequired) { + enrollmentCard.hidden = true; + text(setupMessage, `Enter the one-time code from control/data/${body.codeFile}.`); + } + return setupRequired; + } catch (error) { + setupRequired = false; + setupCard.hidden = true; + authCard.hidden = false; + text(message, error.message || 'First-run setup status is unavailable.'); + return false; + } +} + +function applyAuthenticatedSession(body) { + authenticated = true; + csrfToken = body.csrfToken; + approvedPreview = null; + approvedFilePreview = null; + approvedQuickPreview = null; + selectedNodes.clear(); + configurationContent.value = ''; + inputGeneration++; + logout.hidden = false; + enrollmentCard.hidden = false; + pageOffset = 0; +} + function backendCard(backend) { const item = document.createElement('li'); const state = backend.presenceKnown ? (backend.available ? 'online' : 'offline') : 'unknown'; @@ -218,6 +270,10 @@ function discardAuthenticationState(reason) { approvedQuickPreview = null; inputGeneration++; logout.hidden = true; + enrollmentCard.hidden = true; + enrollmentCredential.value = ''; + enrollmentList.replaceChildren(text(document.createElement('li'), 'Authenticate to manage enrollments.')); + text(enrollmentMessage, ''); selectedNodes.clear(); nodeCapabilities.clear(); nodePlugins.clear(); @@ -278,6 +334,45 @@ async function startConfigurationOperation(path, body, statusElement = operation } } +async function loadEnrollments() { + if (!authenticated || enrollmentInFlight) return; + enrollmentInFlight = true; + refreshEnrollments.disabled = true; + try { + const body = await authorized('/api/v1/enrollments'); + enrollmentList.replaceChildren(); + if (!Array.isArray(body.nodeIds) || body.nodeIds.length === 0) { + enrollmentList.append(text(document.createElement('li'), 'No nodes are enrolled yet.')); + return; + } + body.nodeIds.forEach(nodeId => { + const item = document.createElement('li'); + item.className = 'enrollment-item'; + item.append(text(document.createElement('code'), nodeId)); + const revoke = text(document.createElement('button'), 'Revoke'); + revoke.type = 'button'; + revoke.addEventListener('click', async () => { + if (!window.confirm(`Revoke the credential for ${nodeId}?`)) return; + try { + await authorized(`/api/v1/enrollments/${encodeURIComponent(nodeId)}`, {method: 'DELETE'}); + enrollmentCredential.value = ''; + text(enrollmentMessage, `${nodeId} was revoked.`); + await loadEnrollments(); + } catch (error) { + text(enrollmentMessage, error.message); + } + }); + item.append(revoke); + enrollmentList.append(item); + }); + } catch (error) { + text(enrollmentMessage, error.message || 'Enrollments could not be loaded.'); + } finally { + enrollmentInFlight = false; + refreshEnrollments.disabled = false; + } +} + async function loadNodes() { if (!authenticated) return; refresh.disabled = true; @@ -370,17 +465,8 @@ form.addEventListener('submit', async event => { const body = await response.json(); if (!response.ok) throw new Error(body?.error?.message || 'Authentication failed.'); if (loginGeneration !== authenticationGeneration) return; - authenticated = true; - csrfToken = body.csrfToken; - approvedPreview = null; - approvedFilePreview = null; - approvedQuickPreview = null; - selectedNodes.clear(); - configurationContent.value = ''; - inputGeneration++; - logout.hidden = false; - pageOffset = 0; - await loadNodes(); + applyAuthenticatedSession(body); + await Promise.all([loadEnrollments(), loadNodes()]); } catch (error) { text(message, error.message || 'Authentication failed.'); } finally { @@ -417,13 +503,64 @@ async function restoreSession() { if (!response.ok || response.status === 204) return; const body = await response.json(); if (restoreGeneration !== authenticationGeneration) return; - authenticated = true; - csrfToken = body.csrfToken; - logout.hidden = false; - await loadNodes(); + applyAuthenticatedSession(body); + await Promise.all([loadEnrollments(), loadNodes()]); } catch (_) { /* The login form remains available. */ } } +setupForm.addEventListener('submit', async event => { + event.preventDefault(); + if (loginInFlight) return; + if (setupPassword.value !== setupConfirmPassword.value) { + text(setupMessage, 'The new passwords do not match.'); + return; + } + loginInFlight = true; + const setupGeneration = ++authenticationGeneration; + const request = {setupCode: setupCode.value, password: setupPassword.value}; + setupCode.value = ''; + setupPassword.value = ''; + setupConfirmPassword.value = ''; + try { + const response = await fetch('/api/v1/auth/setup', { + method: 'POST', cache: 'no-store', credentials: 'same-origin', + headers: {'Content-Type': 'application/json'}, body: JSON.stringify(request) + }); + const body = await response.json(); + if (!response.ok) throw new Error(body?.error?.message || 'First-run setup failed.'); + if (setupGeneration !== authenticationGeneration) return; + setupRequired = false; + setupCard.hidden = true; + authCard.hidden = false; + applyAuthenticatedSession(body); + text(message, 'First-run setup completed.'); + await Promise.all([loadEnrollments(), loadNodes()]); + } catch (error) { + text(setupMessage, error.message || 'First-run setup failed.'); + } finally { + loginInFlight = false; + } +}); + +enrollmentForm.addEventListener('submit', async event => { + event.preventDefault(); + if (!authenticated) return; + const nodeId = enrollmentNodeId.value.trim(); + enrollmentCredential.value = ''; + try { + const body = await authorized('/api/v1/enrollments', { + method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({nodeId}) + }); + enrollmentCredential.value = body.credential; + text(enrollmentMessage, + `Credential created for ${body.nodeId}. Copy it now; rotating again immediately invalidates this value.`); + await loadEnrollments(); + } catch (error) { + text(enrollmentMessage, error.message || 'The credential could not be created.'); + } +}); +refreshEnrollments.addEventListener('click', loadEnrollments); + readConfiguration.addEventListener('click', async () => { approvedPreview = null; const readAuthenticationGeneration = authenticationGeneration; @@ -603,7 +740,12 @@ nextPage.addEventListener('click', () => { pageOffset += PAGE_SIZE; loadNodes(); }); -loadHealth(); -updateQuickFields(); -updatePluginSuggestions(); -restoreSession(); +async function initialize() { + await loadHealth(); + updateQuickFields(); + updatePluginSuggestions(); + if (!await loadSetupState()) { + await restoreSession(); + } +} +initialize(); diff --git a/src/main/resources/web/index.html b/src/main/resources/web/index.html index 56a8cb19..fb42ef02 100644 --- a/src/main/resources/web/index.html +++ b/src/main/resources/web/index.html @@ -16,7 +16,28 @@

VotingPlugin Control

Connecting… -
+ + +

Network discovery

Sign in with the WebUI password configured by the Control owner. Sessions use an HttpOnly cookie and expire automatically.

@@ -32,6 +53,33 @@

Network discovery

+ +

Proxies and backends

diff --git a/src/test/java/com/bencodez/votingplugin/control/auth/CredentialStoreTest.java b/src/test/java/com/bencodez/votingplugin/control/auth/CredentialStoreTest.java index 0c1bff81..d8c27e2c 100644 --- a/src/test/java/com/bencodez/votingplugin/control/auth/CredentialStoreTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/auth/CredentialStoreTest.java @@ -56,6 +56,33 @@ class CredentialStoreTest { assertThrows(IllegalArgumentException.class, () -> store.setWebPassword("too-short".toCharArray())); } + @Test void firstRunSetupCodeIsOneTimeHashedAndCreatesTheWebPassword() throws Exception { + CredentialStore store = new CredentialStore(directory); + Path setupFile = store.ensureWebSetupCode(); + assertNotNull(setupFile); + String code = Files.readString(setupFile).trim(); + assertTrue(code.startsWith("vpctl_setup_")); + assertFalse(Files.readString(directory.resolve("credentials.json")).contains(code)); + assertEquals(setupFile, store.ensureWebSetupCode()); + assertEquals(code, Files.readString(setupFile).trim()); + + assertFalse(store.completeWebSetup("vpctl_setup_wrong", "a-secure-browser-password".toCharArray())); + assertTrue(store.completeWebSetup(code, "a-secure-browser-password".toCharArray())); + assertFalse(Files.exists(setupFile)); + assertTrue(store.verifyWebPassword("a-secure-browser-password")); + assertFalse(store.completeWebSetup(code, "a-different-browser-password".toCharArray())); + assertNull(store.ensureWebSetupCode()); + } + + @Test void enrolledNodeIdsAreStableAndSorted() throws Exception { + CredentialStore store = new CredentialStore(directory); + store.rotateNode("proxy-z"); + store.rotateNode("backend-a"); + assertEquals(java.util.List.of("backend-a", "proxy-z"), store.enrolledNodeIds()); + store.revokeNode("backend-a"); + assertEquals(java.util.List.of("proxy-z"), store.enrolledNodeIds()); + } + @Test void oversizedCredentialStoreFailsBeforeReadingItsContents() throws Exception { Files.write(directory.resolve("credentials.json"), new byte[2 * 1024 * 1024 + 1]); CredentialStore store = new CredentialStore(directory); diff --git a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java index 293e6574..e257833d 100644 --- a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java @@ -70,6 +70,10 @@ class ControlHttpServerTest { assertTrue(script.body().contains("if (loginInFlight) return")); assertTrue(script.body().contains("backendItemsTruncated")); assertTrue(web.body().contains("Easy vote reward")); + assertTrue(web.body().contains("First-run setup")); + assertTrue(web.body().contains("Node enrollment")); + assertTrue(script.body().contains("setupForm.addEventListener")); + assertTrue(script.body().contains("loadEnrollments")); assertTrue(script.body().contains("filteredSelection.size !== selectedNodes.size")); assertTrue(script.body().contains("previewGeneration === inputGeneration")); assertTrue(script.body().contains("configurationOperationsInFlight")); @@ -233,6 +237,49 @@ class ControlHttpServerTest { Map.of("Cookie", secondCookie)).statusCode()); } + @Test void firstRunWebSetupConsumesOneTimeCodeAndCreatesSession() throws Exception { + Path setupFile = credentials.ensureWebSetupCode(); + String setupCode = java.nio.file.Files.readString(setupFile).trim(); + JsonNode setupState = json.readTree(get("/api/v1/auth/setup", null).body()); + assertTrue(setupState.get("required").asBoolean()); + assertEquals("web-setup-code.txt", setupState.get("codeFile").asText()); + + assertAuthFailure(send("POST", "/api/v1/auth/setup", + "{\"setupCode\":\"wrong\",\"password\":\"a-secure-web-password\"}", null)); + HttpResponse completed = send("POST", "/api/v1/auth/setup", + "{\"setupCode\":\"" + setupCode + "\",\"password\":\"a-secure-web-password\"}", null); + assertEquals(200, completed.statusCode()); + assertTrue(completed.headers().firstValue("Set-Cookie").orElseThrow().contains("HttpOnly")); + assertTrue(credentials.verifyWebPassword("a-secure-web-password")); + assertFalse(java.nio.file.Files.exists(setupFile)); + assertFalse(json.readTree(get("/api/v1/auth/setup", null).body()).get("required").asBoolean()); + assertError(send("POST", "/api/v1/auth/setup", + "{\"setupCode\":\"" + setupCode + "\",\"password\":\"a-different-web-password\"}", null), + 409, "SETUP_COMPLETE"); + } + + @Test void enrollmentApiCreatesListsRotatesAndRevokesNodeCredentials() throws Exception { + assertAuthFailure(get("/api/v1/enrollments", null)); + JsonNode initial = json.readTree(get("/api/v1/enrollments", adminToken).body()); + assertEquals(java.util.List.of("proxy-a"), + json.convertValue(initial.get("nodeIds"), new com.fasterxml.jackson.core.type.TypeReference>() { })); + + HttpResponse created = send("POST", "/api/v1/enrollments", + "{\"nodeId\":\"backend-lobby\"}", adminToken); + assertEquals(201, created.statusCode()); + String credential = json.readTree(created.body()).get("credential").asText(); + assertTrue(credentials.verifyNode("backend-lobby", credential)); + assertFalse(java.nio.file.Files.readString(directory.resolve("credentials.json")).contains(credential)); + + String rotated = json.readTree(send("POST", "/api/v1/enrollments", + "{\"nodeId\":\"backend-lobby\"}", adminToken).body()).get("credential").asText(); + assertFalse(credentials.verifyNode("backend-lobby", credential)); + assertTrue(credentials.verifyNode("backend-lobby", rotated)); + assertEquals(200, send("DELETE", "/api/v1/enrollments/backend-lobby", null, adminToken).statusCode()); + assertFalse(credentials.verifyNode("backend-lobby", rotated)); + assertError(send("PUT", "/api/v1/enrollments", "{}", adminToken), 405, "METHOD_NOT_ALLOWED"); + } + @Test void concurrentValidPasswordAttemptsQueueBehindVerificationLimit() throws Exception { credentials.setWebPassword("a-secure-web-password".toCharArray()); java.util.List>> attempts = From a7a2b233746f1907283ee22713a51ab267ea83a0 Mon Sep 17 00:00:00 2001 From: Ben Date: Sat, 29 Aug 2026 09:47:30 -0600 Subject: [PATCH 02/14] Clarify first-run setup code location --- src/main/resources/web/index.html | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/resources/web/index.html b/src/main/resources/web/index.html index fb42ef02..4e272c04 100644 --- a/src/main/resources/web/index.html +++ b/src/main/resources/web/index.html @@ -18,9 +18,9 @@

VotingPlugin Control