diff --git a/.containerignore b/.containerignore new file mode 100644 index 0000000..1867d91 --- /dev/null +++ b/.containerignore @@ -0,0 +1,9 @@ +# Keep the build context free of browser sessions, auth exports, source-only +# helpers, and any future unlisted files. Containerfile copies this allow-list. +* +!Containerfile +!.containerignore +!package.json +!server.js +!lib/ +!lib/pow.js diff --git a/.env.example b/.env.example index ec8842e..1c415fe 100644 --- a/.env.example +++ b/.env.example @@ -9,6 +9,10 @@ HOST=127.0.0.1 # Optional bearer token required by every non-health endpoint. Strongly # recommended whenever HOST is not a loopback address. # PROXY_API_KEY=replace-with-a-long-random-value +# Read the bearer token from a mounted secret instead of the environment. +# PROXY_API_KEY_FILE=/run/secrets/proxy-api-key +# Fail startup when neither key source is set (enabled by Containerfile). +REQUIRE_PROXY_API_KEY=0 # Browser requests are accepted from loopback origins. Add exact remote UI # origins as a comma-separated allowlist when needed. # PROXY_CORS_ORIGINS=https://ui.example.com,http://192.168.1.20:3000 diff --git a/Containerfile b/Containerfile new file mode 100644 index 0000000..cdf6a51 --- /dev/null +++ b/Containerfile @@ -0,0 +1,25 @@ +FROM docker.io/library/node:22-alpine + +ENV NODE_ENV=production \ + HOST=0.0.0.0 \ + PORT=9655 \ + NON_INTERACTIVE=1 \ + DEEPSEEK_AUTH_PATH=/run/secrets/deepseek-auth.json \ + PROXY_API_KEY_FILE=/run/secrets/proxy-api-key \ + REQUIRE_PROXY_API_KEY=1 + +WORKDIR /app + +# FreeDeepseekAPI has no npm dependencies. Copy only the files needed by the +# non-interactive proxy; browser auth helpers and credentials stay on the host. +COPY --chown=1000:1000 package.json server.js ./ +COPY --chown=1000:1000 lib/pow.js ./lib/pow.js + +USER 1000:1000 + +EXPOSE 9655 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD ["node", "-e", "const http=require('http');const req=http.get({host:'127.0.0.1',port:process.env.PORT||9655,path:'/health'},res=>{res.resume();process.exit(res.statusCode===200?0:1)});req.on('error',()=>process.exit(1));req.setTimeout(4000,()=>{req.destroy();process.exit(1)})"] + +CMD ["node", "server.js"] diff --git a/README.md b/README.md index 46ca775..1329785 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,7 @@ ForgetMeAI: https://t.me/forgetmeai - [Windows запуск](#-windows-запуск) - [Linux / Chromium запуск](#-linux--chromium-запуск) - [VPS / headless запуск](#-vps--headless-запуск) +- [Rootless Podman](#-rootless-podman) - [Diagnostics / doctor](#-diagnostics--doctor) - [Session reuse и сброс чатов](#-session-reuse-и-сброс-чатов) - [Multi-account pool](#-multi-account-pool) @@ -217,6 +218,89 @@ DEEPSEEK_TOKEN="" npm run auth:import -- --input ./cookies.json --- +## 🦭 Rootless Podman + +Контейнер предназначен только для non-interactive запуска proxy. Авторизацию +через браузер выполните на хосте командой `npm run auth`: auth-скрипты и +`deepseek-auth.json` в образ не копируются. + +Запускайте Podman обычным пользователем, без `sudo`. + +1. Соберите локальный образ: + +```bash +podman build --tag localhost/free-deepseek-api:local --file Containerfile . +``` + +2. Передайте DeepSeek auth и отдельный ключ proxy через Podman secrets: + +```bash +podman secret create --replace free-deepseek-auth ./deepseek-auth.json + +printf 'Proxy API key: ' +IFS= read -r -s PROXY_API_KEY +printf '\n' +printf '%s' "$PROXY_API_KEY" | + podman secret create --replace free-deepseek-proxy-key - +``` + +Используйте длинный случайный ключ. Значение останется в переменной +`PROXY_API_KEY` текущего shell, чтобы проверить API; оно не попадает в образ или +командную строку Podman. + +3. Запустите контейнер с минимальными привилегиями: + +```bash +podman run --detach \ + --name free-deepseek-api \ + --publish 127.0.0.1:9655:9655 \ + --secret free-deepseek-auth,target=deepseek-auth.json,uid=1000,gid=1000,mode=0400 \ + --secret free-deepseek-proxy-key,target=proxy-api-key,uid=1000,gid=1000,mode=0400 \ + --read-only \ + --cap-drop=ALL \ + --security-opt=no-new-privileges \ + localhost/free-deepseek-api:local +``` + +Внутри контейнера заранее выставлены `NON_INTERACTIVE=1`, `HOST=0.0.0.0` и +пути к обоим secrets. `REQUIRE_PROXY_API_KEY=1` не даст контейнеру запуститься, +если secret с ключом отсутствует или пуст. На хосте порт публикуется только на +`127.0.0.1`; не убирайте этот адрес без отдельного сетевого firewall/access +policy. + +4. Проверьте liveness, readiness аккаунта и защищённый endpoint: + +```bash +podman healthcheck run free-deepseek-api +curl --fail http://127.0.0.1:9655/readyz +curl --fail \ + -H "Authorization: Bearer $PROXY_API_KEY" \ + http://127.0.0.1:9655/v1/models +``` + +Встроенный healthcheck проверяет локальный `/health` (жив ли процесс). +`/readyz` дополнительно вернёт `503`, если ни один DeepSeek auth-аккаунт сейчас +не готов обслуживать запросы. Диагностика контейнера: + +```bash +podman logs free-deepseek-api +podman inspect --format '{{.State.Health.Status}}' free-deepseek-api +``` + +Остановка и удаление контейнера вместе с сохранёнными Podman secrets: + +```bash +podman stop free-deepseek-api +podman rm free-deepseek-api +podman secret rm free-deepseek-auth free-deepseek-proxy-key +unset PROXY_API_KEY +``` + +При ротации auth или proxy key замените соответствующий secret и пересоздайте +контейнер, чтобы поведение не зависело от версии Podman. + +--- + ## 🩺 Diagnostics / doctor ```bash diff --git a/server.js b/server.js index e81c5b4..0bad77a 100755 --- a/server.js +++ b/server.js @@ -42,7 +42,28 @@ const SERVER_PUBLIC_IP = (() => { const FORGETMEAI_WATERMARK = 't.me/forgetmeai'; const PORT = Number(process.env.PORT || 9655); const HOST = process.env.HOST || '127.0.0.1'; -const PROXY_API_KEY = String(process.env.PROXY_API_KEY || ''); + +function loadProxyApiKey(env = process.env) { + if (env.PROXY_API_KEY) return String(env.PROXY_API_KEY); + const secretPath = String(env.PROXY_API_KEY_FILE || '').trim(); + if (!secretPath) return ''; + try { + return fs.readFileSync(secretPath, 'utf8').trim(); + } catch (error) { + // A missing optional secret is equivalent to an unset key. Container + // deployments set REQUIRE_PROXY_API_KEY=1 and fail closed in main(). + if (error.code === 'ENOENT') return ''; + throw new Error(`Could not read PROXY_API_KEY_FILE (${secretPath}): ${error.message}`); + } +} + +function requireProxyApiKey(key, required) { + if (required && !key) { + throw new Error('PROXY_API_KEY is required. Set PROXY_API_KEY or mount a secret and set PROXY_API_KEY_FILE.'); + } +} + +const PROXY_API_KEY = loadProxyApiKey(); const PROXY_CORS_ORIGINS = new Set(String(process.env.PROXY_CORS_ORIGINS || '') .split(',') .map(value => normalizeOrigin(value)) @@ -2366,6 +2387,7 @@ async function showStartupMenu() { async function main() { printBanner(); + requireProxyApiKey(PROXY_API_KEY, isTruthy(process.env.REQUIRE_PROXY_API_KEY)); if (!isLoopbackHost(HOST) && !PROXY_API_KEY) { console.warn(`[DS-API] WARNING: HOST=${HOST} exposes the proxy without authentication. Set PROXY_API_KEY or bind to 127.0.0.1.`); } @@ -2439,6 +2461,8 @@ module.exports = { accounts, selectAccountForSession, isProxyAuthorized, + loadProxyApiKey, + requireProxyApiKey, isLoopbackHost, normalizeOrigin, isBrowserOriginAllowed, diff --git a/tests/unit.test.js b/tests/unit.test.js index df6ce00..4805aff 100644 --- a/tests/unit.test.js +++ b/tests/unit.test.js @@ -209,6 +209,54 @@ test('proxy API key authentication is optional and uses exact bearer tokens', () assert.equal(serverInternals.isProxyAuthorized('Bearer secret ', 'secret'), false); }); +test('proxy API key can be loaded from a mounted secret and required explicitly', () => { + const dir = tmpdir(); + const secretPath = path.join(dir, 'proxy-api-key'); + fs.writeFileSync(secretPath, 'mounted-secret\n'); + + assert.equal(serverInternals.loadProxyApiKey({ PROXY_API_KEY_FILE: secretPath }), 'mounted-secret'); + assert.equal(serverInternals.loadProxyApiKey({ PROXY_API_KEY: 'env-secret', PROXY_API_KEY_FILE: secretPath }), 'env-secret'); + assert.equal(serverInternals.loadProxyApiKey({ PROXY_API_KEY_FILE: path.join(dir, 'missing') }), ''); + assert.doesNotThrow(() => serverInternals.requireProxyApiKey('mounted-secret', true)); + assert.throws( + () => serverInternals.requireProxyApiKey('', true), + /PROXY_API_KEY is required/, + ); +}); + +test('Containerfile keeps the rootless Podman runtime minimal and fail-closed', () => { + const containerfile = fs.readFileSync(path.join(ROOT, 'Containerfile'), 'utf8'); + const containerignore = fs.readFileSync(path.join(ROOT, '.containerignore'), 'utf8'); + const readme = fs.readFileSync(path.join(ROOT, 'README.md'), 'utf8'); + const copyLines = containerfile + .split(/\r?\n/) + .map(line => line.trim()) + .filter(line => line.startsWith('COPY ')); + + assert.deepEqual(copyLines, [ + 'COPY --chown=1000:1000 package.json server.js ./', + 'COPY --chown=1000:1000 lib/pow.js ./lib/pow.js', + ]); + assert.doesNotMatch(containerfile, /^\s*(?:COPY|ADD)\s+\.\s/m); + assert.match(containerfile, /^USER 1000:1000$/m); + assert.match(containerfile, /HOST=0\.0\.0\.0/); + assert.match(containerfile, /NON_INTERACTIVE=1/); + assert.match(containerfile, /REQUIRE_PROXY_API_KEY=1/); + assert.match(containerfile, /PROXY_API_KEY_FILE=\/run\/secrets\/proxy-api-key/); + assert.match(containerfile, /^HEALTHCHECK /m); + assert.match(containerfile, /path:'\/health'/); + assert.match(containerfile, /^CMD \["node", "server\.js"\]$/m); + + assert.match(containerignore, /^\*$/m); + assert.doesNotMatch(containerignore, /^!.*(?:auth|secret|\.env)/mi); + assert.match(readme, /--publish 127\.0\.0\.1:9655:9655/); + assert.match(readme, /--secret free-deepseek-auth[^\n]*mode=0400/); + assert.match(readme, /--secret free-deepseek-proxy-key[^\n]*mode=0400/); + assert.match(readme, /--read-only/); + assert.match(readme, /--cap-drop=ALL/); + assert.match(readme, /--security-opt=no-new-privileges/); +}); + test('loopback host detection covers supported local bind addresses', () => { assert.equal(serverInternals.isLoopbackHost('127.0.0.1'), true); assert.equal(serverInternals.isLoopbackHost('::1'), true);