fix(cli): warn when systemd user lingering is disabled (#1855)
This commit is contained in:
@@ -11,6 +11,7 @@ import { isModuleCliExecution, normalizeCliEntryPath } from './cli-entry.js';
|
|||||||
import { requestJson } from './lib/cli-http.js';
|
import { requestJson } from './lib/cli-http.js';
|
||||||
import { requestControlAction } from './lib/cli-control.js';
|
import { requestControlAction } from './lib/cli-control.js';
|
||||||
import { inspectTunnelAttachability } from './lib/cli-lifecycle.js';
|
import { inspectTunnelAttachability } from './lib/cli-lifecycle.js';
|
||||||
|
import { startupCommand } from './lib/commands-startup.js';
|
||||||
import { formatGoal } from './lib/commands-schedule.js';
|
import { formatGoal } from './lib/commands-schedule.js';
|
||||||
import {
|
import {
|
||||||
buildSessionCreatePayload,
|
buildSessionCreatePayload,
|
||||||
@@ -34,6 +35,7 @@ import {
|
|||||||
discoverRunningInstances,
|
discoverRunningInstances,
|
||||||
discoverUnconfirmedRegistryInstanceOnPort,
|
discoverUnconfirmedRegistryInstanceOnPort,
|
||||||
ensureTunnelProfilesMigrated,
|
ensureTunnelProfilesMigrated,
|
||||||
|
EXIT_CODE,
|
||||||
generateUiPassword,
|
generateUiPassword,
|
||||||
getInstanceFilePath,
|
getInstanceFilePath,
|
||||||
getPidFilePath,
|
getPidFilePath,
|
||||||
@@ -1456,3 +1458,93 @@ describe('Windows startup task command builder', () => {
|
|||||||
expect(cmd).not.toContain('-Command ');
|
expect(cmd).not.toContain('-Command ');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('startup command lingering output', () => {
|
||||||
|
const linuxStatus = (lingerEnabled, lingerUser = 'alice') => ({
|
||||||
|
supported: true,
|
||||||
|
platform: 'linux',
|
||||||
|
enabled: true,
|
||||||
|
active: true,
|
||||||
|
activeState: 'active',
|
||||||
|
servicePath: '/home/alice/.config/systemd/user/openchamber.service',
|
||||||
|
lingerEnabled,
|
||||||
|
lingerUser,
|
||||||
|
});
|
||||||
|
|
||||||
|
const dependenciesFor = (status) => ({
|
||||||
|
getStartupStatus: () => status,
|
||||||
|
enableStartupService: () => status,
|
||||||
|
disableStartupService: () => status,
|
||||||
|
});
|
||||||
|
|
||||||
|
const runCommand = (status, options, action) => startupCommand(options, action, dependenciesFor(status));
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['enable', true, 'ok', undefined],
|
||||||
|
['enable', false, 'warning', 'LINGER_DISABLED'],
|
||||||
|
['enable', null, 'warning', 'LINGER_UNKNOWN'],
|
||||||
|
['status', true, 'ok', undefined],
|
||||||
|
['status', false, 'warning', 'LINGER_DISABLED'],
|
||||||
|
['status', null, 'warning', 'LINGER_UNKNOWN'],
|
||||||
|
])('reports %s with Linux linger=%s as JSON-only output', async (action, lingerEnabled, expectedStatus, warningCode) => {
|
||||||
|
const output = await captureStdout(() => runCommand(linuxStatus(lingerEnabled), { json: true }, action));
|
||||||
|
const payload = JSON.parse(output);
|
||||||
|
|
||||||
|
expect(payload.status).toBe(expectedStatus);
|
||||||
|
expect(payload.action).toBe(action);
|
||||||
|
expect(payload.lingerEnabled).toBe(lingerEnabled);
|
||||||
|
expect(payload.messages?.[0]?.code).toBe(warningCode);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['enable', true, 'yes'],
|
||||||
|
['enable', false, 'no'],
|
||||||
|
['enable', null, 'unknown'],
|
||||||
|
['status', true, 'yes'],
|
||||||
|
['status', false, 'no'],
|
||||||
|
['status', null, 'unknown'],
|
||||||
|
])('reports %s with Linux linger=%s in one quiet result line', async (action, lingerEnabled, label) => {
|
||||||
|
const output = await captureStdout(() => runCommand(linuxStatus(lingerEnabled), { quiet: true }, action));
|
||||||
|
|
||||||
|
expect(output.split('\n')).toHaveLength(2);
|
||||||
|
expect(output).toContain(` linger:${label}\n`);
|
||||||
|
expect(output).not.toContain('loginctl');
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([true, false])('warns with an actionable command in human TTY=%s output', async (isTTY) => {
|
||||||
|
const descriptor = Object.getOwnPropertyDescriptor(process.stdout, 'isTTY');
|
||||||
|
Object.defineProperty(process.stdout, 'isTTY', { configurable: true, value: isTTY });
|
||||||
|
try {
|
||||||
|
const output = await captureStdout(() => runCommand(linuxStatus(false), {}, 'enable'));
|
||||||
|
|
||||||
|
expect(output).toContain('[LINGER_DISABLED]');
|
||||||
|
expect(output).toContain('sudo loginctl enable-linger alice');
|
||||||
|
} finally {
|
||||||
|
if (descriptor) Object.defineProperty(process.stdout, 'isTTY', descriptor);
|
||||||
|
else delete process.stdout.isTTY;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports unknown state without inventing a user when detection is unavailable', async () => {
|
||||||
|
const output = await captureStdout(() => runCommand(linuxStatus(null, null), {}, 'status'));
|
||||||
|
|
||||||
|
expect(output).toContain('[LINGER_UNKNOWN]');
|
||||||
|
expect(output).toContain('loginctl show-user "$USER" -p Linger');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports disabled-service linger state without warning or remediation', async () => {
|
||||||
|
const output = await captureStdout(() => runCommand({ ...linuxStatus(false), enabled: false }, {}, 'status'));
|
||||||
|
|
||||||
|
expect(output).toContain('user lingering is disabled');
|
||||||
|
expect(output).not.toContain('[LINGER_DISABLED]');
|
||||||
|
expect(output).not.toContain('loginctl enable-linger');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not emit linger guidance for unsupported systems', async () => {
|
||||||
|
await expect(runCommand(
|
||||||
|
{ supported: false, platform: 'freebsd', enabled: false, servicePath: null },
|
||||||
|
{ json: true },
|
||||||
|
'status'
|
||||||
|
)).rejects.toMatchObject({ exitCode: EXIT_CODE.USAGE_ERROR });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -283,6 +283,53 @@ function runStartupCommand(command, args, options = {}) {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseLingerState(stdout) {
|
||||||
|
if (typeof stdout !== 'string') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const match = stdout.match(/^Linger=([A-Za-z]+)\s*$/m);
|
||||||
|
if (!match) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const value = match[1].toLowerCase();
|
||||||
|
if (value === 'yes') return true;
|
||||||
|
if (value === 'no') return false;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCurrentUsername() {
|
||||||
|
try {
|
||||||
|
const name = os.userInfo().username;
|
||||||
|
if (typeof name === 'string' && name.length > 0) {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// userInfo() can throw when the uid has no passwd entry; fall back to env.
|
||||||
|
}
|
||||||
|
return process.env.USER || process.env.LOGNAME || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// A systemd --user service only keeps running without an active login session
|
||||||
|
// when the user has lingering enabled. Detect it so `startup enable` can warn
|
||||||
|
// that the service may otherwise stop on logout. Returns null when the state
|
||||||
|
// cannot be determined (no username, loginctl unavailable, or odd output).
|
||||||
|
function getUserLingerEnabled(username) {
|
||||||
|
const user = typeof username === 'string' && username.length > 0 ? username : getCurrentUsername();
|
||||||
|
if (!user) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
let result;
|
||||||
|
try {
|
||||||
|
result = runStartupCommand('loginctl', ['show-user', user, '-p', 'Linger'], { allowFailure: true });
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (result.status !== 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return parseLingerState(result.stdout);
|
||||||
|
}
|
||||||
|
|
||||||
function getStartupStatus() {
|
function getStartupStatus() {
|
||||||
const paths = getStartupServicePaths();
|
const paths = getStartupServicePaths();
|
||||||
if (!paths.servicePath) {
|
if (!paths.servicePath) {
|
||||||
@@ -296,6 +343,7 @@ function getStartupStatus() {
|
|||||||
const enabledResult = runStartupCommand('systemctl', ['--user', 'is-enabled', 'openchamber.service'], { allowFailure: true });
|
const enabledResult = runStartupCommand('systemctl', ['--user', 'is-enabled', 'openchamber.service'], { allowFailure: true });
|
||||||
const activeResult = runStartupCommand('systemctl', ['--user', 'is-active', 'openchamber.service'], { allowFailure: true });
|
const activeResult = runStartupCommand('systemctl', ['--user', 'is-active', 'openchamber.service'], { allowFailure: true });
|
||||||
const activeState = (activeResult.stdout || '').trim() || 'inactive';
|
const activeState = (activeResult.stdout || '').trim() || 'inactive';
|
||||||
|
const lingerUser = getCurrentUsername();
|
||||||
return {
|
return {
|
||||||
supported: true,
|
supported: true,
|
||||||
platform: paths.platform,
|
platform: paths.platform,
|
||||||
@@ -303,6 +351,8 @@ function getStartupStatus() {
|
|||||||
active: activeState === 'active',
|
active: activeState === 'active',
|
||||||
activeState,
|
activeState,
|
||||||
servicePath: paths.servicePath,
|
servicePath: paths.servicePath,
|
||||||
|
lingerEnabled: getUserLingerEnabled(lingerUser),
|
||||||
|
lingerUser: lingerUser || null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -9,7 +9,10 @@ import {
|
|||||||
logStatus,
|
logStatus,
|
||||||
} from '../cli-output.js';
|
} from '../cli-output.js';
|
||||||
|
|
||||||
async function startupCommand(options, action = 'status') {
|
async function startupCommand(options, action = 'status', dependencies = {}) {
|
||||||
|
const getStatus = dependencies.getStartupStatus || getStartupStatus;
|
||||||
|
const enableService = dependencies.enableStartupService || enableStartupService;
|
||||||
|
const disableService = dependencies.disableStartupService || disableStartupService;
|
||||||
const normalized = typeof action === 'string' ? action.trim().toLowerCase() : 'status';
|
const normalized = typeof action === 'string' ? action.trim().toLowerCase() : 'status';
|
||||||
if (!['status', 'enable', 'disable'].includes(normalized)) {
|
if (!['status', 'enable', 'disable'].includes(normalized)) {
|
||||||
throw new TunnelCliError(
|
throw new TunnelCliError(
|
||||||
@@ -20,14 +23,14 @@ async function startupCommand(options, action = 'status') {
|
|||||||
|
|
||||||
let status;
|
let status;
|
||||||
if (normalized === 'enable') {
|
if (normalized === 'enable') {
|
||||||
status = enableStartupService(options);
|
status = enableService(options);
|
||||||
} else if (normalized === 'disable') {
|
} else if (normalized === 'disable') {
|
||||||
status = disableStartupService();
|
status = disableService();
|
||||||
} else {
|
} else {
|
||||||
status = getStartupStatus();
|
status = getStatus();
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = { action: normalized, ...status };
|
let result = { action: normalized, ...status };
|
||||||
if (!result.supported) {
|
if (!result.supported) {
|
||||||
throw new TunnelCliError(
|
throw new TunnelCliError(
|
||||||
`Startup integration is not supported on ${result.platform}.`,
|
`Startup integration is not supported on ${result.platform}.`,
|
||||||
@@ -40,13 +43,30 @@ async function startupCommand(options, action = 'status') {
|
|||||||
EXIT_CODE.GENERAL_ERROR
|
EXIT_CODE.GENERAL_ERROR
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (result.platform === 'linux' && result.enabled && result.lingerEnabled !== true) {
|
||||||
|
const user = result.lingerUser || '"$USER"';
|
||||||
|
const disabled = result.lingerEnabled === false;
|
||||||
|
result = {
|
||||||
|
...result,
|
||||||
|
messages: [{
|
||||||
|
level: 'warning',
|
||||||
|
code: disabled ? 'LINGER_DISABLED' : 'LINGER_UNKNOWN',
|
||||||
|
message: disabled
|
||||||
|
? `User lingering is disabled; the startup service may stop after logout. Run \`sudo loginctl enable-linger ${user}\` to keep it running.`
|
||||||
|
: `Could not verify user lingering; the startup service may stop after logout. Check with \`loginctl show-user ${user} -p Linger\`.`,
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
}
|
||||||
if (isJsonMode(options)) {
|
if (isJsonMode(options)) {
|
||||||
printJson(result);
|
printJson(result);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isQuietMode(options)) {
|
if (isQuietMode(options)) {
|
||||||
process.stdout.write(`startup ${result.enabled ? 'enabled' : 'disabled'} platform:${result.platform} supported:${result.supported ? 'yes' : 'no'}${result.servicePath ? ` path:${result.servicePath}` : ''}\n`);
|
const lingerToken = result.platform === 'linux'
|
||||||
|
? ` linger:${result.lingerEnabled === true ? 'yes' : result.lingerEnabled === false ? 'no' : 'unknown'}`
|
||||||
|
: '';
|
||||||
|
process.stdout.write(`startup ${result.enabled ? 'enabled' : 'disabled'} platform:${result.platform} supported:${result.supported ? 'yes' : 'no'}${result.servicePath ? ` path:${result.servicePath}` : ''}${lingerToken}\n`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,6 +78,21 @@ async function startupCommand(options, action = 'status') {
|
|||||||
if (normalized === 'enable') {
|
if (normalized === 'enable') {
|
||||||
logStatus('info', 'service command', 'openchamber serve --foreground');
|
logStatus('info', 'service command', 'openchamber serve --foreground');
|
||||||
}
|
}
|
||||||
|
if (result.platform === 'linux') {
|
||||||
|
if (result.lingerEnabled === true) {
|
||||||
|
logStatus('success', 'user lingering enabled');
|
||||||
|
} else if (result.lingerEnabled === false) {
|
||||||
|
logStatus(result.enabled ? 'warning' : 'info', `${result.enabled ? '[LINGER_DISABLED] ' : ''}user lingering is disabled`, result.enabled ? 'startup service may stop after logout' : undefined);
|
||||||
|
if (result.enabled) {
|
||||||
|
logStatus('info', '[ENABLE_LINGER]', `sudo loginctl enable-linger ${result.lingerUser || '"$USER"'}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logStatus(result.enabled ? 'warning' : 'info', result.enabled ? '[LINGER_UNKNOWN] could not verify user lingering' : 'user lingering state is unknown', result.enabled ? 'startup service may stop after logout' : undefined);
|
||||||
|
if (result.enabled) {
|
||||||
|
logStatus('info', '[CHECK_LINGER]', `loginctl show-user ${result.lingerUser || '"$USER"'} -p Linger`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
clackOutro(normalized === 'status' ? 'status complete' : `${normalized} complete`);
|
clackOutro(normalized === 'status' ? 'status complete' : `${normalized} complete`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user