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:
parent
2473360530
commit
655a98d077
5 changed files with 200 additions and 2 deletions
|
|
@ -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:
|
||||
|
|
|
|||
38
docs/decisions/021-pr-51-docker-port-expose.md
Normal file
38
docs/decisions/021-pr-51-docker-port-expose.md
Normal 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).
|
||||
31
docs/handoff/pr-51-docker-port-expose.md
Normal file
31
docs/handoff/pr-51-docker-port-expose.md
Normal 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.
|
||||
|
|
@ -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/
|
||||
|
|
|
|||
128
tests/test_docker_compose.py
Normal file
128
tests/test_docker_compose.py
Normal 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"])
|
||||
Loading…
Add table
Reference in a new issue