Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 27 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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` |
Expand All @@ -58,23 +61,27 @@ 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
restrictive Content Security Policy and the browser calls the same `/api/v1` endpoints.

## 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
Expand Down Expand Up @@ -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 |
Expand Down Expand Up @@ -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.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -127,9 +127,15 @@ private static Path ownerDataDirectory(String explicit, Map<String, String> envi
static void runServer(Map<String, String> 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()));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down Expand Up @@ -99,13 +101,79 @@ 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;
});
deleteSetupCodeBestEffort();
}

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 synchronized Path ensureWebSetupCode() throws IOException {
Path setupFile = setupCodeFile();
try (FileChannel channel = FileChannel.open(lockFile, StandardOpenOption.CREATE, StandardOpenOption.WRITE);
FileLock ignored = channel.lock()) {
StoreData current = read();
if (passwordRevision(current) != null) {
deleteSetupCodeBestEffort();
return null;
}

String existing = readSetupCode(setupFile);
if (existing != null && constantTimeMatches(current.webSetupHash, existing)) {
return setupFile;
}

String code = token("vpctl_setup_");
current.webSetupHash = hashHex(code);
writeStore(current);
writeSetupCode(setupFile, code);
return setupFile;
}
}

/**
* Atomically consumes the first-run setup code and installs the WebUI password.
* Returns the exact verifier revision installed, or {@code null} when the code
* was invalid or setup had already completed.
*/
public String 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 null;
}

validateWebPassword(password);
byte[] salt = new byte[16];
random.nextBytes(salt);
byte[] verifier = derivePassword(password, salt, WEB_PASSWORD_ITERATIONS);
String[] installedRevision = {null};
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;
installedRevision[0] = passwordRevision(data);
}
});
if (installedRevision[0] != null) {
deleteSetupCodeBestEffort();
}
return installedRevision[0];
}

public java.util.List<String> 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 {
Expand Down Expand Up @@ -243,23 +311,88 @@ private synchronized void mutate(Mutation mutation) throws IOException {
FileLock ignored = channel.lock()) {
StoreData data = read();
mutation.apply(data);
byte[] bytes = json.writerWithDefaultPrettyPrinter().writeValueAsBytes(data);
Path temporary = Files.createTempFile(directory, "credentials-", ".tmp");
writeStore(data);
}
}

private void writeStore(StoreData data) throws IOException {
byte[] bytes = json.writerWithDefaultPrettyPrinter().writeValueAsBytes(data);
Path temporary = Files.createTempFile(directory, "credentials-", ".tmp");
try {
Files.write(temporary, bytes, StandardOpenOption.TRUNCATE_EXISTING);
try (FileChannel temporaryChannel = FileChannel.open(temporary, StandardOpenOption.WRITE)) {
temporaryChannel.force(true);
}
try {
Files.write(temporary, bytes, StandardOpenOption.TRUNCATE_EXISTING);
try (FileChannel temporaryChannel = FileChannel.open(temporary, StandardOpenOption.WRITE)) {
temporaryChannel.force(true);
}
try {
Files.move(temporary, file, StandardCopyOption.ATOMIC_MOVE,
StandardCopyOption.REPLACE_EXISTING);
} catch (java.nio.file.AtomicMoveNotSupportedException e) {
Files.move(temporary, file, StandardCopyOption.REPLACE_EXISTING);
}
DurableFiles.forceDirectory(directory);
} finally {
Files.deleteIfExists(temporary);
Files.move(temporary, file, StandardCopyOption.ATOMIC_MOVE,
StandardCopyOption.REPLACE_EXISTING);
} catch (java.nio.file.AtomicMoveNotSupportedException e) {
Files.move(temporary, file, StandardCopyOption.REPLACE_EXISTING);
}
DurableFiles.forceDirectory(directory);
} finally {
Files.deleteIfExists(temporary);
}
}

private Path setupCodeFile() {
return directory.resolve(WEB_SETUP_CODE_FILE);
}

private void deleteSetupCodeBestEffort() {
try {
Files.deleteIfExists(setupCodeFile());
} catch (IOException ignored) {
// The code verifier is already invalid. A locked stale file must not
// turn a committed password change into a reported setup failure.
}
}

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);
}
}

Expand Down Expand Up @@ -319,6 +452,7 @@ private static final class StoreData {
public String webPasswordSalt;
public String webPasswordHash;
public int webPasswordIterations;
public String webSetupHash;
public Map<String, String> nodeHashes = new HashMap<>();
}
}
Loading
Loading