Skip to content

Commit 31386b1

Browse files
authored
Merge pull request #27 from ForgetMeAI/codex/podman-container
feat: add hardened rootless Podman runtime
2 parents d1f0d5e + acefba5 commit 31386b1

6 files changed

Lines changed: 195 additions & 1 deletion

File tree

‎.containerignore‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# Keep the build context free of browser sessions, auth exports, source-only
2+
# helpers, and any future unlisted files. Containerfile copies this allow-list.
3+
*
4+
!Containerfile
5+
!.containerignore
6+
!package.json
7+
!server.js
8+
!lib/
9+
!lib/pow.js

‎.env.example‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@ HOST=127.0.0.1
99
# Optional bearer token required by every non-health endpoint. Strongly
1010
# recommended whenever HOST is not a loopback address.
1111
# PROXY_API_KEY=replace-with-a-long-random-value
12+
# Read the bearer token from a mounted secret instead of the environment.
13+
# PROXY_API_KEY_FILE=/run/secrets/proxy-api-key
14+
# Fail startup when neither key source is set (enabled by Containerfile).
15+
REQUIRE_PROXY_API_KEY=0
1216
# Browser requests are accepted from loopback origins. Add exact remote UI
1317
# origins as a comma-separated allowlist when needed.
1418
# PROXY_CORS_ORIGINS=https://ui.example.com,http://192.168.1.20:3000

‎Containerfile‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
FROM docker.io/library/node:22-alpine
2+
3+
ENV NODE_ENV=production \
4+
HOST=0.0.0.0 \
5+
PORT=9655 \
6+
NON_INTERACTIVE=1 \
7+
DEEPSEEK_AUTH_PATH=/run/secrets/deepseek-auth.json \
8+
PROXY_API_KEY_FILE=/run/secrets/proxy-api-key \
9+
REQUIRE_PROXY_API_KEY=1
10+
11+
WORKDIR /app
12+
13+
# FreeDeepseekAPI has no npm dependencies. Copy only the files needed by the
14+
# non-interactive proxy; browser auth helpers and credentials stay on the host.
15+
COPY --chown=1000:1000 package.json server.js ./
16+
COPY --chown=1000:1000 lib/pow.js ./lib/pow.js
17+
18+
USER 1000:1000
19+
20+
EXPOSE 9655
21+
22+
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
23+
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)})"]
24+
25+
CMD ["node", "server.js"]

‎README.md‎

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ ForgetMeAI: https://t.me/forgetmeai
3838
- [Windows запуск](#-windows-запуск)
3939
- [Linux / Chromium запуск](#-linux--chromium-запуск)
4040
- [VPS / headless запуск](#-vps--headless-запуск)
41+
- [Rootless Podman](#-rootless-podman)
4142
- [Diagnostics / doctor](#-diagnostics--doctor)
4243
- [Session reuse и сброс чатов](#-session-reuse-и-сброс-чатов)
4344
- [Multi-account pool](#-multi-account-pool)
@@ -217,6 +218,89 @@ DEEPSEEK_TOKEN="<token>" npm run auth:import -- --input ./cookies.json
217218
218219
---
219220

221+
## 🦭 Rootless Podman
222+
223+
Контейнер предназначен только для non-interactive запуска proxy. Авторизацию
224+
через браузер выполните на хосте командой `npm run auth`: auth-скрипты и
225+
`deepseek-auth.json` в образ не копируются.
226+
227+
Запускайте Podman обычным пользователем, без `sudo`.
228+
229+
1. Соберите локальный образ:
230+
231+
```bash
232+
podman build --tag localhost/free-deepseek-api:local --file Containerfile .
233+
```
234+
235+
2. Передайте DeepSeek auth и отдельный ключ proxy через Podman secrets:
236+
237+
```bash
238+
podman secret create --replace free-deepseek-auth ./deepseek-auth.json
239+
240+
printf 'Proxy API key: '
241+
IFS= read -r -s PROXY_API_KEY
242+
printf '\n'
243+
printf '%s' "$PROXY_API_KEY" |
244+
podman secret create --replace free-deepseek-proxy-key -
245+
```
246+
247+
Используйте длинный случайный ключ. Значение останется в переменной
248+
`PROXY_API_KEY` текущего shell, чтобы проверить API; оно не попадает в образ или
249+
командную строку Podman.
250+
251+
3. Запустите контейнер с минимальными привилегиями:
252+
253+
```bash
254+
podman run --detach \
255+
--name free-deepseek-api \
256+
--publish 127.0.0.1:9655:9655 \
257+
--secret free-deepseek-auth,target=deepseek-auth.json,uid=1000,gid=1000,mode=0400 \
258+
--secret free-deepseek-proxy-key,target=proxy-api-key,uid=1000,gid=1000,mode=0400 \
259+
--read-only \
260+
--cap-drop=ALL \
261+
--security-opt=no-new-privileges \
262+
localhost/free-deepseek-api:local
263+
```
264+
265+
Внутри контейнера заранее выставлены `NON_INTERACTIVE=1`, `HOST=0.0.0.0` и
266+
пути к обоим secrets. `REQUIRE_PROXY_API_KEY=1` не даст контейнеру запуститься,
267+
если secret с ключом отсутствует или пуст. На хосте порт публикуется только на
268+
`127.0.0.1`; не убирайте этот адрес без отдельного сетевого firewall/access
269+
policy.
270+
271+
4. Проверьте liveness, readiness аккаунта и защищённый endpoint:
272+
273+
```bash
274+
podman healthcheck run free-deepseek-api
275+
curl --fail http://127.0.0.1:9655/readyz
276+
curl --fail \
277+
-H "Authorization: Bearer $PROXY_API_KEY" \
278+
http://127.0.0.1:9655/v1/models
279+
```
280+
281+
Встроенный healthcheck проверяет локальный `/health` (жив ли процесс).
282+
`/readyz` дополнительно вернёт `503`, если ни один DeepSeek auth-аккаунт сейчас
283+
не готов обслуживать запросы. Диагностика контейнера:
284+
285+
```bash
286+
podman logs free-deepseek-api
287+
podman inspect --format '{{.State.Health.Status}}' free-deepseek-api
288+
```
289+
290+
Остановка и удаление контейнера вместе с сохранёнными Podman secrets:
291+
292+
```bash
293+
podman stop free-deepseek-api
294+
podman rm free-deepseek-api
295+
podman secret rm free-deepseek-auth free-deepseek-proxy-key
296+
unset PROXY_API_KEY
297+
```
298+
299+
При ротации auth или proxy key замените соответствующий secret и пересоздайте
300+
контейнер, чтобы поведение не зависело от версии Podman.
301+
302+
---
303+
220304
## 🩺 Diagnostics / doctor
221305

222306
```bash

‎server.js‎

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,28 @@ const SERVER_PUBLIC_IP = (() => {
4242
const FORGETMEAI_WATERMARK = 't.me/forgetmeai';
4343
const PORT = Number(process.env.PORT || 9655);
4444
const HOST = process.env.HOST || '127.0.0.1';
45-
const PROXY_API_KEY = String(process.env.PROXY_API_KEY || '');
45+
46+
function loadProxyApiKey(env = process.env) {
47+
if (env.PROXY_API_KEY) return String(env.PROXY_API_KEY);
48+
const secretPath = String(env.PROXY_API_KEY_FILE || '').trim();
49+
if (!secretPath) return '';
50+
try {
51+
return fs.readFileSync(secretPath, 'utf8').trim();
52+
} catch (error) {
53+
// A missing optional secret is equivalent to an unset key. Container
54+
// deployments set REQUIRE_PROXY_API_KEY=1 and fail closed in main().
55+
if (error.code === 'ENOENT') return '';
56+
throw new Error(`Could not read PROXY_API_KEY_FILE (${secretPath}): ${error.message}`);
57+
}
58+
}
59+
60+
function requireProxyApiKey(key, required) {
61+
if (required && !key) {
62+
throw new Error('PROXY_API_KEY is required. Set PROXY_API_KEY or mount a secret and set PROXY_API_KEY_FILE.');
63+
}
64+
}
65+
66+
const PROXY_API_KEY = loadProxyApiKey();
4667
const PROXY_CORS_ORIGINS = new Set(String(process.env.PROXY_CORS_ORIGINS || '')
4768
.split(',')
4869
.map(value => normalizeOrigin(value))
@@ -2366,6 +2387,7 @@ async function showStartupMenu() {
23662387

23672388
async function main() {
23682389
printBanner();
2390+
requireProxyApiKey(PROXY_API_KEY, isTruthy(process.env.REQUIRE_PROXY_API_KEY));
23692391
if (!isLoopbackHost(HOST) && !PROXY_API_KEY) {
23702392
console.warn(`[DS-API] WARNING: HOST=${HOST} exposes the proxy without authentication. Set PROXY_API_KEY or bind to 127.0.0.1.`);
23712393
}
@@ -2439,6 +2461,8 @@ module.exports = {
24392461
accounts,
24402462
selectAccountForSession,
24412463
isProxyAuthorized,
2464+
loadProxyApiKey,
2465+
requireProxyApiKey,
24422466
isLoopbackHost,
24432467
normalizeOrigin,
24442468
isBrowserOriginAllowed,

‎tests/unit.test.js‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,54 @@ test('proxy API key authentication is optional and uses exact bearer tokens', ()
209209
assert.equal(serverInternals.isProxyAuthorized('Bearer secret ', 'secret'), false);
210210
});
211211

212+
test('proxy API key can be loaded from a mounted secret and required explicitly', () => {
213+
const dir = tmpdir();
214+
const secretPath = path.join(dir, 'proxy-api-key');
215+
fs.writeFileSync(secretPath, 'mounted-secret\n');
216+
217+
assert.equal(serverInternals.loadProxyApiKey({ PROXY_API_KEY_FILE: secretPath }), 'mounted-secret');
218+
assert.equal(serverInternals.loadProxyApiKey({ PROXY_API_KEY: 'env-secret', PROXY_API_KEY_FILE: secretPath }), 'env-secret');
219+
assert.equal(serverInternals.loadProxyApiKey({ PROXY_API_KEY_FILE: path.join(dir, 'missing') }), '');
220+
assert.doesNotThrow(() => serverInternals.requireProxyApiKey('mounted-secret', true));
221+
assert.throws(
222+
() => serverInternals.requireProxyApiKey('', true),
223+
/PROXY_API_KEY is required/,
224+
);
225+
});
226+
227+
test('Containerfile keeps the rootless Podman runtime minimal and fail-closed', () => {
228+
const containerfile = fs.readFileSync(path.join(ROOT, 'Containerfile'), 'utf8');
229+
const containerignore = fs.readFileSync(path.join(ROOT, '.containerignore'), 'utf8');
230+
const readme = fs.readFileSync(path.join(ROOT, 'README.md'), 'utf8');
231+
const copyLines = containerfile
232+
.split(/\r?\n/)
233+
.map(line => line.trim())
234+
.filter(line => line.startsWith('COPY '));
235+
236+
assert.deepEqual(copyLines, [
237+
'COPY --chown=1000:1000 package.json server.js ./',
238+
'COPY --chown=1000:1000 lib/pow.js ./lib/pow.js',
239+
]);
240+
assert.doesNotMatch(containerfile, /^\s*(?:COPY|ADD)\s+\.\s/m);
241+
assert.match(containerfile, /^USER 1000:1000$/m);
242+
assert.match(containerfile, /HOST=0\.0\.0\.0/);
243+
assert.match(containerfile, /NON_INTERACTIVE=1/);
244+
assert.match(containerfile, /REQUIRE_PROXY_API_KEY=1/);
245+
assert.match(containerfile, /PROXY_API_KEY_FILE=\/run\/secrets\/proxy-api-key/);
246+
assert.match(containerfile, /^HEALTHCHECK /m);
247+
assert.match(containerfile, /path:'\/health'/);
248+
assert.match(containerfile, /^CMD \["node", "server\.js"\]$/m);
249+
250+
assert.match(containerignore, /^\*$/m);
251+
assert.doesNotMatch(containerignore, /^!.*(?:auth|secret|\.env)/mi);
252+
assert.match(readme, /--publish 127\.0\.0\.1:9655:9655/);
253+
assert.match(readme, /--secret free-deepseek-auth[^\n]*mode=0400/);
254+
assert.match(readme, /--secret free-deepseek-proxy-key[^\n]*mode=0400/);
255+
assert.match(readme, /--read-only/);
256+
assert.match(readme, /--cap-drop=ALL/);
257+
assert.match(readme, /--security-opt=no-new-privileges/);
258+
});
259+
212260
test('loopback host detection covers supported local bind addresses', () => {
213261
assert.equal(serverInternals.isLoopbackHost('127.0.0.1'), true);
214262
assert.equal(serverInternals.isLoopbackHost('::1'), true);

0 commit comments

Comments
 (0)