diff --git a/src/compose-generator.ts b/src/compose-generator.ts index cc30de32..b17a825e 100644 --- a/src/compose-generator.ts +++ b/src/compose-generator.ts @@ -3,7 +3,8 @@ import * as path from 'path'; import { DockerComposeConfig, WrapperConfig } from './types'; import { DEFAULT_DNS_SERVERS } from './dns-resolver'; import { parseImageTag } from './image-tag'; -import { SslConfig, getRealUserHome } from './host-env'; +import { SslConfig } from './host-env'; +import { getRealUserHome } from './host-identity'; import { buildSquidService } from './services/squid-service'; import { buildAgentEnvironment, buildAgentVolumes, buildAgentService, buildIptablesInitService } from './services/agent-service'; import { buildApiProxyService } from './services/api-proxy-service'; diff --git a/src/config-writer.ts b/src/config-writer.ts index 999e796c..25b9295b 100644 --- a/src/config-writer.ts +++ b/src/config-writer.ts @@ -5,13 +5,7 @@ import { WrapperConfig, API_PROXY_PORTS } from './types'; import { logger } from './logger'; import { generateSquidConfig, generatePolicyManifest } from './squid-config'; import { generateSessionCa, initSslDb, parseUrlPatterns, isOpenSslAvailable } from './ssl-bump'; -import { - SQUID_PORT, - SslConfig, - getSafeHostUid, - getSafeHostGid, - getRealUserHome, -} from './host-env'; +import { SslConfig, SQUID_PORT, getSafeHostUid, getSafeHostGid, getRealUserHome } from './host-env'; import { generateDockerCompose, redactDockerComposeSecrets } from './compose-generator'; // When bundled with esbuild, this global is replaced at build time with the diff --git a/src/constants.ts b/src/constants.ts new file mode 100644 index 00000000..8a6ae4e4 --- /dev/null +++ b/src/constants.ts @@ -0,0 +1,26 @@ +/** + * Container names used in Docker Compose and referenced by docker CLI commands. + * Extracted as constants so that generateDockerCompose() and helpers like + * fastKillAgentContainer() stay in sync. + */ +export const AGENT_CONTAINER_NAME = 'awf-agent'; +export const SQUID_CONTAINER_NAME = 'awf-squid'; +export const IPTABLES_INIT_CONTAINER_NAME = 'awf-iptables-init'; +export const API_PROXY_CONTAINER_NAME = 'awf-api-proxy'; +export const DOH_PROXY_CONTAINER_NAME = 'awf-doh-proxy'; +export const CLI_PROXY_CONTAINER_NAME = 'awf-cli-proxy'; + +export const SQUID_PORT = 3128; + +/** + * Maximum size (bytes) of a single environment variable value allowed through + * --env-all passthrough. Variables exceeding this are skipped with a warning + * to prevent E2BIG errors from ARG_MAX exhaustion. + */ +export const MAX_ENV_VALUE_SIZE = 64 * 1024; // 64 KB + +/** + * Total environment size (bytes) threshold for issuing an ARG_MAX warning. + * Linux ARG_MAX is ~2 MB for argv + envp combined; warn well before that. + */ +export const ENV_SIZE_WARNING_THRESHOLD = 1_500_000; // ~1.5 MB diff --git a/src/container-cleanup.ts b/src/container-cleanup.ts index 923ed6bc..a34e8c6d 100644 --- a/src/container-cleanup.ts +++ b/src/container-cleanup.ts @@ -10,8 +10,8 @@ import { SQUID_CONTAINER_NAME, IPTABLES_INIT_CONTAINER_NAME, API_PROXY_CONTAINER_NAME, - getLocalDockerEnv, -} from './host-env'; +} from './constants'; +import { getLocalDockerEnv } from './docker-host'; /** * Collects diagnostic logs from AWF containers on failure. diff --git a/src/container-lifecycle.ts b/src/container-lifecycle.ts index 8f2da8cf..11e00321 100644 --- a/src/container-lifecycle.ts +++ b/src/container-lifecycle.ts @@ -10,8 +10,8 @@ import { IPTABLES_INIT_CONTAINER_NAME, API_PROXY_CONTAINER_NAME, CLI_PROXY_CONTAINER_NAME, - getLocalDockerEnv, -} from './host-env'; +} from './constants'; +import { getLocalDockerEnv } from './docker-host'; /** * Flag set by fastKillAgentContainer() to signal runAgentCommand() that diff --git a/src/docker-host.ts b/src/docker-host.ts new file mode 100644 index 00000000..397f164f --- /dev/null +++ b/src/docker-host.ts @@ -0,0 +1,52 @@ +/** + * Optional override for the Docker host used by AWF's own container operations. + * Set via setAwfDockerHost() from the CLI --docker-host flag. + * When undefined, AWF auto-selects the local socket (see getLocalDockerEnv). + */ +let awfDockerHostOverride: string | undefined; + +/** + * Sets the Docker host to use for AWF's own container operations. + * + * When set, overrides DOCKER_HOST for all docker CLI calls made by AWF + * (compose up/down, docker wait, docker logs, etc.). + * + * When not set, AWF auto-detects: + * - unix:// DOCKER_HOST values are kept as-is (local socket). + * - TCP DOCKER_HOST values (e.g. DinD) are cleared so docker falls back + * to the system default socket. + * + * @internal Called from cli.ts when --docker-host flag is provided. + */ +export function setAwfDockerHost(host: string | undefined): void { + awfDockerHostOverride = host; +} + +/** + * Returns an environment object suitable for AWF's own docker CLI calls. + * + * When DOCKER_HOST is set to an external TCP daemon (e.g. a workflow-scope + * DinD sidecar), it is removed so docker/docker-compose use the local Unix + * socket instead. When --docker-host was provided via the CLI, that value + * is used regardless of the environment. + * + * The original DOCKER_HOST value is NOT removed from the agent container's + * environment — see generateDockerCompose for the passthrough logic. + */ +export function getLocalDockerEnv(): NodeJS.ProcessEnv { + const env = { ...process.env }; + + if (awfDockerHostOverride !== undefined) { + // Explicit CLI override — always use this socket for AWF operations + env.DOCKER_HOST = awfDockerHostOverride; + } else { + const dockerHost = env.DOCKER_HOST; + if (dockerHost && !dockerHost.startsWith('unix://')) { + // Non-unix DOCKER_HOST (e.g. tcp://localhost:2375 from a DinD sidecar). + // Clear it so AWF's docker commands target the local daemon, not the DinD one. + delete env.DOCKER_HOST; + } + } + + return env; +} diff --git a/src/github-env.ts b/src/github-env.ts new file mode 100644 index 00000000..47273e00 --- /dev/null +++ b/src/github-env.ts @@ -0,0 +1,223 @@ +import * as fs from 'fs'; +import { logger } from './logger'; + +/** + * Extracts the hostname from GITHUB_SERVER_URL to set GH_HOST for gh CLI. + * Returns the hostname if GITHUB_SERVER_URL points to a non-github.com instance, + * or null if it points to github.com (no GH_HOST needed). + * @param serverUrl - The GITHUB_SERVER_URL environment variable value + * @returns The hostname to use for GH_HOST, or null if not needed + * @internal Exported for testing + */ +export function extractGhHostFromServerUrl(serverUrl: string | undefined): string | null { + if (!serverUrl) { + return null; + } + + try { + const url = new URL(serverUrl); + const hostname = url.hostname; + + // If pointing to public GitHub, no GH_HOST needed + if (hostname === 'github.com') { + return null; + } + + // For GHES/GHEC instances, return the hostname + return hostname; + } catch { + // Invalid URL, return null + return null; + } +} + +/** + * Reads path entries from the $GITHUB_PATH file used by GitHub Actions. + * + * When setup-* actions (e.g., setup-ruby, setup-dart, setup-python) run before AWF, + * they add tool paths to the $GITHUB_PATH file. The Actions runner prepends these + * to $PATH for subsequent steps, but if `sudo` resets PATH (depending on sudoers + * configuration), those entries may be lost by the time AWF reads process.env.PATH. + * + * This function reads the $GITHUB_PATH file directly and returns any path entries + * found, so they can be merged into AWF_HOST_PATH regardless of sudo behavior. + * + * @returns Array of path entries from the $GITHUB_PATH file, or empty array if unavailable + * @internal Exported for testing + */ +export function readGitHubPathEntries(): string[] { + const githubPathFile = process.env.GITHUB_PATH; + if (!githubPathFile) { + logger.debug('GITHUB_PATH env var is not set; skipping $GITHUB_PATH file merge (tools installed by setup-* actions may be missing from PATH if sudo reset it)'); + return []; + } + + try { + const content = fs.readFileSync(githubPathFile, 'utf-8'); + return content + .split('\n') + .map(line => line.trim()) + .filter(line => line.length > 0); + } catch { + // File doesn't exist or isn't readable — expected outside GitHub Actions + logger.debug(`GITHUB_PATH file at '${githubPathFile}' could not be read; skipping file merge`); + return []; + } +} + +/** + * Reads key-value environment entries from the $GITHUB_ENV file. + * + * The Actions runner writes to this file when steps call `core.exportVariable()`. + * When AWF runs via `sudo`, non-standard env vars may be stripped. This function + * reads the file directly to recover them. + * + * Supports both formats used by the Actions runner: + * - Simple: `KEY=VALUE` (value may contain `=`) + * - Heredoc: `KEY< { + const githubEnvFile = process.env.GITHUB_ENV; + if (!githubEnvFile) { + logger.debug('GITHUB_ENV env var is not set; skipping $GITHUB_ENV file read'); + return {}; + } + + try { + const content = fs.readFileSync(githubEnvFile, 'utf-8'); + return parseGitHubEnvFile(content); + } catch { + logger.debug(`GITHUB_ENV file at '${githubEnvFile}' could not be read; skipping`); + return {}; + } +} + +/** + * Parses the content of a $GITHUB_ENV file into key-value pairs. + * @internal Exported for testing + */ +export function parseGitHubEnvFile(content: string): Record { + const result: Record = {}; + // Normalize CRLF to LF + const lines = content.replace(/\r\n/g, '\n').split('\n'); + let i = 0; + + while (i < lines.length) { + const line = lines[i]; + + // Skip empty lines + if (line.trim() === '') { + i++; + continue; + } + + // Check for heredoc format: KEY< 0) { + const key = line.slice(0, eqIdx); + const value = line.slice(eqIdx + 1); + result[key] = value; + } + + i++; + } + + return result; +} + +/** + * Toolchain environment variables that should be recovered from $GITHUB_ENV + * when sudo strips them from process.env. These are set by setup-* actions + * (setup-go, setup-java, setup-dotnet, etc.) and are needed for correct + * tool resolution inside the agent container. + */ +export const TOOLCHAIN_ENV_VARS = [ + 'GOROOT', + 'CARGO_HOME', + 'RUSTUP_HOME', + 'JAVA_HOME', + 'DOTNET_ROOT', + 'BUN_INSTALL', +] as const; + +/** + * Merges path entries from the $GITHUB_PATH file into a PATH string. + * Entries from $GITHUB_PATH are prepended (they have higher priority, matching + * how the Actions runner processes them). Duplicate entries are removed. + * + * @param currentPath - The current PATH string (e.g., from process.env.PATH) + * @param githubPathEntries - Path entries read from the $GITHUB_PATH file + * @returns Merged PATH string with $GITHUB_PATH entries prepended + * @internal Exported for testing + */ +export function mergeGitHubPathEntries(currentPath: string, githubPathEntries: string[]): string { + if (githubPathEntries.length === 0) { + return currentPath; + } + + const currentEntries = currentPath ? currentPath.split(':') : []; + const currentSet = new Set(currentEntries); + + // Only add entries that aren't already in the current PATH + const newEntries = githubPathEntries.filter(entry => !currentSet.has(entry)); + + if (newEntries.length === 0) { + return currentPath; + } + + // Prepend new entries (setup-* actions expect their paths to have priority) + return [...newEntries, ...currentEntries].join(':'); +} + +/** + * Reads environment variables from a KEY=VALUE file (like Docker's --env-file). + * + * Rules: + * - Lines starting with '#' are comments and are ignored. + * - Empty/whitespace-only lines are ignored. + * - Each non-comment line must match the pattern KEY=VALUE where KEY starts with a + * letter or underscore and contains only letters, digits, or underscores. + * - Values may be empty (KEY=). + * - Values are taken literally; no quote-stripping or variable expansion is done. + * + * @param filePath - Absolute or relative path to the env file + * @returns An object mapping variable names to their values + * @throws {Error} If the file cannot be read + */ +export function readEnvFile(filePath: string): Record { + const content = fs.readFileSync(filePath, 'utf-8'); + const result: Record = {}; + for (const raw of content.split('\n')) { + const line = raw.trim(); + // Skip comments and blank lines + if (line === '' || line.startsWith('#')) continue; + const match = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/); + if (match) { + result[match[1]] = match[2]; + } + } + return result; +} diff --git a/src/host-env.ts b/src/host-env.ts index e7ce6b25..250fbf71 100644 --- a/src/host-env.ts +++ b/src/host-env.ts @@ -1,415 +1,41 @@ -import * as fs from 'fs'; -import { logger } from './logger'; import type { CaFiles } from './ssl-bump'; -export const SQUID_PORT = 3128; - -/** - * Container names used in Docker Compose and referenced by docker CLI commands. - * Extracted as constants so that generateDockerCompose() and helpers like - * fastKillAgentContainer() stay in sync. - */ -export const AGENT_CONTAINER_NAME = 'awf-agent'; -export const SQUID_CONTAINER_NAME = 'awf-squid'; -export const IPTABLES_INIT_CONTAINER_NAME = 'awf-iptables-init'; -export const API_PROXY_CONTAINER_NAME = 'awf-api-proxy'; -export const DOH_PROXY_CONTAINER_NAME = 'awf-doh-proxy'; -export const CLI_PROXY_CONTAINER_NAME = 'awf-cli-proxy'; - -/** - * Maximum size (bytes) of a single environment variable value allowed through - * --env-all passthrough. Variables exceeding this are skipped with a warning - * to prevent E2BIG errors from ARG_MAX exhaustion. - */ -export const MAX_ENV_VALUE_SIZE = 64 * 1024; // 64 KB - -/** - * Total environment size (bytes) threshold for issuing an ARG_MAX warning. - * Linux ARG_MAX is ~2 MB for argv + envp combined; warn well before that. - */ -export const ENV_SIZE_WARNING_THRESHOLD = 1_500_000; // ~1.5 MB - - -/** - * Optional override for the Docker host used by AWF's own container operations. - * Set via setAwfDockerHost() from the CLI --docker-host flag. - * When undefined, AWF auto-selects the local socket (see getLocalDockerEnv). - */ -let awfDockerHostOverride: string | undefined; - -/** - * Sets the Docker host to use for AWF's own container operations. - * - * When set, overrides DOCKER_HOST for all docker CLI calls made by AWF - * (compose up/down, docker wait, docker logs, etc.). - * - * When not set, AWF auto-detects: - * - unix:// DOCKER_HOST values are kept as-is (local socket). - * - TCP DOCKER_HOST values (e.g. DinD) are cleared so docker falls back - * to the system default socket. - * - * @internal Called from cli.ts when --docker-host flag is provided. - */ -export function setAwfDockerHost(host: string | undefined): void { - awfDockerHostOverride = host; -} - -/** - * Returns an environment object suitable for AWF's own docker CLI calls. - * - * When DOCKER_HOST is set to an external TCP daemon (e.g. a workflow-scope - * DinD sidecar), it is removed so docker/docker-compose use the local Unix - * socket instead. When --docker-host was provided via the CLI, that value - * is used regardless of the environment. - * - * The original DOCKER_HOST value is NOT removed from the agent container's - * environment — see generateDockerCompose for the passthrough logic. - */ -export function getLocalDockerEnv(): NodeJS.ProcessEnv { - const env = { ...process.env }; - - if (awfDockerHostOverride !== undefined) { - // Explicit CLI override — always use this socket for AWF operations - env.DOCKER_HOST = awfDockerHostOverride; - } else { - const dockerHost = env.DOCKER_HOST; - if (dockerHost && !dockerHost.startsWith('unix://')) { - // Non-unix DOCKER_HOST (e.g. tcp://localhost:2375 from a DinD sidecar). - // Clear it so AWF's docker commands target the local daemon, not the DinD one. - delete env.DOCKER_HOST; - } - } - - return env; -} - - -/** - * Base image for the 'act' preset when building locally. - * Uses catthehacker's GitHub Actions parity image. - */ -export const ACT_PRESET_BASE_IMAGE = 'ghcr.io/catthehacker/ubuntu:act-24.04'; - -/** - * Minimum UID/GID value for regular users. - * UIDs 0-999 are reserved for system users on most Linux distributions. - */ -export const MIN_REGULAR_UID = 1000; - -/** - * Validates that a UID/GID value is safe for use (not in system range). - * Returns the value if valid, or the default (1000) if in system range. - * @internal Exported for testing - */ -export function validateIdNotInSystemRange(id: number): string { - // Reject system UIDs/GIDs (0-999) - use default unprivileged user instead - if (id < MIN_REGULAR_UID) { - return MIN_REGULAR_UID.toString(); - } - return id.toString(); -} - -/** - * Gets the host user's UID, with fallback to 1000 if unavailable, root (0), - * or in the system UID range (0-999). - * When running with sudo, uses SUDO_UID to get the actual user's UID. - * @internal Exported for testing - */ -export function getSafeHostUid(): string { - const uid = process.getuid?.(); - - // When running as root (sudo), try to get the original user's UID - if (!uid || uid === 0) { - const sudoUid = process.env.SUDO_UID; - if (sudoUid) { - const parsedUid = parseInt(sudoUid, 10); - if (!isNaN(parsedUid)) { - return validateIdNotInSystemRange(parsedUid); - } - } - return MIN_REGULAR_UID.toString(); - } - - return validateIdNotInSystemRange(uid); -} - -/** - * Gets the host user's GID, with fallback to 1000 if unavailable, root (0), - * or in the system GID range (0-999). - * When running with sudo, uses SUDO_GID to get the actual user's GID. - * @internal Exported for testing - */ -export function getSafeHostGid(): string { - const gid = process.getgid?.(); - - // When running as root (sudo), try to get the original user's GID - if (!gid || gid === 0) { - const sudoGid = process.env.SUDO_GID; - if (sudoGid) { - const parsedGid = parseInt(sudoGid, 10); - if (!isNaN(parsedGid)) { - return validateIdNotInSystemRange(parsedGid); - } - } - return MIN_REGULAR_UID.toString(); - } - - return validateIdNotInSystemRange(gid); -} - -/** - * Gets the real user's home directory, accounting for sudo. - * When running with sudo, uses SUDO_USER to find the actual user's home. - * @internal Exported for testing - */ -export function getRealUserHome(): string { - const uid = process.getuid?.(); - - // When running as root (sudo), try to get the original user's home - if (!uid || uid === 0) { - // Try SUDO_USER first - look up their home directory from passwd - const sudoUser = process.env.SUDO_USER; - if (sudoUser) { - try { - // Look up user's home directory from /etc/passwd - const passwd = fs.readFileSync('/etc/passwd', 'utf-8'); - const userLine = passwd.split('\n').find(line => line.startsWith(`${sudoUser}:`)); - if (userLine) { - const parts = userLine.split(':'); - if (parts.length >= 6 && parts[5]) { - return parts[5]; // Home directory is the 6th field - } - } - } catch { - // Fall through to use HOME - } - } - } - - // Use HOME environment variable as fallback - return process.env.HOME || '/root'; -} - -/** - * Extracts the hostname from GITHUB_SERVER_URL to set GH_HOST for gh CLI. - * Returns the hostname if GITHUB_SERVER_URL points to a non-github.com instance, - * or null if it points to github.com (no GH_HOST needed). - * @param serverUrl - The GITHUB_SERVER_URL environment variable value - * @returns The hostname to use for GH_HOST, or null if not needed - * @internal Exported for testing - */ -export function extractGhHostFromServerUrl(serverUrl: string | undefined): string | null { - if (!serverUrl) { - return null; - } - - try { - const url = new URL(serverUrl); - const hostname = url.hostname; - - // If pointing to public GitHub, no GH_HOST needed - if (hostname === 'github.com') { - return null; - } - - // For GHES/GHEC instances, return the hostname - return hostname; - } catch { - // Invalid URL, return null - return null; - } -} - -/** - * Reads path entries from the $GITHUB_PATH file used by GitHub Actions. - * - * When setup-* actions (e.g., setup-ruby, setup-dart, setup-python) run before AWF, - * they add tool paths to the $GITHUB_PATH file. The Actions runner prepends these - * to $PATH for subsequent steps, but if `sudo` resets PATH (depending on sudoers - * configuration), those entries may be lost by the time AWF reads process.env.PATH. - * - * This function reads the $GITHUB_PATH file directly and returns any path entries - * found, so they can be merged into AWF_HOST_PATH regardless of sudo behavior. - * - * @returns Array of path entries from the $GITHUB_PATH file, or empty array if unavailable - * @internal Exported for testing - */ -export function readGitHubPathEntries(): string[] { - const githubPathFile = process.env.GITHUB_PATH; - if (!githubPathFile) { - logger.debug('GITHUB_PATH env var is not set; skipping $GITHUB_PATH file merge (tools installed by setup-* actions may be missing from PATH if sudo reset it)'); - return []; - } - - try { - const content = fs.readFileSync(githubPathFile, 'utf-8'); - return content - .split('\n') - .map(line => line.trim()) - .filter(line => line.length > 0); - } catch { - // File doesn't exist or isn't readable — expected outside GitHub Actions - logger.debug(`GITHUB_PATH file at '${githubPathFile}' could not be read; skipping file merge`); - return []; - } -} - -/** - * Reads key-value environment entries from the $GITHUB_ENV file. - * - * The Actions runner writes to this file when steps call `core.exportVariable()`. - * When AWF runs via `sudo`, non-standard env vars may be stripped. This function - * reads the file directly to recover them. - * - * Supports both formats used by the Actions runner: - * - Simple: `KEY=VALUE` (value may contain `=`) - * - Heredoc: `KEY< { - const githubEnvFile = process.env.GITHUB_ENV; - if (!githubEnvFile) { - logger.debug('GITHUB_ENV env var is not set; skipping $GITHUB_ENV file read'); - return {}; - } - - try { - const content = fs.readFileSync(githubEnvFile, 'utf-8'); - return parseGitHubEnvFile(content); - } catch { - logger.debug(`GITHUB_ENV file at '${githubEnvFile}' could not be read; skipping`); - return {}; - } -} - -/** - * Parses the content of a $GITHUB_ENV file into key-value pairs. - * @internal Exported for testing - */ -export function parseGitHubEnvFile(content: string): Record { - const result: Record = {}; - // Normalize CRLF to LF - const lines = content.replace(/\r\n/g, '\n').split('\n'); - let i = 0; - - while (i < lines.length) { - const line = lines[i]; - - // Skip empty lines - if (line.trim() === '') { - i++; - continue; - } - - // Check for heredoc format: KEY< 0) { - const key = line.slice(0, eqIdx); - const value = line.slice(eqIdx + 1); - result[key] = value; - } - - i++; - } - - return result; -} - -/** - * Toolchain environment variables that should be recovered from $GITHUB_ENV - * when sudo strips them from process.env. These are set by setup-* actions - * (setup-go, setup-java, setup-dotnet, etc.) and are needed for correct - * tool resolution inside the agent container. - */ -export const TOOLCHAIN_ENV_VARS = [ - 'GOROOT', - 'CARGO_HOME', - 'RUSTUP_HOME', - 'JAVA_HOME', - 'DOTNET_ROOT', - 'BUN_INSTALL', -] as const; - -/** - * Merges path entries from the $GITHUB_PATH file into a PATH string. - * Entries from $GITHUB_PATH are prepended (they have higher priority, matching - * how the Actions runner processes them). Duplicate entries are removed. - * - * @param currentPath - The current PATH string (e.g., from process.env.PATH) - * @param githubPathEntries - Path entries read from the $GITHUB_PATH file - * @returns Merged PATH string with $GITHUB_PATH entries prepended - * @internal Exported for testing - */ -export function mergeGitHubPathEntries(currentPath: string, githubPathEntries: string[]): string { - if (githubPathEntries.length === 0) { - return currentPath; - } - - const currentEntries = currentPath ? currentPath.split(':') : []; - const currentSet = new Set(currentEntries); - - // Only add entries that aren't already in the current PATH - const newEntries = githubPathEntries.filter(entry => !currentSet.has(entry)); - - if (newEntries.length === 0) { - return currentPath; - } - - // Prepend new entries (setup-* actions expect their paths to have priority) - return [...newEntries, ...currentEntries].join(':'); -} - -/** - * Reads environment variables from a KEY=VALUE file (like Docker's --env-file). - * - * Rules: - * - Lines starting with '#' are comments and are ignored. - * - Empty/whitespace-only lines are ignored. - * - Each non-comment line must match the pattern KEY=VALUE where KEY starts with a - * letter or underscore and contains only letters, digits, or underscores. - * - Values may be empty (KEY=). - * - Values are taken literally; no quote-stripping or variable expansion is done. - * - * @param filePath - Absolute or relative path to the env file - * @returns An object mapping variable names to their values - * @throws {Error} If the file cannot be read - */ -export function readEnvFile(filePath: string): Record { - const content = fs.readFileSync(filePath, 'utf-8'); - const result: Record = {}; - for (const raw of content.split('\n')) { - const line = raw.trim(); - // Skip comments and blank lines - if (line === '' || line.startsWith('#')) continue; - const match = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/); - if (match) { - result[match[1]] = match[2]; - } - } - return result; -} +// Re-export from focused modules for backward compatibility. +export { + SQUID_PORT, + AGENT_CONTAINER_NAME, + SQUID_CONTAINER_NAME, + IPTABLES_INIT_CONTAINER_NAME, + API_PROXY_CONTAINER_NAME, + DOH_PROXY_CONTAINER_NAME, + CLI_PROXY_CONTAINER_NAME, + MAX_ENV_VALUE_SIZE, + ENV_SIZE_WARNING_THRESHOLD, +} from './constants'; + +export { + setAwfDockerHost, + getLocalDockerEnv, +} from './docker-host'; + +export { + ACT_PRESET_BASE_IMAGE, + MIN_REGULAR_UID, + validateIdNotInSystemRange, + getSafeHostUid, + getSafeHostGid, + getRealUserHome, +} from './host-identity'; + +export { + extractGhHostFromServerUrl, + readGitHubPathEntries, + readGitHubEnvEntries, + parseGitHubEnvFile, + TOOLCHAIN_ENV_VARS, + mergeGitHubPathEntries, + readEnvFile, +} from './github-env'; /** * Checks if two subnets overlap diff --git a/src/host-identity.ts b/src/host-identity.ts new file mode 100644 index 00000000..4ec0abf6 --- /dev/null +++ b/src/host-identity.ts @@ -0,0 +1,107 @@ +import * as fs from 'fs'; + +/** + * Base image for the 'act' preset when building locally. + * Uses catthehacker's GitHub Actions parity image. + */ +export const ACT_PRESET_BASE_IMAGE = 'ghcr.io/catthehacker/ubuntu:act-24.04'; + +/** + * Minimum UID/GID value for regular users. + * UIDs 0-999 are reserved for system users on most Linux distributions. + */ +export const MIN_REGULAR_UID = 1000; + +/** + * Validates that a UID/GID value is safe for use (not in system range). + * Returns the value if valid, or the default (1000) if in system range. + * @internal Exported for testing + */ +export function validateIdNotInSystemRange(id: number): string { + // Reject system UIDs/GIDs (0-999) - use default unprivileged user instead + if (id < MIN_REGULAR_UID) { + return MIN_REGULAR_UID.toString(); + } + return id.toString(); +} + +/** + * Gets the host user's UID, with fallback to 1000 if unavailable, root (0), + * or in the system UID range (0-999). + * When running with sudo, uses SUDO_UID to get the actual user's UID. + * @internal Exported for testing + */ +export function getSafeHostUid(): string { + const uid = process.getuid?.(); + + // When running as root (sudo), try to get the original user's UID + if (!uid || uid === 0) { + const sudoUid = process.env.SUDO_UID; + if (sudoUid) { + const parsedUid = parseInt(sudoUid, 10); + if (!isNaN(parsedUid)) { + return validateIdNotInSystemRange(parsedUid); + } + } + return MIN_REGULAR_UID.toString(); + } + + return validateIdNotInSystemRange(uid); +} + +/** + * Gets the host user's GID, with fallback to 1000 if unavailable, root (0), + * or in the system GID range (0-999). + * When running with sudo, uses SUDO_GID to get the actual user's GID. + * @internal Exported for testing + */ +export function getSafeHostGid(): string { + const gid = process.getgid?.(); + + // When running as root (sudo), try to get the original user's GID + if (!gid || gid === 0) { + const sudoGid = process.env.SUDO_GID; + if (sudoGid) { + const parsedGid = parseInt(sudoGid, 10); + if (!isNaN(parsedGid)) { + return validateIdNotInSystemRange(parsedGid); + } + } + return MIN_REGULAR_UID.toString(); + } + + return validateIdNotInSystemRange(gid); +} + +/** + * Gets the real user's home directory, accounting for sudo. + * When running with sudo, uses SUDO_USER to find the actual user's home. + * @internal Exported for testing + */ +export function getRealUserHome(): string { + const uid = process.getuid?.(); + + // When running as root (sudo), try to get the original user's home + if (!uid || uid === 0) { + // Try SUDO_USER first - look up their home directory from passwd + const sudoUser = process.env.SUDO_USER; + if (sudoUser) { + try { + // Look up user's home directory from /etc/passwd + const passwd = fs.readFileSync('/etc/passwd', 'utf-8'); + const userLine = passwd.split('\n').find(line => line.startsWith(`${sudoUser}:`)); + if (userLine) { + const parts = userLine.split(':'); + if (parts.length >= 6 && parts[5]) { + return parts[5]; // Home directory is the 6th field + } + } + } catch { + // Fall through to use HOME + } + } + } + + // Use HOME environment variable as fallback + return process.env.HOME || '/root'; +} diff --git a/src/services/agent-environment.ts b/src/services/agent-environment.ts index 16da27dd..929a5654 100644 --- a/src/services/agent-environment.ts +++ b/src/services/agent-environment.ts @@ -3,17 +3,17 @@ import { SQUID_PORT, MAX_ENV_VALUE_SIZE, ENV_SIZE_WARNING_THRESHOLD, +} from '../constants'; +import { SslConfig } from '../host-env'; +import { getSafeHostUid, getSafeHostGid, getRealUserHome } from '../host-identity'; +import { TOOLCHAIN_ENV_VARS, - SslConfig, - getSafeHostUid, - getSafeHostGid, - getRealUserHome, extractGhHostFromServerUrl, readGitHubPathEntries, readGitHubEnvEntries, mergeGitHubPathEntries, readEnvFile, -} from '../host-env'; +} from '../github-env'; import { logger } from '../logger'; import { PROXY_ENV_VARS } from '../upstream-proxy'; import { WrapperConfig } from '../types'; diff --git a/src/services/agent-service.ts b/src/services/agent-service.ts index 013f275a..63fe9db8 100644 --- a/src/services/agent-service.ts +++ b/src/services/agent-service.ts @@ -3,10 +3,8 @@ import { AGENT_CONTAINER_NAME, IPTABLES_INIT_CONTAINER_NAME, SQUID_PORT, - ACT_PRESET_BASE_IMAGE, - getSafeHostUid, - getSafeHostGid, -} from '../host-env'; +} from '../constants'; +import { ACT_PRESET_BASE_IMAGE, getSafeHostUid, getSafeHostGid } from '../host-identity'; import { buildRuntimeImageRef } from '../image-tag'; import { logger } from '../logger'; import { WrapperConfig } from '../types'; diff --git a/src/services/api-proxy-service.ts b/src/services/api-proxy-service.ts index 2e6c5a77..deecd5bc 100644 --- a/src/services/api-proxy-service.ts +++ b/src/services/api-proxy-service.ts @@ -2,9 +2,9 @@ import * as path from 'path'; import { API_PROXY_CONTAINER_NAME, SQUID_PORT, - readEnvFile, - stripScheme, -} from '../host-env'; +} from '../constants'; +import { stripScheme } from '../host-env'; +import { readEnvFile } from '../github-env'; import { buildRuntimeImageRef } from '../image-tag'; import { logger } from '../logger'; import { WrapperConfig, API_PROXY_PORTS, API_PROXY_HEALTH_PORT } from '../types'; diff --git a/src/services/cli-proxy-service.ts b/src/services/cli-proxy-service.ts index 59954412..445b9bc4 100644 --- a/src/services/cli-proxy-service.ts +++ b/src/services/cli-proxy-service.ts @@ -1,5 +1,6 @@ import * as path from 'path'; -import { CLI_PROXY_CONTAINER_NAME, parseDifcProxyHost } from '../host-env'; +import { CLI_PROXY_CONTAINER_NAME } from '../constants'; +import { parseDifcProxyHost } from '../host-env'; import { buildRuntimeImageRef } from '../image-tag'; import { logger } from '../logger'; import { WrapperConfig, CLI_PROXY_PORT } from '../types'; diff --git a/src/services/doh-proxy-service.ts b/src/services/doh-proxy-service.ts index 28b4f72a..383e6b66 100644 --- a/src/services/doh-proxy-service.ts +++ b/src/services/doh-proxy-service.ts @@ -1,4 +1,4 @@ -import { DOH_PROXY_CONTAINER_NAME } from '../host-env'; +import { DOH_PROXY_CONTAINER_NAME } from '../constants'; import { logger } from '../logger'; import { WrapperConfig } from '../types'; import { NetworkConfig } from './squid-service'; diff --git a/src/services/squid-service.ts b/src/services/squid-service.ts index 033f4d0d..89a9cf8b 100644 --- a/src/services/squid-service.ts +++ b/src/services/squid-service.ts @@ -1,5 +1,6 @@ import * as path from 'path'; -import { SslConfig, SQUID_PORT, SQUID_CONTAINER_NAME } from '../host-env'; +import { SQUID_PORT, SQUID_CONTAINER_NAME } from '../constants'; +import { SslConfig } from '../host-env'; import { parseImageTag, buildRuntimeImageRef } from '../image-tag'; import { logger } from '../logger'; import { WrapperConfig } from '../types';