fix(docker): expose port 4096 on 0.0.0.0 for NPM proxy (#51)

* fix(docker): expose port 4096 on 0.0.0.0 for NPM proxy

* test(docker): add docker-compose port exposure test

* docs(handoff): set PR number for docker port expose

---------

Co-authored-by: opencode-agent <agent@slaid098.dev>
This commit is contained in:
Sergey 2026-07-24 20:27:37 +03:00 committed by GitHub
parent 2473360530
commit 655a98d077
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 200 additions and 2 deletions

View file

@ -43,7 +43,7 @@ services:
- ./app_data/workspaces:/root/workspace
- ./app_data/ssh:/root/.ssh:ro
ports:
- "127.0.0.1:4096:4096"
- "0.0.0.0:4096:4096"
networks:
- opencode_network
depends_on:

View file

@ -0,0 +1,38 @@
# ADR-021 (PR #51): Expose opencode port 4096 on 0.0.0.0 for cross-host NPM proxy
## Статус
Accepted (2026-07-24)
## Контекст
opencode-config мигрирует на linux-1 (`193.3.168.35`). Web UI opencode слушает порт 4096 (`opencode serve --hostname 0.0.0.0 --port 4096`). Публичный доступ к домену `opencode.slaid098.dev` идёт через NPM (Nginx Proxy Manager, контейнер `jc21/nginx-proxy-manager`), который живёт на linux-2 (`92.119.114.77`) и проксирует `opencode.slaid098.dev``193.3.168.35:4096`.
Текущий `docker-compose.yml` биндит порт как `127.0.0.1:4096:4096` (localhost-only). Это работало, пока opencode жил на linux-2: Cloudflare named tunnel (`cloudflared`) запускался на том же хосте и обращался к `127.0.0.1:4096` локально. После миграции на linux-1 NPM на linux-2 должен стучаться на публичный IP linux-1 — localhost-only биндинг отвергает соединение на loopback-интерфейсе, NPM не достучится.
Топология подтверждена SSH-аудитом (memory `technical/opencode-config-migration-state-2026-07-24.md`): NPM на linux-2, opencode мигрирует на linux-1 (раньше Cloudflare tunnel, теперь NPM-прокси).
## Решение
Перевязать порт 4096 в `docker-compose.yml` с `127.0.0.1:4096:4096` на `0.0.0.0:4096:4096`:
```yaml
ports:
- "0.0.0.0:4096:4096"
```
`0.0.0.0:4096` биндит контейнерный порт 4096 на все интерфейсы хоста, NPM на linux-2 достучится через публичный IP linux-1 (`193.3.168.35:4096`).
Дополнительно — 5 тестов в `tests/test_docker_compose.py` (parsed + raw check на оба условия), чтобы предотвратить регрессию обратно к localhost-only.
### Альтернативы
- **Оставить `127.0.0.1:4096` + Cloudflare named tunnel на linux-1** — отклонено: миграционный план переводит публичный доступ на NPM-прокси (centralized SSL-termination + domain management на linux-2), Cloudflare tunnel на linux-1 потребовал бы отдельной настройки и токена. NPM уже управляет 9 доменами `*.slaid098.dev`, добавить `opencode.slaid098.dev` проще, чем поднимать второй tunnel.
- **Биндить на конкретный публичный IP linux-1 (`193.3.168.35:4096:4096`)** — отклонено: hardcoded IP в compose хрупок (при смене хоста/IP потребует правки файла). `0.0.0.0` переносим между хостами; доступ ограничивается firewall на linux-1 (ufw/iptables, разрешить 4096 только с linux-2 IP), что конфигурируется вне compose и не требует правок при миграции.
- **Не открывать, проксировать через SSH-туннель linux-2→linux-1** — отклонено: SSH-туннель — ручная/хрупкая инфраструктура (restart, no systemd unit, no health-check), NPM → прямой TCP — стандартный паттерн reverse proxy. Дополнительный слой без выгоды.
- **Поднять NPM на linux-1 (чтобы NPM и opencode на одном хосте, localhost-only OK)** — отклонено: linux-1 не имеет NPM, установка дублирует инфраструктуру (9 доменов уже на linux-2 NPM, SSL-сертификаты, database.sqlite). Миграция opencode не должна тянуть за собой миграцию NPM.
## Альтернативы
См. блок «Альтернативы» выше (включён в Решение для единого контекста). Кратко: отклонены — Cloudflare tunnel на linux-1, hardcoded public IP, SSH-туннель, NPM на linux-1. Выбран `0.0.0.0` + firewall на linux-1 (переносимость + security на уровне хоста, не compose).

View file

@ -0,0 +1,31 @@
---
pr: 51
title: expose port 4096 on 0.0.0.0 for NPM proxy
---
# PR #51: expose port 4096 on 0.0.0.0 for NPM proxy
## Что сделано
- `docker-compose.yml:46` — порт 4096 перевязан с `127.0.0.1:4096:4096` (localhost-only) на `0.0.0.0:4096:4096` (открыт наружу, все интерфейсы). Единственное изменение в compose-файле, больше ничего не трогалось.
- `tests/test_docker_compose.py` — новый файл, 5 тестов:
- `test_docker_compose_exists` — файл существует на repo root
- `test_port_exposed_on_all_interfaces` — parsed bindings содержит `0.0.0.0:4096:4096` (ручной YAML-парсер `ports:` блоков, без pyyaml)
- `test_port_exposed_on_all_interfaces_raw` — raw text содержит `0.0.0.0:4096:4096` (belt-and-suspenders)
- `test_no_localhost_only_binding` — parsed bindings НЕ содержит `127.0.0.1:4096:4096` (anti-regression)
- `test_no_localhost_only_binding_raw` — raw text НЕ содержит `127.0.0.1:4096:4096`
- Парсинг ручной (pyyaml не в прямых зависимостях), по паттерну `test_agent_frontmatter.py` (split по indent, отслеживание `ports:` блоков).
- ADR-021 + этот handoff.
## Почему
opencode-config мигрирует на linux-1 (`193.3.168.35`). Web UI opencode (порт 4096) должен быть доступен с linux-2, где NPM (Nginx Proxy Manager, контейнер `jc21/nginx-proxy-manager`) проксирует домен `opencode.slaid098.dev``193.3.168.35:4096`. При localhost-only биндинге (`127.0.0.1:4096`) NPM с другого хоста не достучится — соединение отвергается на интерфейсе loopback. `0.0.0.0:4096` биндит на все интерфейсы, NPM достучится через публичный IP linux-1.
Топология (из memory `technical/opencode-config-migration-state-2026-07-24.md`): NPM живёт на linux-2, opencode мигрирует на linux-1. Раньше opencode на linux-2 был доступен через Cloudflare named tunnel напрямую (localhost-only был ОК, т.к. cloudflared на том же хосте). После миграции на linux-1 публичный доступ идёт через NPM на linux-2 → нужен `0.0.0.0`.
## Pending
— (нет)
## Watch out
- **Безопасность: порт 4096 теперь открыт на всех интерфейсах**`0.0.0.0:4096` доступен с любого IP, который может маршрутизироваться до linux-1. opencode serve имеет basic auth (`OPENCODE_SERVER_USERNAME`/`OPENCODE_SERVER_PASSWORD` в `.env`), но bare порт открыт. Для production рекомендуется firewall (ufw/iptables) на linux-1, разрешающий 4096 только с linux-2 IP (`92.119.114.77`), либо полагаться на NPM SSL-termination + path filtering. Этот PR не настраивает firewall — только Docker-биндинг.
- **Тесты НЕ парсят YAML через pyyaml** — pyyaml есть в lock-файле (transitive dep), но НЕ в прямых зависимостях `pyproject.toml`. Для консистентности с `test_agent_frontmatter.py` (и чтобы не добавлять dep ради одного теста) используется ручной парсер `ports:` блоков.
- **Anti-regression тесты (raw + parsed)** — дублирующие raw-проверки добавлены намеренно (как в `test_agent_frontmatter.py`): parsed-check может пропустить edge case (например, если кавычки или inline-формат изменятся), raw-check ловит строку напрямую. Оба должны оставаться.
- ADR number = 021 (sequential, следующий после 020), НЕ PR number.

View file

@ -78,6 +78,7 @@ opencode-config/
│ ├── test_check_adr_refs.py # adr-check.yml validator
│ ├── test_check_permissions.py # permissions-check.yml validator
│ ├── test_cli.py # src/memory/cli.py
│ ├── test_docker_compose.py # docker-compose.yml port exposure (0.0.0.0:4096, no 127.0.0.1) — PR#51
│ ├── test_commit_tool.py # .opencode/tools/commit.ts (via _ts_loader.mjs exec_stub_json) — PR#38
│ ├── test_commit_tool.ts # TS wrapper test (mjs loader) — PR#38
│ ├── test_create_issue_tool.py # .opencode/tools/create-issue.ts (via _ts_loader.mjs exec_stub_json) — PR#38
@ -107,7 +108,7 @@ opencode-config/
├── pyproject.toml # Python project (uv, ruff, pytest config)
├── uv.lock # Locked deps for Python project
├── .pre-commit-config.yaml # ruff + UV hooks
├── docker-compose.yml # 2 services (dind + opencode), opencode_network, 4 bind mounts — PR#24
├── docker-compose.yml # 2 services (dind + opencode), opencode_network, 4 bind mounts, port 4096 on 0.0.0.0 — PR#24, PR#51
├── Dockerfile # node:20-slim + uv + gh + chromium + docker.io + opencode-ai + repomix + cloudflared — PR#24, PR#34
├── .env.example # Placeholder-only env template (user copies to .env) — PR#24, PR#34 (TUNNEL_DOMAIN), PR#36 (OPENCODE_MEMORY_REMOTE/DIR)
├── app_data/

View file

@ -0,0 +1,128 @@
"""Tests for docker-compose.yml port exposure.
Covers issue #50 acceptance criteria:
- ``docker-compose.yml`` exposes port 4096 on ``0.0.0.0`` (open to the network)
so that NPM (Nginx Proxy Manager) on a separate host can reach the opencode
web UI via ``193.3.168.35:4096``.
- The previous ``127.0.0.1:4096:4096`` binding (localhost-only) is gone it
blocked cross-host NPM proxying.
PyYAML is not a direct project dependency, so the compose file is parsed
manually by tracking indentation of the ``ports:`` block (mirrors the
frontmatter parser in ``test_agent_frontmatter.py``). A raw-text assertion
is also included as a belt-and-suspenders check.
"""
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
COMPOSE_FILE = REPO_ROOT / "docker-compose.yml"
EXPECTED_BINDING = "0.0.0.0:4096:4096"
OLD_BINDING = "127.0.0.1:4096:4096"
def _compose_text() -> str:
"""Read docker-compose.yml text (asserts the file exists)."""
assert COMPOSE_FILE.exists(), f"docker-compose.yml missing: {COMPOSE_FILE}"
return COMPOSE_FILE.read_text()
def _parse_ports_bindings(text: str) -> list[str]:
"""Extract port binding strings from the ``ports:`` blocks of compose YAML.
Manual parse (no pyyaml): walk lines, detect any ``ports:`` key (it sits
under a service at indent 4), remember its indent, then collect every
following list entry at a strictly greater indent until the indent drops
back to or below the ``ports:`` indent. A list entry is a line whose
stripped form starts with ``-``.
"""
bindings: list[str] = []
in_ports = False
ports_indent = -1
for line in text.split("\n"):
if not line.strip() or line.strip().startswith("#"):
continue
stripped = line.lstrip()
indent = len(line) - len(stripped)
if stripped.startswith("ports:") and stripped.endswith(":"):
in_ports = True
ports_indent = indent
continue
if in_ports:
if indent <= ports_indent:
in_ports = False
continue
if stripped.startswith("-"):
value = stripped.lstrip("-").strip()
value = value.strip('"').strip("'")
bindings.append(value)
return bindings
# ── file exists ─────────────────────────────────────────────────────────────
def test_docker_compose_exists():
"""docker-compose.yml exists at repo root."""
assert COMPOSE_FILE.exists(), f"docker-compose.yml missing: {COMPOSE_FILE}"
# ── port 4096 exposed on 0.0.0.0 (issue #50 — the required binding) ──────────
def test_port_exposed_on_all_interfaces():
"""docker-compose.yml exposes 4096 on ``0.0.0.0`` (open to the network).
NPM on a separate host must reach the opencode web UI; ``0.0.0.0:4096:4096``
binds to all interfaces, ``127.0.0.1`` would block cross-host access.
"""
bindings = _parse_ports_bindings(_compose_text())
assert EXPECTED_BINDING in bindings, (
f"docker-compose.yml must bind {EXPECTED_BINDING!r} (open to network) — "
f"found port bindings: {bindings}"
)
def test_port_exposed_on_all_interfaces_raw():
"""Raw text check: the compose file contains ``0.0.0.0:4096:4096``.
Belt-and-suspenders alongside the parsed check catches edge cases where
the manual parser might miss a quoted or unquoted binding.
"""
content = _compose_text()
assert EXPECTED_BINDING in content, (
f"docker-compose.yml must contain {EXPECTED_BINDING!r} so NPM can reach "
"the web UI from a separate host"
)
# ── old localhost-only binding is gone (issue #50 — must not regress) ───────
def test_no_localhost_only_binding():
"""docker-compose.yml does NOT bind ``127.0.0.1:4096:4096``.
The localhost-only binding blocks NPM proxying from another host; it must
not be present (and must not come back via a future regression).
"""
bindings = _parse_ports_bindings(_compose_text())
assert OLD_BINDING not in bindings, (
f"docker-compose.yml must not bind {OLD_BINDING!r} (localhost-only) — "
"NPM on a separate host cannot reach it"
)
def test_no_localhost_only_binding_raw():
"""Raw text check: ``127.0.0.1:4096:4096`` is absent from the compose file."""
content = _compose_text()
assert OLD_BINDING not in content, (
f"docker-compose.yml must not contain {OLD_BINDING!r} — the localhost-only "
"binding blocks cross-host NPM proxying"
)
if __name__ == "__main__":
import pytest
pytest.main([__file__, "-v"])