diff --git a/docker-compose.yml b/docker-compose.yml index bc729a1..f6fb418 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/docs/decisions/021-pr-51-docker-port-expose.md b/docs/decisions/021-pr-51-docker-port-expose.md new file mode 100644 index 0000000..3c78821 --- /dev/null +++ b/docs/decisions/021-pr-51-docker-port-expose.md @@ -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). \ No newline at end of file diff --git a/docs/handoff/pr-51-docker-port-expose.md b/docs/handoff/pr-51-docker-port-expose.md new file mode 100644 index 0000000..b3def1a --- /dev/null +++ b/docs/handoff/pr-51-docker-port-expose.md @@ -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. \ No newline at end of file diff --git a/docs/project-map/README.md b/docs/project-map/README.md index 6f25a7b..74e4e81 100644 --- a/docs/project-map/README.md +++ b/docs/project-map/README.md @@ -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/ diff --git a/tests/test_docker_compose.py b/tests/test_docker_compose.py new file mode 100644 index 0000000..ada1254 --- /dev/null +++ b/tests/test_docker_compose.py @@ -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"])