Skip to content
Open
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
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,17 @@ flb install-fleetbase

The installer creates an empty `api/.env` (bind-mounted by `docker-compose.yml`) when one does not exist.

It also writes the realtime socket authentication settings to the project-root `.env` (next to `docker-compose.yml`), which Compose passes to the application, queue, scheduler and socket containers:

- `SOCKETCLUSTER_AUTH_KEY`: shared secret between the API and the socket server (64 random hex characters). An existing key of 32+ characters is kept on re-runs, since changing it invalidates socket tokens already issued.
- `SOCKETCLUSTER_AUTH_ENABLED`: switches socket authentication on for the API and the socket server (default `false`; an existing value is kept). The API's socket token routes, its authorize endpoint and signed HTTP publishing are active only when this is `true` and the key is valid. While it is off, nothing is authenticated, the token routes answer `404`, and the API publishes over the legacy websocket path, so clients that don't fetch socket tokens yet keep working.
- `SOCKETCLUSTER_ORIGIN`: the `Origin` header the API sends when it publishes over the websocket, which it does while socket auth is off. Defaults to the console origin (`http://localhost:4200` for localhost installs, `<scheme>://<host>` otherwise), which the origins written to `SOCKETCLUSTER_OPTIONS` allow; an existing value is kept. Without it the socket server refuses every broadcast with `Invalid origin: *`.
- `SOCKETCLUSTER_AUTH_MODE`: `off`, `log` or `enforce` (default `log`; an existing value is kept). It only takes effect once the switch is on. `enforce` requires `SOCKETCLUSTER_AUTH_ENABLED=true` on the API, since an enforcing socket server refuses the legacy publish path.

These are the same defaults as `scripts/docker-install.sh`. To turn socket authentication on, follow this order: ship clients that fall back to an anonymous connection when the token route answers `404`, then set `SOCKETCLUSTER_AUTH_ENABLED=true` with `SOCKETCLUSTER_AUTH_MODE=log`, and switch to `enforce` once the socket server's deny log only shows traffic you expect to lose. Restart the containers after editing `.env`.

The socket's allowed origins (`SOCKETCLUSTER_OPTIONS`) are still written to `docker-compose.override.yml`. Setting these only in `api/.env` has no effect.

**Example:**
```bash
flb install-fleetbase --host 0.0.0.0 --environment production --directory /opt/fleetbase
Expand Down
94 changes: 94 additions & 0 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -1142,6 +1142,92 @@ async function ensureApiEnvFile(directory) {
console.log('✔ api/.env created');
}

/**
* Write the realtime socket auth settings to the project-root .env file.
* docker-compose.yml reads SOCKETCLUSTER_AUTH_KEY, SOCKETCLUSTER_AUTH_ENABLED and
* SOCKETCLUSTER_AUTH_MODE from this file (Compose loads it automatically) and hands the
* same key to the application, queue, scheduler and socket containers; setting them in
* api/.env has no effect.
* SOCKETCLUSTER_AUTH_ENABLED switches socket auth on for the API (token routes, the
* authorize endpoint, signed HTTP publishing) and the socket server. Fresh installs get
* `false` and mode `log`, the same as scripts/docker-install.sh: the key is provisioned,
* but clients that don't fetch socket tokens yet (released mobile apps, integrations)
* keep working until the switch is turned on. While it is off the API publishes over the
* legacy websocket path, so `enforce` needs the switch on.
* An existing key of at least 32 characters is kept across re-runs, because changing it
* would invalidate every socket token already handed out. An existing switch value and
* mode are kept too. Other lines in the file are left untouched.
* SOCKETCLUSTER_ORIGIN is the Origin header the API's websocket publisher sends (the path
* used while the switch is off). Without it the socket server sees the origin as `*` and,
* with restricted SOCKETCLUSTER_OPTIONS origins, refuses every broadcast with
* "Invalid origin: *". It defaults to `defaultOrigin` (an origin the written origins
* allow); an existing value is kept.
* @param {string} directory resolved installation directory
* @param {string} defaultOrigin console origin allowed by SOCKETCLUSTER_OPTIONS
* @returns {Promise<{ envPath: string, enabled: string, mode: string, origin: string, generated: boolean }>}
*/
async function ensureSocketAuthEnv(directory, defaultOrigin = '') {
const crypto = require('crypto');
const envPath = path.join(directory, '.env');

let lines = [];
if (await fs.pathExists(envPath)) {
const stat = await fs.stat(envPath);
if (stat.isDirectory()) {
console.error(`\n✖ ${envPath} is a directory; remove it and re-run the installer.`);
process.exit(1);
}
lines = (await fs.readFile(envPath, 'utf8')).split(/\r?\n/);
if (lines.length && lines[lines.length - 1] === '') lines.pop();
}

// Last KEY=value in the file wins (same as Compose), with surrounding quotes stripped.
const readValue = (key) => {
const pattern = new RegExp(`^\\s*${key}=(.*)$`);
let value = '';
for (const line of lines) {
const match = line.match(pattern);
if (match) value = match[1].trim().replace(/^(['"])(.*)\1$/, '$2');
}
return value;
};

let authKey = readValue('SOCKETCLUSTER_AUTH_KEY');
let generated = false;
if (authKey.length >= 32) {
console.log('✔ Keeping the existing socket auth key from .env');
} else {
if (authKey) {
console.warn(' ⚠ The socket auth key in .env is shorter than 32 characters; generating a new one.');
}
authKey = crypto.randomBytes(32).toString('hex'); // 64 hex characters
generated = true;
console.log('✔ Socket auth key generated');
}
const enabled = readValue('SOCKETCLUSTER_AUTH_ENABLED') || 'false';
const mode = readValue('SOCKETCLUSTER_AUTH_MODE') || 'log';
const origin = readValue('SOCKETCLUSTER_ORIGIN') || defaultOrigin;
const switchedOn = /^(true|1|yes|on)$/i.test(enabled);

const managed = /^\s*SOCKETCLUSTER_(AUTH_KEY|AUTH_ENABLED|AUTH_MODE|ORIGIN)=/;
const output = lines.filter(line => !managed.test(line));
output.push(
`SOCKETCLUSTER_AUTH_KEY=${authKey}`,
`SOCKETCLUSTER_AUTH_ENABLED=${enabled}`,
`SOCKETCLUSTER_AUTH_MODE=${mode}`,
...(origin ? [`SOCKETCLUSTER_ORIGIN=${origin}`] : []),
''
);

await fs.writeFile(envPath, output.join('\n'), { mode: 0o600 });
try { await fs.chmod(envPath, 0o600); } catch { /* best effort, e.g. on Windows */ }
const summary = switchedOn ? `on, mode: ${mode}` : 'off until SOCKETCLUSTER_AUTH_ENABLED=true';
console.log(`✔ Socket auth written to .env (${summary})`);
if (origin) console.log(`✔ API publisher origin: ${origin}`);

return { envPath, enabled, mode, origin, summary, generated };
}

// Command to install Fleetbase via Docker
async function installFleetbaseCommand(options) {
const crypto = require('crypto');
Expand Down Expand Up @@ -1423,6 +1509,9 @@ async function installFleetbaseCommand(options) {
? 'http://localhost:*,https://localhost:*,ws://localhost:*,wss://localhost:*'
: `${schemeConsole}://${host}:*,wss://${host}:*`;
const socketClusterOptions = JSON.stringify({ origins: socketOrigins });
// Origin the API publisher sends; the socket server matches hostname + port
// against the entries above (e.g. "localhost:*"), so it must fall inside them.
const socketPublisherOrigin = isLocalhost ? 'http://localhost:4200' : `${schemeConsole}://${host}`;
console.log(`✔ SESSION_DOMAIN set to: ${sessionDomain}`);
console.log(`✔ WebSocket origins restricted to: ${socketOrigins}`);

Expand Down Expand Up @@ -1464,6 +1553,10 @@ async function installFleetbaseCommand(options) {
const appKey = 'base64:' + crypto.randomBytes(32).toString('base64');
console.log('✔ APP_KEY generated');

// ── Step 8b: Socket authentication key (project-root .env) ───────────
console.log('\n⏳ Configuring socket authentication...');
const socketAuth = await ensureSocketAuthEnv(directory, socketPublisherOrigin);

// ── Step 9: Write docker-compose.override.yml ─────────────────────────
console.log('⏳ Writing docker-compose.override.yml...');

Expand Down Expand Up @@ -1656,6 +1749,7 @@ ${buildEnvBlock(dbEnvVars)}
mailSetup.configure ? `Mail (${mailConfig.mailMailer})` : null,
storageChoice.driver !== 'public' ? `Storage (${storageChoice.driver.toUpperCase()})` : null,
'WebSocket security (origins restricted)',
`Socket authentication (${socketAuth.summary}; key in .env)`,
thirdPartySetup.configure ? 'Third-party APIs' : null,
].filter(Boolean);

Expand Down
Loading