Skip to content
Merged
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
9 changes: 9 additions & 0 deletions .containerignore
Original file line number Diff line number Diff line change
@@ -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
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 25 additions & 0 deletions Containerfile
Original file line number Diff line number Diff line change
@@ -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"]
84 changes: 84 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -217,6 +218,89 @@ DEEPSEEK_TOKEN="<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
Expand Down
26 changes: 25 additions & 1 deletion server.js
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -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.`);
}
Expand Down Expand Up @@ -2439,6 +2461,8 @@ module.exports = {
accounts,
selectAccountForSession,
isProxyAuthorized,
loadProxyApiKey,
requireProxyApiKey,
isLoopbackHost,
normalizeOrigin,
isBrowserOriginAllowed,
Expand Down
48 changes: 48 additions & 0 deletions tests/unit.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down