Merge pull request #2661 from makeittech/feat/ope-231-opencode-hostname
feat(server): validate OPENCHAMBER_OPENCODE_HOSTNAME bind hostname
This commit is contained in:
@@ -110,7 +110,7 @@ OPENCODE_HOST=https://myhost:4096 OPENCODE_SKIP_START=true openchamber
|
|||||||
| `OPENCODE_HOST` | Full base URL of external server (overrides `OPENCODE_PORT`) |
|
| `OPENCODE_HOST` | Full base URL of external server (overrides `OPENCODE_PORT`) |
|
||||||
| `OPENCODE_PORT` | Port of external server |
|
| `OPENCODE_PORT` | Port of external server |
|
||||||
| `OPENCODE_SKIP_START` | Skip starting embedded OpenCode server |
|
| `OPENCODE_SKIP_START` | Skip starting embedded OpenCode server |
|
||||||
| `OPENCHAMBER_OPENCODE_HOSTNAME` | Bind hostname for managed OpenCode server (default: `127.0.0.1`, use `0.0.0.0` for LAN/remote access — trusted networks only) |
|
| `OPENCHAMBER_OPENCODE_HOSTNAME` | Bind hostname for managed OpenCode server (default: `127.0.0.1`, use `0.0.0.0` for LAN/remote access — trusted networks only). Invalid values are rejected with an error and fall back to loopback |
|
||||||
| `OPENCHAMBER_HOST` | Bind hostname for the OpenChamber web server (default: `127.0.0.1`; use `0.0.0.0` for LAN/remote access — trusted networks only) |
|
| `OPENCHAMBER_HOST` | Bind hostname for the OpenChamber web server (default: `127.0.0.1`; use `0.0.0.0` for LAN/remote access — trusted networks only) |
|
||||||
| `OPENCHAMBER_VERBOSE_REQUEST_LOGS` | Set to `true` to log every HTTP request; disabled by default to keep user logs small |
|
| `OPENCHAMBER_VERBOSE_REQUEST_LOGS` | Set to `true` to log every HTTP request; disabled by default to keep user logs small |
|
||||||
| `OPENCHAMBER_SKIP_API_COMPRESSION` | Set to `true` to disable gzip compression for `/api/*` responses |
|
| `OPENCHAMBER_SKIP_API_COMPRESSION` | Set to `true` to disable gzip compression for `/api/*` responses |
|
||||||
|
|||||||
@@ -1,3 +1,26 @@
|
|||||||
|
import { isIP } from 'node:net';
|
||||||
|
|
||||||
|
const MAX_HOSTNAME_LENGTH = 253;
|
||||||
|
const HOSTNAME_LABEL_RE = /^[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?$/;
|
||||||
|
// All-numeric dotted values must be a real IPv4 address; otherwise typo'd IPs
|
||||||
|
// like "0.0.0.0.0" would slip through as (technically valid) hostnames.
|
||||||
|
const ALL_NUMERIC_DOTTED_RE = /^\d+(?:\.\d+)*$/;
|
||||||
|
|
||||||
|
// Valid bind hostnames for the managed OpenCode server: IPv4, IPv6 (with or
|
||||||
|
// without brackets), or a DNS-style hostname. Everything else (URLs, ports,
|
||||||
|
// paths, whitespace, underscores) is rejected.
|
||||||
|
export const isValidOpenCodeHostname = (value) => {
|
||||||
|
if (typeof value !== 'string') return false;
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed || trimmed.length > MAX_HOSTNAME_LENGTH) return false;
|
||||||
|
if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
|
||||||
|
return isIP(trimmed.slice(1, -1)) === 6;
|
||||||
|
}
|
||||||
|
if (isIP(trimmed) !== 0) return true;
|
||||||
|
if (ALL_NUMERIC_DOTTED_RE.test(trimmed)) return false;
|
||||||
|
return trimmed.split('.').every((label) => HOSTNAME_LABEL_RE.test(label));
|
||||||
|
};
|
||||||
|
|
||||||
export const resolveOpenCodeEnvConfig = (options = {}) => {
|
export const resolveOpenCodeEnvConfig = (options = {}) => {
|
||||||
const env = options.env && typeof options.env === 'object' ? options.env : {};
|
const env = options.env && typeof options.env === 'object' ? options.env : {};
|
||||||
const logger = options.logger ?? console;
|
const logger = options.logger ?? console;
|
||||||
@@ -60,6 +83,14 @@ export const resolveOpenCodeEnvConfig = (options = {}) => {
|
|||||||
);
|
);
|
||||||
return '127.0.0.1';
|
return '127.0.0.1';
|
||||||
}
|
}
|
||||||
|
if (!isValidOpenCodeHostname(trimmed)) {
|
||||||
|
logger.error(
|
||||||
|
`[config] Rejecting OPENCHAMBER_OPENCODE_HOSTNAME=${JSON.stringify(raw)}: `
|
||||||
|
+ 'must be a valid hostname or IP address (for example 127.0.0.1, 0.0.0.0, localhost, [::1]); '
|
||||||
|
+ 'falling back to 127.0.0.1 (loopback only)',
|
||||||
|
);
|
||||||
|
return '127.0.0.1';
|
||||||
|
}
|
||||||
return trimmed;
|
return trimmed;
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { isValidOpenCodeHostname, resolveOpenCodeEnvConfig } from './env-config.js';
|
||||||
|
|
||||||
|
describe('isValidOpenCodeHostname', () => {
|
||||||
|
it('accepts IPv4 addresses', () => {
|
||||||
|
expect(isValidOpenCodeHostname('127.0.0.1')).toBe(true);
|
||||||
|
expect(isValidOpenCodeHostname('0.0.0.0')).toBe(true);
|
||||||
|
expect(isValidOpenCodeHostname('192.168.1.10')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts IPv6 addresses with and without brackets', () => {
|
||||||
|
expect(isValidOpenCodeHostname('::1')).toBe(true);
|
||||||
|
expect(isValidOpenCodeHostname('[::1]')).toBe(true);
|
||||||
|
expect(isValidOpenCodeHostname('::')).toBe(true);
|
||||||
|
expect(isValidOpenCodeHostname('[::]')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts DNS-style hostnames', () => {
|
||||||
|
expect(isValidOpenCodeHostname('localhost')).toBe(true);
|
||||||
|
expect(isValidOpenCodeHostname('tailscale-host')).toBe(true);
|
||||||
|
expect(isValidOpenCodeHostname('my.host.example')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects malformed values', () => {
|
||||||
|
const invalid = [
|
||||||
|
'',
|
||||||
|
' ',
|
||||||
|
'http://localhost',
|
||||||
|
'https://host:4096',
|
||||||
|
'host:4096',
|
||||||
|
'host/path',
|
||||||
|
'bad host',
|
||||||
|
'bad_host',
|
||||||
|
'0.0.0.0.0',
|
||||||
|
'999.999.999.999',
|
||||||
|
'[::1',
|
||||||
|
'::1]',
|
||||||
|
'a'.repeat(254),
|
||||||
|
'1.2.3.4.5.6.7.8.9',
|
||||||
|
];
|
||||||
|
for (const value of invalid) {
|
||||||
|
expect(isValidOpenCodeHostname(value), JSON.stringify(value)).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects non-string values', () => {
|
||||||
|
expect(isValidOpenCodeHostname(undefined)).toBe(false);
|
||||||
|
expect(isValidOpenCodeHostname(null)).toBe(false);
|
||||||
|
expect(isValidOpenCodeHostname(42)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resolveOpenCodeEnvConfig hostname', () => {
|
||||||
|
it('defaults to loopback when the env var is absent', () => {
|
||||||
|
expect(resolveOpenCodeEnvConfig({ env: {} }).configuredOpenCodeHostname).toBe('127.0.0.1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads OPENCHAMBER_OPENCODE_HOSTNAME', () => {
|
||||||
|
const result = resolveOpenCodeEnvConfig({ env: { OPENCHAMBER_OPENCODE_HOSTNAME: '0.0.0.0' } });
|
||||||
|
expect(result.configuredOpenCodeHostname).toBe('0.0.0.0');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('trims surrounding whitespace', () => {
|
||||||
|
const result = resolveOpenCodeEnvConfig({ env: { OPENCHAMBER_OPENCODE_HOSTNAME: ' tailscale-host ' } });
|
||||||
|
expect(result.configuredOpenCodeHostname).toBe('tailscale-host');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('warns and falls back for an empty value', () => {
|
||||||
|
const logger = { warn: vi.fn(), error: vi.fn() };
|
||||||
|
const result = resolveOpenCodeEnvConfig({ env: { OPENCHAMBER_OPENCODE_HOSTNAME: ' ' }, logger });
|
||||||
|
expect(result.configuredOpenCodeHostname).toBe('127.0.0.1');
|
||||||
|
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('empty after trimming'));
|
||||||
|
expect(logger.error).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects invalid values with a clear error and falls back to loopback', () => {
|
||||||
|
const logger = { warn: vi.fn(), error: vi.fn() };
|
||||||
|
const result = resolveOpenCodeEnvConfig({
|
||||||
|
env: { OPENCHAMBER_OPENCODE_HOSTNAME: 'http://nope:4096' },
|
||||||
|
logger,
|
||||||
|
});
|
||||||
|
expect(result.configuredOpenCodeHostname).toBe('127.0.0.1');
|
||||||
|
expect(logger.error).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('Rejecting OPENCHAMBER_OPENCODE_HOSTNAME'),
|
||||||
|
);
|
||||||
|
expect(logger.error).toHaveBeenCalledWith(expect.stringContaining('127.0.0.1'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps other env config intact when the hostname is validated', () => {
|
||||||
|
const result = resolveOpenCodeEnvConfig({
|
||||||
|
env: { OPENCHAMBER_OPENCODE_HOSTNAME: '0.0.0.0', OPENCODE_PORT: '4096' },
|
||||||
|
});
|
||||||
|
expect(result.configuredOpenCodeHostname).toBe('0.0.0.0');
|
||||||
|
expect(result.configuredOpenCodePort).toBe(4096);
|
||||||
|
expect(result.effectivePort).toBe(4096);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -50,7 +50,7 @@ const createMockChild = () => {
|
|||||||
return child;
|
return child;
|
||||||
};
|
};
|
||||||
|
|
||||||
const createRuntime = (overrides = {}, stateOverrides = {}) => {
|
const createRuntime = (overrides = {}, stateOverrides = {}, envOverrides = {}) => {
|
||||||
const state = {
|
const state = {
|
||||||
openCodeWorkingDirectory: '/tmp/project',
|
openCodeWorkingDirectory: '/tmp/project',
|
||||||
openCodeProcess: null,
|
openCodeProcess: null,
|
||||||
@@ -83,6 +83,7 @@ const createRuntime = (overrides = {}, stateOverrides = {}) => {
|
|||||||
ENV_EFFECTIVE_PORT: 3001,
|
ENV_EFFECTIVE_PORT: 3001,
|
||||||
ENV_CONFIGURED_OPENCODE_HOSTNAME: '127.0.0.1',
|
ENV_CONFIGURED_OPENCODE_HOSTNAME: '127.0.0.1',
|
||||||
ENV_SKIP_OPENCODE_START: false,
|
ENV_SKIP_OPENCODE_START: false,
|
||||||
|
...envOverrides,
|
||||||
},
|
},
|
||||||
syncToHmrState: vi.fn(),
|
syncToHmrState: vi.fn(),
|
||||||
syncFromHmrState: vi.fn(),
|
syncFromHmrState: vi.fn(),
|
||||||
@@ -312,6 +313,27 @@ describe('OpenCode lifecycle', () => {
|
|||||||
expect(server.signalCode).toBe('SIGTERM');
|
expect(server.signalCode).toBe('SIGTERM');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('launches managed OpenCode on the configured bind hostname', async () => {
|
||||||
|
delete process.env.OPENCODE_BINARY;
|
||||||
|
const child = createMockChild();
|
||||||
|
spawnMock.mockImplementationOnce(() => {
|
||||||
|
queueMicrotask(() => {
|
||||||
|
child.stdout.emit('data', 'opencode server listening on http://0.0.0.0:45678\n');
|
||||||
|
});
|
||||||
|
return child;
|
||||||
|
});
|
||||||
|
|
||||||
|
const runtime = createRuntime({}, {}, { ENV_CONFIGURED_OPENCODE_HOSTNAME: '0.0.0.0' });
|
||||||
|
const server = await runtime.startOpenCode();
|
||||||
|
const [binary, args] = spawnMock.mock.calls[0];
|
||||||
|
|
||||||
|
expect(binary).toBe('opencode');
|
||||||
|
expect(args).toEqual(['serve', '--hostname', '0.0.0.0', '--port', '45678']);
|
||||||
|
|
||||||
|
await server.close();
|
||||||
|
expect(server.signalCode).toBe('SIGTERM');
|
||||||
|
});
|
||||||
|
|
||||||
it('strips AppImage ARGV0 from managed OpenCode launch env', async () => {
|
it('strips AppImage ARGV0 from managed OpenCode launch env', async () => {
|
||||||
delete process.env.OPENCODE_BINARY;
|
delete process.env.OPENCODE_BINARY;
|
||||||
const previousArgv0 = process.env.ARGV0;
|
const previousArgv0 = process.env.ARGV0;
|
||||||
|
|||||||
Reference in New Issue
Block a user