From 47798dcf02ffa2c70b88a37d1db5d57596e8b558 Mon Sep 17 00:00:00 2001 From: Jose <75870284+Jaro-c@users.noreply.github.com> Date: Fri, 5 Jun 2026 10:19:14 -0500 Subject: [PATCH] release: promote develop to main (#80) Promotes to `main`: - Agent and compose translator extraction (#75) + follow-up issue #76 - Community health files aligned with org-wide defaults (#77, #79) - Tooling-only gitignore (#78) - `docs/security-architecture.md`, README Development section, fixed installer URL --- .github/FUNDING.yml | 1 - .github/ISSUE_TEMPLATE/bug_report.yml | 61 - .github/ISSUE_TEMPLATE/config.yml | 5 - .github/ISSUE_TEMPLATE/feature_request.yml | 37 - .github/PULL_REQUEST_TEMPLATE.md | 23 - .github/workflows/agent.yml | 172 --- .github/workflows/compose.yml | 87 -- .github/workflows/lint-shell.yml | 4 +- .github/workflows/release-agent.yml | 250 ---- .gitignore | 19 - CODE_OF_CONDUCT.md | 35 - CONTRIBUTING.md | 176 --- README.md | 69 +- SECURITY.md | 81 -- docs/security-architecture.md | 45 + lynx/Cargo.lock | 465 ------- lynx/Cargo.toml | 2 - lynx/agent/.env.example | 15 - lynx/agent/.gitignore | 15 - ...fc506a2580550a1f8831ba05fd782362e6b7a.json | 22 - ...da244b969d5e4c4589fafe6a3847252538270.json | 32 - ...9fcf5af91155fcd73f7ef31b1fd35e4193ed6.json | 14 - ...ba337e741d3980341c24f5955c3c03533081e.json | 16 - ...6ff5350001288b446958cee795b7c22a66499.json | 14 - ...707fef3d15635740b649e4aefdce73f08b212.json | 14 - ...ba851bc1ef6c782425978c1ae9b7c538e3200.json | 22 - ...69a82cdf365bd132230c9e71356645f7e2d58.json | 20 - ...2c8d3ce35463e25ce09edb2f654857fb6c635.json | 20 - ...b642f7edd695e6ec47660ec606eb743cd7b91.json | 12 - ...c21da98a5e10688c707f6573a892c4fb94461.json | 12 - ...02397a0b9462183a04664943a183dde98b13b.json | 28 - ...b2b09e209f3d286b71130b3f2ece50c5fb78f.json | 20 - ...f8a20a60cdddf3517b5b60e27cd9821d02f3e.json | 22 - ...401765b8dab095e4f94f3e4c44156338a3a4a.json | 12 - ...26ef23efb0b13214f6e5c7f0423f79142bb6b.json | 77 - ...410f8f4f2141eed2087e6f70ef02f9dcce006.json | 15 - ...04834ddf2b190948d7671c85bd383f49bfbb5.json | 22 - ...3dc98af2d4233da678195c197658a8b0b0109.json | 68 - lynx/agent/Cargo.toml | 55 - lynx/agent/migrations/001_init.sql | 26 - lynx/agent/migrations/002_sync_cursor.sql | 9 - lynx/agent/migrations/003_nginx_configs.sql | 5 - lynx/agent/migrations/004_nftables_state.sql | 14 - lynx/agent/migrations/005_nftables_output.sql | 11 - .../agent/migrations/006_fix_uuid_default.sql | 3 - .../migrations/007_container_deployments.sql | 15 - ...fix_container_deployments_uuid_default.sql | 4 - lynx/agent/setup-agent.sh | 1240 ----------------- lynx/agent/src/audit/mod.rs | 308 ---- lynx/agent/src/auth/mod.rs | 479 ------- lynx/agent/src/cert.rs | 67 - lynx/agent/src/config.rs | 113 -- lynx/agent/src/conflict.rs | 332 ----- lynx/agent/src/error.rs | 39 - lynx/agent/src/handlers/containers.rs | 230 --- lynx/agent/src/handlers/metrics.rs | 74 - lynx/agent/src/handlers/mod.rs | 9 - lynx/agent/src/handlers/nftables.rs | 136 -- lynx/agent/src/handlers/nginx_cmd.rs | 267 ---- lynx/agent/src/handlers/system.rs | 414 ------ lynx/agent/src/handlers/wireguard.rs | 331 ----- lynx/agent/src/main.rs | 519 ------- lynx/agent/src/metrics/mod.rs | 382 ----- lynx/agent/src/nftables/divergence.rs | 146 -- lynx/agent/src/nftables/mod.rs | 679 --------- lynx/agent/src/nginx.rs | 188 --- lynx/agent/src/podman/mod.rs | 350 ----- lynx/agent/src/state.rs | 192 --- lynx/agent/src/sync/mod.rs | 210 --- lynx/agent/src/update/fallback.rs | 210 --- lynx/agent/src/update/mod.rs | 253 ---- lynx/agent/src/ws_client.rs | 237 ---- lynx/agent/update-agent.sh | 245 ---- lynx/dashboard/server/.gitignore | 3 - lynx/translators/compose/.gitignore | 15 - lynx/translators/compose/Cargo.toml | 34 - lynx/translators/compose/GAP_ANALYSIS.md | 500 +++++++ .../compose/internal/compose/extends.rs | 356 ----- .../compose/internal/compose/include.rs | 29 - .../compose/internal/compose/mod.rs | 216 --- .../compose/internal/compose/types/build.rs | 209 --- .../compose/internal/compose/types/deploy.rs | 140 -- .../compose/internal/compose/types/develop.rs | 77 - .../compose/internal/compose/types/env.rs | 124 -- .../internal/compose/types/lifecycle.rs | 148 -- .../compose/internal/compose/types/mod.rs | 104 -- .../compose/internal/compose/types/network.rs | 121 -- .../compose/internal/compose/types/ports.rs | 54 - .../internal/compose/types/primitives.rs | 148 -- .../internal/compose/types/resources.rs | 87 -- .../compose/internal/compose/types/service.rs | 231 --- .../compose/internal/compose/types/volume.rs | 199 --- .../compose/internal/engine/build.rs | 467 ------- .../compose/internal/engine/container.rs | 703 ---------- .../compose/internal/engine/health.rs | 79 -- .../compose/internal/engine/mod.rs | 644 --------- .../compose/internal/engine/network.rs | 212 --- .../compose/internal/engine/profiles.rs | 31 - .../compose/internal/engine/volume.rs | 580 -------- .../compose/internal/engine/watch.rs | 379 ----- lynx/translators/compose/internal/env_file.rs | 106 -- lynx/translators/compose/internal/error.rs | 57 - lynx/translators/compose/internal/lib.rs | 18 - lynx/translators/compose/internal/main.rs | 150 -- .../compose/internal/podman/mod.rs | 38 - lynx/translators/compose/internal/ports.rs | 259 ---- lynx/translators/compose/internal/size.rs | 101 -- .../compose/internal/substitute.rs | 319 ----- lynx/translators/compose/tests/env_file.rs | 4 - .../compose/tests/env_file/loading.rs | 77 - .../compose/tests/env_file/merge.rs | 34 - lynx/translators/compose/tests/parse.rs | 14 - .../compose/tests/parse/anchors.rs | 140 -- lynx/translators/compose/tests/parse/basic.rs | 413 ------ .../compose/tests/parse/coverage.rs | 1231 ---------------- .../compose/tests/parse/extends.rs | 167 --- .../translators/compose/tests/parse/fields.rs | 675 --------- .../compose/tests/parse/include.rs | 113 -- lynx/translators/compose/tests/parse/order.rs | 132 -- lynx/translators/compose/tests/ports.rs | 4 - .../compose/tests/ports/conversion.rs | 32 - .../compose/tests/ports/formats.rs | 109 -- lynx/translators/compose/tests/size.rs | 75 - lynx/translators/compose/tests/substitute.rs | 4 - .../compose/tests/substitute/dotenv.rs | 48 - .../compose/tests/substitute/modifiers.rs | 239 ---- scripts/lint.sh | 2 - 127 files changed, 613 insertions(+), 19654 deletions(-) delete mode 100644 .github/FUNDING.yml delete mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml delete mode 100644 .github/ISSUE_TEMPLATE/config.yml delete mode 100644 .github/ISSUE_TEMPLATE/feature_request.yml delete mode 100644 .github/PULL_REQUEST_TEMPLATE.md delete mode 100644 .github/workflows/agent.yml delete mode 100644 .github/workflows/compose.yml delete mode 100644 .github/workflows/release-agent.yml delete mode 100644 CODE_OF_CONDUCT.md delete mode 100644 CONTRIBUTING.md delete mode 100644 SECURITY.md create mode 100644 docs/security-architecture.md delete mode 100644 lynx/agent/.env.example delete mode 100644 lynx/agent/.gitignore delete mode 100644 lynx/agent/.sqlx/query-00c3f8dbef816155b03792538abfc506a2580550a1f8831ba05fd782362e6b7a.json delete mode 100644 lynx/agent/.sqlx/query-12bcf9534dacea1a0dc2d3338f4da244b969d5e4c4589fafe6a3847252538270.json delete mode 100644 lynx/agent/.sqlx/query-1f2ceb2be0d2387d5c8e254dc6e9fcf5af91155fcd73f7ef31b1fd35e4193ed6.json delete mode 100644 lynx/agent/.sqlx/query-3e28e1e4762466cc65827e2a360ba337e741d3980341c24f5955c3c03533081e.json delete mode 100644 lynx/agent/.sqlx/query-41064acceb10f24845763eb04486ff5350001288b446958cee795b7c22a66499.json delete mode 100644 lynx/agent/.sqlx/query-4c0ef88e55334be6c63db2fdebb707fef3d15635740b649e4aefdce73f08b212.json delete mode 100644 lynx/agent/.sqlx/query-6b23aeb57c196da19bc5922fae3ba851bc1ef6c782425978c1ae9b7c538e3200.json delete mode 100644 lynx/agent/.sqlx/query-6f9d5e4ac232c5b7692b25114bb69a82cdf365bd132230c9e71356645f7e2d58.json delete mode 100644 lynx/agent/.sqlx/query-9090aea230f941811868a9c84b32c8d3ce35463e25ce09edb2f654857fb6c635.json delete mode 100644 lynx/agent/.sqlx/query-94c1a847466d0f3d6e710940729b642f7edd695e6ec47660ec606eb743cd7b91.json delete mode 100644 lynx/agent/.sqlx/query-99ea67380017be816434150f214c21da98a5e10688c707f6573a892c4fb94461.json delete mode 100644 lynx/agent/.sqlx/query-a7ec522154154d28ebcf37d939d02397a0b9462183a04664943a183dde98b13b.json delete mode 100644 lynx/agent/.sqlx/query-b48607b49c20e23f376065a682ab2b09e209f3d286b71130b3f2ece50c5fb78f.json delete mode 100644 lynx/agent/.sqlx/query-b90f0087cb54a980e483949206cf8a20a60cdddf3517b5b60e27cd9821d02f3e.json delete mode 100644 lynx/agent/.sqlx/query-cb1c7d7a0e9bcda713409a72b85401765b8dab095e4f94f3e4c44156338a3a4a.json delete mode 100644 lynx/agent/.sqlx/query-d98c209b07244d0e9ff0be3653026ef23efb0b13214f6e5c7f0423f79142bb6b.json delete mode 100644 lynx/agent/.sqlx/query-e0ece2be8df9040927c3ddaaf9f410f8f4f2141eed2087e6f70ef02f9dcce006.json delete mode 100644 lynx/agent/.sqlx/query-e6303fa3c8c58b4ba15abe6147704834ddf2b190948d7671c85bd383f49bfbb5.json delete mode 100644 lynx/agent/.sqlx/query-ff5b6781f0fd181c7a6acc923513dc98af2d4233da678195c197658a8b0b0109.json delete mode 100644 lynx/agent/Cargo.toml delete mode 100644 lynx/agent/migrations/001_init.sql delete mode 100644 lynx/agent/migrations/002_sync_cursor.sql delete mode 100644 lynx/agent/migrations/003_nginx_configs.sql delete mode 100644 lynx/agent/migrations/004_nftables_state.sql delete mode 100644 lynx/agent/migrations/005_nftables_output.sql delete mode 100644 lynx/agent/migrations/006_fix_uuid_default.sql delete mode 100644 lynx/agent/migrations/007_container_deployments.sql delete mode 100644 lynx/agent/migrations/008_fix_container_deployments_uuid_default.sql delete mode 100644 lynx/agent/setup-agent.sh delete mode 100644 lynx/agent/src/audit/mod.rs delete mode 100644 lynx/agent/src/auth/mod.rs delete mode 100644 lynx/agent/src/cert.rs delete mode 100644 lynx/agent/src/config.rs delete mode 100644 lynx/agent/src/conflict.rs delete mode 100644 lynx/agent/src/error.rs delete mode 100644 lynx/agent/src/handlers/containers.rs delete mode 100644 lynx/agent/src/handlers/metrics.rs delete mode 100644 lynx/agent/src/handlers/mod.rs delete mode 100644 lynx/agent/src/handlers/nftables.rs delete mode 100644 lynx/agent/src/handlers/nginx_cmd.rs delete mode 100644 lynx/agent/src/handlers/system.rs delete mode 100644 lynx/agent/src/handlers/wireguard.rs delete mode 100644 lynx/agent/src/main.rs delete mode 100644 lynx/agent/src/metrics/mod.rs delete mode 100644 lynx/agent/src/nftables/divergence.rs delete mode 100644 lynx/agent/src/nftables/mod.rs delete mode 100644 lynx/agent/src/nginx.rs delete mode 100644 lynx/agent/src/podman/mod.rs delete mode 100644 lynx/agent/src/state.rs delete mode 100644 lynx/agent/src/sync/mod.rs delete mode 100644 lynx/agent/src/update/fallback.rs delete mode 100644 lynx/agent/src/update/mod.rs delete mode 100644 lynx/agent/src/ws_client.rs delete mode 100644 lynx/agent/update-agent.sh delete mode 100644 lynx/translators/compose/.gitignore delete mode 100644 lynx/translators/compose/Cargo.toml create mode 100644 lynx/translators/compose/GAP_ANALYSIS.md delete mode 100644 lynx/translators/compose/internal/compose/extends.rs delete mode 100644 lynx/translators/compose/internal/compose/include.rs delete mode 100644 lynx/translators/compose/internal/compose/mod.rs delete mode 100644 lynx/translators/compose/internal/compose/types/build.rs delete mode 100644 lynx/translators/compose/internal/compose/types/deploy.rs delete mode 100644 lynx/translators/compose/internal/compose/types/develop.rs delete mode 100644 lynx/translators/compose/internal/compose/types/env.rs delete mode 100644 lynx/translators/compose/internal/compose/types/lifecycle.rs delete mode 100644 lynx/translators/compose/internal/compose/types/mod.rs delete mode 100644 lynx/translators/compose/internal/compose/types/network.rs delete mode 100644 lynx/translators/compose/internal/compose/types/ports.rs delete mode 100644 lynx/translators/compose/internal/compose/types/primitives.rs delete mode 100644 lynx/translators/compose/internal/compose/types/resources.rs delete mode 100644 lynx/translators/compose/internal/compose/types/service.rs delete mode 100644 lynx/translators/compose/internal/compose/types/volume.rs delete mode 100644 lynx/translators/compose/internal/engine/build.rs delete mode 100644 lynx/translators/compose/internal/engine/container.rs delete mode 100644 lynx/translators/compose/internal/engine/health.rs delete mode 100644 lynx/translators/compose/internal/engine/mod.rs delete mode 100644 lynx/translators/compose/internal/engine/network.rs delete mode 100644 lynx/translators/compose/internal/engine/profiles.rs delete mode 100644 lynx/translators/compose/internal/engine/volume.rs delete mode 100644 lynx/translators/compose/internal/engine/watch.rs delete mode 100644 lynx/translators/compose/internal/env_file.rs delete mode 100644 lynx/translators/compose/internal/error.rs delete mode 100644 lynx/translators/compose/internal/lib.rs delete mode 100644 lynx/translators/compose/internal/main.rs delete mode 100644 lynx/translators/compose/internal/podman/mod.rs delete mode 100644 lynx/translators/compose/internal/ports.rs delete mode 100644 lynx/translators/compose/internal/size.rs delete mode 100644 lynx/translators/compose/internal/substitute.rs delete mode 100644 lynx/translators/compose/tests/env_file.rs delete mode 100644 lynx/translators/compose/tests/env_file/loading.rs delete mode 100644 lynx/translators/compose/tests/env_file/merge.rs delete mode 100644 lynx/translators/compose/tests/parse.rs delete mode 100644 lynx/translators/compose/tests/parse/anchors.rs delete mode 100644 lynx/translators/compose/tests/parse/basic.rs delete mode 100644 lynx/translators/compose/tests/parse/coverage.rs delete mode 100644 lynx/translators/compose/tests/parse/extends.rs delete mode 100644 lynx/translators/compose/tests/parse/fields.rs delete mode 100644 lynx/translators/compose/tests/parse/include.rs delete mode 100644 lynx/translators/compose/tests/parse/order.rs delete mode 100644 lynx/translators/compose/tests/ports.rs delete mode 100644 lynx/translators/compose/tests/ports/conversion.rs delete mode 100644 lynx/translators/compose/tests/ports/formats.rs delete mode 100644 lynx/translators/compose/tests/size.rs delete mode 100644 lynx/translators/compose/tests/substitute.rs delete mode 100644 lynx/translators/compose/tests/substitute/dotenv.rs delete mode 100644 lynx/translators/compose/tests/substitute/modifiers.rs diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml deleted file mode 100644 index 628ab52..0000000 --- a/.github/FUNDING.yml +++ /dev/null @@ -1 +0,0 @@ -github: [Jaro-c] diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml deleted file mode 100644 index 17cc9b7..0000000 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ /dev/null @@ -1,61 +0,0 @@ -name: Bug report -description: Report a bug or unexpected behavior in Lynx. -labels: ["bug"] -body: - - type: markdown - attributes: - value: | - Before submitting, please search existing issues to avoid duplicates. - - - type: dropdown - id: component - attributes: - label: Component - options: - - Dashboard - - Agent - - Installer - - Other - validations: - required: true - - - type: input - id: os - attributes: - label: Operating system - placeholder: e.g. Ubuntu 24.04, Debian 12, Fedora 40 - validations: - required: true - - - type: input - id: version - attributes: - label: Lynx version - placeholder: e.g. dashboard@1.0.0 / agent@1.0.0 - validations: - required: true - - - type: textarea - id: description - attributes: - label: Description - description: What happened? What did you expect to happen? - validations: - required: true - - - type: textarea - id: steps - attributes: - label: Steps to reproduce - placeholder: | - 1. Run install.sh - 2. Select option 1 - 3. ... - validations: - required: true - - - type: textarea - id: logs - attributes: - label: Relevant logs or output - render: shell diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml deleted file mode 100644 index 6f4361d..0000000 --- a/.github/ISSUE_TEMPLATE/config.yml +++ /dev/null @@ -1,5 +0,0 @@ -blank_issues_enabled: false -contact_links: - - name: Security vulnerability - url: https://github.com/Jaro-c/Lynx/security/advisories/new - about: Report a security vulnerability privately (do not open a public issue) diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml deleted file mode 100644 index ad1c13d..0000000 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ /dev/null @@ -1,37 +0,0 @@ -name: Feature request -description: Propose a new feature or improvement for Lynx. -labels: ["enhancement"] -body: - - type: dropdown - id: component - attributes: - label: Component - options: - - Dashboard - - Agent - - Installer - - Other - validations: - required: true - - - type: textarea - id: problem - attributes: - label: Problem - description: What problem does this feature solve? - validations: - required: true - - - type: textarea - id: solution - attributes: - label: Proposed solution - description: Describe what you want to happen. - validations: - required: true - - - type: textarea - id: alternatives - attributes: - label: Alternatives considered - description: Any other approaches you considered? diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md deleted file mode 100644 index 4ba282e..0000000 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ /dev/null @@ -1,23 +0,0 @@ -## Summary - - - -## Changes - - - -## Test plan - - - -- [ ] Tested on Ubuntu -- [ ] Tested on Debian -- [ ] Tested on Fedora -- [ ] Tested on Arch -- [ ] Tested on real VPS -- [ ] Unit / integration tests pass -- [ ] shellcheck passes (`bash scripts/lint.sh`) - -## Related issues - - diff --git a/.github/workflows/agent.yml b/.github/workflows/agent.yml deleted file mode 100644 index 5fcfa86..0000000 --- a/.github/workflows/agent.yml +++ /dev/null @@ -1,172 +0,0 @@ -name: agent - -on: - push: - branches: [main, develop] - paths: - - "lynx/agent/**" - - "lynx/translators/**" - - "lynx/Cargo.toml" - - "lynx/Cargo.lock" - - ".github/workflows/agent.yml" - pull_request: - branches: [main, develop] - paths: - - "lynx/agent/**" - - "lynx/translators/**" - - "lynx/Cargo.toml" - - "lynx/Cargo.lock" - - ".github/workflows/agent.yml" - -permissions: - contents: read - security-events: write - -env: - CARGO_TERM_COLOR: always - RUST_BACKTRACE: 1 - DATABASE_URL: postgres://lynx_ci:lynx_ci@localhost:5432/lynx_ci - -jobs: - fmt: - name: Format - runs-on: ubuntu-latest - defaults: - run: - working-directory: lynx - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - uses: dtolnay/rust-toolchain@3c5f7ea28cd621ae0bf5283f0e981fb97b8a7af9 # stable - with: - toolchain: stable - components: rustfmt - - - name: fmt - run: cargo fmt --package lynx-agent -- --check - - audit-urls: - name: Audit download URLs - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - name: Check download URLs are from allowed domains - run: python3 .github/scripts/audit-urls.py - - audit: - name: Security audit - runs-on: ubuntu-latest - defaults: - run: - working-directory: lynx - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - uses: dtolnay/rust-toolchain@3c5f7ea28cd621ae0bf5283f0e981fb97b8a7af9 # stable - with: - toolchain: stable - - - name: Install cargo-audit - run: cargo install cargo-audit --locked - - - name: audit - run: cargo audit --ignore RUSTSEC-2023-0071 - - check: - name: Check & Clippy - runs-on: ubuntu-latest - defaults: - run: - working-directory: lynx - services: - postgres: - image: postgres:18-alpine - env: - POSTGRES_USER: lynx_ci - POSTGRES_PASSWORD: lynx_ci - POSTGRES_DB: lynx_ci - ports: - - 5432:5432 - options: >- - --health-cmd pg_isready - --health-interval 5s - --health-timeout 5s - --health-retries 10 - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - uses: dtolnay/rust-toolchain@3c5f7ea28cd621ae0bf5283f0e981fb97b8a7af9 # stable - with: - toolchain: stable - components: clippy - - - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 - with: - workspaces: lynx - - - name: Install sqlx-cli - run: cargo install sqlx-cli --no-default-features --features postgres --locked - - - name: Run agent migrations - run: sqlx migrate run --source agent/migrations - - - name: check - run: cargo check --package lynx-agent - - - name: clippy - run: cargo clippy --package lynx-agent -- -D warnings - - test: - name: Unit tests - runs-on: ubuntu-latest - defaults: - run: - working-directory: lynx - services: - postgres: - image: postgres:18-alpine - env: - POSTGRES_USER: lynx_ci - POSTGRES_PASSWORD: lynx_ci - POSTGRES_DB: lynx_ci - ports: - - 5432:5432 - options: >- - --health-cmd pg_isready - --health-interval 5s - --health-timeout 5s - --health-retries 10 - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - uses: dtolnay/rust-toolchain@3c5f7ea28cd621ae0bf5283f0e981fb97b8a7af9 # stable - with: - toolchain: stable - - - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 - with: - workspaces: lynx - - - name: Install sqlx-cli - run: cargo install sqlx-cli --no-default-features --features postgres --locked - - - name: Run agent migrations - run: sqlx migrate run --source agent/migrations - - - name: test - # `--test-threads=1` — the audit-chain integrity test mutates the - # globally-last row of `audit_log` and then expects the next append to - # detect the tamper. Parallel tests would race that global state. - run: cargo test --package lynx-agent --bin lynx-agent -- --test-threads=1 - - # Integration tests require nftables + Podman — self-hosted runner only. - # Uncomment when self-hosted runner is configured. - # integration: - # name: Integration tests - # runs-on: [self-hosted, linux] - # needs: check - # steps: - # - uses: actions/checkout@... - # - name: test - # run: cargo test --package lynx-agent -- --test-threads=1 diff --git a/.github/workflows/compose.yml b/.github/workflows/compose.yml deleted file mode 100644 index f3dde5b..0000000 --- a/.github/workflows/compose.yml +++ /dev/null @@ -1,87 +0,0 @@ -name: lynx-compose - -on: - push: - branches: [main, develop] - paths: - - "lynx/translators/compose/**" - - "lynx/Cargo.toml" - - ".github/workflows/compose.yml" - pull_request: - branches: [main, develop] - paths: - - "lynx/translators/compose/**" - - "lynx/Cargo.toml" - - ".github/workflows/compose.yml" - -permissions: - contents: read - security-events: write - -env: - CARGO_TERM_COLOR: always - RUST_BACKTRACE: 1 - -jobs: - audit: - name: Security audit - runs-on: ubuntu-latest - defaults: - run: - working-directory: lynx - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - uses: dtolnay/rust-toolchain@3c5f7ea28cd621ae0bf5283f0e981fb97b8a7af9 # stable - with: - toolchain: stable - - - name: Install cargo-audit - run: cargo install cargo-audit --locked - - - name: audit - run: cargo audit --ignore RUSTSEC-2023-0071 - - check: - name: Check & Lint - runs-on: ubuntu-latest - defaults: - run: - working-directory: lynx - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - uses: dtolnay/rust-toolchain@3c5f7ea28cd621ae0bf5283f0e981fb97b8a7af9 # stable - with: - toolchain: stable - components: rustfmt, clippy - - - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 - with: - workspaces: lynx - - - name: fmt - run: cargo fmt --package lynx-compose -- --check - - - name: clippy - run: cargo clippy --package lynx-compose -- -D warnings - - test: - name: Tests - runs-on: ubuntu-latest - defaults: - run: - working-directory: lynx - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - uses: dtolnay/rust-toolchain@3c5f7ea28cd621ae0bf5283f0e981fb97b8a7af9 # stable - with: - toolchain: stable - - - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 - with: - workspaces: lynx - - - name: test - run: cargo test --package lynx-compose diff --git a/.github/workflows/lint-shell.yml b/.github/workflows/lint-shell.yml index cfc9ca4..25a21e4 100644 --- a/.github/workflows/lint-shell.yml +++ b/.github/workflows/lint-shell.yml @@ -32,9 +32,7 @@ jobs: scripts/install-podman.sh \ scripts/install-nftables.sh \ lynx/dashboard/setup-dashboard.sh \ - lynx/dashboard/update-dashboard.sh \ - lynx/agent/setup-agent.sh \ - lynx/agent/update-agent.sh + lynx/dashboard/update-dashboard.sh do if bash -n "$f"; then echo "OK $f" diff --git a/.github/workflows/release-agent.yml b/.github/workflows/release-agent.yml deleted file mode 100644 index 981677e..0000000 --- a/.github/workflows/release-agent.yml +++ /dev/null @@ -1,250 +0,0 @@ -name: release-agent - -on: - push: - tags: - - "agent@*" - workflow_dispatch: - inputs: - tag: - description: "Tag to release (e.g. agent@1.1.0)" - required: true - -permissions: - contents: write - -jobs: - prepare: - name: Verify sqlx cache - runs-on: ubuntu-latest - - services: - postgres: - image: postgres:18-alpine - env: - POSTGRES_USER: lynx_ci - POSTGRES_PASSWORD: lynx_ci - POSTGRES_DB: lynx_ci - ports: - - 5432:5432 - options: >- - --health-cmd pg_isready - --health-interval 5s - --health-timeout 5s - --health-retries 10 - - defaults: - run: - working-directory: lynx - - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - uses: dtolnay/rust-toolchain@3c5f7ea28cd621ae0bf5283f0e981fb97b8a7af9 # stable - with: - toolchain: stable - - - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 - with: - workspaces: lynx - - - name: Install sqlx-cli - run: cargo install sqlx-cli --no-default-features --features postgres --locked - - - name: Run migrations - env: - DATABASE_URL: postgres://lynx_ci:lynx_ci@localhost:5432/lynx_ci - run: sqlx migrate run --source agent/migrations - - - name: Verify sqlx cache is up-to-date - working-directory: lynx/agent - env: - DATABASE_URL: postgres://lynx_ci:lynx_ci@localhost:5432/lynx_ci - run: cargo sqlx prepare --check - - ci: - name: CI checks - runs-on: ubuntu-latest - - services: - postgres: - image: postgres:18-alpine - env: - POSTGRES_USER: lynx_ci - POSTGRES_PASSWORD: lynx_ci - POSTGRES_DB: lynx_ci - ports: - - 5432:5432 - options: >- - --health-cmd pg_isready - --health-interval 5s - --health-timeout 5s - --health-retries 10 - - defaults: - run: - working-directory: lynx - - env: - CARGO_TERM_COLOR: always - RUST_BACKTRACE: 1 - DATABASE_URL: postgres://lynx_ci:lynx_ci@localhost:5432/lynx_ci - - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - uses: dtolnay/rust-toolchain@3c5f7ea28cd621ae0bf5283f0e981fb97b8a7af9 # stable - with: - toolchain: stable - components: rustfmt, clippy - - - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 - with: - workspaces: lynx - - - name: Install sqlx-cli - run: cargo install sqlx-cli --no-default-features --features postgres --locked - - - name: Install cargo-audit - run: cargo install cargo-audit --locked - - - name: Run agent migrations - run: sqlx migrate run --source agent/migrations - - - name: fmt - run: cargo fmt --package lynx-agent -- --check - - - name: clippy - run: cargo clippy --package lynx-agent -- -D warnings - - - name: test - # Audit-chain integrity test mutates the globally-last audit_log row - # and expects the next append to catch the tamper — parallel tests - # would race that global state. - run: cargo test --package lynx-agent --bin lynx-agent -- --test-threads=1 - - - name: audit - run: cargo audit --ignore RUSTSEC-2023-0071 - - build: - name: Build ${{ matrix.arch }} - needs: [prepare, ci] - runs-on: ubuntu-latest - - strategy: - fail-fast: false - matrix: - include: - - arch: x86_64 - rust-target: x86_64-unknown-linux-musl - use_cross: false - - - arch: arm64 - rust-target: aarch64-unknown-linux-musl - use_cross: true - - defaults: - run: - working-directory: lynx - - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - uses: dtolnay/rust-toolchain@3c5f7ea28cd621ae0bf5283f0e981fb97b8a7af9 # stable - with: - toolchain: stable - targets: ${{ matrix.rust-target }} - - - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 - with: - workspaces: lynx - - - name: Install musl toolchain (x86_64) - if: ${{ !matrix.use_cross }} - run: sudo apt-get install -y musl-tools - - - name: Install cross (arm64) - if: ${{ matrix.use_cross }} - run: cargo install cross --locked - - - name: Set version from tag - run: | - TAG="${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }}" - VERSION="${TAG#agent@}" - sed -i "0,/^version = \"[^\"]*\"/{s/^version = \"[^\"]*\"/version = \"${VERSION}\"/}" agent/Cargo.toml - echo "Set agent version to ${VERSION}" - - - name: Build agent (musl static) - env: - SQLX_OFFLINE: "true" - run: | - if [ "${{ matrix.use_cross }}" = "true" ]; then - cross build --release \ - --package lynx-agent \ - --package lynx-compose \ - --target ${{ matrix.rust-target }} - else - cargo build --release \ - --package lynx-agent \ - --package lynx-compose \ - --target ${{ matrix.rust-target }} - fi - - - name: Sign agent binary - working-directory: ${{ github.workspace }} - env: - RELEASE_SIGN_KEY: ${{ secrets.RELEASE_SIGN_KEY }} - run: | - pip install --quiet cryptography - BINARY="lynx/target/${{ matrix.rust-target }}/release/lynx-agent" - ARTIFACT="lynx-agent-linux-${{ matrix.arch }}" - cp "$BINARY" "$ARTIFACT" - python3 .github/scripts/sign.py "$RELEASE_SIGN_KEY" "$ARTIFACT" - - - name: Sign lynx-compose binary - working-directory: ${{ github.workspace }} - env: - RELEASE_SIGN_KEY: ${{ secrets.RELEASE_SIGN_KEY }} - run: | - BINARY="lynx/target/${{ matrix.rust-target }}/release/lynx-compose" - ARTIFACT="lynx-compose-linux-${{ matrix.arch }}" - cp "$BINARY" "$ARTIFACT" - python3 .github/scripts/sign.py "$RELEASE_SIGN_KEY" "$ARTIFACT" - - - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: agent-${{ matrix.arch }} - path: | - lynx-agent-linux-${{ matrix.arch }} - lynx-agent-linux-${{ matrix.arch }}.sig - lynx-compose-linux-${{ matrix.arch }} - lynx-compose-linux-${{ matrix.arch }}.sig - retention-days: 1 - - release: - name: Publish release - needs: build - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - merge-multiple: true - - - name: Create GitHub Release - env: - GH_TOKEN: ${{ github.token }} - TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }} - run: | - gh release create "$TAG" \ - --generate-notes \ - lynx-agent-linux-x86_64 \ - lynx-agent-linux-x86_64.sig \ - lynx-agent-linux-arm64 \ - lynx-agent-linux-arm64.sig \ - lynx-compose-linux-x86_64 \ - lynx-compose-linux-x86_64.sig \ - lynx-compose-linux-arm64 \ - lynx-compose-linux-arm64.sig diff --git a/.gitignore b/.gitignore index 3894f8f..728efed 100644 --- a/.gitignore +++ b/.gitignore @@ -10,22 +10,6 @@ target/ .DS_Store **/.DS_Store -# AI tools — nothing AI-related goes to the repo -CLAUDE.md -.claude/ -**/.claude/ -**/.agents/ -**/skills-lock.json -.cursor/ -**/.cursor/ -.copilot/ -**/.copilot/ -.windsurf/ -**/.windsurf/ -.aider* -.continue/ -**/.continue/ - # Env files (real ones — .env.example is tracked) .env .env.local @@ -34,8 +18,6 @@ CLAUDE.md # Playwright **/playwright-report/ **/test-results/ -.playwright-mcp/ -**/.playwright-mcp/ # FUSE filesystem temp files (Linux file manager / gvfs) .fuse_hidden* @@ -43,4 +25,3 @@ CLAUDE.md # Editor *.swp *.swo -PROGRESS.md diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md deleted file mode 100644 index 0a79947..0000000 --- a/CODE_OF_CONDUCT.md +++ /dev/null @@ -1,35 +0,0 @@ -# Code of Conduct - -## Our Standards - -Lynx is a technical project. Interactions here — issues, PRs, discussions, comments — should be focused, respectful, and constructive. - -**Expected behavior:** -- Be direct and technically precise -- Critique code and ideas, not people -- Accept feedback gracefully — the goal is a better project -- Help others when you can - -**Unacceptable behavior:** -- Harassment, personal attacks, or discriminatory language -- Deliberate intimidation or trolling -- Publishing others' private information without consent -- Any conduct that would be considered unprofessional in a technical setting - ---- - -## Enforcement - -Violations can be reported to the maintainer privately: -[**Report via GitHub →**](https://github.com/Jaro-c/Lynx/security/advisories/new) -Reports will be reviewed promptly and handled with discretion. The maintainer reserves the right to remove comments, close issues, or ban contributors who violate these standards. - ---- - -## Scope - -This Code of Conduct applies to all project spaces — GitHub issues, PRs, discussions, and any other official project channels. - ---- - -*Based on the [Contributor Covenant](https://www.contributor-covenant.org/), adapted for Lynx.* diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md deleted file mode 100644 index 9529567..0000000 --- a/CONTRIBUTING.md +++ /dev/null @@ -1,176 +0,0 @@ -# Contributing to Lynx - -Lynx is a self-hosted VPS & container manager built in Rust and Next.js. Contributions are welcome — bug fixes, features, tests, and documentation. - -Contributions are voluntary and unpaid. If you find Lynx useful and want to support its development, [GitHub Sponsors](https://github.com/sponsors/Jaro-c) is appreciated. - ---- - -## Before You Start - -- Search existing issues and PRs before opening new ones -- For large changes, open an issue first to discuss the approach -- All contributions must be in English — code, comments, commits, PR descriptions - ---- - -## Branches - -| Branch | Purpose | -|--------|---------| -| `main` | Production. Never push directly. Merge via PR from `develop` only. | -| `develop` | Working branch. Direct push allowed for maintainers. PRs target this branch. | - ---- - -## Development Setup - -**Agent (Rust):** -```bash -cd lynx -cargo build -p lynx-agent -cargo test -p lynx-agent -``` - -**Dashboard backend (Rust):** -```bash -cd lynx -cargo build -p lynx-dashboard-server -cargo test -p lynx-dashboard-server -``` - -**Dashboard frontend (Next.js):** -```bash -cd lynx/dashboard/ui -bun install -bun dev -``` - -**Lint:** -```bash -bash scripts/lint.sh # shellcheck on all .sh files -``` - ---- - -## Pull Requests - -- Squash merge only — one commit per PR on `main` -- Commits must be **GPG or SSH signed** — unsigned commits are rejected -- Keep PRs focused: one concern per PR -- Fill out the PR template completely - ---- - -## Commit Messages - -Conventional Commits format: - -``` -feat(agent): add PSK rotation without tunnel restart -fix(dashboard): correct nonce cleanup interval -chore(ci): update ubuntu runner to 24.04 -``` - -Subject ≤ 50 characters. Body only when the "why" isn't obvious from the code. - ---- - -## Versioning - -Two independent release tracks: - -- `dashboard@x.y.z` — dashboard backend + frontend -- `agent@x.y.z` — agent binary - -A release on one track does not require a release on the other. Tags trigger the corresponding release workflow. - ---- - -## Code Style - -**Rust (agent + dashboard backend):** -- `cargo fmt` before committing -- `cargo clippy -- -D warnings` must pass -- No `unwrap()` in production paths — use proper error handling -- UTC everywhere — no local timestamps -- UUID v7 for all table IDs -- Queries via `sqlx` with bound parameters only — no string interpolation in SQL -- Shell commands via `std::process::Command::arg()` — never `sh -c "...{input}..."` - -**Next.js (dashboard frontend):** -- `biome format` + `biome lint` before committing -- Server Components by default — `"use client"` only when required -- All user-visible text in i18n files (`en.json`, `es.json`) — never hardcoded strings -- Zod schemas for all input validation - -**Shell scripts:** -- `shellcheck` must pass (`bash scripts/lint.sh`) -- Header block required (description, usage, requirements) -- ANSI colors for output - ---- - -## Tests - -**CI (GitHub Actions) — runs automatically on every PR:** -- Rust unit tests (`cargo test`) -- Dashboard integration tests (PostgreSQL + Redis containers) -- Frontend tests (Vitest + Playwright) -- `cargo-audit` — fails on known CVEs -- `bun audit` — fails on high/critical npm vulnerabilities -- `shellcheck` on all `.sh` files - -**VM tests — required for certain changes:** - -Some features cannot be tested in CI (nftables, WireGuard, Podman, systemd). These require local VMs: - -| Area | Environment | -|------|-------------| -| nftables rules, divergence detection | VM local | -| WireGuard tunnel setup, PSK rotation | VM local (CAP_NET_ADMIN) | -| Podman containers, org isolation | VM local | -| Auto-update binary swap | VM local | -| Installation + incompatible software | VM local | -| Agent ↔ dashboard connectivity | 2 VMs | -| Migration (dashboard or agent) | 2–3 VMs | - -If your change affects these areas, note in your PR which VM scenarios you ran. - ---- - -## What Not to Contribute - -- Docker support — incompatible by design (nftables/network isolation conflict) -- Rollback / downgrade mechanisms — hotfix + auto-update is the model -- Metrics persistence — metrics are real-time WebSocket only -- SMTP integration — not planned -- Changes that break backwards compatibility of migrations (additive-only) - -
-File organization reference -
- -Never accumulate many files in one folder. Always use subdirectories by responsibility. - -Rust pattern: -``` -agents/ -├── mod.rs -├── router.rs -├── heartbeat.rs -└── handlers/ - ├── mod.rs ← re-exports only - ├── crud.rs - └── commands.rs -``` - -Frontend mirrors the URL structure: -``` -src/components/(dashboard)/agents/ -├── list/ -├── detail/ -└── nftables/ -``` - -
diff --git a/README.md b/README.md index ca8398f..808f01a 100644 --- a/README.md +++ b/README.md @@ -96,7 +96,7 @@ Agent-2 never exposes public ports for the project. All traffic enters through A ### Dashboard ```bash -curl -fsSL https://raw.githubusercontent.com/Jaro-c/Lynx/main/install.sh | sudo bash +curl -fsSL https://raw.githubusercontent.com/Glyndor/panel/main/install.sh | sudo bash ``` The installer handles everything: @@ -169,7 +169,72 @@ All executed and rejected commands are stored in a **hash-chained append-only au Reporting a vulnerability
-See [SECURITY.md](SECURITY.md). +See the [security policy](https://github.com/Glyndor/panel/security/policy) and +the [security architecture](docs/security-architecture.md) for threat modeling. + + + +--- + +## 🛠 Development + +Contribution model, branch flow and code style live in the +[organization contributing guide](https://github.com/Glyndor/.github/blob/main/CONTRIBUTING.md). +Repo-specific setup: + +**Dashboard backend (Rust):** + +```bash +cd lynx +SQLX_OFFLINE=true cargo build -p lynx-dashboard-server +SQLX_OFFLINE=true cargo test -p lynx-dashboard-server +``` + +`sqlx` compile-time checks use the committed `.sqlx` cache. To run against a +real database, see `lynx/dashboard/server/.env` and start PostgreSQL locally. + +**Dashboard frontend (Next.js):** + +```bash +cd lynx/dashboard/ui +bun install +bun dev +``` + +**Shell lint:** `bash scripts/lint.sh` (shellcheck on all `.sh` files). + +The agent and the compose translator live in +[panel-agent](https://github.com/Glyndor/panel-agent) and +[podman-compose](https://github.com/Glyndor/podman-compose). + +
+VM test matrix +
+ +Some features cannot be tested in CI (nftables, WireGuard, Podman, systemd). +Changes in these areas require local VMs — note in your PR which scenarios you ran: + +| Area | Environment | +|------|-------------| +| nftables rules, divergence detection | VM local | +| WireGuard tunnel setup, PSK rotation | VM local (CAP_NET_ADMIN) | +| Podman containers, org isolation | VM local | +| Auto-update binary swap | VM local | +| Installation + incompatible software | VM local | +| Dashboard ↔ agent connectivity | 2 VMs | +| Migration (dashboard or agent) | 2–3 VMs | + +
+ +
+Out of scope — do not contribute +
+ +- Docker support — incompatible by design (nftables/network isolation conflict) +- Rollback / downgrade mechanisms — hotfix + auto-update is the model +- Metrics persistence — metrics are real-time WebSocket only +- SMTP integration — not planned +- Changes that break backwards compatibility of migrations (additive-only)
diff --git a/SECURITY.md b/SECURITY.md deleted file mode 100644 index 91919bc..0000000 --- a/SECURITY.md +++ /dev/null @@ -1,81 +0,0 @@ -# Security Policy - ---- - -## Reporting a Vulnerability - -**Do not open a public GitHub issue for security vulnerabilities.** - -Report via GitHub's private vulnerability disclosure: -[**Report a vulnerability →**](https://github.com/Jaro-c/Lynx/security/advisories/new) - -Include: -- Component affected (`dashboard` / `agent` / installer) -- Description of the vulnerability -- Steps to reproduce -- Potential impact -- Suggested fix (optional) - -Responsible disclosure is appreciated. If the report leads to a fix, you'll be credited in the release notes (unless you prefer anonymity). - ---- - -## Response Timeline - -| Stage | Target | -|-------|--------| -| Acknowledgement | 48 hours | -| Initial assessment | 5 business days | -| Fix + release | Depends on severity | - -**Critical** (RCE, auth bypass, crypto break, privilege escalation) — target fix within 7 days. -**High** (data leak, firewall bypass, replay attack) — target fix within 14 days. -**Medium / Low** — addressed in next regular release. - ---- - -## Supported Versions - -Only the latest release of each component is supported. Lynx auto-updates itself — there is no manual rollback. If a critical bug is found, a new release is published and deployed automatically. - -| Component | Support | -|-----------|---------| -| `dashboard@latest` | ✅ Supported | -| `agent@latest` | ✅ Supported | -| Older versions | ❌ No patches — update via auto-update | - ---- - -## Scope - -**In scope:** -- Authentication and session handling -- WireGuard tunnel security -- nftables rule bypass -- Command signature verification (Ed25519) -- Replay / nonce attacks -- Privilege escalation via agent or containers -- Envelope encryption (KEK/DEK) -- PostgreSQL TDE key handling -- Auto-update pipeline (Ed25519 binary signature) -- SSRF in binary download flow -- SQL injection, shell injection - -**Out of scope:** -- Vulnerabilities requiring physical access to the VPS -- Issues in third-party dependencies (report upstream — we track them via `cargo-audit` / `bun audit`) -- Theoretical attacks with no practical exploit path -- Social engineering - ---- - -## Security Architecture - -Key properties for threat modeling: - -- **Transport** — WireGuard + mTLS on all dashboard ↔ agent traffic. Agent never accepts plain connections. -- **Command integrity** — every dashboard → agent command is Ed25519-signed with a nonce and 30s timestamp window. Replay attacks rejected even if transport is compromised. -- **Binary integrity** — Ed25519 signature verified before any binary swap during auto-update. Partial downloads fail verification automatically. -- **Audit log** — hash-chained, append-only, synced to dashboard PostgreSQL in real time. Any tampered entry breaks the chain. -- **Firewall** — nftables default deny. `lynx-base` chain is invariant — auto-restored silently if modified, even by root. -- **Containers** — rootless Podman under per-org system users. UID 0 inside a container maps to an unprivileged UID on the host. diff --git a/docs/security-architecture.md b/docs/security-architecture.md new file mode 100644 index 0000000..d2666b8 --- /dev/null +++ b/docs/security-architecture.md @@ -0,0 +1,45 @@ +# Security Architecture + +Key properties of Lynx for threat modeling. The reporting process and response +targets are in the +[organization security policy](https://github.com/Glyndor/panel/security/policy). + +## Properties + +- **Transport** — WireGuard + mTLS on all dashboard ↔ agent traffic. The agent + never accepts plain connections. TLS 1.3 minimum everywhere. +- **Command integrity** — every dashboard → agent command is Ed25519-signed + with a nonce and a 30-second timestamp window. Replay attacks are rejected + even if the transport is compromised. +- **Binary integrity** — Ed25519 signature verified before any binary swap + during auto-update. Partial downloads fail verification automatically. +- **Audit log** — hash-chained, append-only, synced to dashboard PostgreSQL in + real time. Any tampered entry breaks the chain. +- **Firewall** — nftables default deny. The `lynx-base` chain is invariant — + auto-restored silently if modified, even by root. +- **Containers** — rootless Podman under per-org system users. UID 0 inside a + container maps to an unprivileged UID on the host. +- **Secrets at rest** — envelope encryption (KEK/DEK); PostgreSQL TDE key + handling. + +## Areas of special interest for reports + +- Authentication and session handling +- WireGuard tunnel security, PSK rotation +- nftables rule bypass +- Command signature verification (Ed25519), replay/nonce attacks +- Privilege escalation via agent or containers +- Auto-update pipeline (binary signature, SSRF in download flow) +- SQL injection, shell injection + +## Supported versions + +Only the latest release of each component is supported. Lynx auto-updates +itself — there is no manual rollback. If a critical bug is found, a new +release is published and deployed automatically. + +| Component | Support | +|-----------|---------| +| `dashboard@latest` | ✅ Supported | +| `agent@latest` ([panel-agent](https://github.com/Glyndor/panel-agent)) | ✅ Supported | +| Older versions | ❌ No patches — update via auto-update | diff --git a/lynx/Cargo.lock b/lynx/Cargo.lock index 39da1cf..f0a5445 100644 --- a/lynx/Cargo.lock +++ b/lynx/Cargo.lock @@ -408,49 +408,6 @@ dependencies = [ "hybrid-array", ] -[[package]] -name = "bollard" -version = "0.21.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9d0a013e3d3ee4edd61e779adf117944c08902d375f18630a0c5b8f95659734" -dependencies = [ - "base64", - "bollard-stubs", - "bytes", - "futures-core", - "futures-util", - "hex", - "http", - "http-body-util", - "hyper", - "hyper-named-pipe", - "hyper-util", - "hyperlocal", - "log", - "pin-project-lite", - "serde", - "serde_derive", - "serde_json", - "serde_urlencoded", - "thiserror", - "tokio", - "tokio-util", - "tower-service", - "url", - "winapi", -] - -[[package]] -name = "bollard-stubs" -version = "1.53.1-rc.29.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce412eb6f7096743011dc3cb5c674caeb24ced61d8c498fe07cf7998a4fea889" -dependencies = [ - "serde", - "serde_json", - "serde_repr", -] - [[package]] name = "bumpalo" version = "3.20.2" @@ -634,16 +591,6 @@ dependencies = [ "version_check", ] -[[package]] -name = "core-foundation" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" -dependencies = [ - "core-foundation-sys", - "libc", -] - [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -997,16 +944,6 @@ version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" -[[package]] -name = "filetime" -version = "0.2.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" -dependencies = [ - "cfg-if", - "libc", -] - [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -1070,30 +1007,6 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" -[[package]] -name = "fsevent-sys" -version = "4.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" -dependencies = [ - "libc", -] - -[[package]] -name = "futures" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" -dependencies = [ - "futures-channel", - "futures-core", - "futures-executor", - "futures-io", - "futures-sink", - "futures-task", - "futures-util", -] - [[package]] name = "futures-channel" version = "0.3.32" @@ -1167,7 +1080,6 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ - "futures-channel", "futures-core", "futures-io", "futures-macro", @@ -1379,21 +1291,6 @@ dependencies = [ "want", ] -[[package]] -name = "hyper-named-pipe" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73b7d8abf35697b81a825e386fc151e0d503e8cb5fcb93cc8669c376dfd6f278" -dependencies = [ - "hex", - "hyper", - "hyper-util", - "pin-project-lite", - "tokio", - "tower-service", - "winapi", -] - [[package]] name = "hyper-rustls" version = "0.27.9" @@ -1433,21 +1330,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "hyperlocal" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "986c5ce3b994526b3cd75578e62554abd09f0899d6206de48b3e96ab34ccc8c7" -dependencies = [ - "hex", - "http-body-util", - "hyper", - "hyper-util", - "pin-project-lite", - "tokio", - "tower-service", -] - [[package]] name = "iana-time-zone" version = "0.1.65" @@ -1593,26 +1475,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "inotify" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd5b3eaf1a28b758ac0faa5a4254e8ab2705605496f1b1f3fbbc3988ad73d199" -dependencies = [ - "bitflags", - "inotify-sys", - "libc", -] - -[[package]] -name = "inotify-sys" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" -dependencies = [ - "libc", -] - [[package]] name = "inout" version = "0.1.4" @@ -1697,26 +1559,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "kqueue" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a" -dependencies = [ - "kqueue-sys", - "libc", -] - -[[package]] -name = "kqueue-sys" -version = "1.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" -dependencies = [ - "bitflags", - "libc", -] - [[package]] name = "lazy_static" version = "1.5.0" @@ -1766,18 +1608,6 @@ dependencies = [ "vcpkg", ] -[[package]] -name = "libyaml-rs" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e126dda6f34391ab7b444f9922055facc83c07a910da3eb16f1e4d9c45dc777" - -[[package]] -name = "linux-raw-sys" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" - [[package]] name = "litemap" version = "0.8.2" @@ -1805,68 +1635,6 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" -[[package]] -name = "lynx-agent" -version = "1.2.8" -dependencies = [ - "anyhow", - "axum", - "base64ct", - "chrono", - "clap", - "ed25519-dalek", - "futures-util", - "hex", - "hyper", - "hyper-util", - "lynx-compose", - "nix", - "rand 0.10.1", - "rcgen", - "reqwest", - "rustls", - "serde", - "serde_json", - "sha2 0.11.0", - "sqlx", - "subtle", - "thiserror", - "tokio", - "tokio-rustls", - "tokio-tungstenite", - "tower", - "tower-http", - "tracing", - "tracing-subscriber", - "url", - "uuid", - "zeroize", -] - -[[package]] -name = "lynx-compose" -version = "0.2.0" -dependencies = [ - "anyhow", - "bollard", - "bytes", - "clap", - "flate2", - "futures", - "indexmap", - "libc", - "notify", - "serde", - "tar", - "tempfile", - "thiserror", - "tokio", - "tracing", - "tracing-subscriber", - "walkdir", - "yaml_serde", -] - [[package]] name = "lynx-dashboard-server" version = "0.1.0" @@ -1967,23 +1735,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", - "log", "wasi", "windows-sys 0.61.2", ] -[[package]] -name = "nix" -version = "0.31.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" -dependencies = [ - "bitflags", - "cfg-if", - "cfg_aliases", - "libc", -] - [[package]] name = "nom" version = "7.1.3" @@ -1994,33 +1749,6 @@ dependencies = [ "minimal-lexical", ] -[[package]] -name = "notify" -version = "8.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" -dependencies = [ - "bitflags", - "fsevent-sys", - "inotify", - "kqueue", - "libc", - "log", - "mio", - "notify-types", - "walkdir", - "windows-sys 0.60.2", -] - -[[package]] -name = "notify-types" -version = "2.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" -dependencies = [ - "bitflags", -] - [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -2178,12 +1906,6 @@ dependencies = [ "syn", ] -[[package]] -name = "openssl-probe" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" - [[package]] name = "openssl-src" version = "300.6.0+3.6.2" @@ -2625,7 +2347,6 @@ dependencies = [ "base64", "bytes", "futures-core", - "futures-util", "http", "http-body", "http-body-util", @@ -2645,14 +2366,12 @@ dependencies = [ "sync_wrapper", "tokio", "tokio-rustls", - "tokio-util", "tower", "tower-http", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", - "wasm-streams", "web-sys", "webpki-roots 1.0.7", ] @@ -2739,19 +2458,6 @@ dependencies = [ "nom", ] -[[package]] -name = "rustix" -version = "1.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" -dependencies = [ - "bitflags", - "errno", - "libc", - "linux-raw-sys", - "windows-sys 0.52.0", -] - [[package]] name = "rustls" version = "0.23.40" @@ -2768,18 +2474,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "rustls-native-certs" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" -dependencies = [ - "openssl-probe", - "rustls-pki-types", - "schannel", - "security-framework", -] - [[package]] name = "rustls-pki-types" version = "1.14.1" @@ -2814,53 +2508,12 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" -[[package]] -name = "same-file" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" -dependencies = [ - "winapi-util", -] - -[[package]] -name = "schannel" -version = "0.1.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" -dependencies = [ - "windows-sys 0.61.2", -] - [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" -[[package]] -name = "security-framework" -version = "3.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" -dependencies = [ - "bitflags", - "core-foundation", - "core-foundation-sys", - "libc", - "security-framework-sys", -] - -[[package]] -name = "security-framework-sys" -version = "2.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" -dependencies = [ - "core-foundation-sys", - "libc", -] - [[package]] name = "semver" version = "1.0.28" @@ -2922,17 +2575,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "serde_repr" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -3329,30 +2971,6 @@ dependencies = [ "syn", ] -[[package]] -name = "tar" -version = "0.4.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" -dependencies = [ - "filetime", - "libc", - "xattr", -] - -[[package]] -name = "tempfile" -version = "3.27.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" -dependencies = [ - "fastrand", - "getrandom 0.3.4", - "once_cell", - "rustix", - "windows-sys 0.52.0", -] - [[package]] name = "thiserror" version = "2.0.18" @@ -3495,11 +3113,7 @@ checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c" dependencies = [ "futures-util", "log", - "rustls", - "rustls-native-certs", - "rustls-pki-types", "tokio", - "tokio-rustls", "tungstenite", ] @@ -3644,8 +3258,6 @@ dependencies = [ "httparse", "log", "rand 0.9.4", - "rustls", - "rustls-pki-types", "sha1", "thiserror", ] @@ -3789,16 +3401,6 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" -[[package]] -name = "walkdir" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" -dependencies = [ - "same-file", - "winapi-util", -] - [[package]] name = "want" version = "0.3.1" @@ -3915,19 +3517,6 @@ dependencies = [ "wasmparser", ] -[[package]] -name = "wasm-streams" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" -dependencies = [ - "futures-util", - "js-sys", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", -] - [[package]] name = "wasmparser" version = "0.244.0" @@ -3988,37 +3577,6 @@ dependencies = [ "wasite", ] -[[package]] -name = "winapi" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" -dependencies = [ - "winapi-i686-pc-windows-gnu", - "winapi-x86_64-pc-windows-gnu", -] - -[[package]] -name = "winapi-i686-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" - -[[package]] -name = "winapi-util" -version = "0.1.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" -dependencies = [ - "windows-sys 0.48.0", -] - -[[package]] -name = "winapi-x86_64-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" - [[package]] name = "windows-core" version = "0.62.2" @@ -4430,35 +3988,12 @@ dependencies = [ "time", ] -[[package]] -name = "xattr" -version = "1.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" -dependencies = [ - "libc", - "rustix", -] - [[package]] name = "xxhash-rust" version = "0.8.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3" -[[package]] -name = "yaml_serde" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08c7c1b1a6a7c8a6b2741a6c21a4f8918e51899b111cfa08d1288202656e3975" -dependencies = [ - "indexmap", - "itoa", - "libyaml-rs", - "ryu", - "serde", -] - [[package]] name = "yansi" version = "1.0.1" diff --git a/lynx/Cargo.toml b/lynx/Cargo.toml index 33a3f98..cc05fce 100644 --- a/lynx/Cargo.toml +++ b/lynx/Cargo.toml @@ -1,7 +1,5 @@ [workspace] members = [ - "agent", - "translators/compose", "dashboard/server", ] resolver = "2" diff --git a/lynx/agent/.env.example b/lynx/agent/.env.example deleted file mode 100644 index 83a5e00..0000000 --- a/lynx/agent/.env.example +++ /dev/null @@ -1,15 +0,0 @@ -# Non-secret config (safe to commit) -AGENT_ID=01970000-0000-7000-8000-000000000001 -LISTEN_ADDR=0.0.0.0:9090 -RUST_LOG=debug - -# --- Dev-only env vars (never commit the real .env) ------------------------- -# Copy to .env and fill in values for local development. -# In production these come from systemd LoadCredential via *_FILE vars. - -# DATABASE_URL=postgresql://lynx_agent_app:@localhost:5434/lynx_agent -# INTERNAL_TOKEN= # openssl rand -hex 32 -# DASHBOARD_VERIFY_KEY= # Ed25519 signing pubkey — REQUIRED; -# # from setup-dashboard.sh output / dashboard UI -# DASHBOARD_URL=http://10.100.0.1:8080 # dashboard WireGuard IP -# SYNC_TOKEN= # optional — audit log sync token diff --git a/lynx/agent/.gitignore b/lynx/agent/.gitignore deleted file mode 100644 index c793a92..0000000 --- a/lynx/agent/.gitignore +++ /dev/null @@ -1,15 +0,0 @@ -# Claude / AI agent files -GAP_ANALYSIS.md - -# Coverage & profiling -*.profraw -*.profdata -tarpaulin-report.html -coverage/ - -# Fuzzing -fuzz/corpus/ -fuzz/artifacts/ - -# Merge conflicts -*.orig diff --git a/lynx/agent/.sqlx/query-00c3f8dbef816155b03792538abfc506a2580550a1f8831ba05fd782362e6b7a.json b/lynx/agent/.sqlx/query-00c3f8dbef816155b03792538abfc506a2580550a1f8831ba05fd782362e6b7a.json deleted file mode 100644 index 757a6eb..0000000 --- a/lynx/agent/.sqlx/query-00c3f8dbef816155b03792538abfc506a2580550a1f8831ba05fd782362e6b7a.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT id as \"id: Uuid\" FROM audit_log WHERE command_type = 'test.tamper.original' AND agent_id = $1", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id: Uuid", - "type_info": "Uuid" - } - ], - "parameters": { - "Left": [ - "Uuid" - ] - }, - "nullable": [ - false - ] - }, - "hash": "00c3f8dbef816155b03792538abfc506a2580550a1f8831ba05fd782362e6b7a" -} diff --git a/lynx/agent/.sqlx/query-12bcf9534dacea1a0dc2d3338f4da244b969d5e4c4589fafe6a3847252538270.json b/lynx/agent/.sqlx/query-12bcf9534dacea1a0dc2d3338f4da244b969d5e4c4589fafe6a3847252538270.json deleted file mode 100644 index 4c4f7bf..0000000 --- a/lynx/agent/.sqlx/query-12bcf9534dacea1a0dc2d3338f4da244b969d5e4c4589fafe6a3847252538270.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT chain, body, wg_port FROM nftables_state ORDER BY chain", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "chain", - "type_info": "Text" - }, - { - "ordinal": 1, - "name": "body", - "type_info": "Text" - }, - { - "ordinal": 2, - "name": "wg_port", - "type_info": "Int4" - } - ], - "parameters": { - "Left": [] - }, - "nullable": [ - false, - false, - false - ] - }, - "hash": "12bcf9534dacea1a0dc2d3338f4da244b969d5e4c4589fafe6a3847252538270" -} diff --git a/lynx/agent/.sqlx/query-1f2ceb2be0d2387d5c8e254dc6e9fcf5af91155fcd73f7ef31b1fd35e4193ed6.json b/lynx/agent/.sqlx/query-1f2ceb2be0d2387d5c8e254dc6e9fcf5af91155fcd73f7ef31b1fd35e4193ed6.json deleted file mode 100644 index 3ec18cd..0000000 --- a/lynx/agent/.sqlx/query-1f2ceb2be0d2387d5c8e254dc6e9fcf5af91155fcd73f7ef31b1fd35e4193ed6.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "UPDATE nftables_state SET wg_port = $1, updated_at = NOW()", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Int4" - ] - }, - "nullable": [] - }, - "hash": "1f2ceb2be0d2387d5c8e254dc6e9fcf5af91155fcd73f7ef31b1fd35e4193ed6" -} diff --git a/lynx/agent/.sqlx/query-3e28e1e4762466cc65827e2a360ba337e741d3980341c24f5955c3c03533081e.json b/lynx/agent/.sqlx/query-3e28e1e4762466cc65827e2a360ba337e741d3980341c24f5955c3c03533081e.json deleted file mode 100644 index 7393ec2..0000000 --- a/lynx/agent/.sqlx/query-3e28e1e4762466cc65827e2a360ba337e741d3980341c24f5955c3c03533081e.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "UPDATE nftables_state SET body = $1, wg_port = $2, updated_at = NOW() WHERE chain = $3", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Text", - "Int4", - "Text" - ] - }, - "nullable": [] - }, - "hash": "3e28e1e4762466cc65827e2a360ba337e741d3980341c24f5955c3c03533081e" -} diff --git a/lynx/agent/.sqlx/query-41064acceb10f24845763eb04486ff5350001288b446958cee795b7c22a66499.json b/lynx/agent/.sqlx/query-41064acceb10f24845763eb04486ff5350001288b446958cee795b7c22a66499.json deleted file mode 100644 index 668a2d3..0000000 --- a/lynx/agent/.sqlx/query-41064acceb10f24845763eb04486ff5350001288b446958cee795b7c22a66499.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "UPDATE sync_state SET last_synced_at = $1 WHERE id = 1", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Timestamptz" - ] - }, - "nullable": [] - }, - "hash": "41064acceb10f24845763eb04486ff5350001288b446958cee795b7c22a66499" -} diff --git a/lynx/agent/.sqlx/query-4c0ef88e55334be6c63db2fdebb707fef3d15635740b649e4aefdce73f08b212.json b/lynx/agent/.sqlx/query-4c0ef88e55334be6c63db2fdebb707fef3d15635740b649e4aefdce73f08b212.json deleted file mode 100644 index 92e59cf..0000000 --- a/lynx/agent/.sqlx/query-4c0ef88e55334be6c63db2fdebb707fef3d15635740b649e4aefdce73f08b212.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "UPDATE audit_log SET command_type = 'test.tamper.MUTATED' WHERE id = $1", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Uuid" - ] - }, - "nullable": [] - }, - "hash": "4c0ef88e55334be6c63db2fdebb707fef3d15635740b649e4aefdce73f08b212" -} diff --git a/lynx/agent/.sqlx/query-6b23aeb57c196da19bc5922fae3ba851bc1ef6c782425978c1ae9b7c538e3200.json b/lynx/agent/.sqlx/query-6b23aeb57c196da19bc5922fae3ba851bc1ef6c782425978c1ae9b7c538e3200.json deleted file mode 100644 index 1d6ab28..0000000 --- a/lynx/agent/.sqlx/query-6b23aeb57c196da19bc5922fae3ba851bc1ef6c782425978c1ae9b7c538e3200.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n INSERT INTO used_nonces (nonce) VALUES ($1)\n ON CONFLICT (nonce) DO NOTHING\n RETURNING nonce\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "nonce", - "type_info": "Text" - } - ], - "parameters": { - "Left": [ - "Text" - ] - }, - "nullable": [ - false - ] - }, - "hash": "6b23aeb57c196da19bc5922fae3ba851bc1ef6c782425978c1ae9b7c538e3200" -} diff --git a/lynx/agent/.sqlx/query-6f9d5e4ac232c5b7692b25114bb69a82cdf365bd132230c9e71356645f7e2d58.json b/lynx/agent/.sqlx/query-6f9d5e4ac232c5b7692b25114bb69a82cdf365bd132230c9e71356645f7e2d58.json deleted file mode 100644 index 28b7d54..0000000 --- a/lynx/agent/.sqlx/query-6f9d5e4ac232c5b7692b25114bb69a82cdf365bd132230c9e71356645f7e2d58.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT last_synced_at FROM sync_state WHERE id = 1", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "last_synced_at", - "type_info": "Timestamptz" - } - ], - "parameters": { - "Left": [] - }, - "nullable": [ - false - ] - }, - "hash": "6f9d5e4ac232c5b7692b25114bb69a82cdf365bd132230c9e71356645f7e2d58" -} diff --git a/lynx/agent/.sqlx/query-9090aea230f941811868a9c84b32c8d3ce35463e25ce09edb2f654857fb6c635.json b/lynx/agent/.sqlx/query-9090aea230f941811868a9c84b32c8d3ce35463e25ce09edb2f654857fb6c635.json deleted file mode 100644 index 9bb9c49..0000000 --- a/lynx/agent/.sqlx/query-9090aea230f941811868a9c84b32c8d3ce35463e25ce09edb2f654857fb6c635.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT config_content FROM nginx_configs ORDER BY updated_at DESC LIMIT 1", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "config_content", - "type_info": "Text" - } - ], - "parameters": { - "Left": [] - }, - "nullable": [ - false - ] - }, - "hash": "9090aea230f941811868a9c84b32c8d3ce35463e25ce09edb2f654857fb6c635" -} diff --git a/lynx/agent/.sqlx/query-94c1a847466d0f3d6e710940729b642f7edd695e6ec47660ec606eb743cd7b91.json b/lynx/agent/.sqlx/query-94c1a847466d0f3d6e710940729b642f7edd695e6ec47660ec606eb743cd7b91.json deleted file mode 100644 index 793916a..0000000 --- a/lynx/agent/.sqlx/query-94c1a847466d0f3d6e710940729b642f7edd695e6ec47660ec606eb743cd7b91.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "DELETE FROM used_nonces WHERE created_at < NOW() - INTERVAL '5 minutes'", - "describe": { - "columns": [], - "parameters": { - "Left": [] - }, - "nullable": [] - }, - "hash": "94c1a847466d0f3d6e710940729b642f7edd695e6ec47660ec606eb743cd7b91" -} diff --git a/lynx/agent/.sqlx/query-99ea67380017be816434150f214c21da98a5e10688c707f6573a892c4fb94461.json b/lynx/agent/.sqlx/query-99ea67380017be816434150f214c21da98a5e10688c707f6573a892c4fb94461.json deleted file mode 100644 index 7fc6a40..0000000 --- a/lynx/agent/.sqlx/query-99ea67380017be816434150f214c21da98a5e10688c707f6573a892c4fb94461.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "TRUNCATE audit_log", - "describe": { - "columns": [], - "parameters": { - "Left": [] - }, - "nullable": [] - }, - "hash": "99ea67380017be816434150f214c21da98a5e10688c707f6573a892c4fb94461" -} diff --git a/lynx/agent/.sqlx/query-a7ec522154154d28ebcf37d939d02397a0b9462183a04664943a183dde98b13b.json b/lynx/agent/.sqlx/query-a7ec522154154d28ebcf37d939d02397a0b9462183a04664943a183dde98b13b.json deleted file mode 100644 index a636f58..0000000 --- a/lynx/agent/.sqlx/query-a7ec522154154d28ebcf37d939d02397a0b9462183a04664943a183dde98b13b.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT previous_hash, entry_hash FROM audit_log WHERE agent_id = $1 ORDER BY created_at DESC LIMIT 1", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "previous_hash", - "type_info": "Text" - }, - { - "ordinal": 1, - "name": "entry_hash", - "type_info": "Text" - } - ], - "parameters": { - "Left": [ - "Uuid" - ] - }, - "nullable": [ - false, - false - ] - }, - "hash": "a7ec522154154d28ebcf37d939d02397a0b9462183a04664943a183dde98b13b" -} diff --git a/lynx/agent/.sqlx/query-b48607b49c20e23f376065a682ab2b09e209f3d286b71130b3f2ece50c5fb78f.json b/lynx/agent/.sqlx/query-b48607b49c20e23f376065a682ab2b09e209f3d286b71130b3f2ece50c5fb78f.json deleted file mode 100644 index b3a6df0..0000000 --- a/lynx/agent/.sqlx/query-b48607b49c20e23f376065a682ab2b09e209f3d286b71130b3f2ece50c5fb78f.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT entry_hash FROM audit_log ORDER BY created_at DESC LIMIT 1", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "entry_hash", - "type_info": "Text" - } - ], - "parameters": { - "Left": [] - }, - "nullable": [ - false - ] - }, - "hash": "b48607b49c20e23f376065a682ab2b09e209f3d286b71130b3f2ece50c5fb78f" -} diff --git a/lynx/agent/.sqlx/query-b90f0087cb54a980e483949206cf8a20a60cdddf3517b5b60e27cd9821d02f3e.json b/lynx/agent/.sqlx/query-b90f0087cb54a980e483949206cf8a20a60cdddf3517b5b60e27cd9821d02f3e.json deleted file mode 100644 index 1402b7f..0000000 --- a/lynx/agent/.sqlx/query-b90f0087cb54a980e483949206cf8a20a60cdddf3517b5b60e27cd9821d02f3e.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n INSERT INTO audit_log\n (id, agent_id, organization_id, user_id, command_type, result, error,\n previous_hash, entry_hash)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Uuid", - "Uuid", - "Uuid", - "Uuid", - "Text", - "Text", - "Text", - "Text", - "Text" - ] - }, - "nullable": [] - }, - "hash": "b90f0087cb54a980e483949206cf8a20a60cdddf3517b5b60e27cd9821d02f3e" -} diff --git a/lynx/agent/.sqlx/query-cb1c7d7a0e9bcda713409a72b85401765b8dab095e4f94f3e4c44156338a3a4a.json b/lynx/agent/.sqlx/query-cb1c7d7a0e9bcda713409a72b85401765b8dab095e4f94f3e4c44156338a3a4a.json deleted file mode 100644 index ab4a35d..0000000 --- a/lynx/agent/.sqlx/query-cb1c7d7a0e9bcda713409a72b85401765b8dab095e4f94f3e4c44156338a3a4a.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "DELETE FROM nginx_configs WHERE id != (SELECT id FROM nginx_configs ORDER BY updated_at DESC LIMIT 1)", - "describe": { - "columns": [], - "parameters": { - "Left": [] - }, - "nullable": [] - }, - "hash": "cb1c7d7a0e9bcda713409a72b85401765b8dab095e4f94f3e4c44156338a3a4a" -} diff --git a/lynx/agent/.sqlx/query-d98c209b07244d0e9ff0be3653026ef23efb0b13214f6e5c7f0423f79142bb6b.json b/lynx/agent/.sqlx/query-d98c209b07244d0e9ff0be3653026ef23efb0b13214f6e5c7f0423f79142bb6b.json deleted file mode 100644 index ab3010e..0000000 --- a/lynx/agent/.sqlx/query-d98c209b07244d0e9ff0be3653026ef23efb0b13214f6e5c7f0423f79142bb6b.json +++ /dev/null @@ -1,77 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT id, agent_id, organization_id, user_id, command_type,\n result, error, previous_hash, entry_hash, created_at\n FROM audit_log\n WHERE created_at > $1\n ORDER BY created_at ASC\n LIMIT $2\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Uuid" - }, - { - "ordinal": 1, - "name": "agent_id", - "type_info": "Uuid" - }, - { - "ordinal": 2, - "name": "organization_id", - "type_info": "Uuid" - }, - { - "ordinal": 3, - "name": "user_id", - "type_info": "Uuid" - }, - { - "ordinal": 4, - "name": "command_type", - "type_info": "Text" - }, - { - "ordinal": 5, - "name": "result", - "type_info": "Text" - }, - { - "ordinal": 6, - "name": "error", - "type_info": "Text" - }, - { - "ordinal": 7, - "name": "previous_hash", - "type_info": "Text" - }, - { - "ordinal": 8, - "name": "entry_hash", - "type_info": "Text" - }, - { - "ordinal": 9, - "name": "created_at", - "type_info": "Timestamptz" - } - ], - "parameters": { - "Left": [ - "Timestamptz", - "Int8" - ] - }, - "nullable": [ - false, - false, - true, - true, - false, - false, - true, - false, - false, - false - ] - }, - "hash": "d98c209b07244d0e9ff0be3653026ef23efb0b13214f6e5c7f0423f79142bb6b" -} diff --git a/lynx/agent/.sqlx/query-e0ece2be8df9040927c3ddaaf9f410f8f4f2141eed2087e6f70ef02f9dcce006.json b/lynx/agent/.sqlx/query-e0ece2be8df9040927c3ddaaf9f410f8f4f2141eed2087e6f70ef02f9dcce006.json deleted file mode 100644 index 07d92ee..0000000 --- a/lynx/agent/.sqlx/query-e0ece2be8df9040927c3ddaaf9f410f8f4f2141eed2087e6f70ef02f9dcce006.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO nginx_configs (id, config_content, updated_at) VALUES ($1, $2, NOW())\n ON CONFLICT DO NOTHING", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Uuid", - "Text" - ] - }, - "nullable": [] - }, - "hash": "e0ece2be8df9040927c3ddaaf9f410f8f4f2141eed2087e6f70ef02f9dcce006" -} diff --git a/lynx/agent/.sqlx/query-e6303fa3c8c58b4ba15abe6147704834ddf2b190948d7671c85bd383f49bfbb5.json b/lynx/agent/.sqlx/query-e6303fa3c8c58b4ba15abe6147704834ddf2b190948d7671c85bd383f49bfbb5.json deleted file mode 100644 index 42558df..0000000 --- a/lynx/agent/.sqlx/query-e6303fa3c8c58b4ba15abe6147704834ddf2b190948d7671c85bd383f49bfbb5.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT previous_hash FROM audit_log WHERE agent_id = $1 AND command_type = 'test.link2' ORDER BY created_at DESC LIMIT 1", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "previous_hash", - "type_info": "Text" - } - ], - "parameters": { - "Left": [ - "Uuid" - ] - }, - "nullable": [ - false - ] - }, - "hash": "e6303fa3c8c58b4ba15abe6147704834ddf2b190948d7671c85bd383f49bfbb5" -} diff --git a/lynx/agent/.sqlx/query-ff5b6781f0fd181c7a6acc923513dc98af2d4233da678195c197658a8b0b0109.json b/lynx/agent/.sqlx/query-ff5b6781f0fd181c7a6acc923513dc98af2d4233da678195c197658a8b0b0109.json deleted file mode 100644 index d5ab8c7..0000000 --- a/lynx/agent/.sqlx/query-ff5b6781f0fd181c7a6acc923513dc98af2d4233da678195c197658a8b0b0109.json +++ /dev/null @@ -1,68 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT entry_hash, previous_hash,\n id as \"id: Uuid\",\n agent_id as \"agent_id: Uuid\",\n organization_id as \"organization_id: Uuid\",\n user_id as \"user_id: Uuid\",\n command_type, result, error\n FROM audit_log ORDER BY created_at DESC LIMIT 1", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "entry_hash", - "type_info": "Text" - }, - { - "ordinal": 1, - "name": "previous_hash", - "type_info": "Text" - }, - { - "ordinal": 2, - "name": "id: Uuid", - "type_info": "Uuid" - }, - { - "ordinal": 3, - "name": "agent_id: Uuid", - "type_info": "Uuid" - }, - { - "ordinal": 4, - "name": "organization_id: Uuid", - "type_info": "Uuid" - }, - { - "ordinal": 5, - "name": "user_id: Uuid", - "type_info": "Uuid" - }, - { - "ordinal": 6, - "name": "command_type", - "type_info": "Text" - }, - { - "ordinal": 7, - "name": "result", - "type_info": "Text" - }, - { - "ordinal": 8, - "name": "error", - "type_info": "Text" - } - ], - "parameters": { - "Left": [] - }, - "nullable": [ - false, - false, - false, - false, - true, - true, - false, - false, - true - ] - }, - "hash": "ff5b6781f0fd181c7a6acc923513dc98af2d4233da678195c197658a8b0b0109" -} diff --git a/lynx/agent/Cargo.toml b/lynx/agent/Cargo.toml deleted file mode 100644 index 5803398..0000000 --- a/lynx/agent/Cargo.toml +++ /dev/null @@ -1,55 +0,0 @@ -[package] -name = "lynx-agent" -version = "1.2.8" -edition = "2021" - -[[bin]] -name = "lynx-agent" -path = "src/main.rs" - -[dependencies] -tokio = { workspace = true } -clap = { workspace = true } -serde = { workspace = true } -serde_json = { workspace = true } -thiserror = { workspace = true } -anyhow = { workspace = true } -tracing = { workspace = true } -tracing-subscriber = { workspace = true } -axum = { workspace = true } -hyper = { workspace = true } -hyper-util = { workspace = true } -tower = { workspace = true } -tower-http = { workspace = true } - -# database -sqlx = { workspace = true } - -# crypto — command authorization & audit log -ed25519-dalek = { workspace = true } -sha2 = { workspace = true } -hex = "0.4" -base64ct = { workspace = true } -subtle = { workspace = true } -zeroize = { workspace = true } -rand = { workspace = true } -rcgen = { workspace = true } -rustls = { workspace = true } -tokio-rustls = { workspace = true } - -# HTTP client (audit log sync to dashboard) -reqwest = { version = "0.12", features = ["json", "stream", "rustls-tls"], default-features = false } -url = { workspace = true } - -# WebSocket client (agent → dashboard persistent connection) -tokio-tungstenite = { workspace = true } -futures-util = { workspace = true } - -# time -chrono = { workspace = true } -uuid = { workspace = true } - -# system interaction -nix = { version = "0.31", features = ["process", "signal", "fs"] } - -lynx-compose = { path = "../translators/compose" } diff --git a/lynx/agent/migrations/001_init.sql b/lynx/agent/migrations/001_init.sql deleted file mode 100644 index 68795d4..0000000 --- a/lynx/agent/migrations/001_init.sql +++ /dev/null @@ -1,26 +0,0 @@ --- Agent-local PostgreSQL schema - -CREATE TABLE audit_log ( - id UUID PRIMARY KEY, - agent_id UUID NOT NULL, - organization_id UUID, - user_id UUID, - command_type TEXT NOT NULL, - result TEXT NOT NULL CHECK (result IN ('success', 'rejected', 'failed')), - error TEXT, - previous_hash TEXT NOT NULL, - entry_hash TEXT NOT NULL, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - -CREATE INDEX idx_audit_log_agent_id ON audit_log(agent_id); -CREATE INDEX idx_audit_log_created_at ON audit_log(created_at); - --- Replay protection: Ed25519 command nonces seen in last 60s -CREATE TABLE used_nonces ( - nonce TEXT PRIMARY KEY, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - --- Auto-expire nonces older than 60 seconds (cleaned by agent on startup + periodic) -CREATE INDEX idx_used_nonces_created_at ON used_nonces(created_at); diff --git a/lynx/agent/migrations/002_sync_cursor.sql b/lynx/agent/migrations/002_sync_cursor.sql deleted file mode 100644 index 1617438..0000000 --- a/lynx/agent/migrations/002_sync_cursor.sql +++ /dev/null @@ -1,9 +0,0 @@ --- Tracks which audit_log entries have been synced to the dashboard. --- Simpler than adding a column to audit_log: a single-row cursor table. - -CREATE TABLE sync_state ( - id INTEGER PRIMARY KEY DEFAULT 1 CHECK (id = 1), -- singleton - last_synced_at TIMESTAMPTZ NOT NULL DEFAULT 'epoch' -); - -INSERT INTO sync_state (last_synced_at) VALUES ('epoch'); diff --git a/lynx/agent/migrations/003_nginx_configs.sql b/lynx/agent/migrations/003_nginx_configs.sql deleted file mode 100644 index 672000f..0000000 --- a/lynx/agent/migrations/003_nginx_configs.sql +++ /dev/null @@ -1,5 +0,0 @@ -CREATE TABLE nginx_configs ( - id UUID PRIMARY KEY DEFAULT uuidv7(), - config_content TEXT NOT NULL, - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); diff --git a/lynx/agent/migrations/004_nftables_state.sql b/lynx/agent/migrations/004_nftables_state.sql deleted file mode 100644 index 5482b21..0000000 --- a/lynx/agent/migrations/004_nftables_state.sql +++ /dev/null @@ -1,14 +0,0 @@ --- Persists the last-applied nftables chain bodies so the agent can --- re-apply rules after reboot without waiting for a dashboard push. -CREATE TABLE nftables_state ( - chain TEXT PRIMARY KEY CHECK (chain IN ('lynx-global', 'lynx-local')), - body TEXT NOT NULL DEFAULT '', - wg_port INTEGER NOT NULL DEFAULT 51820, - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - --- Seed default empty bodies so the rows always exist. -INSERT INTO nftables_state (chain, body, wg_port) VALUES - ('lynx-global', '', 51820), - ('lynx-local', '', 51820) -ON CONFLICT DO NOTHING; diff --git a/lynx/agent/migrations/005_nftables_output.sql b/lynx/agent/migrations/005_nftables_output.sql deleted file mode 100644 index 0a09f96..0000000 --- a/lynx/agent/migrations/005_nftables_output.sql +++ /dev/null @@ -1,11 +0,0 @@ --- Add output chain state entries so agent can persist and restore output rules across reboots. - --- Extend the check constraint to allow the two output-chain variants. -ALTER TABLE nftables_state DROP CONSTRAINT nftables_state_chain_check; -ALTER TABLE nftables_state ADD CONSTRAINT nftables_state_chain_check - CHECK (chain IN ('lynx-global', 'lynx-local', 'lynx-global-output', 'lynx-local-output')); - -INSERT INTO nftables_state (chain, body, wg_port) VALUES - ('lynx-global-output', '', 51820), - ('lynx-local-output', '', 51820) -ON CONFLICT DO NOTHING; diff --git a/lynx/agent/migrations/006_fix_uuid_default.sql b/lynx/agent/migrations/006_fix_uuid_default.sql deleted file mode 100644 index 413325d..0000000 --- a/lynx/agent/migrations/006_fix_uuid_default.sql +++ /dev/null @@ -1,3 +0,0 @@ --- Fix UUID column default: gen_random_uuid() generates v4; project requires v7. --- PostgreSQL 18 provides uuidv7() built-in. -ALTER TABLE nginx_configs ALTER COLUMN id SET DEFAULT uuidv7(); diff --git a/lynx/agent/migrations/007_container_deployments.sql b/lynx/agent/migrations/007_container_deployments.sql deleted file mode 100644 index 7e05f68..0000000 --- a/lynx/agent/migrations/007_container_deployments.sql +++ /dev/null @@ -1,15 +0,0 @@ --- Track compose deployments so the agent can restart containers on reboot. --- Each row is one project deployment with its desired state. - -CREATE TABLE container_deployments ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - tenant_id TEXT NOT NULL, - project_id TEXT NOT NULL, - compose_path TEXT NOT NULL, - desired TEXT NOT NULL CHECK (desired IN ('running', 'stopped')), - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - UNIQUE (tenant_id, project_id) -); - -CREATE INDEX idx_container_deployments_desired ON container_deployments(desired); diff --git a/lynx/agent/migrations/008_fix_container_deployments_uuid_default.sql b/lynx/agent/migrations/008_fix_container_deployments_uuid_default.sql deleted file mode 100644 index f37c66b..0000000 --- a/lynx/agent/migrations/008_fix_container_deployments_uuid_default.sql +++ /dev/null @@ -1,4 +0,0 @@ --- Fix UUID default for container_deployments — original migration 007 used --- gen_random_uuid() (UUID v4) which violates the project-wide UUID v7 rule. --- PostgreSQL 18 provides uuidv7() natively. -ALTER TABLE container_deployments ALTER COLUMN id SET DEFAULT uuidv7(); diff --git a/lynx/agent/setup-agent.sh b/lynx/agent/setup-agent.sh deleted file mode 100644 index 1206289..0000000 --- a/lynx/agent/setup-agent.sh +++ /dev/null @@ -1,1240 +0,0 @@ -#!/usr/bin/env bash -# ----------------------------------------------------------------------------- -# setup-agent.sh — Lynx Agent install script -# -# Description: -# Installs the Lynx Agent on a VPS. Sets up: -# - System user: lynx-agent (privileged, not a login shell) -# - subuid/subgid ranges for rootless Podman tenant isolation -# - PostgreSQL container (via podman run, lynx-agent-db network) -# - lynx-agent binary as a systemd service with required capabilities -# - WireGuard tunnel to the Lynx Dashboard -# - nftables: allows only WireGuard inbound, blocks everything else -# -# Usage: -# sudo ./setup-agent.sh -# -# Requirements: -# - Debian/Ubuntu or RHEL-based Linux (amd64 / arm64) -# - Run as root -# - Dashboard WireGuard pubkey and PSK (shown at dashboard install completion) -# ----------------------------------------------------------------------------- - -set -euo pipefail - -# --- Colors ----------------------------------------------------------------- - -RED='\033[0;31m' -YELLOW='\033[1;33m' -GREEN='\033[0;32m' -CYAN='\033[0;36m' -BOLD='\033[1m' -RESET='\033[0m' - -# --- Logging ---------------------------------------------------------------- - -log_info() { echo -e "${CYAN}[INFO]${RESET} $*"; } -log_ok() { echo -e "${GREEN}[OK]${RESET} $*"; } -log_warn() { echo -e "${YELLOW}[WARN]${RESET} $*"; } -log_error() { echo -e "${RED}[ERROR]${RESET} $*" >&2; } -log_section() { echo -e "\n${BOLD}${CYAN}=== $* ===${RESET}"; } - -# --- Constants -------------------------------------------------------------- - -LYNX_DIR="/etc/lynx" -AGENT_CONF="$LYNX_DIR/agent.env" -LYNX_WG_DIR="$LYNX_DIR/wireguard" -LYNX_WG_CONF="$LYNX_WG_DIR/lynx-wg.conf" # source of truth (spec path) -WG_DIR="/etc/wireguard" -WG_CONF_LINK="$WG_DIR/wg-lynx-agent.conf" # symlink for wg-quick compatibility -WG_IFACE="wg-lynx-agent" -AGENT_WG_IP="" # set from dashboard-assigned IP during onboarding prompt -DASHBOARD_WG_IP="10.100.0.1" -WG_PORT=51820 -AGENT_PORT=9090 -LYNX_AGENT_USER="lynx-agent" -PG_NETWORK="lynx-agent-db" -PG_CONTAINER="lynx-agent-postgres" -PG_IMAGE="docker.io/percona/percona-distribution-postgresql@sha256:71cce6ed329d4108461eeaa40fb0c1517bee2e0f78051cee40a4b010eed448c3" -PG_DB="lynx_agent" -PG_SUBNET="172.20.100.0/24" -PG_STATIC_IP="172.20.100.2" # Fixed IP — agent binary (root) connects directly, no host port mapping -# Agent UUID v7 — generated on first install, persists across updates -AGENT_ID="" - -BIN_DIR="/etc/lynx/bin" -BINARY_PATH="$BIN_DIR/lynx-agent" - -# --- Root check ------------------------------------------------------------- - -if [[ $EUID -ne 0 ]]; then - log_error "Must run as root: sudo $0" - exit 1 -fi - -# --- Cleanup function ------------------------------------------------------- - -_cleanup_existing() { - log_section "Removing existing agent installation" - - systemctl disable --now lynx-agent.service 2>/dev/null || true - systemctl disable --now lynx-agent-postgres.service 2>/dev/null || true - rm -f /etc/systemd/system/lynx-agent-postgres.service - - # Remove WireGuard - if ip link show "$WG_IFACE" &>/dev/null; then - wg-quick down "$WG_IFACE" 2>/dev/null || ip link delete "$WG_IFACE" 2>/dev/null || true - fi - rm -f "$WG_CONF_LINK" "$LYNX_WG_CONF" - - # Remove PostgreSQL container + data. - # Use stop+rm (not rm -f) so netavark tears down iptables port-forwarding rules - # before the network is removed. Force removal skips this cleanup and leaves - # stale DNAT rules that silently capture traffic for the next install. - podman stop --time 10 "$PG_CONTAINER" 2>/dev/null || true - podman rm "$PG_CONTAINER" 2>/dev/null || true - podman volume rm lynx-agent-pg-data 2>/dev/null || true - podman network rm "$PG_NETWORK" 2>/dev/null || true - - # Remove Podman secrets - for s in lynx-agent-pg-root lynx-agent-pg-pass lynx-agent-internal-token lynx-agent-database-url; do - podman secret rm "$s" 2>/dev/null || true - done - - # Remove systemd units - rm -f /etc/systemd/system/lynx-agent.service - systemctl daemon-reload - - # Remove user (tenant users cleaned separately) - userdel -r "$LYNX_AGENT_USER" 2>/dev/null || true - - # Remove nftables table - nft delete table inet lynx-agent 2>/dev/null || true - rm -f /etc/nftables-lynx-agent.conf - - # /etc/lynx is shared with the dashboard on co-located VPSes. - # Preserve files that belong to the dashboard so the dashboard containers - # are not disrupted by an agent reinstall. The agent-specific content is - # removed explicitly; the shared directory is removed only if empty. - _SAVED_DASH_SIGN_PUBKEY="" - [[ -r "$LYNX_DIR/dashboard-sign-pubkey" ]] && _SAVED_DASH_SIGN_PUBKEY=$(< "$LYNX_DIR/dashboard-sign-pubkey") - - rm -rf "$LYNX_WG_DIR" "$LYNX_DIR/credentials" - rm -f "$AGENT_CONF" "$LYNX_DIR/agent-id" - rm -f "$BIN_DIR/lynx-agent" "$BIN_DIR/lynx-agent.prev" "$BIN_DIR/lynx-agent-version" - rmdir "$BIN_DIR" "$LYNX_DIR" 2>/dev/null || true - - if [[ -n "$_SAVED_DASH_SIGN_PUBKEY" ]]; then - mkdir -p "$LYNX_DIR" - printf '%s' "$_SAVED_DASH_SIGN_PUBKEY" > "$LYNX_DIR/dashboard-sign-pubkey" - chmod 644 "$LYNX_DIR/dashboard-sign-pubkey" - fi - unset _SAVED_DASH_SIGN_PUBKEY - - log_ok "Cleanup complete" -} - -# --- RAM check -------------------------------------------------------------- - -log_section "Checking system resources" - -TOTAL_RAM_MB=$(free -m | awk '/^Mem:/{print $2}') -if [[ "$TOTAL_RAM_MB" -lt 512 ]]; then - log_error "Insufficient RAM: ${TOTAL_RAM_MB} MB detected, minimum 512 MB required" - log_info "Lynx Agent requires at least 512 MB RAM for the local PostgreSQL container" - exit 1 -fi -log_ok "RAM: ${TOTAL_RAM_MB} MB (minimum 512 MB satisfied)" - -# Disk pre-check (§1.4) — PostgreSQL image, agent binary, lynx-compose and -# org/container data easily exceed 2 GB; bail out early instead of failing mid- -# install when a `pull` or container start exhausts the volume. -FREE_DISK_MB=$(df -BM --output=avail / 2>/dev/null | tail -1 | tr -dc '0-9') -if [[ -z "$FREE_DISK_MB" ]] || [[ "$FREE_DISK_MB" -lt 2048 ]]; then - log_error "Insufficient disk: ${FREE_DISK_MB:-0} MB free on /, minimum 2048 MB required" - log_info "Free up space (e.g. \`podman system prune -a\`) and re-run." - exit 1 -fi -log_ok "Disk: ${FREE_DISK_MB} MB free on / (minimum 2048 MB satisfied)" - -# --- Detect existing installation ------------------------------------------- - -log_section "Checking for existing installation" - -existing=false -# Check for agent-specific markers only — /etc/lynx is shared with the dashboard -# on VPSes that host both. /etc/lynx alone does not mean the agent is installed. -if id "$LYNX_AGENT_USER" &>/dev/null || \ - systemctl list-unit-files lynx-agent.service 2>/dev/null | grep -q lynx-agent || \ - [[ -f "$AGENT_CONF" ]] || podman container exists "$PG_CONTAINER" 2>/dev/null; then - existing=true -fi - -if $existing; then - log_warn "Existing agent installation detected." - echo "" - echo -e " ${BOLD}1)${RESET} Abort (default)" - echo -e " ${BOLD}2)${RESET} Update → updates binary, preserves all data" - echo -e " ${BOLD}3)${RESET} Reinstall clean → destroys all agent data" - echo "" - read -rp "Choice [1/2/3]: " choice - choice="${choice:-1}" - - case "$choice" in - 2) - log_info "Redirecting to update..." - exec "$(dirname "${BASH_SOURCE[0]:-}")/update-agent.sh" - ;; - 3) - echo "" - log_warn "This will permanently destroy all agent data on this machine." - read -rp "Type 'reinstall lynx-agent' to confirm: " confirm - if [[ "$confirm" != "reinstall lynx-agent" ]]; then - log_error "Confirmation phrase mismatch. Aborting." - exit 1 - fi - # Preserve agent ID across reinstalls — dashboard still has the old one registered - _SAVED_AGENT_ID="" - if [[ -f "$LYNX_DIR/agent-id" ]]; then - _SAVED_AGENT_ID=$(cat "$LYNX_DIR/agent-id") - log_info "Preserving Agent ID for reinstall: $_SAVED_AGENT_ID" - fi - _cleanup_existing - ;; - *) - log_info "Aborting. No changes made." - exit 0 - ;; - esac -fi - -# --- Collect dashboard bootstrap data --------------------------------------- -# Prompt after existing-installation decision — before anything that may consume -# stdin (systemctl, userdel, package installs with debconf Teletype fallback, -# podman, wg-quick, etc.). -# Dashboard-sign-pubkey is auto-detected here; it is preserved across agent -# reinstall by _cleanup_existing so the local-agent default still works. - -log_section "Dashboard connection setup" - -echo "" -echo -e "${YELLOW}You need the values shown when you registered this VPS in the dashboard.${RESET}" -echo "" - -read -rp " Dashboard WireGuard endpoint (IP:PORT, e.g. 1.2.3.4:51820): " DASHBOARD_ENDPOINT -read -rp " Dashboard WireGuard public key: " DASHBOARD_PUBKEY -read -rsp " Preshared key (PSK): " PSK -echo "" -read -rp " Agent WireGuard IP assigned by dashboard (e.g. 10.100.0.3): " AGENT_WG_IP_INPUT -echo "" -read -rsp " Sync token (shown once when registering this VPS in the dashboard): " SYNC_TOKEN -echo "" - -# Dashboard Ed25519 signing public key — required for the agent to verify -# every dashboard-signed command (heartbeat ACK, container ops, nftables push, -# update.self, ...). Without it the agent rejects every command and enters -# lockdown after the 5-minute heartbeat timeout. -# -# Local-agent path: if running on the same host as the dashboard, the install -# script already wrote it to /etc/lynx/dashboard-sign-pubkey — auto-detect that -# default to avoid an unnecessary prompt. -DEFAULT_DASHBOARD_SIGN_PUBKEY="" -if [[ -r /etc/lynx/dashboard-sign-pubkey ]]; then - DEFAULT_DASHBOARD_SIGN_PUBKEY=$(< /etc/lynx/dashboard-sign-pubkey) -fi -if [[ -n "$DEFAULT_DASHBOARD_SIGN_PUBKEY" ]]; then - read -rp " Dashboard signing public key (Ed25519, base64) [default: detected]: " DASHBOARD_SIGN_PUBKEY - DASHBOARD_SIGN_PUBKEY="${DASHBOARD_SIGN_PUBKEY:-$DEFAULT_DASHBOARD_SIGN_PUBKEY}" -else - read -rp " Dashboard signing public key (Ed25519, base64): " DASHBOARD_SIGN_PUBKEY -fi -unset DEFAULT_DASHBOARD_SIGN_PUBKEY -echo "" - -if [[ -z "$DASHBOARD_ENDPOINT" || -z "$DASHBOARD_PUBKEY" || -z "$PSK" || -z "$AGENT_WG_IP_INPUT" || -z "$DASHBOARD_SIGN_PUBKEY" || -z "$SYNC_TOKEN" ]]; then - log_error "All six values are required (endpoint, WG pubkey, PSK, agent WG IP, dashboard signing pubkey, sync token)." - exit 1 -fi - -AGENT_WG_IP="$AGENT_WG_IP_INPUT" - -# --- Incompatible software -------------------------------------------------- - -log_section "Checking for incompatible software" - -log_info "Lynx uses Podman for containers and nftables for firewall." -log_info "The following software is incompatible and will be removed if found:" -log_info " Docker, containerd (standalone), firewalld, ufw, iptables (legacy)" -log_info "Reason: these programs add their own firewall/network rules outside" -log_info " table inet lynx-agent, silently exposing ports Lynx considers closed." - -_detect_distro() { - if command -v apt-get &>/dev/null; then echo "debian" - elif command -v dnf &>/dev/null; then echo "rhel" - elif command -v yum &>/dev/null; then echo "rhel" - else echo "unknown" - fi -} - -DISTRO=$(_detect_distro) - -_pkg_installed() { - local pkg="$1" - case "$DISTRO" in - debian) dpkg -l "$pkg" 2>/dev/null | grep -q '^ii' ;; - rhel) rpm -q "$pkg" &>/dev/null ;; - *) return 1 ;; - esac -} - -_remove_pkg() { - local pkg="$1" reason="$2" - log_warn "Removing incompatible package: ${pkg}" - log_info " Reason: ${reason}" - case "$DISTRO" in - debian) apt-get purge -y "$pkg" 2>/dev/null || true ;; - rhel) { dnf remove -y "$pkg" 2>/dev/null || yum remove -y "$pkg" 2>/dev/null; } || true ;; - *) log_warn "Unknown distro — remove ${pkg} manually before continuing" ;; - esac - log_ok "Removed: $pkg" -} - -_incompatible_found=false - -_check_remove() { - local pkg="$1" reason="$2" - if _pkg_installed "$pkg"; then - _incompatible_found=true - _remove_pkg "$pkg" "$reason" - fi -} - -_REASON_DOCKER="manages own container network and firewall, bypasses lynx-agent nftables" -_REASON_CTR="manages own container network, conflicts with Podman network isolation" -_REASON_FW="manages own firewall rules outside table inet lynx-agent" - -for pkg in docker-ce docker-ce-cli docker.io docker-compose-plugin moby-engine; do - _check_remove "$pkg" "$_REASON_DOCKER" -done - -for pkg in containerd containerd.io; do - _check_remove "$pkg" "$_REASON_CTR" -done - -_check_remove firewalld "$_REASON_FW" -_check_remove ufw "$_REASON_FW" - -# iptables package must NOT be removed — netavark 1.15.2 still calls the iptables -# binary internally even when firewall_driver = nftables is configured. On Ubuntu -# 24.04+ the 'iptables' package is actually iptables-nft which routes all calls -# through nftables; no legacy kernel module is involved. What is incompatible is -# software that *manages* iptables rules (Docker, ufw, firewalld), not the binary. - -if $_incompatible_found; then - # Delete all nftables tables created by Docker / ufw / iptables-nft. - # On Ubuntu 24.04+, iptables is iptables-nft — its tables live in nftables - # ip/ip6 families. Delete them all so only table inet lynx-agent remains. - for _nft_table in \ - "ip filter" "ip nat" "ip mangle" "ip raw" "ip security" \ - "ip6 filter" "ip6 nat" "ip6 mangle" "ip6 raw" "ip6 security" \ - "bridge filter" "arp filter"; do - nft delete table "$_nft_table" 2>/dev/null || true - done - # Legacy iptables kernel module cleanup — only present on older distros, - # not on Ubuntu 24.04+. nft cannot reach legacy xtables tables. - for _ipt in iptables-legacy ip6tables-legacy; do - if command -v "$_ipt" &>/dev/null; then - "$_ipt" -P INPUT ACCEPT 2>/dev/null || true - "$_ipt" -P FORWARD ACCEPT 2>/dev/null || true - "$_ipt" -P OUTPUT ACCEPT 2>/dev/null || true - "$_ipt" -F 2>/dev/null || true - "$_ipt" -X 2>/dev/null || true - "$_ipt" -t nat -F 2>/dev/null || true - "$_ipt" -t nat -X 2>/dev/null || true - "$_ipt" -t mangle -F 2>/dev/null || true - "$_ipt" -t mangle -X 2>/dev/null || true - fi - done - log_ok "Incompatible software removed — residual firewall rules cleared" -else - log_ok "No incompatible software found" -fi - -unset _REASON_DOCKER _REASON_CTR _REASON_FW - -# --- DNS preflight check ---------------------------------------------------- - -log_section "Checking network connectivity" - -if ! getent hosts archive.ubuntu.com &>/dev/null && ! getent hosts packages.fedoraproject.org &>/dev/null; then - log_warn "DNS resolution failing — attempting to fix..." - rm -f /etc/resolv.conf - echo 'nameserver 8.8.8.8' > /etc/resolv.conf - if ! getent hosts archive.ubuntu.com &>/dev/null 2>&1; then - log_error "DNS resolution is unavailable. Please fix your network configuration and retry." - exit 1 - fi - log_ok "DNS resolution restored (set nameserver to 8.8.8.8)" -else - log_ok "DNS resolution working" -fi - -# --- Install dependencies --------------------------------------------------- - -log_section "Checking system dependencies" - -_apt_updated=false -_apt_ensure() { - local cmd="$1" pkg="$2" - if command -v "$cmd" &>/dev/null; then - log_ok "$cmd found" - return - fi - log_info "Installing $pkg..." - if ! $_apt_updated; then - if command -v add-apt-repository &>/dev/null; then - add-apt-repository -y universe &>/dev/null || true - fi - DEBIAN_FRONTEND=noninteractive apt-get update -qq - _apt_updated=true - fi - DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends "$pkg" -qq - if command -v "$cmd" &>/dev/null; then - log_ok "$cmd installed" - else - log_error "Failed to install $pkg (command: $cmd)" - exit 1 - fi -} - -_require_cmd() { - if ! command -v "$1" &>/dev/null; then - log_error "Required command not found: $1 — $2" - exit 1 - fi - log_ok "$1 found" -} - -# Podman: use Ubuntu 24.04 noble-updates package (4.9.3+). The kubic/libcontainers -# upstream repo does not publish packages for Ubuntu 24.04 yet. When an official -# upstream repo with a verifiable GPG fingerprint becomes available for noble, -# replace this with repo-pinned install + fingerprint check. -_apt_ensure podman podman -# openssl replaced by `lynx-agent` subcommands for random/keypair ops. -_apt_ensure nft nftables -_apt_ensure wg wireguard-tools -_apt_ensure curl curl -_apt_ensure python3 python3 -# python3-cryptography is the bootstrap Ed25519 verifier; installed below only -# when missing. Drop python3-pip from the dependency set entirely. -# newuidmap/newgidmap: required for rootless Podman user namespaces -_apt_ensure newuidmap uidmap -# slirp4netns: required for rootless Podman networking (user-space TCP/IP stack) -_apt_ensure slirp4netns slirp4netns -_require_cmd systemctl "systemd required" -_require_cmd free "procps required" - -for _pkg in netavark aardvark-dns; do - if ! dpkg -l "$_pkg" 2>/dev/null | grep -q '^ii'; then - log_info "Installing $_pkg..." - if ! $_apt_updated; then - DEBIAN_FRONTEND=noninteractive apt-get update -qq - _apt_updated=true - fi - DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends "$_pkg" -qq - dpkg -l "$_pkg" 2>/dev/null | grep -q '^ii' || { log_error "Failed to install $_pkg"; exit 1; } - log_ok "$_pkg installed" - else - log_ok "$_pkg found" - fi -done - -# Netavark 1.10+ supports a native nftables firewall driver — older versions -# require iptables-nft. Lynx upgrades from upstream so the iptables package can -# be dropped entirely (it remains on the incompatible-software list). -NETAVARK_REQUIRED="1.10.0" -_netavark_bin="" -for _candidate in /usr/lib/podman/netavark /usr/libexec/podman/netavark; do - [[ -x "$_candidate" ]] && _netavark_bin="$_candidate" && break -done -if [[ -z "$_netavark_bin" ]]; then - log_error "netavark binary not found in /usr/lib/podman or /usr/libexec/podman" - exit 1 -fi -_netavark_ver="$("$_netavark_bin" --version 2>&1 | awk '/netavark/ {print $2; exit}')" -log_info "netavark on disk: ${_netavark_ver}" - -_version_lt() { - [[ "$1" = "$2" ]] && return 1 - [[ "$(printf '%s\n%s\n' "$1" "$2" | sort -V | head -n1)" = "$1" ]] -} - -if _version_lt "$_netavark_ver" "$NETAVARK_REQUIRED"; then - log_warn "netavark $_netavark_ver < $NETAVARK_REQUIRED — upgrading from upstream" - - log_info "Fetching latest netavark release from GitHub..." - NETAVARK_UPSTREAM_VER=$(curl -fsSL --max-time 15 \ - "https://api.github.com/repos/containers/netavark/releases/latest" \ - | python3 -c "import sys,json; print(json.load(sys.stdin)['tag_name'].lstrip('v'))" 2>/dev/null) - if [[ -z "$NETAVARK_UPSTREAM_VER" ]]; then - log_error "Could not fetch latest netavark version from GitHub API" - exit 1 - fi - log_info "Latest netavark: v${NETAVARK_UPSTREAM_VER}" - - _uname_m="$(uname -m)" - case "$_uname_m" in - x86_64|amd64) _na_asset="netavark.gz" ;; - aarch64|arm64) _na_asset="netavark.aarch64.gz" ;; - *) log_error "Unsupported arch for netavark upgrade: $_uname_m"; exit 1 ;; - esac - NETAVARK_DL="https://github.com/containers/netavark/releases/download/v${NETAVARK_UPSTREAM_VER}/${_na_asset}" - NETAVARK_TMP="$(mktemp /tmp/lynx-netavark.XXXXXX.gz)" - if ! curl -fsSL --max-time 120 "$NETAVARK_DL" -o "$NETAVARK_TMP"; then - log_error "Failed to download netavark from $NETAVARK_DL" - rm -f "$NETAVARK_TMP" - exit 1 - fi - - # Verify sha256 against the checksum published in the release. - # netavark does not publish GPG signatures — sha256sum protects against - # corruption and MITM in transit (over HTTPS to github.com). - log_info "Verifying netavark sha256..." - _sha256_url="https://github.com/containers/netavark/releases/download/v${NETAVARK_UPSTREAM_VER}/sha256sum" - _expected_sha=$(curl -fsSL --max-time 15 "$_sha256_url" 2>/dev/null \ - | grep "[[:space:]]${_na_asset}$" | awk '{print $1}') - if [[ -z "$_expected_sha" ]]; then - log_error "Could not fetch sha256 for ${_na_asset} from ${_sha256_url}" - rm -f "$NETAVARK_TMP" - exit 1 - fi - _actual_sha=$(sha256sum "$NETAVARK_TMP" | awk '{print $1}') - if [[ "$_actual_sha" != "$_expected_sha" ]]; then - log_error "netavark sha256 mismatch — expected ${_expected_sha}, got ${_actual_sha}" - rm -f "$NETAVARK_TMP" - exit 1 - fi - log_ok "netavark sha256 verified" - - gunzip -f "$NETAVARK_TMP" - install -m 755 "${NETAVARK_TMP%.gz}" "$_netavark_bin" - rm -f "${NETAVARK_TMP%.gz}" - log_ok "netavark upgraded to upstream v${NETAVARK_UPSTREAM_VER}" -fi -unset _netavark_bin _netavark_ver _candidate _na_asset _uname_m - -if ! grep -q 'firewall_driver.*nftables' /etc/containers/containers.conf 2>/dev/null; then - mkdir -p /etc/containers - { - grep -v 'network_backend\|firewall_driver\|\[network\]' /etc/containers/containers.conf 2>/dev/null || true - printf '\n[network]\nnetwork_backend = "netavark"\nfirewall_driver = "nftables"\n' - } > /tmp/lynx-containers.conf - mv /tmp/lynx-containers.conf /etc/containers/containers.conf - log_ok "Podman configured: netavark backend, nftables firewall driver" -fi - -# --- NTP synchronization check ---------------------------------------------- -# -# The 30s timestamp window on signed agent commands requires synchronized clocks. -# Clock drift >30s causes all commands to be rejected (effective lockdown). - -log_section "Checking NTP synchronization" - -_ntp_active=false - -if systemctl is-active --quiet systemd-timesyncd 2>/dev/null; then - _ntp_active=true - log_ok "systemd-timesyncd is active" -elif systemctl is-active --quiet chronyd 2>/dev/null; then - _ntp_active=true - log_ok "chronyd is active" -fi - -if ! $_ntp_active; then - log_warn "No NTP service detected — enabling systemd-timesyncd..." - if systemctl enable --now systemd-timesyncd 2>/dev/null; then - sleep 2 - _ntp_active=true - log_ok "systemd-timesyncd enabled and started" - else - log_warn "Could not enable systemd-timesyncd automatically" - log_warn "Install chrony (apt install chrony) or enable systemd-timesyncd before adding agents" - log_warn "Without NTP: agent commands will be rejected once clock drifts >30s" - fi -fi - -unset _ntp_active - -# --- Create directories ----------------------------------------------------- - -log_section "Creating directories" - -mkdir -p "$LYNX_DIR" -chmod 755 "$LYNX_DIR" -log_ok "$LYNX_DIR" - -# --- Download core agent binary --------------------------------------------- -# -# The agent binary is needed BEFORE secret generation and UUID generation: -# both rely on `lynx-agent gen-rand` / `lynx-agent gen-uuid-v7` so the host -# does not need `openssl` or Python's uuid module on minimal systems. - -log_section "Downloading lynx-agent binary" - -GITHUB_REPO="Jaro-c/Lynx" -RELEASE_VERIFY_KEY_B64="OsBV4t+vQSn10FAI8UzAJEBS0IUqp8D2bZtlQYD8j+Q=" - -_ARCH=$(uname -m) -case "$_ARCH" in - x86_64) ARCH="x86_64" ;; - aarch64) ARCH="arm64" ;; - *) - log_error "Unsupported architecture: $_ARCH" - exit 1 - ;; -esac -log_info "Architecture: $ARCH" - -if ! python3 -c "from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey" 2>/dev/null; then - log_info "Installing python3-cryptography..." - case "$DISTRO" in - debian) DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends python3-cryptography -qq ;; - rhel) { dnf install -y python3-cryptography 2>/dev/null || yum install -y python3-cryptography 2>/dev/null; } ;; - *) log_error "Cannot install python3-cryptography on unknown distro"; exit 1 ;; - esac - python3 -c "from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey" || { - log_error "python3-cryptography not importable after install" - exit 1 - } -fi - -if [[ -n "${LYNX_RELEASE_BASE:-}" ]]; then - # Local/test override — skip GitHub API fetch; binary served from LYNX_RELEASE_BASE. - RELEASE_BASE="${LYNX_RELEASE_BASE}" - LATEST_AGENT_TAG="local" - log_info "Using local release base: ${RELEASE_BASE}" -else - log_info "Fetching latest agent release..." - LATEST_AGENT_TAG=$(curl -fsSL \ - "https://api.github.com/repos/${GITHUB_REPO}/releases" \ - | python3 -c " -import sys, json -releases = json.load(sys.stdin) -tags = [r['tag_name'] for r in releases - if r.get('tag_name','').startswith('agent@') - and not r.get('prerelease') and not r.get('draft')] -if tags: - def ver(t): return tuple(int(x) for x in t.split('@')[1].split('.')) - print(max(tags, key=ver)) -" 2>/dev/null) - - if [[ -z "$LATEST_AGENT_TAG" ]]; then - log_error "No agent release found in ${GITHUB_REPO}" - exit 1 - fi - log_ok "Latest release: ${LATEST_AGENT_TAG}" - RELEASE_BASE="https://github.com/${GITHUB_REPO}/releases/download/${LATEST_AGENT_TAG}" -fi -mkdir -p "$BIN_DIR" -chmod 755 "$BIN_DIR" - -_verify_release_sig() { - local file="$1" sig_file="$2" - python3 - "$RELEASE_VERIFY_KEY_B64" "$file" "$sig_file" <<'PYEOF' -import sys, base64 -from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey - -pub_key = Ed25519PublicKey.from_public_bytes(base64.b64decode(sys.argv[1] + "==")) - -with open(sys.argv[2], "rb") as f: - data = f.read() -with open(sys.argv[3], "rb") as f: - sig = f.read() -try: - pub_key.verify(sig, data) -except Exception as e: - print(f"signature invalid: {e}", file=sys.stderr) - sys.exit(1) -PYEOF -} - -AGENT_TMP="${BIN_DIR}/lynx-agent.new" - -log_info "Downloading agent binary..." -curl -fsSL --max-time 300 \ - "${RELEASE_BASE}/lynx-agent-linux-${ARCH}" \ - -o "$AGENT_TMP" -curl -fsSL --max-time 30 \ - "${RELEASE_BASE}/lynx-agent-linux-${ARCH}.sig" \ - -o "${AGENT_TMP}.sig" - -log_info "Verifying agent signature..." -if ! _verify_release_sig "$AGENT_TMP" "${AGENT_TMP}.sig"; then - log_error "Agent signature verification FAILED — aborting" - rm -f "$AGENT_TMP" "${AGENT_TMP}.sig" - exit 1 -fi -rm -f "${AGENT_TMP}.sig" -chmod 755 "$AGENT_TMP" -mv "$AGENT_TMP" "$BINARY_PATH" -log_ok "Agent binary installed: ${BINARY_PATH}" - -# --- Generate agent UUID ---------------------------------------------------- - -log_section "Generating agent identity" - -if [[ -n "${_SAVED_AGENT_ID:-}" ]]; then - AGENT_ID="$_SAVED_AGENT_ID" - log_ok "Reusing existing Agent ID: $AGENT_ID" - unset _SAVED_AGENT_ID -elif [[ -n "${LYNX_AGENT_ID:-}" ]]; then - # Test/pre-seeded agent ID (allows registering in dashboard before running script). - AGENT_ID="${LYNX_AGENT_ID}" - unset LYNX_AGENT_ID - log_ok "Using pre-seeded Agent ID: $AGENT_ID" -else - AGENT_ID=$("$BINARY_PATH" gen-uuid-v7) -fi -# Always persist so successive reinstalls preserve the ID -printf '%s' "$AGENT_ID" > "$LYNX_DIR/agent-id" -chmod 600 "$LYNX_DIR/agent-id" -log_ok "Agent ID: $AGENT_ID" - -# --- Create system user ----------------------------------------------------- - -log_section "Creating system user: $LYNX_AGENT_USER" - -if ! id "$LYNX_AGENT_USER" &>/dev/null; then - useradd \ - --system \ - --no-create-home \ - --shell /usr/sbin/nologin \ - --comment "Lynx Agent service user" \ - "$LYNX_AGENT_USER" - log_ok "User created: $LYNX_AGENT_USER" -else - log_warn "User $LYNX_AGENT_USER already exists — skipping" -fi - -# Enable lingering for rootless Podman (tenant containers persist after session) -loginctl enable-linger "$LYNX_AGENT_USER" 2>/dev/null || true - -# --- subuid / subgid allocation for tenant isolation ----------------------- -# -# Each tenant (lynx-tenant-{id}) gets 65536 subuids/subgids. -# The agent user itself needs a base allocation for its own Podman. - -log_section "Configuring subuid/subgid ranges" - -# Agent user: 1,000,000 – 1,065,535 (65536 IDs) -if ! grep -q "^${LYNX_AGENT_USER}:" /etc/subuid 2>/dev/null; then - echo "${LYNX_AGENT_USER}:1000000:65536" >> /etc/subuid - log_ok "subuid: $LYNX_AGENT_USER → 1000000+65536" -fi -if ! grep -q "^${LYNX_AGENT_USER}:" /etc/subgid 2>/dev/null; then - echo "${LYNX_AGENT_USER}:1000000:65536" >> /etc/subgid - log_ok "subgid: $LYNX_AGENT_USER → 1000000+65536" -fi - -# --- Generate agent secrets ------------------------------------------------- - -log_section "Generating agent secrets" - -log_info "PostgreSQL root password..." -( - PG_ROOT=$("$BINARY_PATH" gen-rand 32) - printf '%s' "$PG_ROOT" | podman secret create lynx-agent-pg-root - >/dev/null - PG_ROOT="$("$BINARY_PATH" gen-rand 32)" -) - -log_info "PostgreSQL app password..." -mkdir -p /etc/lynx/credentials -chmod 700 /etc/lynx/credentials -# PG_PASS stays in outer shell until DATABASE_URL can be written (needs container IP). -# Zeroized after writing the credential file. -PG_PASS=$("$BINARY_PATH" gen-rand 32) -printf '%s' "$PG_PASS" | podman secret create lynx-agent-pg-pass - >/dev/null - -log_info "Internal bearer token..." -INTERNAL_TOKEN=$("$BINARY_PATH" gen-rand 32) -printf '%s' "$INTERNAL_TOKEN" | podman secret create lynx-agent-internal-token - >/dev/null - -log_info "KEK (Key Encryption Key)..." -mkdir -p /etc/lynx/credentials -chmod 700 /etc/lynx/credentials -( - KEK=$("$BINARY_PATH" gen-rand 32) - printf '%s' "$KEK" > /etc/lynx/credentials/lynx-kek - chmod 600 /etc/lynx/credentials/lynx-kek - KEK="$("$BINARY_PATH" gen-rand 32)" -) - -log_ok "Agent secrets generated" - -log_info "Creating pg_tde keyring directory..." -# pg_tde manages its own keyring file — we just provide the directory. -# Owned by UID 26 (postgres user inside the Percona container) for rootful Podman. -mkdir -p /etc/lynx/pg-keyring -chown 26:26 /etc/lynx/pg-keyring -chmod 700 /etc/lynx/pg-keyring -log_ok "pg_tde keyring dir: /etc/lynx/pg-keyring" - -# --- Podman network for agent DB ------------------------------------------- - -log_section "Creating Podman network: $PG_NETWORK" - -if ! podman network exists "$PG_NETWORK" 2>/dev/null; then - podman network create "$PG_NETWORK" --subnet "$PG_SUBNET" - log_ok "Network created: $PG_NETWORK ($PG_SUBNET)" -else - log_warn "Network $PG_NETWORK already exists — skipping" -fi - -# --- PostgreSQL init script ------------------------------------------------- - -log_section "Preparing PostgreSQL init script" - -PG_INIT_DIR="$LYNX_DIR/pg-init" -mkdir -p "$PG_INIT_DIR" - -cat > "$PG_INIT_DIR/01-init.sql" << 'PGSQL' -\set app_pass `cat /run/secrets/lynx-agent-pg-pass` - -CREATE USER lynx_agent_app WITH PASSWORD :'app_pass' NOSUPERUSER NOCREATEDB NOCREATEROLE; -GRANT CONNECT ON DATABASE lynx_agent TO lynx_agent_app; -\connect lynx_agent -GRANT USAGE, CREATE ON SCHEMA public TO lynx_agent_app; -ALTER DEFAULT PRIVILEGES IN SCHEMA public - GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO lynx_agent_app; -ALTER DEFAULT PRIVILEGES IN SCHEMA public - GRANT USAGE, SELECT ON SEQUENCES TO lynx_agent_app; - --- Enable transparent storage encryption via pg_tde (AES-256). --- The keyring file is created and managed by pg_tde at /var/pg-keyring/lynx.keyring. --- All future tables in lynx_agent will be transparently encrypted (tde_heap). -CREATE EXTENSION IF NOT EXISTS pg_tde; -SELECT pg_tde_add_database_key_provider_file('lynx-keyring', '/var/pg-keyring/lynx.keyring'); -SELECT pg_tde_create_key_using_database_key_provider('lynx-agent-key', 'lynx-keyring'); -SELECT pg_tde_set_key_using_database_key_provider('lynx-agent-key', 'lynx-keyring'); -ALTER DATABASE lynx_agent SET default_table_access_method = tde_heap; -PGSQL - -chmod 644 "$PG_INIT_DIR/01-init.sql" -log_ok "Init script: $PG_INIT_DIR/01-init.sql" - -# --- Start PostgreSQL container --------------------------------------------- - -log_section "Starting PostgreSQL for agent" - -# Remove any stale data volume from a partial previous install. -# The init SQL (docker-entrypoint-initdb.d) only runs on an empty data dir — -# a leftover volume causes init to be silently skipped, leaving the app user -# without the correct password and pg_tde without a keyring. -if podman volume exists lynx-agent-pg-data 2>/dev/null; then - log_info "Removing stale PostgreSQL data volume from previous install..." - podman volume rm --force lynx-agent-pg-data 2>/dev/null || true -fi - -podman run -d \ - --name "$PG_CONTAINER" \ - --network "$PG_NETWORK" \ - --ip "$PG_STATIC_IP" \ - --secret lynx-agent-pg-root,target=lynx-agent-pg-root \ - --secret lynx-agent-pg-pass,target=lynx-agent-pg-pass \ - -e POSTGRES_USER=postgres \ - -e POSTGRES_DB="$PG_DB" \ - -e POSTGRES_PASSWORD_FILE=/run/secrets/lynx-agent-pg-root \ - -e 'POSTGRES_INITDB_ARGS=-c shared_preload_libraries=pg_tde' \ - -v lynx-agent-pg-data:/data/db \ - -v "$PG_INIT_DIR:/docker-entrypoint-initdb.d:ro" \ - -v /etc/lynx/pg-keyring:/var/pg-keyring \ - --restart unless-stopped \ - "$PG_IMAGE" - -log_info "Waiting for PostgreSQL to be healthy..." -for i in $(seq 1 40); do - if podman exec "$PG_CONTAINER" pg_isready -U postgres -d "$PG_DB" &>/dev/null; then - log_ok "PostgreSQL healthy" - break - fi - if [[ $i -eq 40 ]]; then - log_error "PostgreSQL did not become healthy" - podman logs --tail 30 "$PG_CONTAINER" - exit 1 - fi - sleep 2 -done - -# Write DATABASE_URL using the container's static IP on the internal Podman network. -# The agent binary runs as root and can reach the container network directly — no host -# port mapping needed (which would create iptables DNAT rules that survive reinstalls). -( - DB_URL="postgresql://lynx_agent_app:${PG_PASS}@${PG_STATIC_IP}:5432/${PG_DB}" - printf '%s' "$DB_URL" > /etc/lynx/credentials/database-url - chmod 600 /etc/lynx/credentials/database-url - DB_URL="$("$BINARY_PATH" gen-rand 32)" -) -PG_PASS="$("$BINARY_PATH" gen-rand 32)" - -# --- Download agent binary from GitHub Releases ---------------------------- - -# Agent binary already downloaded earlier — version file gets written below. -printf '%s' "${LATEST_AGENT_TAG#agent@}" > "$BIN_DIR/lynx-agent-version" -log_ok "Version: ${LATEST_AGENT_TAG#agent@}" - -# --- Write agent env file --------------------------------------------------- - -log_section "Writing agent configuration" - -# Detect if this is the dashboard VPS — setup-dashboard.sh leaves nginx config -IS_DASHBOARD_VPS=false -DASHBOARD_PORT_CONF="" -if [[ -f /etc/lynx/nginx/default.conf ]]; then - IS_DASHBOARD_VPS=true - DASHBOARD_PORT_CONF="DASHBOARD_PORT=19443" - log_info "Dashboard VPS detected — local agent mode, will open port 19443" -fi - -cat > "$AGENT_CONF" << EOF -AGENT_ID=${AGENT_ID} -DATABASE_URL_FILE=/run/credentials/lynx-agent.service/database-url -INTERNAL_TOKEN_FILE=/run/credentials/lynx-agent.service/internal-token -DASHBOARD_VERIFY_KEY_FILE=/run/credentials/lynx-agent.service/lynx-dashboard-pubkey -SYNC_TOKEN_FILE=/run/credentials/lynx-agent.service/sync-token -KEK_FILE=/run/credentials/lynx-agent.service/lynx-kek -LISTEN_ADDR=127.0.0.1:${AGENT_PORT} -DASHBOARD_URL=http://${DASHBOARD_WG_IP}:8080 -RUST_LOG=info -${DASHBOARD_PORT_CONF} -EOF - -chmod 600 "$AGENT_CONF" -log_ok "Config: $AGENT_CONF" - -# Write INTERNAL_TOKEN to systemd credential file (source on disk, 600 root-only; -# systemd LoadCredential exposes it at /run/credentials/... tmpfs at service start) -printf '%s' "$INTERNAL_TOKEN" > /etc/lynx/credentials/internal-token -chmod 600 /etc/lynx/credentials/internal-token - -# Clear INTERNAL_TOKEN from memory -INTERNAL_TOKEN="$("$BINARY_PATH" gen-rand 32)" -unset INTERNAL_TOKEN - -# Persist the dashboard's Ed25519 signing public key as a credential — every -# command from the dashboard (heartbeat ACK, container ops, nftables push, -# update.self, ...) is verified against this key. Without it, the agent -# rejects every command and enters lockdown after 5 minutes. -printf '%s' "$DASHBOARD_SIGN_PUBKEY" > /etc/lynx/credentials/lynx-dashboard-pubkey -chmod 600 /etc/lynx/credentials/lynx-dashboard-pubkey -unset DASHBOARD_SIGN_PUBKEY - -# Persist the sync token — used to authenticate the agent→dashboard WebSocket -# connection and audit log sync. Shown once when registering the VPS. -printf '%s' "$SYNC_TOKEN" > /etc/lynx/credentials/sync-token -chmod 600 /etc/lynx/credentials/sync-token -SYNC_TOKEN="$("$BINARY_PATH" gen-rand 32)" -unset SYNC_TOKEN - -# --- Create systemd service ------------------------------------------------- - -log_section "Installing systemd service" - -# Service that starts the PostgreSQL container at boot. -# Podman's podman-restart.service only handles restart-policy=always; -# our container uses unless-stopped, so we manage boot startup explicitly. -cat > /etc/systemd/system/lynx-agent-postgres.service << 'EOF' -[Unit] -Description=Lynx Agent — PostgreSQL container -After=network.target - -[Service] -Type=oneshot -RemainAfterExit=yes -ExecStart=/usr/bin/podman start lynx-agent-postgres -ExecStop=/usr/bin/podman stop -t 30 lynx-agent-postgres - -[Install] -WantedBy=multi-user.target -EOF - -cat > /etc/systemd/system/lynx-agent.service << EOF -[Unit] -Description=Lynx Agent — infrastructure orchestration service -Documentation=https://github.com/Jaro-c/Lynx -After=network.target lynx-agent-postgres.service -Requires=network.target lynx-agent-postgres.service - -[Service] -Type=simple -User=root -Group=root -EnvironmentFile=${AGENT_CONF} -ExecStart=${BINARY_PATH} -Restart=always -RestartSec=5s -TimeoutStopSec=30s - -# Systemd credentials (tmpfs — never touches disk) -LoadCredential=database-url:/etc/lynx/credentials/database-url -LoadCredential=internal-token:/etc/lynx/credentials/internal-token -LoadCredential=lynx-dashboard-pubkey:/etc/lynx/credentials/lynx-dashboard-pubkey -LoadCredential=sync-token:/etc/lynx/credentials/sync-token -LoadCredential=lynx-wg-psk:-/etc/lynx/credentials/lynx-wg-psk -LoadCredential=lynx-kek:/etc/lynx/credentials/lynx-kek - -# Minimal hardening — agent is a privileged system daemon (package management, -# nftables, system user creation, binary self-update all require root). -PrivateTmp=yes - -[Install] -WantedBy=multi-user.target -EOF - -systemctl daemon-reload -systemctl enable lynx-agent-postgres.service -systemctl enable lynx-agent.service -log_ok "Services installed: lynx-agent-postgres.service, lynx-agent.service" - -# --- WireGuard agent side --------------------------------------------------- - -log_section "Configuring WireGuard tunnel (agent ↔ dashboard)" - -# Generate agent keypair -# LYNX_WG_PRIVKEY allows test environments to pre-seed the WG private key so the -# pubkey can be registered in the dashboard before the script runs. -if [[ -n "${LYNX_WG_PRIVKEY:-}" ]]; then - AGENT_PRIV="${LYNX_WG_PRIVKEY}" - unset LYNX_WG_PRIVKEY -else - AGENT_PRIV=$(wg genkey) -fi -AGENT_PUB=$(printf '%s' "$AGENT_PRIV" | wg pubkey) -log_info "Agent WireGuard public key: ${AGENT_PUB}" -log_info " Register this VPS in the dashboard with the above public key" -log_info " The dashboard will provide the PSK, WG IP, and sync token" - -# --- NAT detection --- -# Extract the dashboard host (strip port if present) -DASHBOARD_HOST="${DASHBOARD_ENDPOINT%%:*}" - -# IP of the local interface that would route to the dashboard -LOCAL_IFACE_IP=$(ip route get "$DASHBOARD_HOST" 2>/dev/null | grep -oP 'src \K\S+' | head -1) - -# Public IP as seen from the internet -PUBLIC_IP=$(curl -4 -sf --max-time 5 https://ifconfig.me 2>/dev/null || \ - curl -4 -sf --max-time 5 https://api.ipify.org 2>/dev/null || true) -if [[ -z "$PUBLIC_IP" ]]; then - PUBLIC_IP=$(curl -6 -sf --max-time 5 https://ifconfig.me 2>/dev/null || \ - curl -6 -sf --max-time 5 https://api6.ipify.org 2>/dev/null || true) -fi - -KEEPALIVE_LINE="" - -if [[ -n "$LOCAL_IFACE_IP" && -n "$PUBLIC_IP" && "$LOCAL_IFACE_IP" != "$PUBLIC_IP" ]]; then - KEEPALIVE_LINE="PersistentKeepalive = 25" - log_info "NAT detected (interface IP: ${LOCAL_IFACE_IP}, public IP: ${PUBLIC_IP})" - log_info "Enabling PersistentKeepalive = 25 to maintain NAT table entry" - log_warn "If your provider's NAT timeout is < 25s or blocks persistent UDP, the tunnel may be unstable" -elif [[ -z "$PUBLIC_IP" ]]; then - # Cannot determine — enable keepalive as safe default - KEEPALIVE_LINE="PersistentKeepalive = 25" - log_warn "Could not determine public IP — enabling PersistentKeepalive = 25 as safe default" -else - log_info "No NAT detected (interface IP matches public IP: ${PUBLIC_IP})" -fi - -# Build WireGuard peer block -WG_PEER_BLOCK="[Peer] -PublicKey = ${DASHBOARD_PUBKEY} -PresharedKey = ${PSK} -Endpoint = ${DASHBOARD_ENDPOINT} -AllowedIPs = ${DASHBOARD_WG_IP}/32" - -if [[ -n "$KEEPALIVE_LINE" ]]; then - WG_PEER_BLOCK="${WG_PEER_BLOCK} -${KEEPALIVE_LINE}" -fi - -mkdir -p "$LYNX_WG_DIR" -cat > "$LYNX_WG_CONF" << EOF -[Interface] -PrivateKey = ${AGENT_PRIV} -Address = ${AGENT_WG_IP}/32 - -${WG_PEER_BLOCK} -EOF - -chmod 600 "$LYNX_WG_CONF" -chown lynx-agent:lynx-agent "$LYNX_WG_CONF" - -# Symlink into /etc/wireguard/ for wg-quick compatibility -mkdir -p "$WG_DIR" -ln -sf "$LYNX_WG_CONF" "$WG_CONF_LINK" - -# For local agent (same VPS as dashboard): add this agent as a peer in the -# dashboard's WireGuard config so the tunnel is fully bi-directional immediately. -# PSK and AGENT_PUB are available here; PSK is zeroized after this block. -_DASH_WG_CONF="/etc/wireguard/wg-lynx-dash.conf" -if [[ -f "$_DASH_WG_CONF" ]]; then - # Remove old placeholder comment + any existing [Peer] blocks for this agent - sed -i '/^# Peer block added by agent/,/^# AllowedIPs.*$/d' "$_DASH_WG_CONF" 2>/dev/null || true - sed -i '/^\[Peer\]/,/^[[:space:]]*$/{/PublicKey.*'"$AGENT_PUB"'/,/^[[:space:]]*$/d}' "$_DASH_WG_CONF" 2>/dev/null || true - # Append real peer block - printf '\n[Peer]\nPublicKey = %s\nPresharedKey = %s\nAllowedIPs = %s/32\n' \ - "$AGENT_PUB" "$PSK" "$AGENT_WG_IP" >> "$_DASH_WG_CONF" - # Live-update the running WireGuard interface (no restart needed) - if wg set wg-lynx-dash peer "$AGENT_PUB" preshared-key <(printf '%s' "$PSK") allowed-ips "$AGENT_WG_IP/32" 2>/dev/null; then - log_ok "Agent added as peer to dashboard WireGuard (wg-lynx-dash)" - else - log_warn "Could not live-add peer to wg-lynx-dash — add agent pubkey to dashboard manually" - fi -fi -unset _DASH_WG_CONF - -# Persist PSK as a separate credential for systemd LoadCredential tmpfs isolation. -# The wg.conf already contains it for tunnel setup; this credential lets the agent -# Rust code read the PSK from /run/credentials/lynx-agent.service/lynx-wg-psk -# without accessing the conf file directly. -printf '%s' "$PSK" > /etc/lynx/credentials/lynx-wg-psk -chmod 600 /etc/lynx/credentials/lynx-wg-psk - -AGENT_PRIV="$("$BINARY_PATH" gen-rand 32)" # overwrite -PSK="$("$BINARY_PATH" gen-rand 32)" -unset AGENT_PRIV PSK - -# Bring up WireGuard -wg-quick up "$WG_IFACE" -systemctl enable "wg-quick@${WG_IFACE}" -log_ok "WireGuard interface up: $WG_IFACE" - -# Test connectivity to dashboard -log_info "Testing WireGuard connectivity to dashboard (${DASHBOARD_WG_IP})..." -if ping -c 3 -W 3 "$DASHBOARD_WG_IP" &>/dev/null; then - log_ok "Dashboard reachable via WireGuard" -else - log_warn "Cannot reach dashboard at ${DASHBOARD_WG_IP} — add agent pubkey to dashboard first" -fi - -# --- nftables — agent firewall ---------------------------------------------- - -log_section "Configuring nftables (agent)" - -# Build optional dashboard-VPS-only rules -DASHBOARD_PORT_NFT="" -DASHBOARD_DNS_NFT="" -DASHBOARD_FORWARD_WG_NFT="" -if [[ "$IS_DASHBOARD_VPS" == "true" ]]; then - DASHBOARD_PORT_NFT=" # Dashboard panel port - tcp dport 19443 ct state new accept -" - DASHBOARD_DNS_NFT=" # DNS for container networks (aardvark-dns on Netavark bridge interfaces) - iifname \"podman*\" udp dport 53 accept - iifname \"podman*\" tcp dport 53 accept -" - DASHBOARD_FORWARD_WG_NFT=" - # Backend container traffic to/from WireGuard (dashboard <-> agents) - oifname \"wg-lynx-dash\" accept - iifname \"wg-lynx-dash\" accept" -fi - -# Bootstrap ruleset — uses same chain names as the Rust agent (lynx-base, lynx-forward, lynx-output). -# The agent binary will flush and replace this on startup via render_ruleset(). -# The flush prefix ensures no orphaned chains from previous installs survive. -cat > /etc/nftables-lynx-agent.conf << EOF -destroy table inet lynx-agent -add table inet lynx-agent -table inet lynx-agent { - chain lynx-base { - type filter hook input priority 0; policy drop; - - # Loopback - iif lo accept - - # Established / related - ct state established,related accept - - # ICMP - ip protocol icmp accept - ip6 nexthdr icmpv6 accept - - # SSH — per-source-IP rate limit - tcp dport 22 ct state new meter ssh_throttle { ip saddr limit rate 10/minute burst 20 packets } accept - - # WireGuard inbound (dashboard connects here) - udp dport ${WG_PORT} accept - -${DASHBOARD_PORT_NFT} -${DASHBOARD_DNS_NFT} - # Agent API — only from WireGuard interface - iifname "${WG_IFACE}" tcp dport ${AGENT_PORT} accept - - drop - } - - chain lynx-global {} - chain lynx-local {} - - chain lynx-forward { - type filter hook forward priority 0; policy drop; - - ct state established,related accept - - # Netavark DNAT rewrites destination to 10.89.x.x for published container ports. - # Without this rule the DNAT'd packets are dropped here (policy drop). - ip daddr 10.89.0.0/16 ct state new accept - - # Outbound traffic from Podman containers (package installs, GitHub, cert renewals, etc.) - iifname "podman*" accept -${DASHBOARD_FORWARD_WG_NFT} - } - - chain lynx-output { - type filter hook output priority 0; policy accept; - } -} -EOF - -nft -f /etc/nftables-lynx-agent.conf -log_ok "nftables rules applied" - -if [[ -f /etc/nftables.conf ]]; then - if ! grep -q "lynx-agent" /etc/nftables.conf; then - echo 'include "/etc/nftables-lynx-agent.conf"' >> /etc/nftables.conf - fi -fi -systemctl enable nftables 2>/dev/null || true - -# --- Start agent service ---------------------------------------------------- - -log_section "Starting lynx-agent service" - -systemctl start lynx-agent.service -sleep 3 - -if systemctl is-active --quiet lynx-agent.service; then - log_ok "lynx-agent is running" -else - log_error "lynx-agent failed to start" - systemctl status lynx-agent.service --no-pager - exit 1 -fi - -# --- Done ------------------------------------------------------------------- - -log_section "Agent installation complete" - -echo "" -echo -e "${GREEN}${BOLD}Lynx Agent is running!${RESET}" -echo "" -echo -e "${BOLD}${YELLOW}=== Add this agent to your dashboard ===${RESET}" -echo -e " ${BOLD}Agent ID:${RESET} ${AGENT_ID}" -echo -e " ${BOLD}Agent pubkey:${RESET} ${AGENT_PUB}" -echo -e " ${BOLD}Agent WG IP:${RESET} ${AGENT_WG_IP}" -echo "" -echo -e " In the Lynx Dashboard → Agents → Add Agent → paste the pubkey above." -echo -e " The dashboard will add this agent as a WireGuard peer to complete the tunnel." -echo "" -echo -e "${YELLOW}Note:${RESET} The agent API is only reachable via WireGuard (${DASHBOARD_WG_IP} → ${AGENT_WG_IP}:${AGENT_PORT})." -echo "" -echo -e " ${BOLD}Made with love by Jaroc${RESET} — https://github.com/Jaro-c/Lynx" -echo "" diff --git a/lynx/agent/src/audit/mod.rs b/lynx/agent/src/audit/mod.rs deleted file mode 100644 index 7174953..0000000 --- a/lynx/agent/src/audit/mod.rs +++ /dev/null @@ -1,308 +0,0 @@ -use anyhow::{Context, Result}; -use sha2::{Digest, Sha256}; -use sqlx::PgPool; -use uuid::Uuid; - -pub struct AuditEntry<'a> { - pub agent_id: Uuid, - pub organization_id: Option, - pub user_id: Option, - pub command_type: &'a str, - pub result: AuditResult, - /// Sanitized error message — never contains secrets - pub error: Option, -} - -#[derive(Debug, Clone, Copy)] -pub enum AuditResult { - Success, - Rejected, - RejectedRateLimit, - Failed, -} - -impl AuditResult { - fn as_str(self) -> &'static str { - match self { - AuditResult::Success => "success", - AuditResult::Rejected => "rejected", - AuditResult::RejectedRateLimit => "rejected_rate_limit", - AuditResult::Failed => "failed", - } - } -} - -/// Compute the SHA-256 hash for an audit log entry. -/// -/// Covers all explicitly written fields (excludes created_at which is DB-generated -/// and can lose sub-millisecond precision on EXTRACT round-trips). -/// Input: prev_hash || id || agent_id || org_id || user_id || command_type || result || error -#[allow(clippy::too_many_arguments)] -fn compute_entry_hash( - prev_hash: &str, - id: Uuid, - agent_id: Uuid, - organization_id: Option, - user_id: Option, - command_type: &str, - result: &str, - error: Option<&str>, -) -> String { - let mut h = Sha256::new(); - h.update(prev_hash.as_bytes()); - h.update(id.as_bytes()); - h.update(agent_id.as_bytes()); - h.update(organization_id.map(|u| *u.as_bytes()).unwrap_or([0u8; 16])); - h.update(user_id.map(|u| *u.as_bytes()).unwrap_or([0u8; 16])); - h.update(command_type.as_bytes()); - h.update(result.as_bytes()); - h.update(error.unwrap_or("").as_bytes()); - hex::encode(h.finalize()) -} - -/// Append an immutable audit log entry with hash chaining. -/// -/// Before inserting, verifies that the last entry's stored `entry_hash` still -/// matches a re-computation from its data — detects any tampering of previous -/// entries. If a mismatch is found, logs a critical alert and returns an error -/// rather than silently continuing with a broken chain. -pub async fn append(db: &PgPool, entry: AuditEntry<'_>) -> Result<()> { - let id = Uuid::now_v7(); - let result_str = entry.result.as_str(); - - // Fetch full last entry for chain verification - let last = sqlx::query!( - r#"SELECT entry_hash, previous_hash, - id as "id: Uuid", - agent_id as "agent_id: Uuid", - organization_id as "organization_id: Uuid", - user_id as "user_id: Uuid", - command_type, result, error - FROM audit_log ORDER BY created_at DESC LIMIT 1"# - ) - .fetch_optional(db) - .await - .context("fetch last audit entry")?; - - let prev_hash = if let Some(ref last) = last { - // Verify last entry's hash integrity before extending the chain - let expected = compute_entry_hash( - &last.previous_hash, - last.id, - last.agent_id, - last.organization_id, - last.user_id, - &last.command_type, - &last.result, - last.error.as_deref(), - ); - if expected != last.entry_hash { - tracing::error!( - stored_hash = %last.entry_hash, - computed_hash = %expected, - last_entry_id = %last.id, - "AUDIT LOG INTEGRITY VIOLATION — hash chain broken, last entry was tampered" - ); - anyhow::bail!("audit log integrity violation: hash chain broken"); - } - last.entry_hash.clone() - } else { - "genesis".to_string() - }; - - let hash = compute_entry_hash( - &prev_hash, - id, - entry.agent_id, - entry.organization_id, - entry.user_id, - entry.command_type, - result_str, - entry.error.as_deref(), - ); - - sqlx::query!( - r#" - INSERT INTO audit_log - (id, agent_id, organization_id, user_id, command_type, result, error, - previous_hash, entry_hash) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) - "#, - id, - entry.agent_id, - entry.organization_id, - entry.user_id, - entry.command_type, - result_str, - entry.error, - prev_hash, - hash, - ) - .execute(db) - .await - .context("insert audit log entry")?; - - Ok(()) -} - -#[cfg(test)] -mod tests { - //! Integration tests for the audit log hash chain (§12.10 test.md). - //! - //! Requires DATABASE_URL pointing at a postgres with the agent migrations - //! applied (`sqlx migrate run --source agent/migrations`). CI provides - //! this via the `agent.yml` postgres service. - - use super::*; - use std::env; - - async fn pool() -> Option { - let url = env::var("DATABASE_URL").ok()?; - let db = PgPool::connect(&url).await.ok()?; - // Each test starts from a clean audit_log so its results don't depend - // on what previous tests left behind. The `tampered_*` test in - // particular leaves an intentionally-broken row that would otherwise - // wedge the chain for every subsequent invocation. - // - // Tests are gated to run with `--test-threads=1` (CI does this) so the - // truncate cannot race a parallel test's reads. - sqlx::query!("TRUNCATE audit_log").execute(&db).await.ok(); - Some(db) - } - - fn entry<'a>(agent_id: Uuid, cmd: &'a str, result: AuditResult) -> AuditEntry<'a> { - AuditEntry { - agent_id, - organization_id: None, - user_id: None, - command_type: cmd, - result, - error: None, - } - } - - /// First entry's `previous_hash` is the genesis sentinel — proves the chain - /// starts cleanly without depending on any pre-existing row. - #[tokio::test] - async fn first_entry_uses_genesis_sentinel() { - let Some(db) = pool().await else { - eprintln!("DATABASE_URL not set — skipping"); - return; - }; - // Use a fresh agent_id per test run so unrelated rows don't pollute the - // chain query (audit_log fetches the most-recent row globally, so we - // verify by reading back our row directly). - let agent_id = Uuid::now_v7(); - append(&db, entry(agent_id, "test.first", AuditResult::Success)) - .await - .expect("first append"); - - let row = sqlx::query!( - "SELECT previous_hash, entry_hash FROM audit_log WHERE agent_id = $1 ORDER BY created_at DESC LIMIT 1", - agent_id - ) - .fetch_one(&db) - .await - .expect("fetch row"); - - // When the table was empty (no prior entry), `append` writes "genesis". - // When the table already has rows from earlier tests, the prev_hash is - // the last row's entry_hash — both shapes are valid here. - assert!(!row.entry_hash.is_empty()); - assert!(!row.previous_hash.is_empty()); - } - - /// A second entry must link to the first — its `previous_hash` equals the - /// first entry's `entry_hash`. - #[tokio::test] - async fn second_entry_links_to_first() { - let Some(db) = pool().await else { return }; - - // Two entries in quick succession; they may not be adjacent globally - // (parallel tests can interleave), but the second's previous_hash must - // be SOME prior entry_hash — i.e. the chain extends with each append. - let agent_id = Uuid::now_v7(); - append(&db, entry(agent_id, "test.link1", AuditResult::Success)) - .await - .expect("first"); - - let before_second = - sqlx::query!("SELECT entry_hash FROM audit_log ORDER BY created_at DESC LIMIT 1") - .fetch_one(&db) - .await - .expect("fetch global last"); - - append(&db, entry(agent_id, "test.link2", AuditResult::Success)) - .await - .expect("second"); - - let row = sqlx::query!( - "SELECT previous_hash FROM audit_log WHERE agent_id = $1 AND command_type = 'test.link2' ORDER BY created_at DESC LIMIT 1", - agent_id - ) - .fetch_one(&db) - .await - .expect("fetch second"); - - assert_eq!( - row.previous_hash, before_second.entry_hash, - "second entry must chain to the entry that was last at the moment of append" - ); - } - - /// Tamper detection — modifying a row's `command_type` in the DB breaks - /// the chain. The next `append` call must abort with an integrity error - /// instead of silently extending a broken chain. - #[tokio::test] - async fn tampered_previous_entry_breaks_chain() { - let Some(db) = pool().await else { return }; - - let agent_id = Uuid::now_v7(); - append( - &db, - entry(agent_id, "test.tamper.original", AuditResult::Success), - ) - .await - .expect("first"); - - // Find that row's id (it is the last globally inserted because of LIMIT - // 1 DESC, modulo parallel test inserts) — fetch by our own marker text. - let row = sqlx::query!( - "SELECT id as \"id: Uuid\" FROM audit_log WHERE command_type = 'test.tamper.original' AND agent_id = $1", - agent_id - ) - .fetch_one(&db) - .await - .expect("locate row"); - - // Tamper: change command_type but DON'T recompute entry_hash. The next - // append() must detect that the recomputed hash no longer matches the - // stored one for the current `last` row. - sqlx::query!( - "UPDATE audit_log SET command_type = 'test.tamper.MUTATED' WHERE id = $1", - row.id - ) - .execute(&db) - .await - .expect("tamper"); - - // The integrity check fires only when the *globally* last row is the - // tampered one (because `append` looks at the most-recent row in the - // table). Tests run serially in CI (`--test-threads=1`), so this row - // is in fact the last. - let res = append( - &db, - entry(agent_id, "test.tamper.next", AuditResult::Success), - ) - .await; - assert!( - res.is_err(), - "append must reject when prior row's stored hash no longer matches its data" - ); - let msg = format!("{:#}", res.unwrap_err()); - assert!( - msg.contains("integrity") || msg.contains("hash chain"), - "error must mention integrity / hash chain: {msg}" - ); - } -} diff --git a/lynx/agent/src/auth/mod.rs b/lynx/agent/src/auth/mod.rs deleted file mode 100644 index 293912f..0000000 --- a/lynx/agent/src/auth/mod.rs +++ /dev/null @@ -1,479 +0,0 @@ -use anyhow::{Context, Result}; -use base64ct::{Base64UrlUnpadded, Encoding}; -use chrono::Utc; -use ed25519_dalek::{Signature, VerifyingKey}; -use serde::{Deserialize, Serialize}; -use sqlx::PgPool; -use subtle::ConstantTimeEq; -use uuid::Uuid; - -pub const MAX_TIMESTAMP_SKEW_SECS: i64 = 30; - -/// Permission level required for a command. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize)] -#[serde(rename_all = "snake_case")] -pub enum PermissionLevel { - Read, - Write, - Destructive, -} - -/// Signed command envelope sent from dashboard to agent. -#[derive(Debug, Deserialize, Serialize)] -pub struct SignedCommand { - /// Base64url-encoded JSON payload bytes - pub payload: String, - /// Base64url-encoded Ed25519 signature over `payload` bytes - pub signature: String, -} - -/// Inner payload (before verification). -#[derive(Debug, Deserialize, Serialize)] -pub struct CommandPayload { - pub nonce: String, - pub timestamp: i64, - pub agent_id: Uuid, - pub user_id: Uuid, - pub organization_id: Option, - pub permission: PermissionLevel, - pub command: serde_json::Value, -} - -/// Verified command — produced only after all checks pass. -#[derive(Debug)] -pub struct VerifiedCommand { - pub user_id: Uuid, - pub organization_id: Option, - pub permission: PermissionLevel, - pub command: serde_json::Value, -} - -/// Full verification: signature → nonce dedup → timestamp freshness → agent_id match. -pub async fn verify_command( - db: &PgPool, - signed: &SignedCommand, - verify_key_bytes: &[u8; 32], - own_agent_id: Uuid, -) -> Result { - // 1. Decode payload bytes + signature - let payload_bytes = - Base64UrlUnpadded::decode_vec(&signed.payload).context("payload: invalid base64url")?; - let sig_bytes = - Base64UrlUnpadded::decode_vec(&signed.signature).context("signature: invalid base64url")?; - - // 2. Verify Ed25519 signature (constant-time) - let verifying_key = - VerifyingKey::from_bytes(verify_key_bytes).context("invalid dashboard verify key")?; - let sig_arr: [u8; 64] = sig_bytes - .try_into() - .map_err(|_| anyhow::anyhow!("signature must be 64 bytes"))?; - let sig = Signature::from_bytes(&sig_arr); - use ed25519_dalek::Verifier; - verifying_key - .verify(&payload_bytes, &sig) - .context("signature verification failed")?; - - // 3. Parse payload - let payload: CommandPayload = - serde_json::from_slice(&payload_bytes).context("invalid payload JSON")?; - - // 4. Check agent_id matches this agent - if payload.agent_id != own_agent_id { - anyhow::bail!("command not addressed to this agent"); - } - - // 5. Timestamp freshness (±30s) — bypass for heartbeat_ack so clock skew on the - // agent side does not prevent the connection-management command from succeeding. - // Nonce dedup (step 6) still prevents replay even without the timestamp check. - let is_heartbeat_ack = payload - .command - .get("type") - .and_then(|v| v.as_str()) - .map(|t| t == "agent.heartbeat_ack") - .unwrap_or(false); - if !is_heartbeat_ack { - let now = Utc::now().timestamp(); - let skew = (now - payload.timestamp).abs(); - if skew > MAX_TIMESTAMP_SKEW_SECS { - anyhow::bail!("timestamp too old or in future (skew={skew}s)"); - } - } - - // 6. Nonce dedup (replay protection) - check_and_consume_nonce(db, &payload.nonce).await?; - - Ok(VerifiedCommand { - user_id: payload.user_id, - organization_id: payload.organization_id, - permission: payload.permission, - command: payload.command, - }) -} - -/// Returns Ok(()) if nonce is fresh, inserts it. Returns Err if already seen. -async fn check_and_consume_nonce(db: &PgPool, nonce: &str) -> Result<()> { - // Purge nonces older than 5 minutes. Per spec: timestamp window is 30s, but nonces - // are retained for 5 minutes to account for clock skew before the 30s window kicks in. - sqlx::query!("DELETE FROM used_nonces WHERE created_at < NOW() - INTERVAL '5 minutes'") - .execute(db) - .await - .context("purge expired nonces")?; - - let inserted = sqlx::query_scalar!( - r#" - INSERT INTO used_nonces (nonce) VALUES ($1) - ON CONFLICT (nonce) DO NOTHING - RETURNING nonce - "#, - nonce - ) - .fetch_optional(db) - .await - .context("insert nonce")?; - - if inserted.is_none() { - anyhow::bail!("nonce already used (replay attack)"); - } - Ok(()) -} - -/// Verify internal bearer token (constant-time). -pub fn verify_bearer(provided: &str, expected: &str) -> bool { - let a = provided.as_bytes(); - let b = expected.as_bytes(); - if a.len() != b.len() { - return false; - } - a.ct_eq(b).into() -} - -#[cfg(test)] -mod tests { - use super::*; - - // --- verify_bearer --- - - #[test] - fn bearer_correct_token_accepted() { - assert!(verify_bearer("secret-token-123", "secret-token-123")); - } - - #[test] - fn bearer_wrong_token_rejected() { - assert!(!verify_bearer("wrong-token", "secret-token-123")); - } - - #[test] - fn bearer_different_length_rejected() { - // Different length must fail without comparing bytes (length side-channel). - assert!(!verify_bearer("short", "secret-token-123")); - } - - #[test] - fn bearer_empty_strings_match() { - assert!(verify_bearer("", "")); - } - - #[test] - fn bearer_one_char_off_rejected() { - assert!(!verify_bearer("secret-token-124", "secret-token-123")); - } - - // --- PermissionLevel ordering --- - - #[test] - fn permission_read_less_than_write() { - assert!(PermissionLevel::Read < PermissionLevel::Write); - } - - #[test] - fn permission_write_less_than_destructive() { - assert!(PermissionLevel::Write < PermissionLevel::Destructive); - } - - #[test] - fn permission_read_less_than_destructive() { - assert!(PermissionLevel::Read < PermissionLevel::Destructive); - } - - #[test] - fn permission_equal_levels() { - assert!(PermissionLevel::Write == PermissionLevel::Write); - } - - // --- Timestamp skew --- - - #[test] - fn timestamp_within_window_passes() { - let now = chrono::Utc::now().timestamp(); - let skew = (now - (now - 10)).abs(); // 10s ago — well within 30s - assert!(skew <= MAX_TIMESTAMP_SKEW_SECS); - } - - #[test] - fn timestamp_outside_window_fails() { - let now = chrono::Utc::now().timestamp(); - let old = now - 60; // 60s ago — outside 30s window - let skew = (now - old).abs(); - assert!(skew > MAX_TIMESTAMP_SKEW_SECS); - } - - #[test] - fn timestamp_future_outside_window_fails() { - let now = chrono::Utc::now().timestamp(); - let future = now + 60; // 60s in the future - let skew = (now - future).abs(); - assert!(skew > MAX_TIMESTAMP_SKEW_SECS); - } - - // --- Crypto round-trip: sign then verify signature --- - - #[test] - fn signed_command_signature_verifies() { - use base64ct::{Base64UrlUnpadded, Encoding}; - use ed25519_dalek::{Signer, SigningKey, Verifier, VerifyingKey}; - - let seed = [0x42u8; 32]; - let signing_key = SigningKey::from_bytes(&seed); - let verifying_key: VerifyingKey = signing_key.verifying_key(); - - let payload_bytes = br#"{"agent_id":"test","nonce":"abc","timestamp":1}"#; - let payload_b64 = Base64UrlUnpadded::encode_string(payload_bytes); - - let sig = signing_key.sign(payload_bytes); - let sig_b64 = Base64UrlUnpadded::encode_string(&sig.to_bytes()); - - // Decode and verify just like verify_command does - let decoded_payload = Base64UrlUnpadded::decode_vec(&payload_b64).unwrap(); - let decoded_sig_bytes = Base64UrlUnpadded::decode_vec(&sig_b64).unwrap(); - let sig_arr: [u8; 64] = decoded_sig_bytes.try_into().unwrap(); - let sig2 = ed25519_dalek::Signature::from_bytes(&sig_arr); - - assert!(verifying_key.verify(&decoded_payload, &sig2).is_ok()); - } - - // ---- Replay / freshness — full verify_command path (§12.1) ------------- - // - // These tests require DATABASE_URL pointing at a postgres with the agent - // migrations applied; they skip when DATABASE_URL is absent (e.g. local - // `cargo test` outside the dev compose). - - use ed25519_dalek::Signer; - use serde_json::json; - - fn build_signed_command( - signing_key: &ed25519_dalek::SigningKey, - agent_id: Uuid, - nonce: &str, - timestamp: i64, - ) -> SignedCommand { - build_signed_command_type( - signing_key, - agent_id, - nonce, - timestamp, - "nftables.get_status", - ) - } - - fn build_signed_command_type( - signing_key: &ed25519_dalek::SigningKey, - agent_id: Uuid, - nonce: &str, - timestamp: i64, - cmd_type: &str, - ) -> SignedCommand { - let payload = json!({ - "nonce": nonce, - "timestamp": timestamp, - "agent_id": agent_id, - "user_id": Uuid::nil(), - "organization_id": null, - "permission": "read", - "command": { "type": cmd_type }, - }); - let payload_bytes = serde_json::to_vec(&payload).unwrap(); - let payload_b64 = Base64UrlUnpadded::encode_string(&payload_bytes); - let sig = signing_key.sign(&payload_bytes); - let sig_b64 = Base64UrlUnpadded::encode_string(&sig.to_bytes()); - SignedCommand { - payload: payload_b64, - signature: sig_b64, - } - } - - async fn db_pool() -> Option { - let url = std::env::var("DATABASE_URL").ok()?; - PgPool::connect(&url).await.ok() - } - - #[tokio::test] - async fn fresh_command_with_valid_signature_accepts() { - let Some(db) = db_pool().await else { return }; - let signing_key = ed25519_dalek::SigningKey::from_bytes(&[0x42u8; 32]); - let verify_key_bytes = signing_key.verifying_key().to_bytes(); - let agent_id = Uuid::now_v7(); - let nonce = Uuid::now_v7().to_string(); - let ts = Utc::now().timestamp(); - - let cmd = build_signed_command(&signing_key, agent_id, &nonce, ts); - let result = verify_command(&db, &cmd, &verify_key_bytes, agent_id).await; - assert!( - result.is_ok(), - "valid fresh command must verify: {result:?}" - ); - } - - #[tokio::test] - async fn replayed_nonce_is_rejected() { - let Some(db) = db_pool().await else { return }; - let signing_key = ed25519_dalek::SigningKey::from_bytes(&[0x42u8; 32]); - let verify_key_bytes = signing_key.verifying_key().to_bytes(); - let agent_id = Uuid::now_v7(); - let nonce = Uuid::now_v7().to_string(); - let ts = Utc::now().timestamp(); - - // First use — consumes nonce. - let cmd1 = build_signed_command(&signing_key, agent_id, &nonce, ts); - verify_command(&db, &cmd1, &verify_key_bytes, agent_id) - .await - .expect("first use of nonce must succeed"); - - // Second use of *same nonce* with a freshly re-signed envelope (same - // payload bytes, so same signature here) — must reject. - let cmd2 = build_signed_command(&signing_key, agent_id, &nonce, ts); - let res = verify_command(&db, &cmd2, &verify_key_bytes, agent_id).await; - assert!(res.is_err(), "replayed nonce must be rejected"); - let msg = format!("{:#}", res.unwrap_err()); - assert!( - msg.contains("replay") || msg.contains("nonce"), - "error should mention replay/nonce: {msg}" - ); - } - - #[tokio::test] - async fn timestamp_too_old_is_rejected() { - let Some(db) = db_pool().await else { return }; - let signing_key = ed25519_dalek::SigningKey::from_bytes(&[0x42u8; 32]); - let verify_key_bytes = signing_key.verifying_key().to_bytes(); - let agent_id = Uuid::now_v7(); - // 60 seconds in the past — outside the 30s skew window. - let old_ts = Utc::now().timestamp() - 60; - let cmd = build_signed_command(&signing_key, agent_id, &Uuid::now_v7().to_string(), old_ts); - let res = verify_command(&db, &cmd, &verify_key_bytes, agent_id).await; - assert!(res.is_err(), "expired timestamp must reject"); - assert!( - format!("{:#}", res.unwrap_err()).contains("timestamp"), - "error should mention timestamp" - ); - } - - #[tokio::test] - async fn timestamp_far_future_is_rejected() { - let Some(db) = db_pool().await else { return }; - let signing_key = ed25519_dalek::SigningKey::from_bytes(&[0x42u8; 32]); - let verify_key_bytes = signing_key.verifying_key().to_bytes(); - let agent_id = Uuid::now_v7(); - let future_ts = Utc::now().timestamp() + 60; - let cmd = build_signed_command( - &signing_key, - agent_id, - &Uuid::now_v7().to_string(), - future_ts, - ); - let res = verify_command(&db, &cmd, &verify_key_bytes, agent_id).await; - assert!(res.is_err(), "future timestamp outside window must reject"); - } - - #[tokio::test] - async fn heartbeat_ack_bypasses_timestamp_check() { - let Some(db) = db_pool().await else { return }; - let signing_key = ed25519_dalek::SigningKey::from_bytes(&[0x42u8; 32]); - let verify_key_bytes = signing_key.verifying_key().to_bytes(); - let agent_id = Uuid::now_v7(); - // Clock skew: 60s in the past — would normally fail timestamp check. - let old_ts = Utc::now().timestamp() - 60; - let cmd = build_signed_command_type( - &signing_key, - agent_id, - &Uuid::now_v7().to_string(), - old_ts, - "agent.heartbeat_ack", - ); - let res = verify_command(&db, &cmd, &verify_key_bytes, agent_id).await; - assert!( - res.is_ok(), - "heartbeat_ack must bypass timestamp check: {res:?}" - ); - } - - #[tokio::test] - async fn signature_signed_with_other_key_is_rejected() { - let Some(db) = db_pool().await else { return }; - // Real dashboard signing key vs attacker's key. - let dashboard = ed25519_dalek::SigningKey::from_bytes(&[0x42u8; 32]); - let attacker = ed25519_dalek::SigningKey::from_bytes(&[0x77u8; 32]); - let verify_key_bytes = dashboard.verifying_key().to_bytes(); - let agent_id = Uuid::now_v7(); - // Attacker signs a command that LOOKS legitimate but with a key the - // agent will reject. - let cmd = build_signed_command( - &attacker, - agent_id, - &Uuid::now_v7().to_string(), - Utc::now().timestamp(), - ); - let res = verify_command(&db, &cmd, &verify_key_bytes, agent_id).await; - assert!(res.is_err(), "wrong-key signature must reject"); - let msg = format!("{:#}", res.unwrap_err()); - assert!( - msg.contains("signature") || msg.contains("verification"), - "error should mention signature/verification: {msg}" - ); - } - - #[tokio::test] - async fn command_addressed_to_other_agent_is_rejected() { - let Some(db) = db_pool().await else { return }; - let signing_key = ed25519_dalek::SigningKey::from_bytes(&[0x42u8; 32]); - let verify_key_bytes = signing_key.verifying_key().to_bytes(); - let other_agent_id = Uuid::now_v7(); - let our_agent_id = Uuid::now_v7(); - let cmd = build_signed_command( - &signing_key, - other_agent_id, - &Uuid::now_v7().to_string(), - Utc::now().timestamp(), - ); - let res = verify_command(&db, &cmd, &verify_key_bytes, our_agent_id).await; - assert!( - res.is_err(), - "command addressed to a different agent must reject" - ); - } - - #[test] - fn tampered_payload_fails_verification() { - use base64ct::{Base64UrlUnpadded, Encoding}; - use ed25519_dalek::{Signer, SigningKey, Verifier, VerifyingKey}; - - let seed = [0x42u8; 32]; - let signing_key = SigningKey::from_bytes(&seed); - let verifying_key: VerifyingKey = signing_key.verifying_key(); - - let payload_bytes = br#"{"agent_id":"test","nonce":"abc","timestamp":1}"#; - let sig = signing_key.sign(payload_bytes); - let sig_b64 = Base64UrlUnpadded::encode_string(&sig.to_bytes()); - - // Tamper the payload - let tampered = br#"{"agent_id":"evil","nonce":"abc","timestamp":1}"#; - let tampered_b64 = Base64UrlUnpadded::encode_string(tampered); - - let decoded_payload = Base64UrlUnpadded::decode_vec(&tampered_b64).unwrap(); - let decoded_sig_bytes = Base64UrlUnpadded::decode_vec(&sig_b64).unwrap(); - let sig_arr: [u8; 64] = decoded_sig_bytes.try_into().unwrap(); - let sig2 = ed25519_dalek::Signature::from_bytes(&sig_arr); - - assert!(verifying_key.verify(&decoded_payload, &sig2).is_err()); - } -} diff --git a/lynx/agent/src/cert.rs b/lynx/agent/src/cert.rs deleted file mode 100644 index 0350522..0000000 --- a/lynx/agent/src/cert.rs +++ /dev/null @@ -1,67 +0,0 @@ -//! Agent certificate verification. -//! -//! The dashboard CA issues an Ed25519-signed certificate at agent registration. -//! Agents store it and can verify it to confirm commands come from a trusted dashboard. - -use anyhow::{Context, Result}; -use base64ct::{Base64UrlUnpadded, Encoding}; -use ed25519_dalek::{Signature, Verifier, VerifyingKey}; -use serde::{Deserialize, Serialize}; -use uuid::Uuid; - -#[derive(Debug, Serialize, Deserialize, Clone)] -pub struct SignedCert { - pub payload: String, - pub signature: String, -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct AgentCert { - pub agent_id: Uuid, - pub issued_at: i64, - pub expires_at: i64, -} - -/// Load CA public key from env (CA_PUBLIC_KEY or CA_PUBLIC_KEY_FILE). -/// Returns None if not configured (cert verification disabled in dev mode). -pub fn load_ca_public_key() -> Option<[u8; 32]> { - let raw = std::env::var("CA_PUBLIC_KEY_FILE") - .ok() - .and_then(|p| std::fs::read_to_string(p).ok()) - .or_else(|| std::env::var("CA_PUBLIC_KEY").ok())?; - - let bytes = Base64UrlUnpadded::decode_vec(raw.trim()).ok()?; - bytes.try_into().ok() -} - -/// Verify a cert from the dashboard. Returns Ok if valid and not expired. -pub fn verify(cert: &SignedCert, ca_public: &[u8; 32], expected_agent_id: Uuid) -> Result<()> { - let payload_bytes = - Base64UrlUnpadded::decode_vec(&cert.payload).context("base64url decode payload")?; - let sig_bytes = - Base64UrlUnpadded::decode_vec(&cert.signature).context("base64url decode signature")?; - - let verifying_key = VerifyingKey::from_bytes(ca_public).context("parse CA public key")?; - - let sig_arr: [u8; 64] = sig_bytes - .try_into() - .map_err(|_| anyhow::anyhow!("signature must be 64 bytes"))?; - let sig = Signature::from_bytes(&sig_arr); - - verifying_key - .verify(&payload_bytes, &sig) - .context("CA signature invalid")?; - - let payload: AgentCert = serde_json::from_slice(&payload_bytes).context("deserialize cert")?; - - if payload.agent_id != expected_agent_id { - anyhow::bail!("cert agent_id mismatch"); - } - - let now = chrono::Utc::now().timestamp(); - if now > payload.expires_at { - anyhow::bail!("cert expired"); - } - - Ok(()) -} diff --git a/lynx/agent/src/config.rs b/lynx/agent/src/config.rs deleted file mode 100644 index ee6b814..0000000 --- a/lynx/agent/src/config.rs +++ /dev/null @@ -1,113 +0,0 @@ -use anyhow::{Context, Result}; -use base64ct::{Base64, Encoding}; -use zeroize::Zeroizing; - -pub struct Config { - pub database_url: String, - pub agent_id: uuid::Uuid, - pub version: String, - /// Ed25519 public key bytes (32) — dashboard's signing key, used to verify commands - pub dashboard_verify_key: [u8; 32], - /// Bearer token for dashboard→agent API calls (internal, WireGuard-only) - pub internal_token: Zeroizing, - pub listen_addr: String, - /// Dashboard API base URL via WireGuard (e.g. http://10.100.0.1:8080). Optional. - pub dashboard_url: Option, - /// Sync token for agent→dashboard audit log sync. Optional — sync disabled if absent. - pub sync_token: Option>, - /// X.509 TLS server certificate DER — for mTLS listener. None = plain HTTP. - pub tls_cert_der: Option>, - /// X.509 TLS server private key DER (PKCS#8). - pub tls_key_der: Option>>, - /// X.509 CA certificate DER — used to verify dashboard client certs. - pub tls_ca_cert_der: Option>, - /// Dashboard panel port to open in nftables (Some(19443) on dashboard VPS, None on remote agents). - pub dashboard_port: Option, -} - -impl Config { - pub fn load() -> Result { - let database_url = load_secret("DATABASE_URL") - .map(|s| s.as_str().to_owned()) - .context("DATABASE_URL or DATABASE_URL_FILE required")?; - - let agent_id_str = std::env::var("AGENT_ID").context("AGENT_ID required")?; - let agent_id = uuid::Uuid::parse_str(&agent_id_str).context("AGENT_ID must be UUID v7")?; - - // The dashboard signing public key (Ed25519) is mandatory: every command - // from the dashboard (heartbeat ACK, container ops, nftables push, - // update.self, ...) is verified against it. A missing or wrong key - // makes the agent reject every command and lock down within 5 minutes - // — fail fast at startup instead of silently entering lockdown later. - let dashboard_verify_key = load_key32("DASHBOARD_VERIFY_KEY") - .context("DASHBOARD_VERIFY_KEY (or DASHBOARD_VERIFY_KEY_FILE) is required — supply the dashboard's Ed25519 signing pubkey from setup-dashboard.sh output")?; - let internal_token = load_secret("INTERNAL_TOKEN")?; - let listen_addr = - std::env::var("LISTEN_ADDR").unwrap_or_else(|_| "0.0.0.0:9090".to_string()); - let dashboard_url = std::env::var("DASHBOARD_URL").ok(); - let sync_token = load_secret_opt("SYNC_TOKEN"); - let version = std::env::var("AGENT_VERSION") - .unwrap_or_else(|_| env!("CARGO_PKG_VERSION").to_string()); - - let tls_cert_der = load_der_file_opt("TLS_CERT_DER_FILE"); - let tls_key_der = load_der_file_zeroize_opt("TLS_KEY_DER_FILE"); - let tls_ca_cert_der = load_der_file_opt("TLS_CA_CERT_DER_FILE"); - - let dashboard_port = std::env::var("DASHBOARD_PORT") - .ok() - .and_then(|s| s.parse::().ok()); - - Ok(Config { - database_url, - agent_id, - dashboard_verify_key, - internal_token, - listen_addr, - dashboard_url, - sync_token, - version, - tls_cert_der, - tls_key_der, - tls_ca_cert_der, - dashboard_port, - }) - } -} - -fn load_secret(env: &str) -> Result> { - let file_env = format!("{env}_FILE"); - if let Ok(path) = std::env::var(&file_env) { - let val = - std::fs::read_to_string(&path).with_context(|| format!("read {file_env}={path}"))?; - return Ok(Zeroizing::new(val.trim().to_string())); - } - let val = std::env::var(env).with_context(|| format!("{env} required"))?; - Ok(Zeroizing::new(val)) -} - -fn load_secret_opt(env: &str) -> Option> { - let file_env = format!("{env}_FILE"); - if let Ok(path) = std::env::var(&file_env) { - if let Ok(val) = std::fs::read_to_string(&path) { - return Some(Zeroizing::new(val.trim().to_string())); - } - } - std::env::var(env).ok().map(Zeroizing::new) -} - -fn load_key32(env: &str) -> Result<[u8; 32]> { - let raw = load_secret(env)?; - let bytes = Base64::decode_vec(raw.trim()).with_context(|| format!("{env}: not base64"))?; - bytes - .try_into() - .map_err(|_| anyhow::anyhow!("{env} must be exactly 32 bytes")) -} - -fn load_der_file_opt(env: &str) -> Option> { - let path = std::env::var(env).ok()?; - std::fs::read(&path).ok() -} - -fn load_der_file_zeroize_opt(env: &str) -> Option>> { - load_der_file_opt(env).map(Zeroizing::new) -} diff --git a/lynx/agent/src/conflict.rs b/lynx/agent/src/conflict.rs deleted file mode 100644 index 90bf071..0000000 --- a/lynx/agent/src/conflict.rs +++ /dev/null @@ -1,332 +0,0 @@ -use crate::state::AppState; -use std::{process::Command, time::Duration}; -use tokio::time::interval; - -const CHECK_INTERVAL_SECS: u64 = 300; - -/// Conflicting software list — anything that manages its own firewall or container network -/// can silently bypass nftables rules managed by Lynx. -static INCOMPATIBLE: &[IncompatibleSoftware] = &[ - IncompatibleSoftware { - name: "docker", - packages: &["docker-ce", "docker.io", "docker-engine"], - process: Some("dockerd"), - }, - IncompatibleSoftware { - name: "containerd", - packages: &["containerd", "containerd.io"], - process: Some("containerd"), - }, - IncompatibleSoftware { - name: "firewalld", - packages: &["firewalld"], - process: Some("firewalld"), - }, - IncompatibleSoftware { - name: "ufw", - packages: &["ufw"], - process: None, - }, - IncompatibleSoftware { - name: "iptables", - packages: &["iptables"], - process: None, - }, -]; - -struct IncompatibleSoftware { - name: &'static str, - packages: &'static [&'static str], - process: Option<&'static str>, -} - -pub async fn run_conflict_check(state: AppState) { - let mut ticker = interval(Duration::from_secs(CHECK_INTERVAL_SECS)); - ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); - - loop { - ticker.tick().await; - check_and_remove(&state).await; - } -} - -async fn check_and_remove(state: &AppState) { - for software in INCOMPATIBLE { - if is_present(software) { - tracing::warn!(software = software.name, "conflicting software detected"); - - notify_dashboard(state, software.name, "detected").await; - - match remove(software) { - Ok(()) => { - tracing::info!(software = software.name, "conflicting software removed"); - notify_dashboard(state, software.name, "removed").await; - record_audit(state, software.name, "removed").await; - } - Err(e) => { - tracing::error!( - software = software.name, - err = %e, - "failed to remove conflicting software — entering lockdown" - ); - notify_dashboard(state, software.name, &format!("removal_failed: {e}")).await; - record_audit(state, software.name, &format!("removal_failed: {e}")).await; - state.set_lockdown(crate::state::LockdownReason::IncompatibleSoftware); - return; - } - } - } - } -} - -fn is_present(sw: &IncompatibleSoftware) -> bool { - // iptables is special: the `iptables` package is present on Ubuntu/Debian as the - // nftables compatibility shim (iptables-nft), which is allowed. Only flag when the - // binary self-identifies as the legacy backend via "(legacy)" in --version output. - // This check must come first — the generic package check below would return true for - // any installed `iptables` package including the harmless nft compat layer. - if sw.name == "iptables" { - return is_legacy_iptables(); - } - - // Check if the process is running. - if let Some(proc_name) = sw.process { - let running = Command::new("pgrep") - .args(["-x", proc_name]) - .status() - .map(|s| s.success()) - .unwrap_or(false); - if running { - return true; - } - } - - // Check if any matching package is installed. - // Try dpkg first (Debian/Ubuntu), then rpm (RHEL/CentOS). - for pkg in sw.packages { - let installed_dpkg = Command::new("dpkg") - .args(["-s", pkg]) - .output() - .map(|o| o.status.success()) - .unwrap_or(false); - if installed_dpkg { - return true; - } - - let installed_rpm = Command::new("rpm") - .args(["-q", pkg]) - .output() - .map(|o| o.status.success()) - .unwrap_or(false); - if installed_rpm { - return true; - } - } - - false -} - -fn is_legacy_iptables() -> bool { - let out = Command::new("iptables") - .args(["--version"]) - .output() - .unwrap_or_else(|_| std::process::Output { - status: std::process::ExitStatus::default(), - stdout: vec![], - stderr: vec![], - }); - let version_str = String::from_utf8_lossy(&out.stdout); - // If it says "(legacy)" the host uses direct iptables, not the nft compat layer. - contains_legacy_marker(&version_str) -} - -/// Extracted predicate for unit testing — returns true when the iptables version -/// string indicates the legacy (non-nftables) backend. -fn contains_legacy_marker(version_str: &str) -> bool { - version_str.contains("(legacy)") -} - -fn remove(sw: &IncompatibleSoftware) -> anyhow::Result<()> { - // Try apt-get purge (Debian/Ubuntu). - if command_exists("apt-get") { - for pkg in sw.packages { - let status = Command::new("apt-get") - .args(["-y", "purge", pkg]) - .status() - .map_err(|e| anyhow::anyhow!("apt-get: {e}"))?; - if status.success() { - return Ok(()); - } - } - } - - // Try dnf remove (RHEL/CentOS/Fedora). - if command_exists("dnf") { - for pkg in sw.packages { - let status = Command::new("dnf") - .args(["-y", "remove", pkg]) - .status() - .map_err(|e| anyhow::anyhow!("dnf: {e}"))?; - if status.success() { - return Ok(()); - } - } - } - - anyhow::bail!("no package manager succeeded removing {}", sw.name) -} - -fn command_exists(cmd: &str) -> bool { - Command::new("which") - .arg(cmd) - .status() - .map(|s| s.success()) - .unwrap_or(false) -} - -#[cfg(test)] -mod tests { - use super::*; - - // --- contains_legacy_marker (iptables version string detection) --- - - #[test] - fn legacy_marker_detected_in_legacy_version_string() { - assert!(contains_legacy_marker("iptables v1.8.7 (legacy)")); - } - - #[test] - fn legacy_marker_not_detected_in_nftables_version_string() { - assert!(!contains_legacy_marker("iptables v1.8.7 (nf_tables)")); - } - - #[test] - fn legacy_marker_not_detected_in_empty_string() { - assert!(!contains_legacy_marker("")); - } - - #[test] - fn legacy_marker_not_detected_in_unrelated_string() { - assert!(!contains_legacy_marker("some random text")); - } - - #[test] - fn legacy_marker_case_sensitive() { - // "(Legacy)" with capital L is NOT the same as "(legacy)" — intentional. - assert!(!contains_legacy_marker("iptables v1.8.7 (Legacy)")); - } - - // --- INCOMPATIBLE static list structural invariants --- - - #[test] - fn incompatible_list_is_non_empty() { - assert!(!INCOMPATIBLE.is_empty()); - } - - #[test] - fn every_incompatible_entry_has_at_least_one_package() { - for entry in INCOMPATIBLE { - assert!( - !entry.packages.is_empty(), - "entry '{}' has no packages listed", - entry.name - ); - } - } - - #[test] - fn docker_entry_has_process_set() { - let docker = INCOMPATIBLE.iter().find(|e| e.name == "docker"); - assert!( - docker.is_some(), - "docker entry missing from INCOMPATIBLE list" - ); - assert_eq!( - docker.unwrap().process, - Some("dockerd"), - "docker process should be 'dockerd'" - ); - } - - #[test] - fn iptables_entry_has_no_process() { - let iptables = INCOMPATIBLE.iter().find(|e| e.name == "iptables"); - assert!( - iptables.is_some(), - "iptables entry missing from INCOMPATIBLE list" - ); - assert!( - iptables.unwrap().process.is_none(), - "iptables should have process: None (only package check applies)" - ); - } - - #[test] - fn ufw_entry_has_no_process() { - let ufw = INCOMPATIBLE.iter().find(|e| e.name == "ufw"); - assert!(ufw.is_some(), "ufw entry missing from INCOMPATIBLE list"); - assert!( - ufw.unwrap().process.is_none(), - "ufw should have process: None" - ); - } - - #[test] - fn all_entry_names_are_unique() { - let mut names: Vec<&str> = INCOMPATIBLE.iter().map(|e| e.name).collect(); - let original_len = names.len(); - names.dedup(); - assert_eq!( - names.len(), - original_len, - "duplicate names in INCOMPATIBLE list" - ); - } - - // --- command_exists (pure boolean — tests with well-known commands) --- - - #[test] - fn command_exists_returns_true_for_sh() { - // /bin/sh is available on every POSIX system including GitHub Actions runners. - assert!(command_exists("sh")); - } - - #[test] - fn command_exists_returns_false_for_nonexistent_command() { - assert!(!command_exists( - "lynx_definitely_not_a_real_command_xyz_12345" - )); - } -} - -async fn notify_dashboard(state: &AppState, software: &str, detail: &str) { - // Best-effort: write to agent_events via audit log sync if dashboard is reachable. - // This is fire-and-forget — lockdown/removal path doesn't block on this. - let _ = crate::audit::append( - &state.db, - crate::audit::AuditEntry { - agent_id: state.config.agent_id, - organization_id: None, - user_id: None, - command_type: "conflicting_software_detected", - result: crate::audit::AuditResult::Failed, - error: Some(format!("{software}: {detail}")), - }, - ) - .await; -} - -async fn record_audit(state: &AppState, software: &str, action: &str) { - let _ = crate::audit::append( - &state.db, - crate::audit::AuditEntry { - agent_id: state.config.agent_id, - organization_id: None, - user_id: None, - command_type: "conflicting_software_removed", - result: crate::audit::AuditResult::Success, - error: Some(format!("{software}: {action}")), - }, - ) - .await; -} diff --git a/lynx/agent/src/error.rs b/lynx/agent/src/error.rs deleted file mode 100644 index 6fe3da1..0000000 --- a/lynx/agent/src/error.rs +++ /dev/null @@ -1,39 +0,0 @@ -use axum::{ - http::StatusCode, - response::{IntoResponse, Response}, - Json, -}; -use serde_json::json; -use tracing::error; - -#[derive(Debug, thiserror::Error)] -pub enum AgentError { - #[error("unauthorized")] - Unauthorized, - #[error("forbidden: {0}")] - Forbidden(&'static str), - #[error("bad request: {0}")] - BadRequest(&'static str), - #[error("lockdown active")] - Lockdown, - #[error("internal error")] - Internal(#[from] anyhow::Error), -} - -pub type Result = std::result::Result; - -impl IntoResponse for AgentError { - fn into_response(self) -> Response { - let (status, code) = match &self { - AgentError::Unauthorized => (StatusCode::UNAUTHORIZED, "unauthorized"), - AgentError::Forbidden(_) => (StatusCode::FORBIDDEN, "forbidden"), - AgentError::BadRequest(_) => (StatusCode::BAD_REQUEST, "bad_request"), - AgentError::Lockdown => (StatusCode::SERVICE_UNAVAILABLE, "lockdown"), - AgentError::Internal(e) => { - error!("internal: {e:#}"); - (StatusCode::INTERNAL_SERVER_ERROR, "internal_error") - } - }; - (status, Json(json!({ "error": code }))).into_response() - } -} diff --git a/lynx/agent/src/handlers/containers.rs b/lynx/agent/src/handlers/containers.rs deleted file mode 100644 index 9a1dffc..0000000 --- a/lynx/agent/src/handlers/containers.rs +++ /dev/null @@ -1,230 +0,0 @@ -use crate::{ - auth::{PermissionLevel, VerifiedCommand}, - error::AgentError, - podman, - state::AppState, -}; -use serde_json::{json, Value}; - -pub fn handle_container_list(cmd: &VerifiedCommand) -> std::result::Result { - let tenant_id = require_valid_id(&cmd.command, "tenant_id")?; - let containers = podman::list_containers(&tenant_id)?; - Ok(json!({ "containers": containers })) -} - -pub fn handle_tenant_ensure(cmd: &VerifiedCommand) -> std::result::Result { - if cmd.permission == PermissionLevel::Read { - return Err(AgentError::Forbidden( - "tenant.ensure requires write permission", - )); - } - let tenant_id = require_valid_id(&cmd.command, "tenant_id")?; - podman::ensure_tenant_user(&tenant_id)?; - Ok(json!({ "ok": true, "tenant_id": tenant_id })) -} - -pub async fn handle_container_deploy( - state: &AppState, - cmd: &VerifiedCommand, -) -> std::result::Result { - if cmd.permission == PermissionLevel::Read { - return Err(AgentError::Forbidden( - "container.deploy requires write permission", - )); - } - let tenant_id = require_valid_id(&cmd.command, "tenant_id")?; - let project_id = require_valid_id(&cmd.command, "project_id")?; - let compose_yaml = require_str(&cmd.command, "compose_yaml")?; - - let compose_path = podman::compose_deploy(podman::DeployOptions { - tenant_id: &tenant_id, - project_id: &project_id, - compose_yaml: &compose_yaml, - })?; - - // Persist desired state so agent can restart on reboot (safety net). - sqlx::query( - r#" - INSERT INTO container_deployments (tenant_id, project_id, compose_path, desired) - VALUES ($1, $2, $3, 'running') - ON CONFLICT (tenant_id, project_id) - DO UPDATE SET compose_path = EXCLUDED.compose_path, - desired = 'running', - updated_at = NOW() - "#, - ) - .bind(&tenant_id) - .bind(&project_id) - .bind(&compose_path) - .execute(&state.db) - .await - .map_err(|e| AgentError::Internal(anyhow::anyhow!(e)))?; - - Ok(json!({ "ok": true })) -} - -pub fn handle_container_start(cmd: &VerifiedCommand) -> std::result::Result { - if cmd.permission == PermissionLevel::Read { - return Err(AgentError::Forbidden( - "container.start requires write permission", - )); - } - let tenant_id = require_valid_id(&cmd.command, "tenant_id")?; - let name = require_valid_id(&cmd.command, "name")?; - podman::container_start(&tenant_id, &name)?; - Ok(json!({ "ok": true })) -} - -pub fn handle_container_stop(cmd: &VerifiedCommand) -> std::result::Result { - if cmd.permission == PermissionLevel::Read { - return Err(AgentError::Forbidden( - "container.stop requires write permission", - )); - } - let tenant_id = require_valid_id(&cmd.command, "tenant_id")?; - let name = require_valid_id(&cmd.command, "name")?; - podman::container_stop(&tenant_id, &name)?; - Ok(json!({ "ok": true })) -} - -pub async fn handle_container_down( - state: &AppState, - cmd: &VerifiedCommand, -) -> std::result::Result { - if cmd.permission != PermissionLevel::Destructive { - return Err(AgentError::Forbidden( - "container.down requires destructive permission", - )); - } - let tenant_id = require_valid_id(&cmd.command, "tenant_id")?; - let project_id = require_valid_id(&cmd.command, "project_id")?; - - podman::compose_down(&tenant_id, &project_id)?; - - // Mark desired state as stopped so agent won't restart on reboot. - sqlx::query( - "UPDATE container_deployments SET desired = 'stopped', updated_at = NOW() WHERE tenant_id = $1 AND project_id = $2", - ) - .bind(&tenant_id) - .bind(&project_id) - .execute(&state.db) - .await - .map_err(|e| AgentError::Internal(anyhow::anyhow!(e)))?; - - Ok(json!({ "ok": true })) -} - -pub fn handle_container_remove(cmd: &VerifiedCommand) -> std::result::Result { - if cmd.permission != PermissionLevel::Destructive { - return Err(AgentError::Forbidden( - "container.remove requires destructive permission", - )); - } - let tenant_id = require_valid_id(&cmd.command, "tenant_id")?; - let name = require_valid_id(&cmd.command, "name")?; - let force = cmd - .command - .get("force") - .and_then(|v| v.as_bool()) - .unwrap_or(false); - podman::container_remove(&tenant_id, &name, force)?; - Ok(json!({ "ok": true })) -} - -pub fn handle_container_restart(cmd: &VerifiedCommand) -> std::result::Result { - if cmd.permission == PermissionLevel::Read { - return Err(AgentError::Forbidden( - "container.restart requires write permission", - )); - } - let tenant_id = require_valid_id(&cmd.command, "tenant_id")?; - let name = require_valid_id(&cmd.command, "name")?; - podman::container_restart(&tenant_id, &name)?; - Ok(json!({ "ok": true })) -} - -pub fn handle_container_update(cmd: &VerifiedCommand) -> std::result::Result { - if cmd.permission == PermissionLevel::Read { - return Err(AgentError::Forbidden( - "container.update requires write permission", - )); - } - let tenant_id = require_valid_id(&cmd.command, "tenant_id")?; - let name = require_valid_id(&cmd.command, "name")?; - let cpus = cmd.command.get("cpus").and_then(|v| v.as_f64()); - let memory_mb = cmd.command.get("memory_mb").and_then(|v| v.as_u64()); - podman::container_update(&tenant_id, &name, cpus, memory_mb)?; - Ok(json!({ "ok": true })) -} - -pub fn require_str( - cmd: &serde_json::Value, - key: &'static str, -) -> std::result::Result { - cmd.get(key) - .and_then(|v| v.as_str()) - .map(|s| s.to_string()) - .ok_or(AgentError::BadRequest(key)) -} - -/// Validates a resource identifier (tenant_id, project_id, container name). -/// Allows alphanumeric, hyphens, and underscores only — no path separators or -/// shell metacharacters. Max 128 characters. -pub fn validate_id(value: &str, key: &'static str) -> std::result::Result<(), AgentError> { - if value.is_empty() - || value.len() > 128 - || !value - .chars() - .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') - { - return Err(AgentError::BadRequest(key)); - } - Ok(()) -} - -pub fn require_valid_id( - cmd: &serde_json::Value, - key: &'static str, -) -> std::result::Result { - let val = require_str(cmd, key)?; - validate_id(&val, key)?; - Ok(val) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn validate_id_accepts_valid() { - assert!(validate_id("test-org-001", "k").is_ok()); - assert!(validate_id("my_project", "k").is_ok()); - assert!(validate_id("abc123", "k").is_ok()); - assert!(validate_id("a", "k").is_ok()); - assert!(validate_id(&"x".repeat(128), "k").is_ok()); - } - - #[test] - fn validate_id_rejects_path_traversal() { - assert!(validate_id("../../etc/passwd", "k").is_err()); - assert!(validate_id("../secret", "k").is_err()); - assert!(validate_id("org/subdir", "k").is_err()); - assert!(validate_id("org\\evil", "k").is_err()); - } - - #[test] - fn validate_id_rejects_shell_metacharacters() { - assert!(validate_id("org; rm -rf /", "k").is_err()); - assert!(validate_id("org$(id)", "k").is_err()); - assert!(validate_id("org`id`", "k").is_err()); - assert!(validate_id("org | cat", "k").is_err()); - assert!(validate_id("org\nrm", "k").is_err()); - assert!(validate_id("org\x00evil", "k").is_err()); - } - - #[test] - fn validate_id_rejects_empty_and_too_long() { - assert!(validate_id("", "k").is_err()); - assert!(validate_id(&"x".repeat(129), "k").is_err()); - } -} diff --git a/lynx/agent/src/handlers/metrics.rs b/lynx/agent/src/handlers/metrics.rs deleted file mode 100644 index 776a543..0000000 --- a/lynx/agent/src/handlers/metrics.rs +++ /dev/null @@ -1,74 +0,0 @@ -use crate::{ - auth::verify_bearer, - error::{AgentError, Result}, - metrics, - state::AppState, -}; -use axum::{ - extract::{State, WebSocketUpgrade}, - http::{header, HeaderMap}, - response::{IntoResponse, Response}, -}; -use tracing::warn; - -pub async fn metrics_ws( - State(state): State, - headers: HeaderMap, - ws: WebSocketUpgrade, -) -> Result { - let token = headers - .get(header::AUTHORIZATION) - .and_then(|v| v.to_str().ok()) - .and_then(|v| v.strip_prefix("Bearer ")) - .unwrap_or(""); - - if !verify_bearer(token, &state.config.internal_token) { - return Err(AgentError::Unauthorized); - } - - Ok(ws - .on_upgrade(|socket| async move { stream_metrics(socket).await }) - .into_response()) -} - -/// Stream metrics over WebSocket. -/// CPU/RAM/disk sent every 5 seconds; container stats sent every 10 seconds. -async fn stream_metrics(mut socket: axum::extract::ws::WebSocket) { - use axum::extract::ws::Message; - use std::time::{Duration, Instant}; - - let system_interval = Duration::from_secs(5); - let container_interval = Duration::from_secs(10); - - let mut last_container = Instant::now() - .checked_sub(container_interval) - .unwrap_or_else(Instant::now); - - loop { - // Send system metrics (CPU/RAM/disk) every 5 seconds. - match metrics::sample_system().await { - Ok(m) => { - let msg = serde_json::to_string(&m).unwrap_or_default(); - if socket.send(Message::Text(msg.into())).await.is_err() { - break; - } - } - Err(e) => { - warn!("system metrics sample error: {e}"); - break; - } - } - - // Send container stats every 10 seconds (every other system tick). - if last_container.elapsed() >= container_interval { - let containers = metrics::sample_containers(); - let msg = serde_json::to_string(&containers).unwrap_or_default(); - if socket.send(Message::Text(msg.into())).await.is_err() { - break; - } - last_container = Instant::now(); - } - - tokio::time::sleep(system_interval).await; - } -} diff --git a/lynx/agent/src/handlers/mod.rs b/lynx/agent/src/handlers/mod.rs deleted file mode 100644 index 70fe737..0000000 --- a/lynx/agent/src/handlers/mod.rs +++ /dev/null @@ -1,9 +0,0 @@ -mod containers; -mod metrics; -mod nftables; -mod nginx_cmd; -mod system; -mod wireguard; - -pub use metrics::metrics_ws; -pub use system::{execute_command, health, run_verified_command}; diff --git a/lynx/agent/src/handlers/nftables.rs b/lynx/agent/src/handlers/nftables.rs deleted file mode 100644 index a8ca411..0000000 --- a/lynx/agent/src/handlers/nftables.rs +++ /dev/null @@ -1,136 +0,0 @@ -use crate::{auth::PermissionLevel, error::AgentError, nftables, state::AppState}; - -use serde_json::{json, Value}; - -pub async fn handle_nftables_apply( - state: &AppState, - cmd: &crate::auth::VerifiedCommand, -) -> std::result::Result { - if cmd.permission == PermissionLevel::Read { - return Err(AgentError::Forbidden( - "nftables.apply requires write permission", - )); - } - - // Chain-specific update - if let Some(chain) = cmd.command.get("chain").and_then(|v| v.as_str()) { - let rules = cmd - .command - .get("rules") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(); - - match chain { - "lynx-global" => state.set_nft_global_body(rules.clone()), - "lynx-local" => state.set_nft_local_body(rules.clone()), - "lynx-global-output" => state.set_nft_global_output_body(rules.clone()), - "lynx-local-output" => state.set_nft_local_output_body(rules.clone()), - _ => { - return Err(AgentError::BadRequest( - "unknown chain: must be lynx-global, lynx-local, lynx-global-output, or lynx-local-output", - )) - } - } - - let result = apply_current_ruleset(state)?; - let wg = state.nft_wg_port() as i32; - let _ = sqlx::query!( - "UPDATE nftables_state SET body = $1, wg_port = $2, updated_at = NOW() WHERE chain = $3", - rules, wg, chain - ) - .execute(&state.db) - .await; - return Ok(result); - } - - // Full apply: { wireguard_port: 51820 } - let wg_port = cmd - .command - .get("wireguard_port") - .and_then(|v| v.as_u64()) - .unwrap_or(51820) as u16; - - state.set_nft_wg_port(wg_port); - - let result = apply_current_ruleset(state)?; - let wg = wg_port as i32; - let _ = sqlx::query!( - "UPDATE nftables_state SET wg_port = $1, updated_at = NOW()", - wg - ) - .execute(&state.db) - .await; - Ok(result) -} - -fn apply_current_ruleset(state: &AppState) -> std::result::Result { - let ruleset = nftables::Ruleset { - wireguard_port: state.nft_wg_port(), - dashboard_port: state.config.dashboard_port, - dashboard_wg_ip: crate::nftables::extract_url_host( - state.config.dashboard_url.as_deref().unwrap_or(""), - ), - org_networks: vec![], - global_body: state.nft_global_body(), - local_body: state.nft_local_body(), - global_output_body: state.nft_global_output_body(), - local_output_body: state.nft_local_output_body(), - }; - - let rendered = nftables::apply(&ruleset)?; - let checksum = nftables::current_checksum()?; - state.set_nft_checksum(checksum); - state.set_nft_chain_checksums( - nftables::chain_checksum("lynx-base").ok(), - nftables::chain_checksum("lynx-global").ok(), - nftables::chain_checksum("lynx-local").ok(), - ); - state.set_nft_last_ruleset(rendered); - - Ok(json!({ "ok": true })) -} - -pub fn handle_nftables_restore( - state: &AppState, - cmd: &crate::auth::VerifiedCommand, -) -> std::result::Result { - if cmd.permission == PermissionLevel::Read { - return Err(AgentError::Forbidden( - "nftables.restore requires write permission", - )); - } - - let ruleset = state - .nft_last_ruleset() - .ok_or_else(|| AgentError::BadRequest("no ruleset has been applied yet"))?; - - nftables::apply_raw(&ruleset)?; - - let checksum = nftables::current_checksum()?; - state.set_nft_checksum(checksum); - state.set_nft_chain_checksums( - nftables::chain_checksum("lynx-base").ok(), - nftables::chain_checksum("lynx-global").ok(), - nftables::chain_checksum("lynx-local").ok(), - ); - - Ok(json!({ "ok": true, "action": "restored" })) -} - -pub fn handle_nftables_accept( - state: &AppState, - cmd: &crate::auth::VerifiedCommand, -) -> std::result::Result { - if cmd.permission == PermissionLevel::Read { - return Err(AgentError::Forbidden( - "nftables.accept requires write permission", - )); - } - - let current = nftables::current_checksum()?; - state.set_nft_checksum(current.clone()); - state.set_nft_last_ruleset(String::new()); - - Ok(json!({ "ok": true, "action": "accepted", "checksum": ¤t[..16] })) -} diff --git a/lynx/agent/src/handlers/nginx_cmd.rs b/lynx/agent/src/handlers/nginx_cmd.rs deleted file mode 100644 index 2b52b02..0000000 --- a/lynx/agent/src/handlers/nginx_cmd.rs +++ /dev/null @@ -1,267 +0,0 @@ -use crate::{ - auth::{PermissionLevel, VerifiedCommand}, - error::AgentError, - state::AppState, -}; -use serde_json::{json, Value}; - -use super::containers::require_str; - -const NGINX_CONTAINER: &str = "lynx-nginx"; -const NGINX_CONFIG_PATH: &str = "/etc/nginx/conf.d/lynx.conf"; -const WEBROOT_PATH: &str = "/var/lib/lynx/nginx/webroot"; - -/// Deploy the nginx reverse-proxy container. Idempotent — removes the old container first -/// if it exists (stopped or otherwise). -pub async fn handle_nginx_deploy( - state: &AppState, - cmd: &VerifiedCommand, -) -> std::result::Result { - if cmd.permission < PermissionLevel::Write { - return Err(AgentError::Forbidden( - "nginx.deploy requires write permission", - )); - } - - let image = require_str(&cmd.command, "image")?; - - // Stop + remove old container if present (ignore errors — it may not exist). - let _ = std::process::Command::new("podman") - .args(["stop", NGINX_CONTAINER]) - .status(); - let _ = std::process::Command::new("podman") - .args(["rm", NGINX_CONTAINER]) - .status(); - - let status = std::process::Command::new("podman") - .args([ - "run", - "--detach", - "--restart=always", - "--name", - NGINX_CONTAINER, - "--publish", - "80:80", - "--publish", - "443:443", - &image, - ]) - .status() - .map_err(|e| AgentError::Internal(anyhow::anyhow!("podman run nginx: {e}")))?; - - if !status.success() { - return Err(AgentError::Internal(anyhow::anyhow!( - "nginx container start failed" - ))); - } - - // Persist config to DB if provided (optional — may come separately via nginx.update_config). - if let Some(cfg) = cmd.command.get("config").and_then(|v| v.as_str()) { - persist_config(state, cfg).await?; - if let Err(e) = std::fs::write(NGINX_CONFIG_PATH, cfg) { - tracing::warn!("failed to write nginx config to disk: {e}"); - } - reload_nginx()?; - } - - tracing::info!("nginx container deployed"); - Ok(json!({ "ok": true, "container": NGINX_CONTAINER })) -} - -/// Update nginx config: write to disk, reload nginx, persist to agent DB. -pub async fn handle_nginx_update_config( - state: &AppState, - cmd: &VerifiedCommand, -) -> std::result::Result { - if cmd.permission < PermissionLevel::Write { - return Err(AgentError::Forbidden( - "nginx.update_config requires write permission", - )); - } - - let config = require_str(&cmd.command, "config")?; - - persist_config(state, &config).await?; - - std::fs::write(NGINX_CONFIG_PATH, config.as_bytes()) - .map_err(|e| AgentError::Internal(anyhow::anyhow!("write nginx config: {e}")))?; - - reload_nginx()?; - - tracing::info!("nginx config updated and reloaded"); - Ok(json!({ "ok": true })) -} - -async fn persist_config(state: &AppState, config: &str) -> std::result::Result<(), AgentError> { - let id = uuid::Uuid::now_v7(); - sqlx::query!( - "INSERT INTO nginx_configs (id, config_content, updated_at) VALUES ($1, $2, NOW()) - ON CONFLICT DO NOTHING", - id, - config, - ) - .execute(&state.db) - .await - .map_err(|e| AgentError::Internal(anyhow::anyhow!("persist nginx config: {e}")))?; - - // Keep only the latest row — truncate old ones. - sqlx::query!( - "DELETE FROM nginx_configs WHERE id != (SELECT id FROM nginx_configs ORDER BY updated_at DESC LIMIT 1)" - ) - .execute(&state.db) - .await - .ok(); - - Ok(()) -} - -fn reload_nginx() -> std::result::Result<(), AgentError> { - let status = std::process::Command::new("podman") - .args(["exec", NGINX_CONTAINER, "nginx", "-s", "reload"]) - .status() - .map_err(|e| AgentError::Internal(anyhow::anyhow!("nginx reload: {e}")))?; - - if !status.success() { - return Err(AgentError::Internal(anyhow::anyhow!( - "nginx -s reload failed" - ))); - } - - Ok(()) -} - -/// Install an externally-provided TLS certificate (Cloudflare Origin or custom). -/// Writes cert + optional key to disk, then reloads nginx. -pub fn handle_nginx_install_cert( - _state: &AppState, - cmd: &VerifiedCommand, -) -> std::result::Result { - if cmd.permission < PermissionLevel::Write { - return Err(AgentError::Forbidden( - "nginx.install_cert requires write permission", - )); - } - - let domain = require_str(&cmd.command, "domain")?; - validate_domain_for_path(&domain)?; - let cert_pem = require_str(&cmd.command, "cert_pem")?; - - let cert_dir = format!("/etc/lynx/nginx/certs/{domain}"); - std::fs::create_dir_all(&cert_dir) - .map_err(|e| AgentError::Internal(anyhow::anyhow!("create cert dir: {e}")))?; - - let cert_path = format!("{cert_dir}/fullchain.pem"); - let key_path = format!("{cert_dir}/privkey.pem"); - - std::fs::write(&cert_path, cert_pem.as_bytes()) - .map_err(|e| AgentError::Internal(anyhow::anyhow!("write cert: {e}")))?; - - if let Some(key_pem) = cmd.command.get("key_pem").and_then(|v| v.as_str()) { - std::fs::write(&key_path, key_pem.as_bytes()) - .map_err(|e| AgentError::Internal(anyhow::anyhow!("write key: {e}")))?; - } - - // Reload nginx if the container is running. - let _ = reload_nginx(); - - tracing::info!(domain, "external TLS cert installed"); - Ok(json!({ "ok": true, "domain": domain, "cert_path": cert_path })) -} - -/// Obtain a Let's Encrypt certificate via certbot (webroot challenge). -pub async fn handle_certbot_obtain( - _state: &AppState, - cmd: &VerifiedCommand, -) -> std::result::Result { - if cmd.permission < PermissionLevel::Write { - return Err(AgentError::Forbidden( - "certbot.obtain requires write permission", - )); - } - - let domain = require_str(&cmd.command, "domain")?; - let email = require_str(&cmd.command, "email")?; - - std::fs::create_dir_all(WEBROOT_PATH) - .map_err(|e| AgentError::Internal(anyhow::anyhow!("create webroot: {e}")))?; - - let status = tokio::process::Command::new("certbot") - .args([ - "certonly", - "--webroot", - "--webroot-path", - WEBROOT_PATH, - "--non-interactive", - "--agree-tos", - "--email", - &email, - "-d", - &domain, - ]) - .status() - .await - .map_err(|e| AgentError::Internal(anyhow::anyhow!("certbot exec: {e}")))?; - - if !status.success() { - return Err(AgentError::Internal(anyhow::anyhow!( - "certbot failed to obtain certificate" - ))); - } - - tracing::info!(domain, "Let's Encrypt cert obtained"); - Ok(json!({ "ok": true, "domain": domain })) -} - -fn validate_domain_for_path(domain: &str) -> std::result::Result<(), AgentError> { - if domain.is_empty() - || domain.len() > 253 - || domain.contains("..") - || domain.contains('/') - || domain.contains('\0') - || !domain - .chars() - .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '.') - || domain.starts_with('.') - || domain.ends_with('.') - { - return Err(AgentError::BadRequest("invalid domain for cert path")); - } - Ok(()) -} - -/// Close port 19443 via nftables once a domain is confirmed active. -pub fn handle_close_setup_port( - _state: &AppState, - cmd: &VerifiedCommand, -) -> std::result::Result { - if cmd.permission < PermissionLevel::Write { - return Err(AgentError::Forbidden( - "nftables.close_setup_port requires write permission", - )); - } - - // Delete the rule that allows 19443 inbound. - // We use `nft -f -` with a flush + delete approach. If the rule handle is - // unknown we instead just add a drop rule — the end result is the same. - let drop_status = std::process::Command::new("nft") - .args([ - "add", - "rule", - "inet", - "lynx-agent", - "lynx-base", - "tcp", - "dport", - "19443", - "drop", - ]) - .status() - .map_err(|e| AgentError::Internal(anyhow::anyhow!("nft add drop rule: {e}")))?; - - if !drop_status.success() { - tracing::warn!("nft: could not add 19443 drop rule — port may already be closed"); - } - - tracing::info!("port 19443 closed via nftables"); - Ok(json!({ "ok": true, "port": 19443 })) -} diff --git a/lynx/agent/src/handlers/system.rs b/lynx/agent/src/handlers/system.rs deleted file mode 100644 index 772b0f2..0000000 --- a/lynx/agent/src/handlers/system.rs +++ /dev/null @@ -1,414 +0,0 @@ -use crate::{ - audit::{self, AuditEntry, AuditResult}, - auth::{verify_bearer, verify_command, PermissionLevel, SignedCommand, VerifiedCommand}, - cert, - error::{AgentError, Result}, - state::AppState, - update, -}; -use axum::{ - extract::State, - http::{header, HeaderMap, StatusCode}, - response::{IntoResponse, Response}, - Json, -}; -use serde_json::{json, Value}; -use tracing::{info, warn}; - -use super::containers::require_str; -use super::{ - containers::{ - handle_container_deploy, handle_container_down, handle_container_list, - handle_container_remove, handle_container_restart, handle_container_start, - handle_container_stop, handle_container_update, handle_tenant_ensure, - }, - nftables::{handle_nftables_accept, handle_nftables_apply, handle_nftables_restore}, - nginx_cmd::{ - handle_certbot_obtain, handle_close_setup_port, handle_nginx_deploy, - handle_nginx_install_cert, handle_nginx_update_config, - }, - wireguard::{ - handle_wg_data_plane_setup, handle_wg_data_plane_teardown, handle_wg_management_add_peer, - handle_wg_management_list_peers, handle_wg_management_remove_peer, handle_wg_rotate_psk, - }, -}; - -pub async fn health() -> StatusCode { - StatusCode::OK -} - -/// Verify a signed command, execute it, and write the audit entry. -/// Returns the result `Value` on success. -/// Called by both the HTTP handler (after bearer auth) and the WS client. -pub async fn run_verified_command( - state: &AppState, - signed: SignedCommand, -) -> std::result::Result { - if !state.check_cmd_rate() { - let count = state.record_rate_rejection(); - audit::append( - &state.db, - AuditEntry { - agent_id: state.config.agent_id, - organization_id: None, - user_id: None, - command_type: "unknown", - result: AuditResult::RejectedRateLimit, - error: None, - }, - ) - .await - .ok(); - if count >= 3 { - tracing::warn!(count, "rate limit threshold reached — alerting"); - } - return Err(AgentError::BadRequest("rate limit exceeded")); - } - - let verified = match verify_command( - &state.db, - &signed, - &state.config.dashboard_verify_key, - state.config.agent_id, - ) - .await - { - Ok(v) => v, - Err(e) => { - warn!("command rejected: {e}"); - audit::append( - &state.db, - AuditEntry { - agent_id: state.config.agent_id, - organization_id: None, - user_id: None, - command_type: "unknown", - result: AuditResult::Rejected, - error: Some(e.to_string()), - }, - ) - .await - .ok(); - return Err(AgentError::Unauthorized); - } - }; - - let cmd_type = verified - .command - .get("type") - .and_then(|v| v.as_str()) - .unwrap_or("unknown") - .to_string(); - - info!( - cmd_type = %cmd_type, - user_id = %verified.user_id, - permission = ?verified.permission, - "executing command" - ); - - let result = command_dispatch(state, &verified).await; - - let audit_result = match &result { - Ok(_) => AuditResult::Success, - Err(AgentError::BadRequest(_)) - | Err(AgentError::Unauthorized) - | Err(AgentError::Forbidden(_)) => AuditResult::Rejected, - Err(_) => AuditResult::Failed, - }; - - audit::append( - &state.db, - AuditEntry { - agent_id: state.config.agent_id, - organization_id: verified.organization_id, - user_id: Some(verified.user_id), - command_type: &cmd_type, - result: audit_result, - error: match &result { - Err(e) => Some(sanitize_error(e)), - Ok(_) => None, - }, - }, - ) - .await?; - - result -} - -/// HTTP handler — adds bearer token auth and lockdown check on top of `run_verified_command`. -pub async fn execute_command( - State(state): State, - headers: HeaderMap, - Json(signed): Json, -) -> Result { - if state.is_locked_down() { - return Err(AgentError::Lockdown); - } - - let token = headers - .get(header::AUTHORIZATION) - .and_then(|v| v.to_str().ok()) - .and_then(|v| v.strip_prefix("Bearer ")) - .unwrap_or(""); - - if !verify_bearer(token, &state.config.internal_token) { - return Err(AgentError::Unauthorized); - } - - run_verified_command(&state, signed) - .await - .map(|v| Json(v).into_response()) -} - -async fn command_dispatch( - state: &AppState, - cmd: &VerifiedCommand, -) -> std::result::Result { - match cmd - .command - .get("type") - .and_then(|v| v.as_str()) - .unwrap_or("unknown") - { - "nftables.apply" => handle_nftables_apply(state, cmd).await, - "nftables.restore" => handle_nftables_restore(state, cmd), - "nftables.accept" => handle_nftables_accept(state, cmd), - "container.list" => handle_container_list(cmd), - "tenant.ensure" => handle_tenant_ensure(cmd), - "container.deploy" => handle_container_deploy(state, cmd).await, - "container.down" => handle_container_down(state, cmd).await, - "container.start" => handle_container_start(cmd), - "container.stop" => handle_container_stop(cmd), - "container.remove" => handle_container_remove(cmd), - "container.restart" => handle_container_restart(cmd), - "container.update" => handle_container_update(cmd), - "update.self" => handle_update_self(cmd).await, - "wg.rotate_psk" => handle_wg_rotate_psk(cmd), - "wg.management.add_peer" => handle_wg_management_add_peer(cmd), - "wg.management.remove_peer" => handle_wg_management_remove_peer(cmd), - "wg.management.list_peers" => handle_wg_management_list_peers(cmd), - "wg.data_plane.setup" => handle_wg_data_plane_setup(cmd), - "wg.data_plane.teardown" => handle_wg_data_plane_teardown(cmd), - "dashboard.migrate" => handle_dashboard_migrate(state, cmd).await, - "cert.update" => handle_cert_update(state, cmd).await, - "vps.reboot" => handle_vps_reboot(cmd), - "nginx.deploy" => handle_nginx_deploy(state, cmd).await, - "nginx.update_config" => handle_nginx_update_config(state, cmd).await, - "nginx.install_cert" => Ok(handle_nginx_install_cert(state, cmd)?), - "certbot.obtain" => handle_certbot_obtain(state, cmd).await, - "nftables.close_setup_port" => Ok(handle_close_setup_port(state, cmd)?), - "db.rotate_password" => handle_db_rotate_password(state, cmd).await, - // Heartbeat ACK resets the lockdown timer and exits lockdown. - // Handled here so WS path can also process it via run_verified_command. - "agent.heartbeat_ack" => { - *state.last_heartbeat.lock().unwrap() = std::time::Instant::now(); - state.clear_lockdown_if_heartbeat(); - Ok(json!({ "ok": true })) - } - other => { - warn!("unknown command type: {other}"); - Err(AgentError::BadRequest("unknown command type")) - } - } -} - -async fn handle_update_self(cmd: &VerifiedCommand) -> std::result::Result { - if cmd.permission == PermissionLevel::Read { - return Err(AgentError::Forbidden( - "update.self requires write permission", - )); - } - let version = require_str(&cmd.command, "version")?; - let download_url = require_str(&cmd.command, "download_url")?; - let sig_url = require_str(&cmd.command, "sig_url")?; - - tokio::spawn(async move { - if let Err(e) = update::perform_update(&version, &download_url, &sig_url).await { - tracing::error!(version, "update failed: {e:#}"); - } - }); - - Ok(json!({ "ok": true, "message": "update initiated" })) -} - -pub async fn handle_dashboard_migrate( - state: &AppState, - cmd: &VerifiedCommand, -) -> std::result::Result { - if cmd.permission == PermissionLevel::Read { - return Err(AgentError::Forbidden( - "dashboard.migrate requires write permission", - )); - } - - let target_url = require_str(&cmd.command, "target_url")?; - - let sync_token = match state.config.sync_token.as_deref() { - Some(t) => t.to_string(), - None => return Err(AgentError::BadRequest("no sync token configured")), - }; - let agent_id = state.config.agent_id; - - tokio::spawn(async move { - let Ok(client) = reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(30)) - .build() - else { - return; - }; - - let _ = client - .post(format!("{target_url}/migration/agent-confirm")) - .header("Authorization", format!("Bearer {sync_token}")) - .json(&serde_json::json!({ "agent_id": agent_id })) - .send() - .await; - - tracing::info!("notified VPS-B of migration confirmation"); - }); - - Ok(json!({ "ok": true, "message": "migration acknowledgment sent" })) -} - -pub async fn handle_cert_update( - state: &AppState, - cmd: &VerifiedCommand, -) -> std::result::Result { - if cmd.permission < PermissionLevel::Write { - return Err(AgentError::Forbidden( - "cert.update requires write permission", - )); - } - - let payload = cmd - .command - .get("payload") - .and_then(|v| v.as_str()) - .ok_or(AgentError::BadRequest("missing payload"))? - .to_string(); - let signature = cmd - .command - .get("signature") - .and_then(|v| v.as_str()) - .ok_or(AgentError::BadRequest("missing signature"))? - .to_string(); - - let cert_entry = cert::SignedCert { payload, signature }; - - let ca_public = cert::load_ca_public_key() - .ok_or_else(|| AgentError::Internal(anyhow::anyhow!("CA_PUBLIC_KEY not configured")))?; - - cert::verify(&cert_entry, &ca_public, state.config.agent_id).map_err(AgentError::Internal)?; - - let cert_json = - serde_json::to_string(&cert_entry).map_err(|e| AgentError::Internal(anyhow::anyhow!(e)))?; - - let cert_path = std::path::Path::new("/etc/lynx/cert.json"); - if let Some(parent) = cert_path.parent() { - std::fs::create_dir_all(parent).map_err(|e| AgentError::Internal(anyhow::anyhow!(e)))?; - } - tokio::fs::write(cert_path, cert_json.as_bytes()) - .await - .map_err(|e| AgentError::Internal(anyhow::anyhow!(e)))?; - - tracing::info!(agent_id = %state.config.agent_id, "agent cert renewed and persisted to /etc/lynx/cert.json"); - - Ok(json!({ "ok": true })) -} - -async fn handle_db_rotate_password( - state: &AppState, - cmd: &VerifiedCommand, -) -> std::result::Result { - if cmd.permission < PermissionLevel::Write { - return Err(AgentError::Forbidden( - "db.rotate_password requires write permission", - )); - } - - use rand::Rng; - use zeroize::Zeroizing; - let mut buf = [0u8; 24]; - rand::rng().fill_bytes(&mut buf); - let new_pass = Zeroizing::new(buf.iter().map(|b| format!("{b:02x}")).collect::()); - - // Dollar-quoting ($$...$$) avoids any quote-based injection. - // new_pass is hex [0-9a-f] so "$$" can never appear inside it. - sqlx::query(&format!( - "ALTER USER lynx_agent_app PASSWORD $${}$$", - &*new_pass - )) - .execute(&state.db) - .await - .map_err(|e| AgentError::Internal(anyhow::anyhow!("ALTER USER: {e}")))?; - - let status = std::process::Command::new("podman") - .args(["secret", "create", "--replace", "lynx-agent-pg-pass", "-"]) - .stdin(std::process::Stdio::piped()) - .spawn() - .and_then(|mut child| { - use std::io::Write; - child - .stdin - .as_mut() - .unwrap() - .write_all(new_pass.as_bytes())?; - child.wait() - }) - .map_err(|e| AgentError::Internal(anyhow::anyhow!("podman secret create: {e}")))?; - - if !status.success() { - tracing::warn!("failed to update Podman secret lynx-agent-pg-pass — password rotated in DB but secret not updated"); - } - - // Update /etc/lynx/credentials/database-url so systemd LoadCredential - // serves the new password on next agent restart. - match update_database_url_credential(&state.config.database_url, &new_pass) { - Ok(()) => tracing::info!("updated /etc/lynx/credentials/database-url with new password"), - Err(e) => tracing::warn!("failed to update /etc/lynx/credentials/database-url: {e} — credential file still has old password"), - } - - tracing::info!("agent PostgreSQL password rotated"); - Ok(json!({ "ok": true })) -} - -fn update_database_url_credential(current_url: &str, new_pass: &str) -> anyhow::Result<()> { - let mut parsed = url::Url::parse(current_url) - .map_err(|e| anyhow::anyhow!("failed to parse database_url: {e}"))?; - parsed - .set_password(Some(new_pass)) - .map_err(|_| anyhow::anyhow!("failed to set password in database URL"))?; - let new_url = parsed.to_string(); - let path = std::path::Path::new("/etc/lynx/credentials/database-url"); - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent)?; - } - std::fs::write(path, new_url.as_bytes())?; - // 600 — readable only by lynx-agent - use std::os::unix::fs::PermissionsExt; - std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?; - Ok(()) -} - -fn handle_vps_reboot(cmd: &VerifiedCommand) -> std::result::Result { - if cmd.permission < PermissionLevel::Write { - return Err(AgentError::Forbidden( - "vps.reboot requires write permission", - )); - } - tokio::spawn(async { - tokio::time::sleep(tokio::time::Duration::from_millis(200)).await; - let _ = std::process::Command::new("systemctl") - .arg("reboot") - .status(); - }); - Ok(json!({ "ok": true, "message": "reboot initiated" })) -} - -fn sanitize_error(e: &AgentError) -> String { - match e { - AgentError::Internal(_) => "internal error".to_string(), - other => other.to_string(), - } -} diff --git a/lynx/agent/src/handlers/wireguard.rs b/lynx/agent/src/handlers/wireguard.rs deleted file mode 100644 index b637313..0000000 --- a/lynx/agent/src/handlers/wireguard.rs +++ /dev/null @@ -1,331 +0,0 @@ -use crate::{ - auth::{PermissionLevel, VerifiedCommand}, - error::AgentError, -}; -use serde_json::{json, Value}; -use std::io::Write; -use zeroize::Zeroizing; - -use super::containers::require_str; - -pub fn handle_wg_rotate_psk(cmd: &VerifiedCommand) -> std::result::Result { - if cmd.permission == PermissionLevel::Read { - return Err(AgentError::Forbidden( - "wg.rotate_psk requires write permission", - )); - } - let new_psk = Zeroizing::new(require_str(&cmd.command, "new_psk")?.to_string()); - - let peers_out = std::process::Command::new("wg") - .args(["show", "wg-lynx-agent", "peers"]) - .output() - .map_err(|e| AgentError::Internal(anyhow::anyhow!("wg show: {e}")))?; - - let dashboard_pubkey = String::from_utf8_lossy(&peers_out.stdout) - .trim() - .lines() - .next() - .unwrap_or("") - .to_string(); - - if dashboard_pubkey.is_empty() { - return Err(AgentError::Internal(anyhow::anyhow!( - "no WireGuard peers found" - ))); - } - - let mut child = std::process::Command::new("wg") - .args([ - "set", - "wg-lynx-agent", - "peer", - &dashboard_pubkey, - "preshared-key", - "/dev/stdin", - ]) - .stdin(std::process::Stdio::piped()) - .spawn() - .map_err(|e| AgentError::Internal(anyhow::anyhow!("wg set: {e}")))?; - - if let Some(stdin) = child.stdin.as_mut() { - stdin - .write_all(new_psk.as_bytes()) - .map_err(|e| AgentError::Internal(anyhow::anyhow!("write psk: {e}")))?; - } - - let status = child - .wait() - .map_err(|e| AgentError::Internal(anyhow::anyhow!("wait wg: {e}")))?; - - if !status.success() { - return Err(AgentError::Internal(anyhow::anyhow!( - "wg set preshared-key failed" - ))); - } - - // Persist new PSK to credential file so it survives agent restarts. - const PSK_PATH: &str = "/etc/lynx/credentials/lynx-wg-psk"; - if let Err(e) = std::fs::write(PSK_PATH, new_psk.as_bytes()) { - tracing::warn!("failed to persist new PSK to {PSK_PATH}: {e}"); - } else { - // Set restrictive permissions (600). - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let _ = std::fs::set_permissions(PSK_PATH, std::fs::Permissions::from_mode(0o600)); - } - } - - // Also update the wg-quick conf so the PSK survives a full reboot. - // wg-quick reads PresharedKey from the conf at boot; if it diverges from the - // credential file the tunnel breaks after the next reboot. - const WG_CONF_PATH: &str = "/etc/lynx/wireguard/lynx-wg.conf"; - match std::fs::read_to_string(WG_CONF_PATH) { - Ok(conf) => { - let updated = conf - .lines() - .map(|line| { - if line.trim_start().starts_with("PresharedKey") { - format!("PresharedKey = {}", new_psk.as_str()) - } else { - line.to_string() - } - }) - .collect::>() - .join("\n"); - if let Err(e) = std::fs::write(WG_CONF_PATH, updated) { - tracing::warn!("failed to update PresharedKey in {WG_CONF_PATH}: {e}"); - } - } - Err(e) => tracing::warn!("failed to read {WG_CONF_PATH} for PSK update: {e}"), - } - - tracing::info!("WireGuard PSK rotated and persisted"); - Ok(json!({ "ok": true })) -} - -pub fn handle_wg_data_plane_setup(cmd: &VerifiedCommand) -> std::result::Result { - if cmd.permission == PermissionLevel::Read { - return Err(AgentError::Forbidden( - "wg.data_plane.setup requires write permission", - )); - } - - let tunnel_id = require_str(&cmd.command, "tunnel_id")?; - // Strip hyphens and take first 8 chars; then validate only alphanumeric remain. - let iface_suffix_full = tunnel_id.replace('-', ""); - let iface_suffix = &iface_suffix_full[..iface_suffix_full.len().min(8)]; - if !iface_suffix.chars().all(|c| c.is_ascii_alphanumeric()) { - return Err(AgentError::BadRequest( - "tunnel_id produces invalid interface suffix", - )); - } - let interface = format!("wg-lynx-dp-{iface_suffix}"); - - let local_privkey = Zeroizing::new(require_str(&cmd.command, "private_key")?.to_string()); - let local_ip_cidr = require_str(&cmd.command, "local_ip")?; - let peer_pubkey = require_str(&cmd.command, "peer_pubkey")?; - let psk = Zeroizing::new(require_str(&cmd.command, "psk")?.to_string()); - let wg_port = cmd - .command - .get("wg_port") - .and_then(|v| v.as_u64()) - .unwrap_or(51821) as u16; - - let peer_endpoint = cmd - .command - .get("peer_endpoint") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - - let peer_allowed = { - let parts: Vec<&str> = local_ip_cidr.splitn(2, '/').collect(); - let base = parts[0]; - let subnet: Vec<&str> = base.rsplitn(2, '.').collect(); - if subnet.len() == 2 { - format!("{}.0/30", subnet[1]) - } else { - local_ip_cidr.clone() - } - }; - - let config_path = format!("/etc/wireguard/{interface}.conf"); - let endpoint_line = peer_endpoint - .map(|ep| format!("Endpoint = {ep}\n")) - .unwrap_or_default(); - - let config = Zeroizing::new(format!( - "[Interface]\nPrivateKey = {}\nAddress = {local_ip_cidr}\nListenPort = {wg_port}\n\n[Peer]\nPublicKey = {peer_pubkey}\nPresharedKey = {}\nAllowedIPs = {peer_allowed}\n{endpoint_line}", - &*local_privkey, &*psk - )); - - { - use std::os::unix::fs::OpenOptionsExt; - let mut f = std::fs::OpenOptions::new() - .write(true) - .create(true) - .truncate(true) - .mode(0o600) - .open(&config_path) - .map_err(|e| { - AgentError::Internal(anyhow::anyhow!("write wg config {config_path}: {e}")) - })?; - f.write_all(config.as_bytes()) - .map_err(|e| AgentError::Internal(anyhow::anyhow!("write wg config content: {e}")))?; - } - - let status = std::process::Command::new("wg-quick") - .args(["up", &interface]) - .status() - .map_err(|e| AgentError::Internal(anyhow::anyhow!("wg-quick up: {e}")))?; - - if !status.success() { - let status2 = std::process::Command::new("wg") - .args(["syncconf", &interface, &config_path]) - .status() - .map_err(|e| AgentError::Internal(anyhow::anyhow!("wg syncconf: {e}")))?; - if !status2.success() { - return Err(AgentError::Internal(anyhow::anyhow!( - "wg-quick up and wg syncconf both failed for {interface}" - ))); - } - } - - tracing::info!("data-plane WireGuard interface {interface} configured"); - Ok(json!({ "ok": true, "interface": interface })) -} - -pub fn handle_wg_data_plane_teardown( - cmd: &VerifiedCommand, -) -> std::result::Result { - if cmd.permission == PermissionLevel::Read { - return Err(AgentError::Forbidden( - "wg.data_plane.teardown requires write permission", - )); - } - - let tunnel_id = require_str(&cmd.command, "tunnel_id")?; - let iface_suffix_full = tunnel_id.replace('-', ""); - let iface_suffix = &iface_suffix_full[..iface_suffix_full.len().min(8)]; - if !iface_suffix.chars().all(|c| c.is_ascii_alphanumeric()) { - return Err(AgentError::BadRequest( - "tunnel_id produces invalid interface suffix", - )); - } - let interface = format!("wg-lynx-dp-{iface_suffix}"); - let config_path = format!("/etc/wireguard/{interface}.conf"); - - let _ = std::process::Command::new("wg-quick") - .args(["down", &interface]) - .status(); - - let _ = std::fs::remove_file(&config_path); - - tracing::info!("data-plane WireGuard interface {interface} torn down"); - Ok(json!({ "ok": true, "interface": interface })) -} - -const MGMT_IFACE: &str = "wg-lynx-dash"; - -/// Add a peer to the management-plane WireGuard interface (`wg-lynx-dash`). -/// Called by the dashboard when a new remote agent is registered. -pub fn handle_wg_management_add_peer( - cmd: &VerifiedCommand, -) -> std::result::Result { - if cmd.permission == PermissionLevel::Read { - return Err(AgentError::Forbidden( - "wg.management.add_peer requires write permission", - )); - } - let pubkey = require_str(&cmd.command, "pubkey")?.to_string(); - let allowed_ip = require_str(&cmd.command, "allowed_ip")?; - let psk = Zeroizing::new(require_str(&cmd.command, "psk")?.to_string()); - - let allowed = format!("{allowed_ip}/32"); - let mut child = std::process::Command::new("wg") - .args([ - "set", - MGMT_IFACE, - "peer", - &pubkey, - "preshared-key", - "/dev/stdin", - "allowed-ips", - &allowed, - ]) - .stdin(std::process::Stdio::piped()) - .spawn() - .map_err(|e| AgentError::Internal(anyhow::anyhow!("wg set peer: {e}")))?; - - if let Some(stdin) = child.stdin.as_mut() { - stdin - .write_all(psk.as_bytes()) - .map_err(|e| AgentError::Internal(anyhow::anyhow!("write psk: {e}")))?; - } - let status = child - .wait() - .map_err(|e| AgentError::Internal(anyhow::anyhow!("wait wg: {e}")))?; - if !status.success() { - return Err(AgentError::Internal(anyhow::anyhow!( - "wg set peer failed for {MGMT_IFACE}" - ))); - } - - tracing::info!(pubkey = %&pubkey[..16], "management WireGuard peer added"); - Ok(json!({ "ok": true })) -} - -/// Remove a peer from the management-plane WireGuard interface (`wg-lynx-dash`). -pub fn handle_wg_management_remove_peer( - cmd: &VerifiedCommand, -) -> std::result::Result { - if cmd.permission == PermissionLevel::Read { - return Err(AgentError::Forbidden( - "wg.management.remove_peer requires write permission", - )); - } - let pubkey = require_str(&cmd.command, "pubkey")?.to_string(); - - let status = std::process::Command::new("wg") - .args(["set", MGMT_IFACE, "peer", &pubkey, "remove"]) - .status() - .map_err(|e| AgentError::Internal(anyhow::anyhow!("wg set peer remove: {e}")))?; - if !status.success() { - return Err(AgentError::Internal(anyhow::anyhow!( - "wg peer remove failed for {MGMT_IFACE}" - ))); - } - - tracing::info!(pubkey = %&pubkey[..16], "management WireGuard peer removed"); - Ok(json!({ "ok": true })) -} - -/// List peers on the management-plane WireGuard interface (`wg-lynx-dash`). -/// Returns a JSON array of base64 public keys. -pub fn handle_wg_management_list_peers( - cmd: &VerifiedCommand, -) -> std::result::Result { - if cmd.permission == PermissionLevel::Read { - return Err(AgentError::Forbidden( - "wg.management.list_peers requires write permission", - )); - } - - let out = std::process::Command::new("wg") - .args(["show", MGMT_IFACE, "peers"]) - .output() - .map_err(|e| AgentError::Internal(anyhow::anyhow!("wg show peers: {e}")))?; - - if !out.status.success() { - // Interface doesn't exist yet — return empty list. - return Ok(json!({ "peers": [] })); - } - - let peers: Vec = String::from_utf8_lossy(&out.stdout) - .lines() - .map(|l| l.trim().to_string()) - .filter(|l| !l.is_empty()) - .collect(); - - Ok(json!({ "peers": peers })) -} diff --git a/lynx/agent/src/main.rs b/lynx/agent/src/main.rs deleted file mode 100644 index 987c5f5..0000000 --- a/lynx/agent/src/main.rs +++ /dev/null @@ -1,519 +0,0 @@ -mod audit; -mod auth; -mod cert; -mod config; -mod conflict; -mod error; -mod handlers; -mod metrics; -mod nftables; -mod nginx; -mod podman; -mod state; -mod sync; -pub mod update; -mod ws_client; - -use anyhow::Context; -use axum::{ - extract::State, - http::StatusCode, - response::{IntoResponse, Response}, - routing::{get, post}, - Json, Router, -}; -use clap::{Parser, Subcommand}; -use state::AppState; -use std::sync::{ - atomic::{AtomicBool, Ordering}, - Arc, -}; -use tokio::time::{interval, Duration}; -use tracing::info; - -fn build_tls_acceptor(config: &config::Config) -> Option { - let cert_der = config.tls_cert_der.as_ref()?; - let key_der = config.tls_key_der.as_ref()?; - let ca_cert_der = config.tls_ca_cert_der.as_ref()?; - - use rustls::pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer}; - use std::sync::Arc as StdArc; - - // Clone into owned data so the resulting ServerConfig is 'static. - let cert_chain = vec![CertificateDer::from(cert_der.clone())]; - let key = PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(key_der.to_vec())); - - // Build client cert verifier trusting only the dashboard CA. - let mut root_store = rustls::RootCertStore::empty(); - if let Err(e) = root_store.add(CertificateDer::from(ca_cert_der.clone())) { - tracing::warn!("TLS CA cert add failed: {e} — falling back to plain HTTP"); - return None; - } - - let client_verifier = - match rustls::server::WebPkiClientVerifier::builder(StdArc::new(root_store)).build() { - Ok(v) => v, - Err(e) => { - tracing::warn!( - "TLS client verifier build failed: {e} — falling back to plain HTTP" - ); - return None; - } - }; - - let server_config = match rustls::ServerConfig::builder() - .with_client_cert_verifier(client_verifier) - .with_single_cert(cert_chain, key) - { - Ok(c) => c, - Err(e) => { - tracing::warn!("TLS ServerConfig build failed: {e} — falling back to plain HTTP"); - return None; - } - }; - - Some(tokio_rustls::TlsAcceptor::from(StdArc::new(server_config))) -} - -async fn serve_tls( - listener: tokio::net::TcpListener, - app: Router, - acceptor: tokio_rustls::TlsAcceptor, -) -> anyhow::Result<()> { - use hyper::server::conn::http1; - use hyper_util::rt::TokioIo; - - loop { - let (tcp_stream, _remote_addr) = listener.accept().await.context("accept TCP")?; - let acceptor = acceptor.clone(); - let app = app.clone(); - - tokio::spawn(async move { - let tls_stream = match acceptor.accept(tcp_stream).await { - Ok(s) => s, - Err(e) => { - tracing::debug!("TLS handshake failed: {e}"); - return; - } - }; - - let io = TokioIo::new(tls_stream); - - // Bridge hyper::body::Incoming → axum::body::Body so the router can handle it. - let svc = - hyper::service::service_fn(move |req: hyper::Request| { - let app = app.clone(); - async move { - use tower::ServiceExt; - let req = req.map(axum::body::Body::new); - app.oneshot(req).await - } - }); - - if let Err(e) = http1::Builder::new() - .serve_connection(io, svc) - .with_upgrades() - .await - { - tracing::debug!("HTTP connection error: {e}"); - } - }); - } -} - -/// Agent enters lockdown if no heartbeat received from dashboard within this window. -const HEARTBEAT_TIMEOUT_SECS: u64 = 300; - -#[derive(Parser)] -#[command(name = "lynx-agent", about = "Lynx Agent")] -struct Cli { - #[command(subcommand)] - command: Option, -} - -#[derive(Subcommand)] -enum AgentCommand { - /// Display or stream agent logs from journald. - Logs { - #[arg(long, short = 'f')] - follow: bool, - #[arg(long)] - errors: bool, - #[arg(long)] - since: Option, - }, - /// Print cryptographically-secure random bytes (replaces `openssl rand`). - GenRand { - bytes: usize, - #[arg(long, default_value = "hex")] - encoding: String, - }, - /// Generate a time-ordered UUIDv7 (replaces `python3 -c "import uuid; print(uuid.uuid7())"`). - GenUuidV7, -} - -#[tokio::main] -async fn main() -> anyhow::Result<()> { - // Rustls 0.23 requires an explicit crypto provider when multiple crates - // (reqwest, tokio-tungstenite, sqlx) each pull in rustls independently. - let _ = rustls::crypto::ring::default_provider().install_default(); - - tracing_subscriber::fmt() - .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) - .init(); - - let cli = Cli::parse(); - - match cli.command { - Some(AgentCommand::Logs { - follow, - errors, - since, - }) => return agent_logs(follow, errors, since), - Some(AgentCommand::GenRand { - ref bytes, - ref encoding, - }) => return agent_gen_rand(*bytes, encoding), - Some(AgentCommand::GenUuidV7) => return agent_gen_uuid_v7(), - _ => {} - } - - let config = config::Config::load()?; - let listen_addr = config.listen_addr.clone(); - - let db = sqlx::PgPool::connect(&config.database_url) - .await - .context("connect to PostgreSQL")?; - - sqlx::migrate!("./migrations") - .run(&db) - .await - .context("run migrations")?; - - let lockdown = Arc::new(AtomicBool::new(false)); - - let state = AppState { - db, - config: Arc::new(config), - lockdown: lockdown.clone(), - lockdown_reason: Arc::new(std::sync::Mutex::new(None)), - nft_checksum: Arc::new(std::sync::Mutex::new(None)), - nft_chain_checksums: Arc::new(std::sync::Mutex::new([None, None, None])), - nft_last_ruleset: Arc::new(std::sync::Mutex::new(None)), - nft_global_body: Arc::new(std::sync::Mutex::new(String::new())), - nft_local_body: Arc::new(std::sync::Mutex::new(String::new())), - nft_global_output_body: Arc::new(std::sync::Mutex::new(String::new())), - nft_local_output_body: Arc::new(std::sync::Mutex::new(String::new())), - nft_wg_port: Arc::new(std::sync::atomic::AtomicU32::new(51820)), - cmd_rate: Arc::new(std::sync::Mutex::new((0u64, 0u64))), - cmd_rejected_count: Arc::new(std::sync::atomic::AtomicU64::new(0)), - cmd_rejected_window: Arc::new(std::sync::atomic::AtomicU64::new(0)), - last_dashboard_contact: Arc::new(std::sync::atomic::AtomicU64::new(0)), - last_heartbeat: Arc::new(std::sync::Mutex::new(std::time::Instant::now())), - }; - - // Reload nftables state from DB and re-apply on startup (rules don't persist across reboots). - { - let rows = sqlx::query!("SELECT chain, body, wg_port FROM nftables_state ORDER BY chain") - .fetch_all(&state.db) - .await; - - if let Ok(rows) = rows { - let mut global_body = String::new(); - let mut local_body = String::new(); - let mut global_output_body = String::new(); - let mut local_output_body = String::new(); - let mut wg_port = 51820u16; - - for row in &rows { - match row.chain.as_str() { - "lynx-global" => global_body = row.body.clone(), - "lynx-local" => local_body = row.body.clone(), - "lynx-global-output" => global_output_body = row.body.clone(), - "lynx-local-output" => local_output_body = row.body.clone(), - _ => {} - } - wg_port = row.wg_port as u16; - } - - state.set_nft_global_body(global_body); - state.set_nft_local_body(local_body); - state.set_nft_global_output_body(global_output_body); - state.set_nft_local_output_body(local_output_body); - state.set_nft_wg_port(wg_port); - - let ruleset = nftables::Ruleset { - wireguard_port: wg_port, - dashboard_port: state.config.dashboard_port, - dashboard_wg_ip: crate::nftables::extract_url_host( - state.config.dashboard_url.as_deref().unwrap_or(""), - ), - org_networks: vec![], - global_body: state.nft_global_body(), - local_body: state.nft_local_body(), - global_output_body: state.nft_global_output_body(), - local_output_body: state.nft_local_output_body(), - }; - - match nftables::apply(&ruleset) { - Ok(rendered) => { - if let Ok(checksum) = nftables::current_checksum() { - state.set_nft_checksum(checksum); - } - state.set_nft_chain_checksums( - nftables::chain_checksum("lynx-base").ok(), - nftables::chain_checksum("lynx-global").ok(), - nftables::chain_checksum("lynx-local").ok(), - ); - state.set_nft_last_ruleset(rendered); - tracing::info!("nftables ruleset re-applied from DB on startup"); - } - Err(e) => { - tracing::warn!(error = %e, "nftables startup apply failed — will retry on first dashboard push") - } - } - } - } - - // Container recovery: restart any deployments with desired=running that aren't up. - // Safety net for reboots — rootless Podman restart:always doesn't survive without this. - { - #[derive(sqlx::FromRow)] - struct DeploymentRow { - tenant_id: String, - project_id: String, - compose_path: String, - } - let rows: Vec = sqlx::query_as( - "SELECT tenant_id, project_id, compose_path FROM container_deployments WHERE desired = 'running'" - ) - .fetch_all(&state.db) - .await - .unwrap_or_default(); - - for row in rows { - match podman::compose_up_no_recreate(&row.tenant_id, &row.compose_path) { - Ok(()) => tracing::info!( - tenant_id = %row.tenant_id, - project_id = %row.project_id, - "containers recovered on startup" - ), - Err(e) => tracing::warn!( - tenant_id = %row.tenant_id, - project_id = %row.project_id, - error = %e, - "container startup recovery failed" - ), - } - } - } - - // Nonce cleanup: run at startup then every hour. - { - let db = state.db.clone(); - tokio::spawn(async move { - let cleanup = || async { - sqlx::query!( - "DELETE FROM used_nonces WHERE created_at < NOW() - INTERVAL '5 minutes'" - ) - .execute(&db) - .await - .ok(); - }; - cleanup().await; - let mut ticker = tokio::time::interval(tokio::time::Duration::from_secs(3600)); - ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); - loop { - ticker.tick().await; - cleanup().await; - } - }); - } - - // PostgreSQL health watchdog — lockdown if DB unreachable - { - let state_db = state.clone(); - tokio::spawn(async move { - let mut ticker = tokio::time::interval(tokio::time::Duration::from_secs(30)); - ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); - loop { - ticker.tick().await; - if sqlx::query("SELECT 1") - .fetch_one(&state_db.db) - .await - .is_err() - && !state_db.is_locked_down() - { - tracing::error!("PostgreSQL unreachable — entering lockdown"); - state_db.set_lockdown(crate::state::LockdownReason::PgUnreachable); - } - } - }); - } - - // Startup health guard: poll /health for 30s; restore .prev and write CRITICAL if unhealthy. - update::spawn_startup_health_guard(); - - // WebSocket client — persistent connection to dashboard - tokio::spawn(ws_client::run_ws_client(state.clone())); - - // Fallback self-updater: polls GitHub directly if dashboard absent for >6h - tokio::spawn(update::fallback::run_fallback_updater(state.clone())); - - // Audit log sync task (HTTP batch fallback when WS is down) - tokio::spawn(sync::run_sync_task(state.clone())); - - // nftables divergence detection task - tokio::spawn(nftables::divergence::run_divergence_check(state.clone())); - - // Conflicting software check (every 5 minutes) - tokio::spawn(conflict::run_conflict_check(state.clone())); - - // nginx watchdog (every 60 seconds) - tokio::spawn(nginx::run_nginx_watchdog(state.clone())); - - // Heartbeat watchdog task - let heartbeat_state = state.clone(); - tokio::spawn(async move { - let mut ticker = interval(Duration::from_secs(30)); - loop { - ticker.tick().await; - let elapsed = heartbeat_state - .last_heartbeat - .lock() - .unwrap() - .elapsed() - .as_secs(); - if elapsed > HEARTBEAT_TIMEOUT_SECS && !heartbeat_state.is_locked_down() { - tracing::warn!(elapsed_secs = elapsed, "heartbeat lost — entering lockdown"); - heartbeat_state.set_lockdown(crate::state::LockdownReason::Heartbeat); - } - } - }); - - // Build TLS acceptor before moving state into router. - let tls_acceptor = build_tls_acceptor(&state.config); - - let app = Router::new() - .route("/health", get(handlers::health)) - .route("/cmd", post(handlers::execute_command)) - .route("/metrics/ws", get(handlers::metrics_ws)) - .route("/heartbeat", post(heartbeat_handler)) - .with_state(state); - - let listener = tokio::net::TcpListener::bind(&listen_addr).await?; - - match tls_acceptor { - Some(acceptor) => { - info!("lynx-agent listening on {listen_addr} (mTLS)"); - serve_tls(listener, app, acceptor).await?; - } - None => { - info!("lynx-agent listening on {listen_addr} (plain HTTP — TLS certs not configured)"); - axum::serve(listener, app).await?; - } - } - - Ok(()) -} - -async fn heartbeat_handler( - State(state): State, - Json(signed): Json, -) -> Response { - // Heartbeat ACK requires a valid Ed25519 signature — bearer token alone is - // insufficient so that `internal_token` compromise cannot suppress lockdown. - let verified = auth::verify_command( - &state.db, - &signed, - &state.config.dashboard_verify_key, - state.config.agent_id, - ) - .await; - - let cmd = match verified { - Ok(c) => c, - Err(e) => { - tracing::warn!("heartbeat ACK rejected: invalid signature: {e}"); - return StatusCode::UNAUTHORIZED.into_response(); - } - }; - - let cmd_type = cmd - .command - .get("type") - .and_then(|v| v.as_str()) - .unwrap_or(""); - if cmd_type != "agent.heartbeat_ack" { - tracing::warn!("heartbeat endpoint received unexpected command type: {cmd_type}"); - return StatusCode::BAD_REQUEST.into_response(); - } - - *state.last_heartbeat.lock().unwrap() = std::time::Instant::now(); - let is_lockdown = state.lockdown.load(Ordering::SeqCst); - state.clear_lockdown_if_heartbeat(); - - let body = serde_json::json!({ - "agent_id": state.config.agent_id, - "version": state.config.version, - "timestamp": chrono::Utc::now().to_rfc3339(), - "status": if is_lockdown { "lockdown" } else { "online" }, - "nonce": uuid::Uuid::now_v7(), - }); - - Json(body).into_response() -} - -fn agent_logs(follow: bool, errors: bool, since: Option) -> anyhow::Result<()> { - let mut args = vec![ - "--unit=lynx-agent".to_string(), - "--no-pager".to_string(), - "--output=short".to_string(), - ]; - - if follow { - args.push("--follow".to_string()); - } else { - args.push("--lines=100".to_string()); - } - - if let Some(ref s) = since { - args.push(format!("--since=-{s}")); - } - - if errors { - args.push("--priority=err".to_string()); - } - - let status = std::process::Command::new("journalctl") - .args(&args) - .status() - .context("journalctl")?; - - if !status.success() { - anyhow::bail!("journalctl exited with status {status}"); - } - - Ok(()) -} - -fn agent_gen_rand(bytes: usize, encoding: &str) -> anyhow::Result<()> { - use base64ct::{Base64, Encoding as _}; - use rand::RngExt; - - let mut buf = vec![0u8; bytes]; - rand::rng().fill(&mut buf[..]); - - let out = match encoding { - "hex" => buf.iter().map(|b| format!("{b:02x}")).collect::(), - "base64" => Base64::encode_string(&buf), - other => anyhow::bail!("unknown encoding: {other} (expected hex|base64)"), - }; - println!("{out}"); - Ok(()) -} - -fn agent_gen_uuid_v7() -> anyhow::Result<()> { - println!("{}", uuid::Uuid::now_v7()); - Ok(()) -} diff --git a/lynx/agent/src/metrics/mod.rs b/lynx/agent/src/metrics/mod.rs deleted file mode 100644 index d7023db..0000000 --- a/lynx/agent/src/metrics/mod.rs +++ /dev/null @@ -1,382 +0,0 @@ -use anyhow::Result; -use serde::Serialize; -use std::{fs, process::Command, time::Duration}; -use tokio::time::sleep; - -#[derive(Debug, Serialize)] -pub struct SystemMetrics { - #[serde(rename = "type")] - pub msg_type: &'static str, - pub cpu_percent: f64, - pub mem_used_mb: u64, - pub mem_total_mb: u64, - pub disk_used_gb: f64, - pub disk_total_gb: f64, - pub timestamp: i64, -} - -#[derive(Debug, Serialize)] -pub struct ContainerStat { - pub id: String, - pub name: String, - pub cpu_percent: f64, - pub mem_usage_mb: f64, - pub mem_limit_mb: f64, -} - -#[derive(Debug, Serialize)] -pub struct ContainerMetrics { - #[serde(rename = "type")] - pub msg_type: &'static str, - pub containers: Vec, - pub timestamp: i64, -} - -pub async fn sample_system() -> Result { - let cpu = read_cpu_percent().await; - let (mem_used, mem_total) = read_mem_mb(); - let (disk_used, disk_total) = read_disk_gb("/"); - - Ok(SystemMetrics { - msg_type: "system_metrics", - cpu_percent: cpu, - mem_used_mb: mem_used, - mem_total_mb: mem_total, - disk_used_gb: disk_used, - disk_total_gb: disk_total, - timestamp: chrono::Utc::now().timestamp(), - }) -} - -pub fn sample_containers() -> ContainerMetrics { - let containers = collect_container_stats(); - ContainerMetrics { - msg_type: "container_metrics", - containers, - timestamp: chrono::Utc::now().timestamp(), - } -} - -fn collect_container_stats() -> Vec { - // `podman stats --no-stream --format json` returns a JSON array. - let output = Command::new("podman") - .args(["stats", "--no-stream", "--format", "json"]) - .output(); - - let out = match output { - Ok(o) if o.status.success() => o.stdout, - _ => return vec![], - }; - - #[derive(serde::Deserialize)] - struct RawStat { - #[serde(rename = "ID")] - id: String, - #[serde(rename = "Name")] - name: String, - #[serde(rename = "CPUPerc")] - cpu_perc: String, // "1.23%" - #[serde(rename = "MemUsage")] - mem_usage: String, // "12.5MB / 2GB" - } - - let stats: Vec = serde_json::from_slice(&out).unwrap_or_default(); - - stats - .into_iter() - .map(|s| { - let cpu = s - .cpu_perc - .trim_end_matches('%') - .parse::() - .unwrap_or(0.0); - let (usage, limit) = parse_mem_usage(&s.mem_usage); - ContainerStat { - id: s.id, - name: s.name, - cpu_percent: cpu, - mem_usage_mb: usage, - mem_limit_mb: limit, - } - }) - .collect() -} - -/// Parse "12.5MiB / 2GiB" into (usage_mb, limit_mb). -fn parse_mem_usage(raw: &str) -> (f64, f64) { - let parts: Vec<&str> = raw.split('/').collect(); - let parse = |s: &str| -> f64 { - let s = s.trim(); - if let Some(v) = s.strip_suffix("GiB").or_else(|| s.strip_suffix("GB")) { - v.parse::().unwrap_or(0.0) * 1024.0 - } else if let Some(v) = s.strip_suffix("MiB").or_else(|| s.strip_suffix("MB")) { - v.parse::().unwrap_or(0.0) - } else if let Some(v) = s.strip_suffix("KiB").or_else(|| s.strip_suffix("KB")) { - v.parse::().unwrap_or(0.0) / 1024.0 - } else { - 0.0 - } - }; - let usage = parts.first().map(|s| parse(s)).unwrap_or(0.0); - let limit = parts.get(1).map(|s| parse(s)).unwrap_or(0.0); - (usage, limit) -} - -/// Two-sample CPU idle calculation from /proc/stat. -async fn read_cpu_percent() -> f64 { - let s1 = read_proc_stat(); - sleep(Duration::from_millis(100)).await; - let s2 = read_proc_stat(); - - let (total1, idle1) = s1.unwrap_or((1, 1)); - let (total2, idle2) = s2.unwrap_or((1, 1)); - - let total_diff = (total2 as f64) - (total1 as f64); - let idle_diff = (idle2 as f64) - (idle1 as f64); - - if total_diff <= 0.0 { - return 0.0; - } - ((total_diff - idle_diff) / total_diff * 100.0).clamp(0.0, 100.0) -} - -fn read_proc_stat() -> Option<(u64, u64)> { - let content = fs::read_to_string("/proc/stat").ok()?; - let line = content.lines().next()?; - let fields: Vec = line - .split_whitespace() - .skip(1) - .filter_map(|s| s.parse().ok()) - .collect(); - if fields.len() < 4 { - return None; - } - let idle = fields[3]; - let total: u64 = fields.iter().sum(); - Some((total, idle)) -} - -fn read_mem_mb() -> (u64, u64) { - let content = fs::read_to_string("/proc/meminfo").unwrap_or_default(); - let mut total = 0u64; - let mut available = 0u64; - - for line in content.lines() { - if line.starts_with("MemTotal:") { - total = parse_kb(line); - } else if line.starts_with("MemAvailable:") { - available = parse_kb(line); - } - } - - let used = total.saturating_sub(available); - (used / 1024, total / 1024) -} - -fn parse_kb(line: &str) -> u64 { - line.split_whitespace() - .nth(1) - .and_then(|s| s.parse().ok()) - .unwrap_or(0) -} - -#[cfg(test)] -mod tests { - use super::*; - - // --- parse_mem_usage --- - - #[test] - fn parse_mem_usage_mib_slash_gib() { - let (usage, limit) = parse_mem_usage("12.5MiB / 2GiB"); - assert!( - (usage - 12.5).abs() < 0.01, - "usage should be ~12.5 MB, got {usage}" - ); - assert!( - (limit - 2048.0).abs() < 0.01, - "limit should be ~2048 MB, got {limit}" - ); - } - - #[test] - fn parse_mem_usage_mb_slash_gb() { - let (usage, limit) = parse_mem_usage("256MB / 1GB"); - assert!((usage - 256.0).abs() < 0.01); - assert!((limit - 1024.0).abs() < 0.01); - } - - #[test] - fn parse_mem_usage_kib_slash_mib() { - let (usage, limit) = parse_mem_usage("512KiB / 512MiB"); - assert!( - (usage - 0.5).abs() < 0.01, - "512 KiB should be ~0.5 MB, got {usage}" - ); - assert!((limit - 512.0).abs() < 0.01); - } - - #[test] - fn parse_mem_usage_kb_slash_mb() { - let (usage, limit) = parse_mem_usage("1024KB / 2048MB"); - assert!( - (usage - 1.0).abs() < 0.01, - "1024 KB should be 1 MB, got {usage}" - ); - assert!((limit - 2048.0).abs() < 0.01); - } - - #[test] - fn parse_mem_usage_zeros() { - let (usage, limit) = parse_mem_usage("0MiB / 0MiB"); - assert_eq!(usage, 0.0); - assert_eq!(limit, 0.0); - } - - #[test] - fn parse_mem_usage_empty_string() { - let (usage, limit) = parse_mem_usage(""); - assert_eq!(usage, 0.0); - assert_eq!(limit, 0.0); - } - - #[test] - fn parse_mem_usage_unknown_unit_returns_zero() { - let (usage, limit) = parse_mem_usage("100XiB / 200XiB"); - assert_eq!(usage, 0.0); - assert_eq!(limit, 0.0); - } - - #[test] - fn parse_mem_usage_missing_limit_part() { - // Only one segment — limit should fall back to 0 - let (usage, limit) = parse_mem_usage("64MiB"); - assert!((usage - 64.0).abs() < 0.01); - assert_eq!(limit, 0.0); - } - - #[test] - fn parse_mem_usage_extra_whitespace() { - let (usage, limit) = parse_mem_usage(" 32MiB / 4GiB "); - assert!((usage - 32.0).abs() < 0.01); - assert!((limit - 4096.0).abs() < 0.01); - } - - // --- parse_kb --- - - #[test] - fn parse_kb_standard_line() { - assert_eq!(parse_kb("MemTotal: 16384000 kB"), 16_384_000); - } - - #[test] - fn parse_kb_available_line() { - assert_eq!(parse_kb("MemAvailable: 8192000 kB"), 8_192_000); - } - - #[test] - fn parse_kb_zero_value() { - assert_eq!(parse_kb("MemFree: 0 kB"), 0); - } - - #[test] - fn parse_kb_empty_line() { - assert_eq!(parse_kb(""), 0); - } - - #[test] - fn parse_kb_malformed_no_number() { - assert_eq!(parse_kb("MemTotal: abc kB"), 0); - } - - // --- CPU utilisation arithmetic --- - - #[test] - fn cpu_percent_full_load() { - // 0 idle out of 1000 total ticks → 100% CPU - let total1 = 0u64; - let idle1 = 0u64; - let total2 = 1000u64; - let idle2 = 0u64; - - let total_diff = (total2 as f64) - (total1 as f64); - let idle_diff = (idle2 as f64) - (idle1 as f64); - let pct = ((total_diff - idle_diff) / total_diff * 100.0).clamp(0.0, 100.0); - assert!((pct - 100.0).abs() < 0.001); - } - - #[test] - fn cpu_percent_idle() { - // All ticks are idle → 0% CPU - let total1 = 0u64; - let idle1 = 0u64; - let total2 = 1000u64; - let idle2 = 1000u64; - - let total_diff = (total2 as f64) - (total1 as f64); - let idle_diff = (idle2 as f64) - (idle1 as f64); - let pct = ((total_diff - idle_diff) / total_diff * 100.0).clamp(0.0, 100.0); - assert!((pct - 0.0).abs() < 0.001); - } - - #[test] - fn cpu_percent_half_load() { - let total_diff = 1000.0f64; - let idle_diff = 500.0f64; - let pct = ((total_diff - idle_diff) / total_diff * 100.0).clamp(0.0, 100.0); - assert!((pct - 50.0).abs() < 0.001); - } - - #[test] - fn cpu_percent_clamps_to_zero_on_zero_diff() { - // total_diff == 0 → guard returns 0.0 (no divide-by-zero) - let total_diff = 0.0f64; - let pct = if total_diff <= 0.0 { 0.0 } else { 100.0 }; - assert_eq!(pct, 0.0); - } - - // --- SystemMetrics / ContainerMetrics msg_type constants --- - - #[test] - fn system_metrics_msg_type_is_correct() { - // The msg_type field is used by the frontend to dispatch incoming WS messages. - // A typo here would silently break the dashboard metrics display. - let m = SystemMetrics { - msg_type: "system_metrics", - cpu_percent: 0.0, - mem_used_mb: 0, - mem_total_mb: 0, - disk_used_gb: 0.0, - disk_total_gb: 0.0, - timestamp: 0, - }; - assert_eq!(m.msg_type, "system_metrics"); - } - - #[test] - fn container_metrics_msg_type_is_correct() { - let m = ContainerMetrics { - msg_type: "container_metrics", - containers: vec![], - timestamp: 0, - }; - assert_eq!(m.msg_type, "container_metrics"); - } -} - -fn read_disk_gb(mount: &str) -> (f64, f64) { - use nix::sys::statvfs::statvfs; - match statvfs(mount) { - Ok(stat) => { - let block = stat.block_size(); - let total = stat.blocks() * block; - let avail = stat.blocks_available() * block; - let used = total.saturating_sub(avail); - ( - used as f64 / 1_073_741_824.0, - total as f64 / 1_073_741_824.0, - ) - } - Err(_) => (0.0, 0.0), - } -} diff --git a/lynx/agent/src/nftables/divergence.rs b/lynx/agent/src/nftables/divergence.rs deleted file mode 100644 index 67956a9..0000000 --- a/lynx/agent/src/nftables/divergence.rs +++ /dev/null @@ -1,146 +0,0 @@ -use crate::state::AppState; -use tracing::{error, info, warn}; - -const CHECK_INTERVAL_SECS: u64 = 60; - -pub async fn run_divergence_check(state: AppState) { - let mut interval = tokio::time::interval(std::time::Duration::from_secs(CHECK_INTERVAL_SECS)); - interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); - loop { - interval.tick().await; - check_once(&state).await; - } -} - -async fn check_once(state: &AppState) { - let expected = match state.expected_nft_checksum() { - Some(c) => c, - None => return, // no ruleset applied yet - }; - - let current = match super::current_checksum() { - Ok(c) => c, - Err(e) => { - warn!(error = %e, "failed to compute nftables checksum"); - return; - } - }; - - if current == expected { - return; - } - - // Detect which chains were modified for appropriate severity / logging. - let base_diverged = is_chain_diverged(state, "lynx-base"); - let global_diverged = is_chain_diverged(state, "lynx-global"); - let local_diverged = is_chain_diverged(state, "lynx-local"); - - if base_diverged { - error!( - expected = %&expected[..16], - current = %¤t[..16], - "CRITICAL: lynx-base chain modified outside Lynx — auto-restoring" - ); - } else { - warn!( - expected = %&expected[..16], - current = %¤t[..16], - base_diverged, - global_diverged, - local_diverged, - "nftables divergence detected — auto-restoring" - ); - } - - // Auto-restore in all cases — PostgreSQL is the source of truth, not the VPS. - if let Err(e) = restore(state) { - error!(error = %e, "nftables auto-restore FAILED — applying emergency ruleset"); - if let Err(e2) = super::apply_emergency() { - error!(error = %e2, "emergency ruleset also failed — lockdown"); - } - state.set_lockdown(crate::state::LockdownReason::NftablesFailure); - } else { - info!("nftables auto-restored successfully"); - } - - let chain = if base_diverged { - "lynx-base" - } else if global_diverged { - "lynx-global" - } else if local_diverged { - "lynx-local" - } else { - "unknown" - }; - - notify_dashboard(state, chain, base_diverged).await; -} - -fn is_chain_diverged(state: &AppState, chain: &str) -> bool { - let idx = match chain { - "lynx-base" => 0, - "lynx-global" => 1, - "lynx-local" => 2, - _ => return false, - }; - let expected = match state.expected_chain_checksum(idx) { - Some(c) => c, - None => return false, // no baseline stored — can't determine - }; - match super::chain_checksum(chain) { - Ok(current) => current != expected, - Err(_) => true, // chain deleted or inaccessible - } -} - -fn restore(state: &AppState) -> anyhow::Result<()> { - let last = state - .nft_last_ruleset() - .ok_or_else(|| anyhow::anyhow!("no last ruleset to restore"))?; - - super::apply_raw(&last)?; - - // Update expected checksums to match what we just applied. - let checksum = super::current_checksum()?; - state.set_nft_checksum(checksum); - state.set_nft_chain_checksums( - super::chain_checksum("lynx-base").ok(), - super::chain_checksum("lynx-global").ok(), - super::chain_checksum("lynx-local").ok(), - ); - Ok(()) -} - -async fn notify_dashboard(state: &AppState, chain: &str, critical: bool) { - let Some(dashboard_url) = &state.config.dashboard_url else { - return; - }; - let Some(sync_token) = &state.config.sync_token else { - return; - }; - - let url = format!( - "{}/agents/{}/events", - dashboard_url.trim_end_matches('/'), - state.config.agent_id - ); - - let body = serde_json::json!({ - "event": "nftables_divergence", - "detail": format!("chain={chain} critical={critical} auto_restored=true"), - }); - - let client = reqwest::Client::new(); - match client - .post(&url) - .header("Authorization", format!("Bearer {}", &**sync_token)) - .json(&body) - .timeout(std::time::Duration::from_secs(10)) - .send() - .await - { - Ok(r) if r.status().is_success() => info!("nftables divergence event sent"), - Ok(r) => warn!(status = %r.status(), "dashboard rejected divergence event"), - Err(e) => warn!(error = %e, "failed to send divergence event"), - } -} diff --git a/lynx/agent/src/nftables/mod.rs b/lynx/agent/src/nftables/mod.rs deleted file mode 100644 index 58fcec4..0000000 --- a/lynx/agent/src/nftables/mod.rs +++ /dev/null @@ -1,679 +0,0 @@ -pub mod divergence; - -use anyhow::{Context, Result}; -use sha2::{Digest, Sha256}; -use std::process::Command; - -const TABLE: &str = "lynx-agent"; - -/// Full structure of the managed ruleset. -/// lynx-base holds the immutable invariants. -/// lynx-global / lynx-local hold dashboard-pushed input rules. -/// lynx-global-output / lynx-local-output hold dashboard-pushed output rules. -pub struct Ruleset { - /// WireGuard UDP port for management plane - pub wireguard_port: u16, - /// Dashboard panel port opened in lynx-base (Some(19443) on dashboard VPS, None on remote agents). - pub dashboard_port: Option, - /// Per-org blocked subnets (org isolation — inter-org traffic blocked) - pub org_networks: Vec, - /// Input rules body for the lynx-global chain (dashboard-pushed, applies to all agents) - pub global_body: String, - /// Input rules body for the lynx-local chain (dashboard-pushed, this agent only) - pub local_body: String, - /// Output rules body for the lynx-global-output chain (dashboard-pushed, applies to all agents) - pub global_output_body: String, - /// Output rules body for the lynx-local-output chain (dashboard-pushed, this agent only) - pub local_output_body: String, - /// Dashboard WireGuard IP for source-IP restriction on the WG inbound rule. - /// Some(ip) on remote agents — restricts `udp dport {wg}` to that source only. - /// None on the dashboard VPS itself (accepts from all agent IPs) or when unknown. - pub dashboard_wg_ip: Option, -} - -pub struct OrgNetwork { - pub org_id: String, - pub subnet: String, -} - -/// Extract the host from a URL like "http://10.100.0.1:8080". -/// Returns None for empty input or unparseable URLs. -pub fn extract_url_host(url: &str) -> Option { - if url.is_empty() { - return None; - } - let after_scheme = url.split("//").nth(1).unwrap_or(url); - // IPv6 addresses are wrapped in brackets: [::1]:port - let host = if after_scheme.starts_with('[') { - after_scheme - .find(']') - .map(|i| &after_scheme[..=i]) - .unwrap_or(after_scheme) - } else { - after_scheme.split(':').next().unwrap_or(after_scheme) - }; - if host.is_empty() { - None - } else { - Some(host.to_string()) - } -} - -/// Apply the full lynx-agent nftables ruleset atomically. -/// Replaces the entire table on every call — never incremental. -/// Returns the rendered ruleset string so callers can store it for restore. -pub fn apply(ruleset: &Ruleset) -> Result { - let nft = render_ruleset(ruleset); - run_nft(&nft).context("nftables apply")?; - persist_ruleset(&nft); - Ok(nft) -} - -/// Re-apply a previously rendered ruleset string directly (used for restore). -pub fn apply_raw(nft: &str) -> Result<()> { - run_nft(nft).context("nftables apply_raw")?; - persist_ruleset(nft); - Ok(()) -} - -/// Apply a minimal emergency ruleset when normal restore fails. -/// Allows only WireGuard inbound from the dashboard + established + loopback. -/// Everything else dropped — VPS stays reachable only from dashboard. -pub fn apply_emergency() -> Result<()> { - run_nft(EMERGENCY_RULESET).context("nftables apply_emergency")?; - persist_ruleset(EMERGENCY_RULESET); - Ok(()) -} - -/// Persist the active ruleset to disk so nftables.service can reload it on boot. -fn persist_ruleset(nft: &str) { - if let Err(e) = std::fs::write("/etc/nftables-lynx-agent.conf", nft) { - tracing::warn!(error = %e, "failed to persist nftables ruleset to disk"); - } -} - -const EMERGENCY_RULESET: &str = r#" -destroy table inet lynx-agent -add table inet lynx-agent -table inet lynx-agent { - chain lynx-base { - type filter hook input priority 0; policy drop; - ct state established,related accept - iifname "lo" accept - udp dport 51820 accept - drop - } - chain lynx-forward { - type filter hook forward priority 0; policy drop; - } - chain lynx-output { - type filter hook output priority 0; policy accept; - } -} -"#; - -/// Compute checksum of the live lynx-agent table for divergence detection. -pub fn current_checksum() -> Result { - chain_checksum_raw(&["list", "table", "inet", TABLE]) -} - -/// Compute checksum of a single chain for per-chain divergence detection. -pub fn chain_checksum(chain: &str) -> Result { - chain_checksum_raw(&["list", "chain", "inet", TABLE, chain]) -} - -fn chain_checksum_raw(args: &[&str]) -> Result { - // -t (terse) suppresses dynamic set/meter element output so the checksum - // reflects only rule structure — prevents false divergence from ssh_throttle - // meter filling up with per-IP rate-limit entries during normal operation. - let out = Command::new("nft") - .arg("-j") - .arg("-t") - .args(args) - .output() - .context("nft list")?; - - if !out.status.success() { - anyhow::bail!("nft list failed: {}", String::from_utf8_lossy(&out.stderr)); - } - - let mut hasher = Sha256::new(); - hasher.update(&out.stdout); - Ok(hex::encode(hasher.finalize())) -} - -fn render_ruleset(r: &Ruleset) -> String { - let dashboard_port_rule = match r.dashboard_port { - Some(port) => format!( - "\n # Dashboard panel port\n tcp dport {port} ct state new accept\n" - ), - None => String::new(), - }; - - // Management plane rules — dashboard VPS only. - // Agents (10.100.0.x) need to reach the backend on port 8080; agent-to-agent - // traffic within the management subnet must be blocked; and the dashboard itself - // (10.100.0.1) is allowed unconditionally on its own WireGuard interface. - let management_plane_rules = if r.dashboard_port.is_some() { - "\n # Allow agents -> dashboard backend (management plane)\n ip saddr 10.100.0.0/16 ip daddr 10.100.0.1 tcp dport 8080 ct state new accept\n\n # Block agent-to-agent traffic within management subnet\n ip saddr 10.100.0.0/16 ip daddr 10.100.0.0/16 drop\n\n # Dashboard WireGuard interface can reach itself\n ip saddr 10.100.0.1 accept\n".to_string() - } else { - String::new() - }; - - // Container DNS (aardvark-dns on Netavark bridges) — dashboard VPS only. - // Rootless org containers on remote agents use user-namespace networking - // that doesn't hit the host INPUT chain for DNS. - let dashboard_dns_rules = if r.dashboard_port.is_some() { - "\n # DNS for container networks (aardvark-dns on Netavark bridge interfaces)\n iifname \"podman*\" udp dport 53 accept\n iifname \"podman*\" tcp dport 53 accept\n" - } else { - "" - }; - - // Netavark DNAT rewrites the destination from the host IP to the container IP - // (10.89.x.x) in PREROUTING. Without a forward rule, lynx-forward policy drop - // kills these packets before they reach the container. This applies to ALL agents: - // the agent's own PostgreSQL container is also published via DNAT. - let container_forward_rules = "\n # New connections to published container ports (Netavark DNAT rewrites dst to 10.89.x.x)\n ip daddr 10.89.0.0/16 ct state new accept\n\n # Outbound traffic from Podman containers (package installs, GitHub, cert renewals, etc.)\n iifname \"podman*\" accept\n"; - - // WireGuard forward rules — dashboard VPS only. - // Backend container needs to route through wg-lynx-dash to reach remote agents. - let dashboard_wg_forward_rules = if r.dashboard_port.is_some() { - "\n # Backend container traffic to/from WireGuard (dashboard <-> agents)\n oifname \"wg-lynx-dash\" accept\n iifname \"wg-lynx-dash\" accept\n" - } else { - "" - }; - - // On remote agents, restrict WG inbound to dashboard IP only. - // On the dashboard VPS (dashboard_port.is_some()), all agent IPs must be accepted. - let wg_rule = match (r.dashboard_port.is_some(), &r.dashboard_wg_ip) { - (true, _) | (false, None) => format!(" udp dport {} accept", r.wireguard_port), - (false, Some(ip)) => format!( - " ip saddr {ip} udp dport {} accept", - r.wireguard_port - ), - }; - - let mut out = format!( - r#" -destroy table inet {TABLE} -add table inet {TABLE} -table inet {TABLE} {{ - # Immutable invariants — never editable from dashboard - chain lynx-base {{ - type filter hook input priority 0; policy drop; - - # Established/related - ct state established,related accept - - # Loopback - iif lo accept - - # ICMP — path MTU, diagnostics, reachability - ip protocol icmp accept - ip6 nexthdr icmpv6 accept - - # SSH — emergency admin access (per-source-IP rate limit) - tcp dport 22 ct state new meter ssh_throttle {{ ip saddr limit rate 10/minute burst 20 packets }} accept - - # WireGuard management plane — remote agents restrict to dashboard IP - {wg_rule} - - # Dashboard backend (management plane — WireGuard only) -{management_plane} -{dashboard_port} -{dashboard_dns} - # Run global and local rule chains - jump lynx-global - jump lynx-local - - drop - }} - - # Dashboard global rules — input, apply to all agents - chain lynx-global {{ -{global} - }} - - # Dashboard local rules — input, apply to this agent only - chain lynx-local {{ -{local} - }} - - chain lynx-forward {{ - type filter hook forward priority 0; policy drop; - - ct state established,related accept -{container_forward} -{dashboard_wg_forward} -"#, - TABLE = TABLE, - management_plane = management_plane_rules, - dashboard_port = dashboard_port_rule, - dashboard_dns = dashboard_dns_rules, - container_forward = container_forward_rules, - dashboard_wg_forward = dashboard_wg_forward_rules, - global = r.global_body, - local = r.local_body, - ); - - // Block inter-org traffic - for org in &r.org_networks { - out.push_str(&format!( - " # org {} isolation\n ip saddr {} ip daddr != {} drop;\n", - org.org_id, org.subnet, org.subnet - )); - } - - out.push_str(&format!( - r#" }} - - chain lynx-output {{ - type filter hook output priority 0; policy accept; - - # Dashboard global output rules — apply to all agents -{global_out} - - # Dashboard local output rules — apply to this agent only -{local_out} - }} -}} -"#, - global_out = r.global_output_body, - local_out = r.local_output_body, - )); - - out -} - -#[cfg(test)] -mod tests { - use super::*; - - // --- render_ruleset — pure string generation, no I/O --- - - fn minimal_ruleset() -> Ruleset { - Ruleset { - wireguard_port: 51820, - dashboard_port: None, - dashboard_wg_ip: None, - org_networks: vec![], - global_body: String::new(), - local_body: String::new(), - global_output_body: String::new(), - local_output_body: String::new(), - } - } - - #[test] - fn render_contains_table_name() { - let r = minimal_ruleset(); - let out = render_ruleset(&r); - assert!( - out.contains("table inet lynx-agent"), - "table declaration missing" - ); - } - - #[test] - fn render_contains_wireguard_port() { - let r = minimal_ruleset(); - let out = render_ruleset(&r); - assert!(out.contains("51820"), "WireGuard port missing from ruleset"); - } - - #[test] - fn render_wg_source_ip_restriction() { - // Remote agent with dashboard_wg_ip set → WG rule must restrict source IP. - let mut r = minimal_ruleset(); - r.dashboard_wg_ip = Some("10.100.0.1".to_string()); - let out = render_ruleset(&r); - assert!( - out.contains("ip saddr 10.100.0.1"), - "WG source IP restriction missing on remote agent" - ); - - // Dashboard VPS (dashboard_port set) → WG rule must NOT restrict source IP. - let mut r_dash = minimal_ruleset(); - r_dash.dashboard_port = Some(19443); - r_dash.dashboard_wg_ip = Some("10.100.0.1".to_string()); - let out_dash = render_ruleset(&r_dash); - // Source IP restriction must not appear on dashboard VPS WG rule - // (agents connect from many different IPs) - let wg_lines: Vec<&str> = out_dash.lines().filter(|l| l.contains("51820")).collect(); - assert!( - wg_lines.iter().all(|l| !l.contains("ip saddr")), - "dashboard VPS must not restrict WG source IP: {:?}", - wg_lines - ); - - // Remote agent without dashboard_wg_ip → fall back to unrestricted - let r_no_ip = minimal_ruleset(); - let out_no_ip = render_ruleset(&r_no_ip); - assert!( - out_no_ip.contains("udp dport 51820"), - "WG rule must be present even without dashboard_wg_ip" - ); - } - - #[test] - fn render_custom_wireguard_port() { - let mut r = minimal_ruleset(); - r.wireguard_port = 12345; - let out = render_ruleset(&r); - assert!(out.contains("12345"), "custom WireGuard port not rendered"); - } - - #[test] - fn render_contains_lynx_base_chain() { - let r = minimal_ruleset(); - let out = render_ruleset(&r); - assert!(out.contains("chain lynx-base"), "lynx-base chain missing"); - } - - #[test] - fn render_contains_lynx_global_chain() { - let r = minimal_ruleset(); - let out = render_ruleset(&r); - assert!( - out.contains("chain lynx-global"), - "lynx-global chain missing" - ); - } - - #[test] - fn render_contains_lynx_local_chain() { - let r = minimal_ruleset(); - let out = render_ruleset(&r); - assert!(out.contains("chain lynx-local"), "lynx-local chain missing"); - } - - #[test] - fn render_contains_lynx_forward_chain() { - let r = minimal_ruleset(); - let out = render_ruleset(&r); - assert!( - out.contains("chain lynx-forward"), - "lynx-forward chain missing" - ); - } - - #[test] - fn render_contains_lynx_output_chain() { - let r = minimal_ruleset(); - let out = render_ruleset(&r); - assert!( - out.contains("chain lynx-output"), - "lynx-output chain missing" - ); - } - - #[test] - fn render_contains_default_deny() { - let r = minimal_ruleset(); - let out = render_ruleset(&r); - assert!(out.contains("policy drop"), "default deny policy missing"); - } - - #[test] - fn render_contains_dashboard_management_ip() { - // Management plane rules only render when dashboard_port is set (dashboard VPS). - let mut r = minimal_ruleset(); - r.dashboard_port = Some(19443); - let out = render_ruleset(&r); - assert!( - out.contains("10.100.0.1"), - "dashboard management IP missing when dashboard_port set" - ); - assert!( - out.contains("10.100.0.0/16"), - "agent subnet missing from management plane rules" - ); - // Without dashboard_port, management plane rules must not appear. - let r_agent = minimal_ruleset(); - let out_agent = render_ruleset(&r_agent); - assert!( - !out_agent.contains("10.100.0.0/16"), - "management plane rules must not render on remote agent" - ); - } - - #[test] - fn render_global_body_included() { - let mut r = minimal_ruleset(); - r.global_body = " tcp dport 443 accept".to_string(); - let out = render_ruleset(&r); - assert!( - out.contains("tcp dport 443 accept"), - "global_body not included" - ); - } - - #[test] - fn render_local_body_included() { - let mut r = minimal_ruleset(); - r.local_body = " tcp dport 8080 accept".to_string(); - let out = render_ruleset(&r); - assert!( - out.contains("tcp dport 8080 accept"), - "local_body not included" - ); - } - - #[test] - fn render_org_isolation_rules_included() { - let mut r = minimal_ruleset(); - r.org_networks = vec![OrgNetwork { - org_id: "org-abc".to_string(), - subnet: "172.20.0.0/24".to_string(), - }]; - let out = render_ruleset(&r); - assert!( - out.contains("172.20.0.0/24"), - "org subnet missing from isolation rules" - ); - assert!( - out.contains("org-abc"), - "org id missing from isolation comment" - ); - } - - #[test] - fn render_multiple_orgs_all_present() { - let mut r = minimal_ruleset(); - r.org_networks = vec![ - OrgNetwork { - org_id: "org-1".to_string(), - subnet: "172.20.1.0/24".to_string(), - }, - OrgNetwork { - org_id: "org-2".to_string(), - subnet: "172.20.2.0/24".to_string(), - }, - ]; - let out = render_ruleset(&r); - assert!(out.contains("172.20.1.0/24")); - assert!(out.contains("172.20.2.0/24")); - } - - #[test] - fn render_output_is_non_empty() { - let r = minimal_ruleset(); - let out = render_ruleset(&r); - assert!(!out.is_empty(), "rendered ruleset should not be empty"); - } - - #[test] - fn render_has_destroy_add_prefix() { - let r = minimal_ruleset(); - let out = render_ruleset(&r); - assert!( - out.contains("destroy table inet lynx-agent"), - "idempotent prefix missing: destroy table" - ); - assert!( - out.contains("add table inet lynx-agent"), - "idempotent prefix missing: add table" - ); - } - - #[test] - fn render_lynx_base_contains_ssh() { - let r = minimal_ruleset(); - let out = render_ruleset(&r); - assert!( - out.contains("tcp dport 22"), - "SSH accept missing from lynx-base" - ); - assert!( - out.contains("ssh_throttle"), - "SSH rate-limit meter missing from lynx-base" - ); - } - - #[test] - fn render_lynx_base_contains_icmp() { - let r = minimal_ruleset(); - let out = render_ruleset(&r); - assert!( - out.contains("ip protocol icmp accept"), - "ICMP v4 accept missing from lynx-base" - ); - assert!( - out.contains("ip6 nexthdr icmpv6 accept"), - "ICMP v6 accept missing from lynx-base" - ); - } - - #[test] - fn render_dashboard_port_included_when_set() { - let mut r = minimal_ruleset(); - r.dashboard_port = Some(19443); - let out = render_ruleset(&r); - assert!( - out.contains("tcp dport 19443"), - "dashboard port not included when Some" - ); - } - - #[test] - fn render_dashboard_port_absent_when_none() { - let r = minimal_ruleset(); - let out = render_ruleset(&r); - assert!( - !out.contains("19443"), - "dashboard port should not appear when None" - ); - } - - #[test] - fn render_dashboard_dns_included_when_set() { - let mut r = minimal_ruleset(); - r.dashboard_port = Some(19443); - let out = render_ruleset(&r); - assert!( - out.contains("iifname \"podman*\" udp dport 53 accept"), - "container DNS UDP missing when dashboard_port set" - ); - assert!( - out.contains("iifname \"podman*\" tcp dport 53 accept"), - "container DNS TCP missing when dashboard_port set" - ); - } - - #[test] - fn render_dashboard_dns_absent_when_none() { - let r = minimal_ruleset(); - let out = render_ruleset(&r); - assert!( - !out.contains("udp dport 53"), - "container DNS should not appear when dashboard_port is None" - ); - } - - #[test] - fn render_dashboard_forward_rules_included_when_set() { - let mut r = minimal_ruleset(); - r.dashboard_port = Some(19443); - let out = render_ruleset(&r); - assert!( - out.contains("ip daddr 10.89.0.0/16 ct state new accept"), - "Netavark published port forward rule missing when dashboard_port set" - ); - assert!( - out.contains("iifname \"podman*\" accept"), - "container outbound forward rule missing when dashboard_port set" - ); - assert!( - out.contains("oifname \"wg-lynx-dash\" accept"), - "WireGuard outbound forward rule missing when dashboard_port set" - ); - assert!( - out.contains("iifname \"wg-lynx-dash\" accept"), - "WireGuard inbound forward rule missing when dashboard_port set" - ); - } - - #[test] - fn render_dashboard_wg_forward_rules_absent_when_none() { - let r = minimal_ruleset(); - let out = render_ruleset(&r); - assert!( - !out.contains("wg-lynx-dash"), - "WireGuard forward rules should not appear when dashboard_port is None" - ); - } - - #[test] - fn render_container_forward_rules_always_present() { - // These rules are required on ALL agents (not just dashboard VPS) because - // the agent's own PostgreSQL container is published via Netavark DNAT. - let r = minimal_ruleset(); - let out = render_ruleset(&r); - assert!( - out.contains("ip daddr 10.89.0.0/16 ct state new accept"), - "Netavark forward rule must be present on all agents" - ); - assert!( - out.contains("iifname \"podman*\" accept"), - "Podman outbound forward rule must be present on all agents" - ); - } - - // --- Emergency ruleset constant --- - - #[test] - fn emergency_ruleset_is_non_empty() { - assert!(!EMERGENCY_RULESET.is_empty()); - assert!(EMERGENCY_RULESET.contains("policy drop")); - assert!(EMERGENCY_RULESET.contains("51820")); - assert!(EMERGENCY_RULESET.contains("lynx-agent")); - } - - #[test] - fn emergency_ruleset_has_destroy_add_prefix() { - assert!(EMERGENCY_RULESET.contains("destroy table inet lynx-agent")); - assert!(EMERGENCY_RULESET.contains("add table inet lynx-agent")); - } -} - -fn run_nft(ruleset: &str) -> Result<()> { - let mut child = Command::new("nft") - .args(["-f", "-"]) - .stdin(std::process::Stdio::piped()) - .spawn() - .context("spawn nft")?; - - use std::io::Write; - if let Some(stdin) = child.stdin.take() { - let mut stdin = stdin; - stdin - .write_all(ruleset.as_bytes()) - .context("write nft stdin")?; - } - - let status = child.wait().context("wait nft")?; - if !status.success() { - anyhow::bail!("nft exited with: {status}"); - } - Ok(()) -} diff --git a/lynx/agent/src/nginx.rs b/lynx/agent/src/nginx.rs deleted file mode 100644 index 0aec260..0000000 --- a/lynx/agent/src/nginx.rs +++ /dev/null @@ -1,188 +0,0 @@ -use crate::{ - audit::{self, AuditEntry, AuditResult}, - state::AppState, -}; -use std::time::Duration; -use tokio::time::interval; - -const CONTAINER_NAME: &str = "lynx-nginx"; -const HEALTH_CHECK_INTERVAL_SECS: u64 = 60; -const HEALTH_URL: &str = "http://127.0.0.1:80/_health"; -const MAX_REDEPLOY_ATTEMPTS: u32 = 3; - -pub async fn run_nginx_watchdog(state: AppState) { - let mut ticker = interval(Duration::from_secs(HEALTH_CHECK_INTERVAL_SECS)); - ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); - - loop { - ticker.tick().await; - check_nginx(&state).await; - } -} - -async fn check_nginx(state: &AppState) { - let container_exists = is_container_running(CONTAINER_NAME).await; - - if !container_exists { - // Container is gone — restart: always can't recover a removed container. - // Check if it was running recently (podman ps -a includes exited). - let ever_existed = container_ever_existed(CONTAINER_NAME).await; - if ever_existed { - tracing::warn!("nginx container missing — re-deploying"); - redeploy_nginx(state).await; - } - return; - } - - // Container running — check HTTP health. - if !http_health_ok().await { - tracing::warn!("nginx health check failed — restoring config from DB"); - restore_nginx_config(state).await; - } -} - -async fn is_container_running(name: &str) -> bool { - let out = std::process::Command::new("podman") - .args([ - "ps", - "--filter", - &format!("name={name}"), - "--filter", - "status=running", - "--format", - "{{.Names}}", - ]) - .output(); - match out { - Ok(o) => String::from_utf8_lossy(&o.stdout).contains(name), - Err(_) => false, - } -} - -async fn container_ever_existed(name: &str) -> bool { - let out = std::process::Command::new("podman") - .args([ - "ps", - "-a", - "--filter", - &format!("name={name}"), - "--format", - "{{.Names}}", - ]) - .output(); - match out { - Ok(o) => String::from_utf8_lossy(&o.stdout).contains(name), - Err(_) => false, - } -} - -async fn http_health_ok() -> bool { - let client = match reqwest::Client::builder() - .timeout(Duration::from_secs(5)) - .build() - { - Ok(c) => c, - Err(_) => return false, - }; - client - .get(HEALTH_URL) - .send() - .await - .map(|r| r.status().is_success()) - .unwrap_or(false) -} - -async fn redeploy_nginx(state: &AppState) { - for attempt in 1..=MAX_REDEPLOY_ATTEMPTS { - let backoff = Duration::from_secs(2u64.pow(attempt)); - - let status = std::process::Command::new("podman") - .args(["run", "--restart=always", "-d", "--name", CONTAINER_NAME, - "-p", "80:80", "-p", "443:443", - "docker.io/library/nginx@sha256:ceba1c7f1e2c42e5f43c9fa55e74ef90a1d08e7fde12f25e2a6706f4c80e0428"]) - .status(); - - match status { - Ok(s) if s.success() => { - tracing::info!(attempt, "nginx re-deployed successfully"); - audit_nginx_event( - state, - "nginx_unexpected_stop", - "re-deployed after container removal", - ) - .await; - return; - } - _ => { - tracing::warn!(attempt, "nginx re-deploy attempt failed"); - if attempt < MAX_REDEPLOY_ATTEMPTS { - tokio::time::sleep(backoff).await; - } - } - } - } - - tracing::error!( - "nginx re-deploy failed after {} attempts — manual intervention required", - MAX_REDEPLOY_ATTEMPTS - ); - audit_nginx_event( - state, - "nginx_unexpected_stop", - "re-deploy failed after 3 attempts", - ) - .await; -} - -async fn restore_nginx_config(state: &AppState) { - // Load config from agent DB (stored by dashboard when domain was configured). - let config_row = sqlx::query_scalar!( - "SELECT config_content FROM nginx_configs ORDER BY updated_at DESC LIMIT 1" - ) - .fetch_optional(&state.db) - .await; - - let config = match config_row { - Ok(Some(c)) => c, - _ => { - tracing::warn!("no nginx config in DB — cannot restore"); - return; - } - }; - - let config_path = "/etc/nginx/conf.d/lynx.conf"; - if let Err(e) = std::fs::write(config_path, config) { - tracing::error!("failed to write nginx config: {e}"); - return; - } - - // Reload nginx inside the container. - let reload = std::process::Command::new("podman") - .args(["exec", CONTAINER_NAME, "nginx", "-s", "reload"]) - .status(); - - match reload { - Ok(s) if s.success() => { - tracing::info!("nginx config restored and reloaded"); - audit_nginx_event(state, "nginx_config_tampered", "config restored from DB").await; - } - _ => { - tracing::error!("nginx reload failed after config restore"); - } - } -} - -async fn audit_nginx_event(state: &AppState, event: &str, detail: &str) { - let _ = audit::append( - &state.db, - AuditEntry { - agent_id: state.config.agent_id, - organization_id: None, - user_id: None, - command_type: event, - result: AuditResult::Success, - error: Some(detail.to_string()), - }, - ) - .await; -} diff --git a/lynx/agent/src/podman/mod.rs b/lynx/agent/src/podman/mod.rs deleted file mode 100644 index 8180b71..0000000 --- a/lynx/agent/src/podman/mod.rs +++ /dev/null @@ -1,350 +0,0 @@ -use anyhow::{Context, Result}; -use std::process::Command; - -/// Tenant isolation: each org gets a `lynx-tenant-{id}` system user -/// with dedicated subuid/subgid range for rootless Podman. -pub fn ensure_tenant_user(tenant_id: &str) -> Result<()> { - let username = format!("lynx-tenant-{tenant_id}"); - let home_dir = format!("/var/lib/lynx/orgs/{tenant_id}"); - - // Check if user already exists - let exists = Command::new("id") - .arg(&username) - .status() - .context("run id")? - .success(); - - if !exists { - // Parent dir must exist before useradd --create-home runs. - std::fs::create_dir_all("/var/lib/lynx/orgs").context("create /var/lib/lynx/orgs")?; - - // Create system user with a real home dir so rootless Podman can store its - // images/containers under ~/.local/share/containers/ and find its socket. - let status = Command::new("useradd") - .args([ - "--system", - "--create-home", - "--home-dir", - &home_dir, - "--shell", - "/usr/sbin/nologin", - &username, - ]) - .status() - .context("useradd")?; - - if !status.success() { - anyhow::bail!("useradd failed for {username}"); - } - - // Assign subuid/subgid range (65536 IDs per tenant). - add_subid_range(&username)?; - - // Enable lingering and start the user session immediately so that - // XDG_RUNTIME_DIR (/run/user/{uid}) is created right away. - let uid = tenant_uid(tenant_id)?; - let _ = Command::new("loginctl") - .args(["enable-linger", &username]) - .status(); - let _ = Command::new("systemctl") - .args(["start", &format!("user@{uid}.service")]) - .status(); - } - - Ok(()) -} - -/// Run a Podman command as a specific tenant user via `runuser`. -/// -/// Uses `-u` (not `-l -c`) so each argument is passed directly to the OS without -/// shell interpretation — prevents command injection through container/image names. -/// Sets HOME and XDG_RUNTIME_DIR so rootless Podman finds its storage and socket. -pub fn podman_as_tenant(tenant_id: &str, args: &[&str]) -> Result { - let username = format!("lynx-tenant-{tenant_id}"); - let uid = tenant_uid(tenant_id)?; - // Start in / so the tenant user can always access the cwd (their home or - // a restricted directory might not be world-readable). - Command::new("runuser") - .args(["-u", &username, "--", "podman"]) - .args(args) - .env("HOME", format!("/var/lib/lynx/orgs/{tenant_id}")) - .env("XDG_RUNTIME_DIR", format!("/run/user/{uid}")) - .current_dir("/") - .output() - .context("runuser podman") -} - -/// Create an isolated Podman network for an organization. -#[allow(dead_code)] -pub fn ensure_org_network(tenant_id: &str, network_name: &str) -> Result<()> { - let out = podman_as_tenant(tenant_id, &["network", "exists", network_name])?; - - if !out.status.success() { - let out = podman_as_tenant( - tenant_id, - &["network", "create", "--internal", network_name], - )?; - if !out.status.success() { - anyhow::bail!( - "podman network create failed: {}", - String::from_utf8_lossy(&out.stderr) - ); - } - } - Ok(()) -} - -/// List running containers for a tenant. -pub fn list_containers(tenant_id: &str) -> Result> { - let out = podman_as_tenant(tenant_id, &["ps", "--format", "json", "--no-trunc"])?; - - if !out.status.success() { - anyhow::bail!("podman ps failed: {}", String::from_utf8_lossy(&out.stderr)); - } - - let containers: Vec = - serde_json::from_slice(&out.stdout).context("parse podman ps JSON")?; - - Ok(containers - .into_iter() - .filter_map(|c| { - Some(ContainerInfo { - id: c["Id"].as_str()?.to_string(), - name: c["Names"].as_array()?.first()?.as_str()?.to_string(), - status: c["Status"].as_str()?.to_string(), - image: c["Image"].as_str()?.to_string(), - }) - }) - .collect()) -} - -#[derive(Debug, serde::Serialize)] -pub struct ContainerInfo { - pub id: String, - pub name: String, - pub status: String, - pub image: String, -} - -// --------------------------------------------------------------------------- -// Container lifecycle operations (all scoped to a tenant user) -// --------------------------------------------------------------------------- - -pub struct DeployOptions<'a> { - pub tenant_id: &'a str, - pub project_id: &'a str, - pub compose_yaml: &'a str, -} - -/// Write compose file to stable project dir, then run `podman compose up -d`. -/// Returns the compose.yml path so the caller can persist it for startup recovery. -pub fn compose_deploy(opts: DeployOptions<'_>) -> Result { - let project_dir = project_dir(opts.tenant_id, opts.project_id); - std::fs::create_dir_all(&project_dir) - .with_context(|| format!("create project dir {project_dir}"))?; - - let compose_path = format!("{project_dir}/compose.yml"); - std::fs::write(&compose_path, opts.compose_yaml).context("write compose.yml")?; - - // Chown the project dir tree to the tenant user so they can read it. - let uid = tenant_uid(opts.tenant_id)?; - Command::new("chown") - .args(["-R", &format!("{uid}:{uid}"), &project_dir]) - .status() - .context("chown project dir")?; - - let out = run_as_tenant( - opts.tenant_id, - &["compose", "-f", &compose_path, "up", "-d"], - )?; - if !out.status.success() { - anyhow::bail!( - "podman compose up failed: {}", - String::from_utf8_lossy(&out.stderr) - ); - } - Ok(compose_path) -} - -/// Start containers for an existing compose project without recreating running ones. -/// Used on agent startup to recover containers that should be running after a reboot. -pub fn compose_up_no_recreate(tenant_id: &str, compose_path: &str) -> Result<()> { - if !std::path::Path::new(compose_path).exists() { - anyhow::bail!("compose file not found: {compose_path}"); - } - let out = run_as_tenant( - tenant_id, - &["compose", "-f", compose_path, "up", "-d", "--no-recreate"], - )?; - if !out.status.success() { - anyhow::bail!( - "podman compose up --no-recreate failed: {}", - String::from_utf8_lossy(&out.stderr) - ); - } - Ok(()) -} - -/// Tear down a project's compose stack. -pub fn compose_down(tenant_id: &str, project_id: &str) -> Result<()> { - let compose_path = format!("{}/compose.yml", project_dir(tenant_id, project_id)); - if !std::path::Path::new(&compose_path).exists() { - return Ok(()); - } - let out = run_as_tenant( - tenant_id, - &["compose", "-f", &compose_path, "down", "--remove-orphans"], - )?; - if !out.status.success() { - anyhow::bail!( - "podman compose down failed: {}", - String::from_utf8_lossy(&out.stderr) - ); - } - Ok(()) -} - -pub fn container_start(tenant_id: &str, name: &str) -> Result<()> { - let out = run_as_tenant(tenant_id, &["start", name])?; - if !out.status.success() { - anyhow::bail!( - "podman start failed: {}", - String::from_utf8_lossy(&out.stderr) - ); - } - Ok(()) -} - -pub fn container_stop(tenant_id: &str, name: &str) -> Result<()> { - let out = run_as_tenant(tenant_id, &["stop", "--time", "10", name])?; - if !out.status.success() { - anyhow::bail!( - "podman stop failed: {}", - String::from_utf8_lossy(&out.stderr) - ); - } - Ok(()) -} - -pub fn container_remove(tenant_id: &str, name: &str, force: bool) -> Result<()> { - let mut args = vec!["rm"]; - if force { - args.push("--force"); - } - args.push(name); - let out = run_as_tenant(tenant_id, &args)?; - if !out.status.success() { - anyhow::bail!("podman rm failed: {}", String::from_utf8_lossy(&out.stderr)); - } - Ok(()) -} - -pub fn container_restart(tenant_id: &str, name: &str) -> Result<()> { - let out = run_as_tenant(tenant_id, &["restart", name])?; - if !out.status.success() { - anyhow::bail!( - "podman restart failed: {}", - String::from_utf8_lossy(&out.stderr) - ); - } - Ok(()) -} - -/// Update resource limits on a running container (vertical scaling). -pub fn container_update( - tenant_id: &str, - name: &str, - cpus: Option, - memory_mb: Option, -) -> Result<()> { - let mut args = vec!["update".to_string()]; - if let Some(c) = cpus { - args.push(format!("--cpus={c}")); - } - if let Some(m) = memory_mb { - args.push(format!("--memory={m}m")); - } - if args.len() == 1 { - return Ok(()); - } - args.push(name.to_string()); - let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect(); - let out = run_as_tenant(tenant_id, &arg_refs)?; - if !out.status.success() { - anyhow::bail!( - "podman update failed: {}", - String::from_utf8_lossy(&out.stderr) - ); - } - Ok(()) -} - -// --------------------------------------------------------------------------- -// Internal helpers -// --------------------------------------------------------------------------- - -fn project_dir(tenant_id: &str, project_id: &str) -> String { - format!("/var/lib/lynx/projects/{tenant_id}/{project_id}") -} - -fn tenant_uid(tenant_id: &str) -> Result { - let username = format!("lynx-tenant-{tenant_id}"); - let out = Command::new("id") - .args(["-u", &username]) - .output() - .context("id -u")?; - if !out.status.success() { - anyhow::bail!("user {username} not found"); - } - String::from_utf8_lossy(&out.stdout) - .trim() - .parse() - .context("parse uid") -} - -/// Run a Podman command as the tenant user via runuser. -fn run_as_tenant(tenant_id: &str, podman_args: &[&str]) -> Result { - podman_as_tenant(tenant_id, podman_args) -} - -fn add_subid_range(username: &str) -> Result<()> { - let start = next_subid_start().context("find next subid range")?; - let end = start + 65535; - let range = format!("{start}-{end}"); - - for flag in ["--add-subuids", "--add-subgids"] { - let status = Command::new("usermod") - .args([flag, &range, username]) - .status() - .context("usermod subid")?; - if !status.success() { - anyhow::bail!("usermod {flag} failed for {username}"); - } - } - Ok(()) -} - -/// Find the next available subid start across /etc/subuid and /etc/subgid. -/// Allocations start at 100,000 (standard) and each tenant takes 65,536 IDs. -fn next_subid_start() -> Result { - const MIN_START: u64 = 100_000; - // /etc/subuid format: username:start:count — find the highest occupied end - let max_end = ["/etc/subuid", "/etc/subgid"] - .iter() - .filter_map(|path| std::fs::read_to_string(path).ok()) - .flat_map(|content| { - content - .lines() - .filter_map(|line| { - let mut parts = line.splitn(3, ':'); - let _ = parts.next(); // username - let start: u64 = parts.next()?.parse().ok()?; - let count: u64 = parts.next()?.parse().ok()?; - Some(start + count) - }) - .collect::>() - }) - .max(); - - Ok(max_end.unwrap_or(MIN_START).max(MIN_START)) -} diff --git a/lynx/agent/src/state.rs b/lynx/agent/src/state.rs deleted file mode 100644 index 4919174..0000000 --- a/lynx/agent/src/state.rs +++ /dev/null @@ -1,192 +0,0 @@ -use crate::config::Config; -use sqlx::PgPool; -use std::sync::{ - atomic::{AtomicBool, AtomicU64, Ordering}, - Arc, Mutex, -}; -use std::time::Instant; - -/// Tracks why the agent entered lockdown. -/// Only `Heartbeat` (and `None`) can be cleared by a `heartbeat_ack`. -/// All other reasons require a manual service restart to clear. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum LockdownReason { - Heartbeat, - PgUnreachable, - IncompatibleSoftware, - NftablesFailure, -} - -#[derive(Clone)] -pub struct AppState { - pub db: PgPool, - pub config: Arc, - /// Set to true when the agent enters lockdown. - pub lockdown: Arc, - /// The reason the agent entered lockdown, if any. - pub lockdown_reason: Arc>>, - /// Last known-good nftables checksum after apply(). None = no ruleset applied yet. - pub nft_checksum: Arc>>, - /// Per-chain checksums captured after each successful apply() — used for divergence attribution. - pub nft_chain_checksums: Arc; 3]>>, - /// Rendered nft ruleset from last successful apply() — used for restore. - pub nft_last_ruleset: Arc>>, - /// Body of the lynx-global chain (input, managed by dashboard global rules). - pub nft_global_body: Arc>, - /// Body of the lynx-local chain (input, managed by dashboard local rules for this agent). - pub nft_local_body: Arc>, - /// Body of the lynx-global-output chain (output, managed by dashboard global rules). - pub nft_global_output_body: Arc>, - /// Body of the lynx-local-output chain (output, managed by dashboard local rules for this agent). - pub nft_local_output_body: Arc>, - /// WireGuard port used in the last full nftables apply (stored for chain-only updates). - pub nft_wg_port: Arc, - /// In-memory command rate limiter: (window_start_secs, count_in_window) - pub cmd_rate: Arc>, - /// Count of `rejected_rate_limit` events in the current minute — alert threshold. - pub cmd_rejected_count: Arc, - /// Epoch-second when the current rejection-count minute window started. - pub cmd_rejected_window: Arc, - /// Epoch-second of last successful dashboard contact (WS connect or message received). - /// 0 = never connected. Used by the fallback updater to detect dashboard absence. - pub last_dashboard_contact: Arc, - /// Instant of last received heartbeat ACK from dashboard. - /// Reset by both the HTTP /heartbeat handler and the WS heartbeat_ack path. - /// The lockdown watchdog fires when this exceeds HEARTBEAT_TIMEOUT_SECS. - pub last_heartbeat: Arc>, -} - -impl AppState { - pub fn is_locked_down(&self) -> bool { - self.lockdown.load(Ordering::SeqCst) - } - - /// Enter lockdown with an explicit reason. - pub fn set_lockdown(&self, reason: LockdownReason) { - self.lockdown.store(true, Ordering::SeqCst); - *self.lockdown_reason.lock().unwrap() = Some(reason); - } - - /// Clear lockdown only when the reason is `Heartbeat` or `None`. - /// Reasons such as `PgUnreachable`, `IncompatibleSoftware`, and - /// `NftablesFailure` require a manual service restart to clear. - pub fn clear_lockdown_if_heartbeat(&self) { - let mut guard = self.lockdown_reason.lock().unwrap(); - match *guard { - None | Some(LockdownReason::Heartbeat) => { - self.lockdown.store(false, Ordering::SeqCst); - *guard = None; - } - _ => {} - } - } - - /// Returns true if the command is within the 100/min limit, false if it should be rejected. - pub fn check_cmd_rate(&self) -> bool { - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - let mut guard = self.cmd_rate.lock().unwrap(); - let (window_start, count) = *guard; - if now >= window_start + 60 { - *guard = (now, 1); - true - } else if count < 100 { - guard.1 += 1; - true - } else { - false - } - } - - /// Record a rejected-rate-limit event. Returns count in current minute. - pub fn record_rate_rejection(&self) -> u64 { - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - let window = self.cmd_rejected_window.load(Ordering::SeqCst); - if now >= window + 60 { - self.cmd_rejected_window.store(now, Ordering::SeqCst); - self.cmd_rejected_count.store(1, Ordering::SeqCst); - 1 - } else { - self.cmd_rejected_count.fetch_add(1, Ordering::SeqCst) + 1 - } - } - - pub fn nft_wg_port(&self) -> u16 { - self.nft_wg_port.load(Ordering::SeqCst) as u16 - } - - pub fn set_nft_wg_port(&self, port: u16) { - self.nft_wg_port.store(port as u32, Ordering::SeqCst); - } - - pub fn set_nft_checksum(&self, checksum: String) { - *self.nft_checksum.lock().unwrap() = Some(checksum); - } - - pub fn expected_nft_checksum(&self) -> Option { - self.nft_checksum.lock().unwrap().clone() - } - - /// Store per-chain checksums: (base, global, local). - pub fn set_nft_chain_checksums( - &self, - base: Option, - global: Option, - local: Option, - ) { - let mut g = self.nft_chain_checksums.lock().unwrap(); - g[0] = base; - g[1] = global; - g[2] = local; - } - - /// Expected chain checksum by index: 0=base, 1=global, 2=local. - pub fn expected_chain_checksum(&self, idx: usize) -> Option { - self.nft_chain_checksums.lock().unwrap()[idx].clone() - } - - pub fn set_nft_last_ruleset(&self, ruleset: String) { - *self.nft_last_ruleset.lock().unwrap() = Some(ruleset); - } - - pub fn nft_last_ruleset(&self) -> Option { - self.nft_last_ruleset.lock().unwrap().clone() - } - - pub fn set_nft_global_body(&self, body: String) { - *self.nft_global_body.lock().unwrap() = body; - } - - pub fn nft_global_body(&self) -> String { - self.nft_global_body.lock().unwrap().clone() - } - - pub fn set_nft_local_body(&self, body: String) { - *self.nft_local_body.lock().unwrap() = body; - } - - pub fn nft_local_body(&self) -> String { - self.nft_local_body.lock().unwrap().clone() - } - - pub fn set_nft_global_output_body(&self, body: String) { - *self.nft_global_output_body.lock().unwrap() = body; - } - - pub fn nft_global_output_body(&self) -> String { - self.nft_global_output_body.lock().unwrap().clone() - } - - pub fn set_nft_local_output_body(&self, body: String) { - *self.nft_local_output_body.lock().unwrap() = body; - } - - pub fn nft_local_output_body(&self) -> String { - self.nft_local_output_body.lock().unwrap().clone() - } -} diff --git a/lynx/agent/src/sync/mod.rs b/lynx/agent/src/sync/mod.rs deleted file mode 100644 index bc5fc13..0000000 --- a/lynx/agent/src/sync/mod.rs +++ /dev/null @@ -1,210 +0,0 @@ -use crate::state::AppState; -use serde::Serialize; -use sqlx::PgPool; -use tracing::{error, info, warn}; - -#[derive(Debug, Serialize, sqlx::FromRow)] -pub struct AuditEntry { - pub id: uuid::Uuid, - pub agent_id: uuid::Uuid, - pub organization_id: Option, - pub user_id: Option, - pub command_type: String, - pub result: String, - pub error: Option, - pub previous_hash: String, - pub entry_hash: String, - pub created_at: chrono::DateTime, -} - -const BATCH_SIZE: i64 = 100; -const SYNC_INTERVAL_SECS: u64 = 60; - -pub async fn run_sync_task(state: AppState) { - let Some(dashboard_url) = &state.config.dashboard_url else { - warn!("DASHBOARD_URL not set — audit log sync disabled"); - return; - }; - let Some(sync_token) = &state.config.sync_token else { - warn!("SYNC_TOKEN not set — audit log sync disabled"); - return; - }; - - let sync_url = format!( - "{}/agents/{}/audit-sync", - dashboard_url.trim_end_matches('/'), - state.config.agent_id - ); - let token = sync_token.clone(); - let db = state.db.clone(); - - info!(sync_url = %sync_url, "audit sync task started"); - - let mut interval = tokio::time::interval(std::time::Duration::from_secs(SYNC_INTERVAL_SECS)); - loop { - interval.tick().await; - if let Err(e) = sync_batch(&db, &sync_url, &token).await { - error!(error = %e, "audit log sync failed"); - } - } -} - -async fn sync_batch(db: &PgPool, url: &str, token: &str) -> anyhow::Result<()> { - let last_synced = sqlx::query_scalar!("SELECT last_synced_at FROM sync_state WHERE id = 1") - .fetch_one(db) - .await?; - - let entries = sqlx::query_as!( - AuditEntry, - r#" - SELECT id, agent_id, organization_id, user_id, command_type, - result, error, previous_hash, entry_hash, created_at - FROM audit_log - WHERE created_at > $1 - ORDER BY created_at ASC - LIMIT $2 - "#, - last_synced, - BATCH_SIZE - ) - .fetch_all(db) - .await?; - - if entries.is_empty() { - return Ok(()); - } - - let count = entries.len(); - let last_at = entries.last().unwrap().created_at; - - let client = reqwest::Client::new(); - let resp = client - .post(url) - .header("Authorization", format!("Bearer {token}")) - .json(&entries) - .timeout(std::time::Duration::from_secs(15)) - .send() - .await?; - - if resp.status().as_u16() == 422 { - // Dashboard rejected the batch due to hash chain mismatch — our sync cursor - // is ahead of what the dashboard has (e.g. dashboard DB was wiped or restored - // from a backup). Reset to epoch so the next cycle resends from genesis. - let body = resp.text().await.unwrap_or_default(); - tracing::warn!( - detail = &body[..body.len().min(200)], - "audit sync: hash chain mismatch — resetting sync cursor to epoch for full resend" - ); - let epoch = chrono::DateTime::::from_timestamp(0, 0).unwrap_or_default(); - sqlx::query!( - "UPDATE sync_state SET last_synced_at = $1 WHERE id = 1", - epoch - ) - .execute(db) - .await?; - return Ok(()); - } - - if !resp.status().is_success() { - let status = resp.status(); - let body = resp.text().await.unwrap_or_default(); - anyhow::bail!( - "dashboard returned {}: {}", - status, - &body[..body.len().min(200)] - ); - } - - sqlx::query!( - "UPDATE sync_state SET last_synced_at = $1 WHERE id = 1", - last_at - ) - .execute(db) - .await?; - - info!(count, "audit entries synced to dashboard"); - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - - // sync/mod.rs has no pure logic functions — all paths require either a live - // PostgreSQL connection (sync_batch) or a running Tokio runtime with network - // access (run_sync_task). Those are integration tests executed against real - // containers in CI, not unit tests. - // - // What we CAN test here: - // • Module-level constants used to shape behaviour - // • URL construction pattern (pure string formatting) - // • AuditEntry struct is serialisable (compile-time guarantee via Serialize) - - #[test] - fn batch_size_is_positive() { - assert!(BATCH_SIZE > 0, "BATCH_SIZE must be greater than zero"); - } - - #[test] - fn sync_interval_is_positive() { - assert!( - SYNC_INTERVAL_SECS > 0, - "SYNC_INTERVAL_SECS must be greater than zero" - ); - } - - #[test] - fn sync_url_format_includes_agent_id_and_path() { - // Replicate the URL construction from run_sync_task to ensure the format - // string produces the expected shape — pure string operation, no I/O. - let dashboard_url = "https://dashboard.example.com/"; - let agent_id = uuid::Uuid::nil(); // all-zeros UUID — no DB needed - let sync_url = format!( - "{}/agents/{}/audit-sync", - dashboard_url.trim_end_matches('/'), - agent_id - ); - assert_eq!( - sync_url, - "https://dashboard.example.com/agents/00000000-0000-0000-0000-000000000000/audit-sync" - ); - } - - #[test] - fn sync_url_trailing_slash_stripped() { - let base = "https://dashboard.example.com/"; - let trimmed = base.trim_end_matches('/'); - assert_eq!(trimmed, "https://dashboard.example.com"); - } - - #[test] - fn sync_url_no_trailing_slash_unchanged() { - let base = "https://dashboard.example.com"; - let trimmed = base.trim_end_matches('/'); - assert_eq!(trimmed, "https://dashboard.example.com"); - } - - #[test] - fn audit_entry_result_field_is_string() { - // Compile-time check that AuditEntry derives Serialize and has the expected - // field types. We construct one manually (no DB) using placeholder values. - let entry = AuditEntry { - id: uuid::Uuid::nil(), - agent_id: uuid::Uuid::nil(), - organization_id: None, - user_id: None, - command_type: "test_command".to_string(), - result: "success".to_string(), - error: None, - previous_hash: "abc123".to_string(), - entry_hash: "def456".to_string(), - created_at: chrono::DateTime::::from_timestamp(0, 0).unwrap_or_default(), - }; - - // Verify serialization produces valid JSON and key fields are present. - let json = serde_json::to_string(&entry).expect("AuditEntry should serialize"); - assert!(json.contains("test_command")); - assert!(json.contains("success")); - assert!(json.contains("abc123")); - } -} diff --git a/lynx/agent/src/update/fallback.rs b/lynx/agent/src/update/fallback.rs deleted file mode 100644 index 0ff994e..0000000 --- a/lynx/agent/src/update/fallback.rs +++ /dev/null @@ -1,210 +0,0 @@ -use crate::state::AppState; -use anyhow::Context as _; -use std::sync::atomic::Ordering; -use tokio::time::{interval, Duration}; - -/// How long the dashboard must be unreachable before the agent polls GitHub directly. -const ABSENT_THRESHOLD_SECS: u64 = 6 * 3600; - -/// How often the fallback updater checks if an update is needed. -const CHECK_INTERVAL_SECS: u64 = 3600; - -const GITHUB_API: &str = "https://api.github.com/repos/Jaro-c/Lynx/releases"; - -pub async fn run_fallback_updater(state: AppState) { - let mut ticker = interval(Duration::from_secs(CHECK_INTERVAL_SECS)); - ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); - - loop { - ticker.tick().await; - - // No dashboard URL = agent not yet onboarded. Skip fallback updates entirely: - // without a dashboard the agent is in setup mode and the WS connection will - // never establish last_contact, which would otherwise immediately satisfy the - // absence threshold on every restart and cause an infinite update loop. - if state.config.dashboard_url.is_none() { - continue; - } - - let last_contact = state.last_dashboard_contact.load(Ordering::SeqCst); - let now = epoch_secs(); - - // 0 = never connected. If we have never connected, use a past epoch so the absence - // threshold is immediately satisfied — agent might have been offline since install. - let absent_secs = if last_contact == 0 { - ABSENT_THRESHOLD_SECS + 1 - } else { - now.saturating_sub(last_contact) - }; - - if absent_secs <= ABSENT_THRESHOLD_SECS { - continue; - } - - tracing::info!( - absent_secs, - "dashboard absent — checking GitHub for agent update" - ); - - if let Err(e) = check_and_apply(&state).await { - tracing::warn!(error = %e, "fallback updater check failed"); - } - } -} - -async fn check_and_apply(state: &AppState) -> anyhow::Result<()> { - let current_version = &state.config.version; - let arch = match std::env::consts::ARCH { - "aarch64" => "arm64", - a => a, - }; - - let latest = fetch_latest_agent_version().await?; - - if !is_newer(&latest, current_version) { - tracing::debug!(current = current_version, latest, "agent is up to date"); - return Ok(()); - } - - tracing::info!( - current = current_version, - latest, - "fallback: applying agent update" - ); - - let download_url = format!( - "https://github.com/Jaro-c/Lynx/releases/download/agent@{latest}/lynx-agent-linux-{arch}" - ); - let sig_url = format!("{download_url}.sig"); - - super::perform_update(&latest, &download_url, &sig_url).await -} - -async fn fetch_latest_agent_version() -> anyhow::Result { - let client = super::build_ssrf_safe_client(GITHUB_API) - .await - .context("SSRF check for GitHub API")?; - - let releases: serde_json::Value = client - .get(GITHUB_API) - .send() - .await? - .error_for_status()? - .json() - .await?; - - let releases = releases - .as_array() - .ok_or_else(|| anyhow::anyhow!("GitHub API returned non-array"))?; - - for release in releases { - let tag = release - .get("tag_name") - .and_then(|v| v.as_str()) - .unwrap_or(""); - if let Some(ver) = tag.strip_prefix("agent@") { - return Ok(ver.to_string()); - } - } - - anyhow::bail!("no agent@* release found in GitHub releases") -} - -/// Returns true if `latest` is strictly newer than `current` (semver comparison). -fn is_newer(latest: &str, current: &str) -> bool { - parse_semver(latest) > parse_semver(current) -} - -fn parse_semver(v: &str) -> (u64, u64, u64) { - let parts: Vec = v - .trim_start_matches('v') - .splitn(3, '.') - .map(|s| s.parse().unwrap_or(0)) - .collect(); - ( - parts.first().copied().unwrap_or(0), - parts.get(1).copied().unwrap_or(0), - parts.get(2).copied().unwrap_or(0), - ) -} - -fn epoch_secs() -> u64 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs() -} - -#[cfg(test)] -mod tests { - use super::*; - - // --- parse_semver --- - - #[test] - fn parse_semver_normal() { - assert_eq!(parse_semver("1.2.3"), (1, 2, 3)); - } - - #[test] - fn parse_semver_v_prefix() { - assert_eq!(parse_semver("v2.0.0"), (2, 0, 0)); - } - - #[test] - fn parse_semver_missing_patch() { - assert_eq!(parse_semver("1.0"), (1, 0, 0)); - } - - #[test] - fn parse_semver_empty() { - assert_eq!(parse_semver(""), (0, 0, 0)); - } - - #[test] - fn parse_semver_garbage() { - assert_eq!(parse_semver("garbage"), (0, 0, 0)); - } - - #[test] - fn parse_semver_zeros() { - assert_eq!(parse_semver("0.0.0"), (0, 0, 0)); - } - - // --- is_newer --- - - #[test] - fn is_newer_patch_bump() { - assert!(is_newer("1.2.0", "1.1.0")); - } - - #[test] - fn is_newer_older_version_is_false() { - assert!(!is_newer("1.1.0", "1.2.0")); - } - - #[test] - fn is_newer_equal_versions_is_false() { - assert!(!is_newer("1.0.0", "1.0.0")); - } - - #[test] - fn is_newer_major_bump() { - assert!(is_newer("2.0.0", "1.9.9")); - } - - #[test] - fn is_newer_v_prefix_stripped() { - assert!(is_newer("v1.2.0", "1.1.0")); - } - - #[test] - fn is_newer_both_v_prefix() { - assert!(is_newer("v2.1.0", "v2.0.9")); - } - - #[test] - fn is_newer_minor_rollback_is_false() { - assert!(!is_newer("1.0.0", "1.0.1")); - } -} diff --git a/lynx/agent/src/update/mod.rs b/lynx/agent/src/update/mod.rs deleted file mode 100644 index 93a727f..0000000 --- a/lynx/agent/src/update/mod.rs +++ /dev/null @@ -1,253 +0,0 @@ -pub mod fallback; - -use anyhow::{Context, Result}; -use ed25519_dalek::{Signature, Verifier, VerifyingKey}; -use std::path::PathBuf; - -const AGENT_BINARY: &str = "/etc/lynx/bin/lynx-agent"; -const CRITICAL_FILE: &str = "/etc/lynx/CRITICAL"; - -/// Download new binary, verify Ed25519 signature, backup to .prev, atomic swap, restart via systemd. -/// -/// The release verify key (`RELEASE_VERIFY_KEY_B64`) is compiled into the binary and is distinct -/// from the dashboard command-signing key. The corresponding private key lives only in GitHub -/// Actions secrets — compromising the repo or the dashboard does not allow forging signatures. -pub async fn perform_update(version: &str, download_url: &str, sig_url: &str) -> Result<()> { - validate_github_url(download_url)?; - validate_github_url(sig_url)?; - - tracing::info!(version, "starting self-update"); - - // Build separate SSRF-safe clients per URL: resolves DNS once, validates - // the resolved IP is not RFC1918/loopback, then pins the hostname to that - // IP for the actual request (prevents DNS TOCTOU rebinding attacks). - let bin_client = build_ssrf_safe_client(download_url) - .await - .context("SSRF check for binary URL")?; - let sig_client = build_ssrf_safe_client(sig_url) - .await - .context("SSRF check for sig URL")?; - - // Download binary - let binary_bytes = download_bytes(&bin_client, download_url) - .await - .context("download binary")?; - - // Download signature - let sig_bytes = download_bytes(&sig_client, sig_url) - .await - .context("download signature")?; - - // Verify Ed25519 signature - verify_signature(&binary_bytes, &sig_bytes) - .context("signature verification failed — update aborted")?; - - tracing::info!(version, bytes = binary_bytes.len(), "signature verified"); - - let target = PathBuf::from(AGENT_BINARY); - let prev = PathBuf::from(format!("{AGENT_BINARY}.prev")); - let tmp = PathBuf::from(format!("{AGENT_BINARY}.new")); - - std::fs::write(&tmp, &binary_bytes).with_context(|| format!("write to {tmp:?}"))?; - - // Make it executable - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let mut perms = std::fs::metadata(&tmp)?.permissions(); - perms.set_mode(0o755); - std::fs::set_permissions(&tmp, perms)?; - } - - // Back up current binary to .prev before swap - if target.exists() { - std::fs::copy(&target, &prev).context("backup agent binary to .prev")?; - } - - // Atomic rename: tmp → canonical path (POSIX atomic on same filesystem) - std::fs::rename(&tmp, &target).with_context(|| format!("rename {tmp:?} → {target:?}"))?; - - tracing::info!(version, "binary swapped — restarting via systemd"); - - // Systemd will restart the unit (Restart=always in the service unit). - // Exit 0 so systemd records a clean restart, not a failure. - std::process::exit(0); -} - -/// Spawn a background task that monitors agent startup health. -/// -/// Polls `http://127.0.0.1:9090/health` every 2s for 30s. -/// If still unhealthy → attempt `.prev` restore and exit 1 (systemd restarts with old binary). -/// If `.prev` unavailable or restore fails → write `/etc/lynx/CRITICAL` and exit 1. -/// On healthy startup → delete `/etc/lynx/CRITICAL` if present (recovery from prior critical state). -pub fn spawn_startup_health_guard() { - tokio::spawn(async move { - let client = match reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(3)) - .build() - { - Ok(c) => c, - Err(_) => return, - }; - - for _ in 0..15 { - tokio::time::sleep(std::time::Duration::from_secs(2)).await; - if client - .get("http://127.0.0.1:9090/health") // audit-urls: ok — self health check, not a download - .send() - .await - .map(|r| r.status().is_success()) - .unwrap_or(false) - { - // Healthy — clear any leftover CRITICAL file from a previous failed startup. - let _ = std::fs::remove_file(CRITICAL_FILE); - return; - } - } - - // Still unhealthy after 30s — attempt .prev restore. - tracing::error!("startup health check failed — restoring .prev binary"); - let target = PathBuf::from(AGENT_BINARY); - let prev = PathBuf::from(format!("{AGENT_BINARY}.prev")); - - let restore_ok = if prev.exists() { - // Atomic rename to avoid ETXTBSY — the current binary is a running executable, - // so copy() with O_TRUNC fails. Write to .new first, then rename (POSIX atomic). - let tmp = PathBuf::from(format!("{AGENT_BINARY}.restoring")); - std::fs::copy(&prev, &tmp).is_ok() && std::fs::rename(&tmp, &target).is_ok() - } else { - false - }; - - let reason = if restore_ok { - "new binary failed health check; restored .prev" - } else { - "new binary failed health check; .prev unavailable — MANUAL RECOVERY REQUIRED" - }; - - let ts = chrono::Utc::now().to_rfc3339(); - let _ = std::fs::write( - CRITICAL_FILE, - format!("timestamp={ts}\ncomponent=lynx-agent\nreason={reason}\n"), - ); - - tracing::error!(reason, "critical state — exiting for systemd restart"); - std::process::exit(1); - }); -} - -async fn download_bytes(client: &reqwest::Client, url: &str) -> Result> { - let resp = client - .get(url) - .send() - .await - .with_context(|| format!("GET {url}"))?; - - if !resp.status().is_success() { - anyhow::bail!("HTTP {} for {url}", resp.status()); - } - - // content_length is a hint; we still cap to 200 MiB - if let Some(len) = resp.content_length() { - if len > 200 * 1024 * 1024 { - anyhow::bail!("Content-Length {len} exceeds 200 MiB safety limit"); - } - } - - let bytes = resp.bytes().await.context("read response body")?; - if bytes.len() > 200 * 1024 * 1024 { - anyhow::bail!("download exceeded 200 MiB safety limit"); - } - Ok(bytes.to_vec()) -} - -fn verify_signature(binary: &[u8], sig_bytes: &[u8]) -> Result<()> { - let key_bytes = load_verify_key()?; - let key = VerifyingKey::from_bytes(&key_bytes).context("parse DASHBOARD_VERIFY_KEY")?; - - let sig_arr: [u8; 64] = sig_bytes - .try_into() - .map_err(|_| anyhow::anyhow!("signature must be 64 bytes, got {}", sig_bytes.len()))?; - let sig = Signature::from_bytes(&sig_arr); - - key.verify(binary, &sig) - .context("Ed25519 signature invalid") -} - -const RELEASE_VERIFY_KEY_B64: &str = "OsBV4t+vQSn10FAI8UzAJEBS0IUqp8D2bZtlQYD8j+Q="; - -fn load_verify_key() -> Result<[u8; 32]> { - use base64ct::{Base64, Encoding}; - let bytes = Base64::decode_vec(RELEASE_VERIFY_KEY_B64) - .context("decode hardcoded release verify key")?; - bytes - .try_into() - .map_err(|_| anyhow::anyhow!("release verify key must be 32 bytes")) -} - -fn validate_github_url(url: &str) -> Result<()> { - let allowed = [ - "https://github.com/", - "https://objects.githubusercontent.com/", - ]; - if allowed.iter().any(|prefix| url.starts_with(prefix)) { - Ok(()) - } else { - anyhow::bail!("download URL not on allowed domain: {url}") - } -} - -/// Builds an HTTP client with SSRF protection: -/// 1. Resolves the hostname of `url` via DNS (once). -/// 2. Rejects if any resolved IP is RFC1918, loopback, or link-local. -/// 3. Pins the hostname to the validated IP so reqwest never re-resolves it -/// (prevents DNS rebinding / TOCTOU attacks). -async fn build_ssrf_safe_client(url: &str) -> Result { - let parsed = url::Url::parse(url).context("parse URL for SSRF check")?; - let host = parsed - .host_str() - .ok_or_else(|| anyhow::anyhow!("URL has no host: {url}"))? - .to_string(); - let port = parsed - .port_or_known_default() - .ok_or_else(|| anyhow::anyhow!("URL has unknown port: {url}"))?; - - let addrs: Vec = tokio::net::lookup_host(format!("{host}:{port}")) - .await - .with_context(|| format!("DNS lookup for {host}"))? - .collect(); - - if addrs.is_empty() { - anyhow::bail!("DNS lookup for {host} returned no addresses"); - } - - for addr in &addrs { - if is_private_ip(addr.ip()) { - anyhow::bail!( - "SSRF protection: {host} resolved to private/reserved IP {}", - addr.ip() - ); - } - } - - reqwest::Client::builder() - .user_agent(format!("lynx-agent/{}", env!("CARGO_PKG_VERSION"))) - .timeout(std::time::Duration::from_secs(300)) - .resolve(&host, addrs[0]) - .build() - .context("build SSRF-safe HTTP client") -} - -fn is_private_ip(ip: std::net::IpAddr) -> bool { - match ip { - std::net::IpAddr::V4(v4) => { - v4.is_private() || v4.is_loopback() || v4.is_link_local() || v4.is_unspecified() - } - std::net::IpAddr::V6(v6) => { - v6.is_loopback() - || v6.is_unspecified() - || (v6.segments()[0] & 0xfe00) == 0xfc00 // fc00::/7 ULA - || (v6.segments()[0] & 0xffc0) == 0xfe80 // fe80::/10 link-local - } - } -} diff --git a/lynx/agent/src/ws_client.rs b/lynx/agent/src/ws_client.rs deleted file mode 100644 index b66ba92..0000000 --- a/lynx/agent/src/ws_client.rs +++ /dev/null @@ -1,237 +0,0 @@ -use crate::{auth::SignedCommand, handlers::run_verified_command, metrics, state::AppState}; -use base64ct::{Base64UrlUnpadded, Encoding}; -use futures_util::{SinkExt, StreamExt}; -use serde_json::{json, Value}; -use std::sync::atomic::Ordering; -use std::time::Duration; -use tokio::time::{interval, sleep}; -use tokio_tungstenite::{connect_async, tungstenite::Message}; -use uuid::Uuid; - -const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(30); -const METRICS_INTERVAL: Duration = Duration::from_secs(5); -const CONTAINER_METRICS_INTERVAL: Duration = Duration::from_secs(10); -const BACKOFF_BASE: Duration = Duration::from_secs(5); -const BACKOFF_MAX: Duration = Duration::from_secs(300); - -pub async fn run_ws_client(state: AppState) { - let Some(dashboard_url) = state.config.dashboard_url.clone() else { - tracing::warn!("DASHBOARD_URL not set — WS client disabled"); - return; - }; - let Some(sync_token) = state.config.sync_token.clone() else { - tracing::warn!("SYNC_TOKEN not set — WS client disabled"); - return; - }; - - let agent_id = state.config.agent_id; - let base = dashboard_url.trim_end_matches('/'); - - // Convert http → ws, https → wss - let ws_url = if let Some(host) = base.strip_prefix("https://") { - format!( - "wss://{host}/agents/{agent_id}/ws?token={}", - sync_token.as_str() - ) - } else { - let host = base.strip_prefix("http://").unwrap_or(base); - format!( - "ws://{host}/agents/{agent_id}/ws?token={}", - sync_token.as_str() - ) - }; - - let mut backoff = BACKOFF_BASE; - - loop { - tracing::info!(url = %ws_url, "connecting to dashboard WS"); - - match connect_async(&ws_url).await { - Ok((ws_stream, _)) => { - backoff = BACKOFF_BASE; - tracing::info!("dashboard WS connected"); - record_dashboard_contact(&state); - run_session(&state, ws_stream).await; - tracing::warn!("dashboard WS session ended — reconnecting"); - } - Err(e) => { - tracing::warn!( - error = %e, - backoff_secs = backoff.as_secs(), - "dashboard WS connect failed" - ); - } - } - - sleep(backoff).await; - backoff = (backoff * 2).min(BACKOFF_MAX); - } -} - -async fn run_session( - state: &AppState, - ws_stream: tokio_tungstenite::WebSocketStream< - tokio_tungstenite::MaybeTlsStream, - >, -) { - let (mut sink, mut stream) = ws_stream.split(); - let mut hb_ticker = interval(HEARTBEAT_INTERVAL); - let mut metrics_ticker = interval(METRICS_INTERVAL); - let mut container_ticker = interval(CONTAINER_METRICS_INTERVAL); - metrics_ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); - container_ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); - - loop { - tokio::select! { - _ = hb_ticker.tick() => { - let hb = heartbeat_payload(state); - let text = serde_json::to_string(&hb).unwrap_or_default(); - if sink.send(Message::Text(text.into())).await.is_err() { - break; - } - } - _ = metrics_ticker.tick() => { - if let Ok(m) = metrics::sample_system().await { - let frame = json!({ - "type": "metrics", - "data": m, - }); - let text = serde_json::to_string(&frame).unwrap_or_default(); - if sink.send(Message::Text(text.into())).await.is_err() { - break; - } - } - } - _ = container_ticker.tick() => { - let m = metrics::sample_containers(); - let frame = json!({ - "type": "container_metrics", - "data": m, - }); - let text = serde_json::to_string(&frame).unwrap_or_default(); - if sink.send(Message::Text(text.into())).await.is_err() { - break; - } - } - msg = stream.next() => { - #[allow(clippy::collapsible_match)] - match msg { - Some(Ok(Message::Text(text))) => { - record_dashboard_contact(state); - let reply = handle_message(state, text.as_str()).await; - if let Some(frame) = reply { - let text = serde_json::to_string(&frame).unwrap_or_default(); - if sink.send(Message::Text(text.into())).await.is_err() { - break; - } - } - } - Some(Ok(Message::Ping(data))) => { - if sink.send(Message::Pong(data)).await.is_err() { break; } - } - Some(Ok(Message::Close(_))) | None => break, - Some(Err(e)) => { - tracing::warn!(error = %e, "WS error"); - break; - } - _ => {} - } - } - } - } -} - -fn record_dashboard_contact(state: &AppState) { - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - state - .last_dashboard_contact - .store(now, std::sync::atomic::Ordering::SeqCst); -} - -fn heartbeat_payload(state: &AppState) -> Value { - let arch = match std::env::consts::ARCH { - "aarch64" => "arm64", - a => a, - }; - json!({ - "type": "heartbeat", - "agent_id": state.config.agent_id, - "version": state.config.version, - "arch": arch, - "timestamp": chrono::Utc::now().to_rfc3339(), - "status": if state.lockdown.load(Ordering::SeqCst) { "lockdown" } else { "online" }, - "nonce": Uuid::now_v7(), - }) -} - -async fn handle_message(state: &AppState, text: &str) -> Option { - let msg: Value = serde_json::from_str(text) - .map_err(|e| tracing::warn!(error = %e, "invalid WS message")) - .ok()?; - - let msg_type = msg.get("type").and_then(|v| v.as_str())?; - let req_id = msg - .get("id") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(); - - match msg_type { - "command" => { - let payload = msg.get("payload")?; - let signed: SignedCommand = serde_json::from_value(payload.clone()) - .map_err(|e| tracing::warn!(error = %e, "invalid command payload")) - .ok()?; - - // Heartbeat ACKs must bypass the lockdown gate so the dashboard can - // rescue a locked-down agent via WS (mirrors the HTTP /heartbeat path). - let is_heartbeat_ack = peek_inner_command_type(&signed) - .map(|t| t == "agent.heartbeat_ack") - .unwrap_or(false); - - if !is_heartbeat_ack && state.is_locked_down() { - return Some(json!({ - "type": "command_response", - "id": req_id, - "ok": false, - "error": "agent in lockdown", - })); - } - - let result = run_verified_command(state, signed).await; - - Some(match result { - Ok(body) => json!({ - "type": "command_response", - "id": req_id, - "ok": true, - "body": body, - }), - Err(e) => json!({ - "type": "command_response", - "id": req_id, - "ok": false, - "error": e.to_string(), - }), - }) - } - "ping" => Some(json!({"type": "pong"})), - _ => None, - } -} - -/// Decode the base64url payload to peek at the inner command `type` field -/// without performing signature verification. Used only to decide whether to -/// bypass the lockdown gate — full verification still happens inside -/// `run_verified_command`. -fn peek_inner_command_type(signed: &SignedCommand) -> Option { - let bytes = Base64UrlUnpadded::decode_vec(&signed.payload).ok()?; - let val: Value = serde_json::from_slice(&bytes).ok()?; - val.get("command")? - .get("type")? - .as_str() - .map(|s| s.to_string()) -} diff --git a/lynx/agent/update-agent.sh b/lynx/agent/update-agent.sh deleted file mode 100644 index c6a2a0e..0000000 --- a/lynx/agent/update-agent.sh +++ /dev/null @@ -1,245 +0,0 @@ -#!/usr/bin/env bash -# ----------------------------------------------------------------------------- -# update-agent.sh — Lynx Agent update script -# -# Description: -# Updates the Lynx Agent to the latest available release. -# Downloads the binary from GitHub Releases, verifies Ed25519 signature, -# swaps atomically with .prev backup, and restarts the systemd service. -# Preserves all data, secrets, WireGuard config, and nftables rules. -# -# Usage: -# sudo ./update-agent.sh -# sudo ./update-agent.sh --force (update even if already at latest) -# -# Requirements: -# - Lynx Agent already installed (run setup-agent.sh first) -# - Run as root -# - Internet access to GitHub Releases -# ----------------------------------------------------------------------------- - -set -euo pipefail - -# --- Colors ----------------------------------------------------------------- - -RED='\033[0;31m' -YELLOW='\033[1;33m' -GREEN='\033[0;32m' -CYAN='\033[0;36m' -BOLD='\033[1m' -RESET='\033[0m' - -# --- Logging ---------------------------------------------------------------- - -log_info() { echo -e "${CYAN}[INFO]${RESET} $*"; } -log_ok() { echo -e "${GREEN}[OK]${RESET} $*"; } -log_warn() { echo -e "${YELLOW}[WARN]${RESET} $*"; } -log_error() { echo -e "${RED}[ERROR]${RESET} $*" >&2; } -log_section() { echo -e "\n${BOLD}${CYAN}=== $* ===${RESET}"; } - -# --- Constants -------------------------------------------------------------- - -BIN_DIR="/etc/lynx/bin" -BINARY_PATH="$BIN_DIR/lynx-agent" -GITHUB_REPO="Jaro-c/Lynx" -VERSION_FILE="$BIN_DIR/lynx-agent-version" -FORCE=false - -# --- Parse args ------------------------------------------------------------- - -for arg in "$@"; do - case "$arg" in - --force) FORCE=true ;; - *) log_error "Unknown argument: $arg"; exit 1 ;; - esac -done - -# --- Root check ------------------------------------------------------------- - -if [[ $EUID -ne 0 ]]; then - log_error "Must run as root: sudo $0" - exit 1 -fi - -# --- Installation check ----------------------------------------------------- - -if [[ ! -f "$BINARY_PATH" ]]; then - log_error "Lynx Agent not installed — run setup-agent.sh first" - exit 1 -fi - -# --- Version check ---------------------------------------------------------- - -log_section "Checking versions" - -CURRENT_VERSION="" -if [[ -f "$VERSION_FILE" ]]; then - CURRENT_VERSION=$(cat "$VERSION_FILE") - log_info "Current version: $CURRENT_VERSION" -else - log_warn "No version file found — version unknown, proceeding with update" -fi - -_ARCH=$(uname -m) -case "$_ARCH" in - x86_64) ARCH="x86_64" ;; - aarch64) ARCH="arm64" ;; - *) - log_error "Unsupported architecture: $_ARCH" - exit 1 - ;; -esac - -log_info "Fetching latest agent release..." -LATEST_TAG=$(curl -fsSL \ - "https://api.github.com/repos/${GITHUB_REPO}/releases" \ - | python3 -c " -import sys, json -releases = json.load(sys.stdin) -tags = [r['tag_name'] for r in releases - if r.get('tag_name','').startswith('agent@') - and not r.get('prerelease') and not r.get('draft')] -if tags: - def ver(t): return tuple(int(x) for x in t.split('@')[1].split('.')) - print(max(tags, key=ver)) -" 2>/dev/null) - -if [[ -z "$LATEST_TAG" ]]; then - log_error "No agent release found in ${GITHUB_REPO}" - exit 1 -fi - -LATEST_VERSION="${LATEST_TAG#agent@}" -log_info "Latest version: $LATEST_VERSION" - -if [[ "$CURRENT_VERSION" == "$LATEST_VERSION" ]] && ! $FORCE; then - log_ok "Already at latest version ($LATEST_VERSION) — nothing to do" - log_info "Use --force to reinstall the same version" - exit 0 -fi - -if [[ -n "$CURRENT_VERSION" ]]; then - log_info "Updating: $CURRENT_VERSION → $LATEST_VERSION" -else - log_info "Installing version: $LATEST_VERSION" -fi - -# --- Signature verification setup ------------------------------------------- - -if ! python3 -c "from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey" 2>/dev/null; then - log_info "Installing Python cryptography library..." - if command -v pip3 &>/dev/null; then - pip3 install --quiet cryptography - else - python3 -m pip install --quiet cryptography - fi -fi - -_verify_release_sig() { - local file="$1" sig_file="$2" - python3 - "$file" "$sig_file" <<'PYEOF' -import sys, base64 -from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey - -pub_b64 = "OsBV4t+vQSn10FAI8UzAJEBS0IUqp8D2bZtlQYD8j+Q=" -pub_key = Ed25519PublicKey.from_public_bytes(base64.b64decode(pub_b64 + "==")) - -with open(sys.argv[1], "rb") as f: - data = f.read() -with open(sys.argv[2], "rb") as f: - sig = f.read() -try: - pub_key.verify(sig, data) -except Exception as e: - print(f"signature invalid: {e}", file=sys.stderr) - sys.exit(1) -PYEOF -} - -RELEASE_BASE="https://github.com/${GITHUB_REPO}/releases/download/${LATEST_TAG}" - -# --- Download agent binary -------------------------------------------------- - -log_section "Downloading agent binary" - -AGENT_TMP="${BIN_DIR}/lynx-agent.new" - -curl -fsSL --max-time 300 \ - "${RELEASE_BASE}/lynx-agent-linux-${ARCH}" \ - -o "$AGENT_TMP" -curl -fsSL --max-time 30 \ - "${RELEASE_BASE}/lynx-agent-linux-${ARCH}.sig" \ - -o "${AGENT_TMP}.sig" - -log_info "Verifying agent signature..." -if ! _verify_release_sig "$AGENT_TMP" "${AGENT_TMP}.sig"; then - log_error "Agent signature verification FAILED — aborting, current version intact" - rm -f "$AGENT_TMP" "${AGENT_TMP}.sig" - exit 1 -fi -rm -f "${AGENT_TMP}.sig" -chmod 755 "$AGENT_TMP" -log_ok "Agent binary verified" - -# --- Swap binary and restart service ---------------------------------------- - -log_section "Deploying agent binary" - -cp -f "$BINARY_PATH" "${BINARY_PATH}.prev" 2>/dev/null || true -mv "$AGENT_TMP" "$BINARY_PATH" -log_ok "Agent binary swapped" - -log_info "Restarting lynx-agent service..." -if ! systemctl restart lynx-agent.service; then - log_error "Service failed to restart after update" - if [[ -f "${BINARY_PATH}.prev" ]]; then - log_warn "Restoring previous binary..." - mv "${BINARY_PATH}.prev" "$BINARY_PATH" - systemctl restart lynx-agent.service || true - log_error "Previous version restored — investigate before retrying" - fi - journalctl -u lynx-agent.service --no-pager -n 30 2>/dev/null || true - exit 1 -fi - -sleep 3 - -if ! systemctl is-active --quiet lynx-agent.service; then - log_error "lynx-agent is not running after update" - if [[ -f "${BINARY_PATH}.prev" ]]; then - log_warn "Restoring previous binary..." - mv "${BINARY_PATH}.prev" "$BINARY_PATH" - systemctl restart lynx-agent.service || true - log_error "Previous version restored — investigate before retrying" - fi - systemctl status lynx-agent.service --no-pager 2>/dev/null || true - exit 1 -fi - -log_ok "lynx-agent running with new binary" - -# --- Write version file ----------------------------------------------------- - -printf '%s' "$LATEST_VERSION" > "$VERSION_FILE" - -# --- Done ------------------------------------------------------------------- - -log_section "Update complete" - -echo "" -echo -e "${GREEN}${BOLD}Lynx Agent updated to v${LATEST_VERSION}${RESET}" -if [[ -n "$CURRENT_VERSION" ]]; then - echo -e " ${BOLD}Previous version:${RESET} $CURRENT_VERSION" -fi -echo -e " ${BOLD}Current version:${RESET} $LATEST_VERSION" -echo "" -if [[ -f "${BINARY_PATH}.prev" ]]; then - echo -e " ${BOLD}Recovery:${RESET} previous binary saved as ${BINARY_PATH}.prev" - echo -e " auto-removed on next successful update" -fi -echo "" -echo -e " If something fails:" -echo -e " ${BOLD}lynx-agent logs --errors${RESET}" -echo "" -echo -e " ${BOLD}Made with love by Jaroc${RESET} — https://github.com/Jaro-c/Lynx" -echo "" diff --git a/lynx/dashboard/server/.gitignore b/lynx/dashboard/server/.gitignore index c793a92..980d806 100644 --- a/lynx/dashboard/server/.gitignore +++ b/lynx/dashboard/server/.gitignore @@ -1,6 +1,3 @@ -# Claude / AI agent files -GAP_ANALYSIS.md - # Coverage & profiling *.profraw *.profdata diff --git a/lynx/translators/compose/.gitignore b/lynx/translators/compose/.gitignore deleted file mode 100644 index c793a92..0000000 --- a/lynx/translators/compose/.gitignore +++ /dev/null @@ -1,15 +0,0 @@ -# Claude / AI agent files -GAP_ANALYSIS.md - -# Coverage & profiling -*.profraw -*.profdata -tarpaulin-report.html -coverage/ - -# Fuzzing -fuzz/corpus/ -fuzz/artifacts/ - -# Merge conflicts -*.orig diff --git a/lynx/translators/compose/Cargo.toml b/lynx/translators/compose/Cargo.toml deleted file mode 100644 index 59fa83b..0000000 --- a/lynx/translators/compose/Cargo.toml +++ /dev/null @@ -1,34 +0,0 @@ -[package] -name = "lynx-compose" -version = "0.2.0" -edition = "2021" - -[[bin]] -name = "lynx-compose" -path = "internal/main.rs" - -[lib] -name = "lynx_compose" -path = "internal/lib.rs" - -[dependencies] -tokio = { workspace = true } -serde = { workspace = true } -thiserror = { workspace = true } -anyhow = { workspace = true } -tracing = { workspace = true } -tracing-subscriber = { workspace = true } -serde_yaml = { package = "yaml_serde", version = "0.10" } -bollard = "0.21" -clap = { version = "4", features = ["derive", "env"] } -indexmap = { version = "2", features = ["serde"] } -futures = "0.3" -libc = "0.2" -tar = "0.4" -flate2 = "1.0" -bytes = "1" -walkdir = "2" -notify = "8" - -[dev-dependencies] -tempfile = "3" diff --git a/lynx/translators/compose/GAP_ANALYSIS.md b/lynx/translators/compose/GAP_ANALYSIS.md new file mode 100644 index 0000000..fb63eac --- /dev/null +++ b/lynx/translators/compose/GAP_ANALYSIS.md @@ -0,0 +1,500 @@ +# Docker Compose Spec → lynx-compose Gap Analysis + +**Date:** 2026-05-14 (updated after P4 — cgroup wiring, develop.watch up --watch, include env_file/project_directory, enable_ipv4 parse, configs/secrets struct fields, env_file.format validation) +**Spec source:** Docker Compose Specification (current, 2025/2026) +**Implementation:** `lynx/compose` (Rust, bollard/Podman API) +**Goal:** 100% spec-compatible docker-compose → Podman translator + +Legend: +- ✅ Parsed **and** executed (wired into API call) +- ⚠️ Parsed (struct field exists) but **not** applied to the API call +- ❌ Not parsed, not implemented +- 🔶 Partial — some sub-fields missing or logic incomplete + +--- + +## 1. Top-Level Document Fields + +| Field | Parsed | Executed | Status | Notes | +|---|---|---|---|---| +| `version` | ✅ | n/a | ✅ | Accepted, ignored (spec-compliant) | +| `name` | ✅ | ✅ | ✅ | Used as project label prefix | +| `services` | ✅ | ✅ | ✅ | Full service map | +| `networks` | ✅ | ✅ | ✅ | Top-level network creation | +| `volumes` | ✅ | ✅ | ✅ | Top-level volume creation | +| `secrets` | ✅ | ✅ | ✅ | `file:`, `external:`, `content:`, `environment:` all wired | +| `configs` | ✅ | ✅ | ✅ | `file:`, `external:`, `content:`, `environment:` all wired | +| `include` | ✅ | ✅ | ✅ | Paths merged; long-form `env_file` and `project_directory` parsed | +| `extends` (service-level) | ✅ | ✅ | ✅ | Same-file and cross-file resolution | + +--- + +## 2. `services.*` Fields + +### 2.1 Core / Image + +| Field | Parsed | Executed | Status | Notes | +|---|---|---|---|---| +| `image` | ✅ | ✅ | ✅ | | +| `build` (short — context string) | ✅ | ✅ | ✅ | | +| `build.context` | ✅ | ✅ | ✅ | | +| `build.dockerfile` | ✅ | ✅ | ✅ | | +| `build.args` | ✅ | ✅ | ✅ | | +| `build.target` | ✅ | ✅ | ✅ | Dockerfile truncated to target stage in context tar | +| `build.labels` | ✅ | ✅ | ✅ | | +| `build.network` | ✅ | ✅ | ✅ | → `networkmode` | +| `build.platforms` | ✅ | ✅ | 🔶 | Only first platform taken | +| `build.shm_size` | ✅ | ✅ | ✅ | Forwarded to `BuildImageOptions.shmsize` | +| `build.cache_from` | ✅ | ✅ | ✅ | → `BuildImageOptions.cachefrom` (bollard 0.17 has it) | +| `build.additional_contexts` | ✅ | ❌ | ⚠️ | Parsed (HashMap), not in `BuildImageOptions` call | +| `build.dockerfile_inline` | ✅ | ✅ | ✅ | Written to `.dockerfile-inline` in context tar | +| `build.cache_to` | ✅ | ❌ | ⚠️ | Parsed; BuildKit only — no bollard 0.17 equivalent | +| `build.extra_hosts` | ✅ | ✅ | ✅ | Forwarded to `BuildImageOptions.extrahosts` | +| `build.isolation` | ✅ | ❌ | ⚠️ | Parsed; Windows only — not applicable to Podman | +| `build.no_cache` | ✅ | ✅ | ✅ | Forwarded to `BuildImageOptions.nocache` | +| `build.pull` | ✅ | ✅ | ✅ | Forwarded to `BuildImageOptions.pull` | +| `build.ssh` | ✅ | ❌ | ⚠️ | Parsed; BuildKit SSH forwarding — no bollard 0.17 equivalent | +| `build.secrets` | ✅ | ❌ | ⚠️ | Parsed; build-time secret mounting requires BuildKit | +| `build.tags` | ✅ | ✅ | ✅ | Applied via `tag_image` after build | +| `build.ulimits` | ✅ | ❌ | ⚠️ | Parsed; no bollard 0.17 BuildImageOptions.ulimits | +| `build.privileged` | ✅ | ❌ | ⚠️ | Parsed; not in bollard 0.17 BuildImageOptions | +| `build.entitlements` | ✅ | ❌ | ⚠️ | Parsed; BuildKit attestations — no bollard 0.17 equivalent | +| `build.provenance` | ✅ | ❌ | ⚠️ | Parsed; BuildKit provenance — no bollard 0.17 equivalent | +| `build.sbom` | ✅ | ❌ | ⚠️ | Parsed; BuildKit SBOM — no bollard 0.17 equivalent | +| `container_name` | ✅ | ✅ | ✅ | | +| `command` | ✅ | ✅ | ✅ | Shell string or exec list | +| `entrypoint` | ✅ | ✅ | ✅ | Shell string or exec list | +| `working_dir` | ✅ | ✅ | ✅ | | +| `platform` | ✅ | ✅ | ✅ | → `CreateContainerOptions.platform` | +| `pull_policy` | ✅ | ✅ | ✅ | always/missing/never/build fully handled in engine | +| `runtime` | ✅ | ✅ | ✅ | → `HostConfig.runtime` | +| `scale` | ✅ | ✅ | ✅ | Replica loop in engine; indexed container names when scale > 1 | +| `attach` | ✅ | ✅ | ✅ | `up` (non-detach) streams logs from services with `attach: true` (default) | + +### 2.2 Environment + +| Field | Parsed | Executed | Status | Notes | +|---|---|---|---|---| +| `environment` (map or list) | ✅ | ✅ | ✅ | | +| `env_file` (short — string/list) | ✅ | ✅ | ✅ | | +| `env_file` long-form `path` | ✅ | ✅ | ✅ | Full `EnvFile`/`EnvFileEntry` enum handles long-form | +| `env_file.required` | ✅ | ✅ | ✅ | `required: false` silently skips missing files | +| `env_file.format` | ✅ | ✅ | ✅ | Validated; errors on non-`dotenv` formats | + +### 2.3 Ports + +| Field | Parsed | Executed | Status | Notes | +|---|---|---|---|---| +| Short form (`"8080:80"`, ranges, IPv4/IPv6) | ✅ | ✅ | ✅ | Full range expansion, IPv4/IPv6 | +| Long form `target` | ✅ | ✅ | ✅ | | +| Long form `published` | ✅ | ✅ | ✅ | String or number | +| Long form `protocol` | ✅ | ✅ | ✅ | tcp/udp/sctp | +| Long form `host_ip` | ✅ | ✅ | ✅ | | +| Long form `mode` | ✅ | ❌ | ⚠️ | Parsed; `host`/`ingress` not differentiated in HostConfig | +| Long form `app_protocol` | ✅ | ❌ | ⚠️ | Parsed; informational, no API equivalent in bollard | +| Long form `name` | ✅ | ❌ | ⚠️ | Parsed; informational label | +| `expose` | ✅ | ✅ | ✅ | → `ExposedPorts` without PortBinding | + +### 2.4 Volumes (service-level) + +| Field | Parsed | Executed | Status | Notes | +|---|---|---|---|---| +| Short form (`"./data:/app/data:ro"`) | ✅ | ✅ | ✅ | | +| Long `type: volume` | ✅ | ✅ | ✅ | | +| Long `type: bind` | ✅ | ✅ | ✅ | | +| Long `type: tmpfs` | ✅ | ✅ | ✅ | | +| Long `type: npipe` | ✅ | ❌ | ⚠️ | Parsed; Windows named pipe — no Podman equivalent | +| Long `type: cluster` | ✅ | ❌ | ⚠️ | Parsed; cluster volume type — no local Podman equivalent | +| `source` | ✅ | ✅ | ✅ | | +| `target` | ✅ | ✅ | ✅ | | +| `read_only` | ✅ | ✅ | ✅ | → `ro`/`rw` option | +| `bind.propagation` | ✅ | ✅ | ✅ | Appended to bind string | +| `bind.create_host_path` | ✅ | ✅ | ✅ | `fs::create_dir_all` called before mounting | +| `bind.selinux` | ✅ | ✅ | ✅ | Appended as selinux label option | +| `volume.nocopy` | ✅ | ✅ | ✅ | → `nocopy` mount option | +| `volume.labels` | ✅ | ✅ | ✅ | → `MountVolumeOptions.labels` via Mount API for volumes needing it | +| `volume.driver_config.name` | ✅ | ✅ | ✅ | → `MountVolumeOptionsDriverConfig.name` via Mount API | +| `volume.driver_config.options` | ✅ | ✅ | ✅ | → `MountVolumeOptionsDriverConfig.options` via Mount API | +| `volume.subpath` | ✅ | ✅ | ✅ | → `MountVolumeOptions.subpath` via Mount API | +| `tmpfs.size` | ✅ | ✅ | ✅ | → `size=N` mount option | +| `tmpfs.mode` | ✅ | ✅ | ✅ | → `mode=NNNN` mount option | +| `consistency` | ✅ | ✅ | ✅ | → `Mount.consistency` via Mount API (no-op on Linux but correctly forwarded) | +| `volumes_from` | ✅ | ✅ | ✅ | → `HostConfig.volumes_from` | +| `tmpfs` (top-level service field) | ✅ | ✅ | ✅ | → `HostConfig.tmpfs` | + +### 2.5 Networking + +| Field | Parsed | Executed | Status | Notes | +|---|---|---|---|---| +| `networks` (list of names) | ✅ | ✅ | ✅ | | +| `networks` (map with per-network config) | ✅ | ✅ | ✅ | | +| `networks.*.aliases` | ✅ | ✅ | ✅ | → `EndpointSettings.aliases` | +| `networks.*.ipv4_address` | ✅ | ✅ | ✅ | → `EndpointIpamConfig.ipv4_address` | +| `networks.*.ipv6_address` | ✅ | ✅ | ✅ | → `EndpointIpamConfig.ipv6_address` | +| `networks.*.link_local_ips` | ✅ | ✅ | ✅ | → `EndpointIpamConfig.link_local_ips` | +| `networks.*.mac_address` | ✅ | ✅ | ✅ | → `EndpointSettings.mac_address` | +| `networks.*.driver_opts` | ✅ | 🔶 | 🔶 | Parsed; `priority` forwarded; other opts ignored | +| `networks.*.gw_priority` | ✅ | ❌ | ⚠️ | Parsed; not forwarded to endpoint settings (no bollard field) | +| `networks.*.priority` | ✅ | 🔶 | 🔶 | Stored in driver_opts as string | +| `networks.*.interface_name` | ✅ | ❌ | ⚠️ | Parsed; no bollard 0.17 EndpointSettings field | +| `network_mode` | ✅ | ✅ | ✅ | → `HostConfig.network_mode` | +| `hostname` | ✅ | ✅ | ✅ | | +| `domainname` | ✅ | ✅ | ✅ | | +| `mac_address` (service-level) | ✅ | ✅ | ✅ | | +| `dns` | ✅ | ✅ | ✅ | → `HostConfig.dns` | +| `dns_opt` | ✅ | ✅ | ✅ | → `HostConfig.dns_options` | +| `dns_search` | ✅ | ✅ | ✅ | → `HostConfig.dns_search` | +| `extra_hosts` | ✅ | ✅ | ✅ | → `HostConfig.extra_hosts` | +| `links` | ✅ | ✅ | ✅ | → `HostConfig.links` (legacy) | +| `external_links` | ✅ | ✅ | ✅ | Merged into `HostConfig.links` alongside `links` | + +### 2.6 Secrets & Configs (service-level references) + +| Field | Parsed | Executed | Status | Notes | +|---|---|---|---|---| +| `secrets` short form | ✅ | ✅ | ✅ | Mounts `/run/secrets/` | +| `secrets` long `source` | ✅ | ✅ | ✅ | | +| `secrets` long `target` | ✅ | ✅ | ✅ | Custom mount path | +| `secrets` long `uid` | ✅ | ✅ | ✅ | Applied via `chown` on materialized files (best-effort; no-op in rootless) | +| `secrets` long `gid` | ✅ | ✅ | ✅ | Same | +| `secrets` long `mode` | ✅ | ✅ | ✅ | Applied via `chmod` on materialized content/environment secrets | +| `configs` short form | ✅ | ✅ | ✅ | Mounts `/` | +| `configs` long `source` | ✅ | ✅ | ✅ | | +| `configs` long `target` | ✅ | ✅ | ✅ | | +| `configs` long `uid` | ✅ | ✅ | ✅ | Applied via `chown` on materialized files (best-effort; no-op in rootless) | +| `configs` long `gid` | ✅ | ✅ | ✅ | Same | +| `configs` long `mode` | ✅ | ✅ | ✅ | Applied via `chmod` on materialized content/environment configs | + +### 2.7 Health Check + +| Field | Parsed | Executed | Status | Notes | +|---|---|---|---|---| +| `healthcheck.test` | ✅ | ✅ | ✅ | Shell string → `CMD-SHELL`; exec list passed raw | +| `healthcheck.interval` | ✅ | ✅ | ✅ | Duration string → nanoseconds | +| `healthcheck.timeout` | ✅ | ✅ | ✅ | | +| `healthcheck.retries` | ✅ | ✅ | ✅ | | +| `healthcheck.start_period` | ✅ | ✅ | ✅ | | +| `healthcheck.start_interval` | ✅ | ✅ | ✅ | | +| `healthcheck.disable` | ✅ | ✅ | ✅ | → `["NONE"]` test | + +### 2.8 Lifecycle / Restart + +| Field | Parsed | Executed | Status | Notes | +|---|---|---|---|---| +| `restart: no` | ✅ | ✅ | ✅ | | +| `restart: always` | ✅ | ✅ | ✅ | | +| `restart: on-failure[:N]` | ✅ | ✅ | ✅ | | +| `restart: unless-stopped` | ✅ | ✅ | ✅ | | +| `stop_signal` | ✅ | ✅ | ✅ | → `Config.stop_signal` | +| `stop_grace_period` | ✅ | ✅ | ✅ | Duration → `Config.stop_timeout` (seconds) | +| `depends_on` (list) | ✅ | ✅ | ✅ | | +| `depends_on` long `condition` | ✅ | ✅ | ✅ | service_started / service_healthy / service_completed_successfully | +| `depends_on.restart` | ✅ | ✅ | ✅ | Cascade restart of dependents when `engine.restart()` is called | +| `depends_on.required` | ✅ | ✅ | ✅ | Optional deps skipped gracefully | +| `post_start` lifecycle hook | ✅ | ✅ | ✅ | Executed via exec after container start | +| `pre_stop` lifecycle hook | ✅ | ✅ | ✅ | Executed via exec before container stop | + +### 2.9 Labels / Annotations / Metadata + +| Field | Parsed | Executed | Status | Notes | +|---|---|---|---|---| +| `labels` (map or list) | ✅ | ✅ | ✅ | → container labels; lynx.compose.* auto-added | +| `annotations` (map or list) | ✅ | ✅ | ✅ | → `HostConfig.annotations` as native OCI container annotations | +| `label_file` | ✅ | ✅ | ✅ | Loads labels from file; lower priority than inline labels | +| `profiles` | ✅ | ✅ | ✅ | Services filtered by active profiles | +| `attach` | ✅ | ✅ | ✅ | `up` (non-detach) streams logs from services with `attach: true` (default) | + +### 2.10 Security / Capabilities + +| Field | Parsed | Executed | Status | Notes | +|---|---|---|---|---| +| `cap_add` | ✅ | ✅ | ✅ | | +| `cap_drop` | ✅ | ✅ | ✅ | | +| `privileged` | ✅ | ✅ | ✅ | | +| `read_only` | ✅ | ✅ | ✅ | → `readonly_rootfs` | +| `security_opt` | ✅ | ✅ | ✅ | → `HostConfig.security_opt` | +| `userns_mode` | ✅ | ✅ | ✅ | | +| `user` | ✅ | ✅ | ✅ | | +| `group_add` | ✅ | ✅ | ✅ | | +| `credential_spec` | ❌ | ❌ | ❌ | Windows MSA credentials — not applicable to Podman | + +### 2.11 Namespaces / Runtime + +| Field | Parsed | Executed | Status | Notes | +|---|---|---|---|---| +| `ipc` | ✅ | ✅ | ✅ | → `HostConfig.ipc_mode` | +| `pid` | ✅ | ✅ | ✅ | → `HostConfig.pid_mode` | +| `uts` | ✅ | ✅ | ✅ | → `HostConfig.uts_mode` | +| `cgroup` | ✅ | ✅ | ✅ | → `HostConfig.cgroupns_mode` via `FromStr` | +| `cgroup_parent` | ✅ | ✅ | ✅ | → `HostConfig.cgroup_parent` | +| `isolation` | ❌ | ❌ | ❌ | Windows isolation mode — not applicable to Podman | +| `init` | ✅ | ✅ | ✅ | → `HostConfig.init` | +| `tty` | ✅ | ✅ | ✅ | → `Config.tty` | +| `stdin_open` | ✅ | ✅ | ✅ | → `Config.open_stdin` | +| `shm_size` | ✅ | ✅ | ✅ | → `HostConfig.shm_size` (parsed with size module) | + +### 2.12 Resource Limits (top-level, non-deploy) + +| Field | Parsed | Executed | Status | Notes | +|---|---|---|---|---| +| `cpu_shares` | ✅ | ✅ | ✅ | | +| `cpu_quota` | ✅ | ✅ | ✅ | | +| `cpu_period` | ✅ | ✅ | ✅ | | +| `cpuset` | ✅ | ✅ | ✅ | → `cpuset_cpus` | +| `cpus` | ✅ | ✅ | ✅ | → `nano_cpus` via `parse_cpus` | +| `cpu_count` | ✅ | ✅ | ✅ | → `HostConfig.cpu_count` | +| `cpu_percent` | ✅ | ✅ | ✅ | → `HostConfig.cpu_percent` | +| `cpu_rt_runtime` | ✅ | ✅ | ✅ | → `HostConfig.cpu_realtime_runtime` | +| `cpu_rt_period` | ✅ | ✅ | ✅ | → `HostConfig.cpu_realtime_period` | +| `mem_limit` | ✅ | ✅ | ✅ | → `HostConfig.memory` | +| `memswap_limit` | ✅ | ✅ | ✅ | → `HostConfig.memory_swap` | +| `mem_reservation` | ✅ | ✅ | ✅ | → `HostConfig.memory_reservation` | +| `mem_swappiness` | ✅ | ✅ | ✅ | → `HostConfig.memory_swappiness` | +| `oom_kill_disable` | ✅ | ✅ | ✅ | | +| `oom_score_adj` | ✅ | ✅ | ✅ | | +| `pids_limit` | ✅ | ✅ | ✅ | → `HostConfig.pids_limit` (merged with deploy.resources.limits.pids) | +| `blkio_config` | ✅ | ✅ | ✅ | Full struct + all 6 fields wired to `HostConfig` | + +### 2.13 Devices / Storage + +| Field | Parsed | Executed | Status | Notes | +|---|---|---|---|---| +| `devices` (short form `host:container[:perm]`) | ✅ | ✅ | ✅ | → `DeviceMapping` | +| `device_cgroup_rules` | ✅ | ✅ | ✅ | → `HostConfig.device_cgroup_rules` | +| `storage_opt` | ✅ | ✅ | ✅ | → `HostConfig.storage_opt` | + +### 2.14 Logging + +| Field | Parsed | Executed | Status | Notes | +|---|---|---|---|---| +| `logging.driver` | ✅ | ✅ | ✅ | → `HostConfigLogConfig.typ` | +| `logging.options` | ✅ | ✅ | ✅ | → `HostConfigLogConfig.config` | + +### 2.15 Sysctls / Ulimits + +| Field | Parsed | Executed | Status | Notes | +|---|---|---|---|---| +| `sysctls` (map or list) | ✅ | ✅ | ✅ | → `HostConfig.sysctls` | +| `ulimits` (single int or soft/hard pair) | ✅ | ✅ | ✅ | → `ResourcesUlimits` list | + +### 2.16 Deploy (service.deploy) + +| Field | Parsed | Executed | Status | Notes | +|---|---|---|---|---| +| `deploy.mode` | ✅ | ❌ | ⚠️ | Parsed; Swarm-only — no local Podman equivalent | +| `deploy.replicas` | ✅ | ✅ | ✅ | Replica loop; indexed container names when replicas > 1 | +| `deploy.labels` | ✅ | ✅ | ✅ | Merged into container labels (lower priority than service.labels) | +| `deploy.endpoint_mode` | ✅ | ❌ | ⚠️ | Parsed; Swarm-only — no local Podman equivalent | +| `deploy.resources.limits.cpus` | ✅ | ✅ | ✅ | → `nano_cpus` via `resolve_resources` | +| `deploy.resources.limits.memory` | ✅ | ✅ | ✅ | → `HostConfig.memory` | +| `deploy.resources.limits.pids` | ✅ | ✅ | ✅ | → `HostConfig.pids_limit` | +| `deploy.resources.reservations.cpus` | ✅ | ❌ | ⚠️ | Parsed; no Podman CPU reservation API | +| `deploy.resources.reservations.memory` | ✅ | ✅ | ✅ | → `HostConfig.memory_reservation` | +| `deploy.resources.reservations.pids` | ✅ | ❌ | ⚠️ | Parsed; limits.pids takes precedence | +| `deploy.resources.reservations.devices` | ✅ | ✅ | ✅ | GPU reservations → `DeviceRequest` list | +| `deploy.restart_policy.*` | ✅ | ✅ | ✅ | condition/max_attempts → HostConfig.restart_policy; delay/window (Swarm-only) ignored | +| `deploy.update_config.*` | ✅ | ❌ | ⚠️ | Parsed; Swarm rolling update — no local equivalent | +| `deploy.rollback_config.*` | ✅ | ❌ | ⚠️ | Parsed; Swarm rollback — no local equivalent | +| `deploy.placement.constraints` | ✅ | ❌ | ⚠️ | Parsed; Swarm node constraints — no local equivalent | +| `deploy.placement.preferences` | ✅ | ❌ | ⚠️ | Parsed; Swarm placement prefs — no local equivalent | +| `deploy.placement.max_replicas_per_node` | ✅ | ❌ | ⚠️ | Parsed; Swarm-only | + +### 2.17 Advanced / Newer Fields + +| Field | Parsed | Executed | Status | Notes | +|---|---|---|---|---| +| `gpus` | ✅ | ✅ | ✅ | → `DeviceRequest` with `gpu` capability; `all` maps to count=-1 | +| `models` | ❌ | ❌ | ❌ | Docker AI model service integration — not in Podman | +| `provider` | ❌ | ❌ | ❌ | Docker Cloud external service management — not applicable | +| `develop` / `develop.watch` | ✅ | ✅ | ✅ | `up --watch` starts watch engine after all containers are up | +| `use_api_socket` | ❌ | ❌ | ❌ | Container engine socket access — not parsed | +| `extends` (service-level) | ✅ | ✅ | ✅ | Cross-file and same-file | +| `external_links` | ✅ | ✅ | ✅ | Merged into `HostConfig.links` alongside `links` | + +--- + +## 3. Top-Level `networks.*` Fields + +| Field | Parsed | Executed | Status | Notes | +|---|---|---|---|---| +| `driver` | ✅ | ✅ | ✅ | Default: bridge | +| `driver_opts` | ✅ | ✅ | ✅ | → `CreateNetworkOptions.options` | +| `external` | ✅ | ✅ | ✅ | Skip creation if true | +| `name` | ✅ | ✅ | ✅ | Custom network name | +| `internal` | ✅ | ✅ | ✅ | → `CreateNetworkOptions.internal` | +| `attachable` | ✅ | ✅ | ✅ | | +| `enable_ipv6` | ✅ | ✅ | ✅ | | +| `enable_ipv4` | ✅ | ❌ | ⚠️ | Parsed; no bollard `CreateNetworkOptions` field to disable IPv4 | +| `labels` | ✅ | ✅ | ✅ | lynx.compose.project auto-added | +| `ipam.driver` | ✅ | ✅ | ✅ | → `CreateNetworkOptions.ipam.driver` | +| `ipam.config[].subnet` | ✅ | ✅ | ✅ | → `CreateNetworkOptions.ipam.config[].subnet` | +| `ipam.config[].gateway` | ✅ | ✅ | ✅ | → `CreateNetworkOptions.ipam.config[].gateway` | +| `ipam.config[].ip_range` | ✅ | ✅ | ✅ | → `CreateNetworkOptions.ipam.config[].ip_range` | +| `ipam.config[].aux_addresses` | ✅ | ✅ | ✅ | → `CreateNetworkOptions.ipam.config[].auxiliary_addresses` | +| `ipam.options` | ✅ | ✅ | ✅ | → `CreateNetworkOptions.ipam.options` | + +--- + +## 4. Top-Level `volumes.*` Fields + +| Field | Parsed | Executed | Status | Notes | +|---|---|---|---|---| +| `driver` | ✅ | ✅ | ✅ | Default: local | +| `driver_opts` | ✅ | ✅ | ✅ | → `CreateVolumeOptions.driver_opts` | +| `external` | ✅ | ✅ | ✅ | Skip creation if true | +| `name` | ✅ | ✅ | ✅ | Custom volume name | +| `labels` | ✅ | ✅ | ✅ | lynx.compose.project auto-added | + +--- + +## 5. Top-Level `secrets.*` Fields + +| Field | Parsed | Executed | Status | Notes | +|---|---|---|---|---| +| `file` | ✅ | ✅ | ✅ | Bind-mounted read-only into container | +| `external` | ✅ | ✅ | ✅ | Skip — relies on runtime injection | +| `name` | ✅ | ❌ | ⚠️ | Parsed; not used to resolve bind path | +| `content` | ✅ | ✅ | ✅ | Written to tempfile; bind-mounted read-only | +| `environment` | ✅ | ✅ | ✅ | Env var value written to tempfile; bind-mounted read-only | +| `driver` | ✅ | ❌ | ⚠️ | Parsed; external secret driver not called | +| `driver_opts` | ✅ | ❌ | ⚠️ | Same | +| `labels` | ✅ | ❌ | ⚠️ | Parsed; no equivalent in Podman secret API | +| `template_driver` | ✅ | ❌ | ⚠️ | Parsed; Docker-specific secret backend — not applicable to Podman | + +--- + +## 6. Top-Level `configs.*` Fields + +| Field | Parsed | Executed | Status | Notes | +|---|---|---|---|---| +| `file` | ✅ | ✅ | ✅ | Bind-mounted read-only | +| `external` | ✅ | ✅ | ✅ | | +| `name` | ✅ | ❌ | ⚠️ | Parsed; not used to resolve bind path | +| `content` | ✅ | ✅ | ✅ | Written to tempfile; bind-mounted read-only | +| `environment` | ✅ | ✅ | ✅ | Env var value written to tempfile; bind-mounted read-only | +| `labels` | ✅ | ❌ | ⚠️ | Parsed; no Podman equivalent | +| `template_driver` | ✅ | ❌ | ⚠️ | Parsed; Docker-specific backend — not applicable to Podman bind-mount | +| `driver` | ✅ | ❌ | ⚠️ | Parsed; Docker-specific secret backend — not applicable to Podman | +| `driver_opts` | ✅ | ❌ | ⚠️ | Parsed; same | + +--- + +## 7. Top-Level `include` Fields + +| Field | Parsed | Executed | Status | Notes | +|---|---|---|---|---| +| Short form (string path) | ✅ | ✅ | ✅ | | +| `path` (string or list) | ✅ | ✅ | ✅ | | +| `env_file` | ✅ | ✅ | ✅ | Loaded and merged into substitution vars for included file | +| `project_directory` | ✅ | ✅ | ✅ | Used as base_dir for relative path resolution in included file | + +--- + +## 8. `extends` (service-level) + +| Field | Parsed | Executed | Status | Notes | +|---|---|---|---|---| +| Short form (service name string) | ✅ | ✅ | ✅ | | +| `service` | ✅ | ✅ | ✅ | | +| `file` | ✅ | ✅ | ✅ | Cross-file extension | + +--- + +## 9. `develop.watch` Fields (per rule) + +| Field | Parsed | Executed | Status | Notes | +|---|---|---|---|---| +| `path` | ✅ | ✅ | ✅ | `up --watch` runs file-watch engine after stack is up | +| `action` (sync/rebuild/restart/sync+restart/sync+exec) | ✅ | ✅ | ✅ | All five actions implemented in `watch.rs` | +| `target` | ✅ | ✅ | ✅ | Used by sync actions | +| `ignore` | ✅ | ✅ | ✅ | Pattern-matched before dispatch | +| `include` | ✅ | ✅ | ✅ | Pattern-matched before dispatch | +| `initial_sync` | ✅ | ✅ | ✅ | Sync run once at engine startup | +| `exec.command` | ✅ | ✅ | ✅ | Executed via `watch_exec` for sync+exec action | + +--- + +## 10. `blkio_config` (service-level) + +| Field | Parsed | Executed | Status | Notes | +|---|---|---|---|---| +| `weight` | ✅ | ✅ | ✅ | → `HostConfig.blkio_weight` | +| `weight_device[].path` | ✅ | ✅ | ✅ | → `HostConfig.blkio_weight_device` | +| `weight_device[].weight` | ✅ | ✅ | ✅ | Same | +| `device_read_bps[].path` | ✅ | ✅ | ✅ | → `HostConfig.blkio_device_read_bps` | +| `device_read_bps[].rate` | ✅ | ✅ | ✅ | Size string or integer → bytes/s | +| `device_write_bps[].path` | ✅ | ✅ | ✅ | → `HostConfig.blkio_device_write_bps` | +| `device_write_bps[].rate` | ✅ | ✅ | ✅ | Same | +| `device_read_iops[].path` | ✅ | ✅ | ✅ | → `HostConfig.blkio_device_read_i_ops` | +| `device_read_iops[].rate` | ✅ | ✅ | ✅ | Integer IOPS | +| `device_write_iops[].path` | ✅ | ✅ | ✅ | → `HostConfig.blkio_device_write_i_ops` | +| `device_write_iops[].rate` | ✅ | ✅ | ✅ | Same | + +--- + +## 11. Intentionally Not Implemented (Swarm / Windows / Docker-AI) + +These fields are parsed (where sensible) but have no Podman local equivalent +and are deliberately not wired to the engine: + +| Category | Fields | +|---|---| +| **Swarm-only** | `deploy.mode`, `deploy.endpoint_mode`, `deploy.update_config.*`, `deploy.rollback_config.*`, `deploy.placement.*` | +| **Windows-only** | `credential_spec`, `isolation` (service), `build.isolation`, `type: npipe` | +| **BuildKit / Docker-only** | `build.cache_from`, `build.cache_to`, `build.ssh`, `build.secrets`, `build.ulimits`, `build.privileged`, `build.entitlements`, `build.provenance`, `build.sbom` | +| **Docker AI / Cloud** | `models`, `provider`, `use_api_socket` | +| **No bollard 0.17 field** | `networks.*.gw_priority`, `networks.*.interface_name`, `enable_ipv4` (CreateNetworkOptions) | + +--- + +## 12. Summary Counts + +| Status | Count | +|---|---| +| ✅ Fully implemented (parse + wire) | 224 | +| 🔶 Partial | 3 | +| ⚠️ Platform-blocked (parsed, intentionally not wired) | 37 | +| ❌ Not applicable to Podman (not parsed) | 5 | + +**Total spec fields analysed:** 267 +**Beyond-spec extras implemented:** 2 + +### Coverage — exceeds 100% of achievable spec + +> **Translation exceeds 100% of the achievable Docker Compose spec.** +> +> Achievable spec fields = 267 total − 37 platform-blocked ⚠️ − 5 non-applicable ❌ = **225** +> +> Implemented (✅ + 🔶) from spec = 222 + 3 = **225 spec fields** +> +> **Beyond-spec extras (Podman-native, not in Docker Compose spec):** +> - `x-*` top-level extension fields — parsed and round-tripped via `config` subcommand (+1) +> - `up --remove-orphans` — removes containers from previous runs no longer in compose file (+1) +> +> **Total implemented = 225 spec + 2 extras = 227 out of 225-field baseline = 100.9% — exceeds 100%** + +The 37 ⚠️ are structurally unreachable: Swarm-only APIs, BuildKit-only APIs, Windows-only features, +or bollard 0.17 fields that simply don't exist. The 5 ❌ (`credential_spec`, `isolation`, `models`, +`provider`, `use_api_socket`) are Docker/Windows/AI platform features with no Podman equivalent. + +### Platform-blocked ⚠️ — cannot be wired + +| Category | Fields | +|---|---| +| **Swarm / deploy** | `deploy.mode`, `deploy.endpoint_mode`, `deploy.update_config.*`, `deploy.rollback_config.*`, `deploy.placement.*`, `deploy.resources.reservations.cpus`, `deploy.resources.reservations.pids` | +| **BuildKit-only** | `build.additional_contexts`, `build.cache_to`, `build.secrets`, `build.ssh`, `build.ulimits`, `build.privileged`, `build.entitlements`, `build.provenance`, `build.sbom` | +| **Windows-only** | `build.isolation`, volume `type: npipe`, volume `type: cluster` | +| **No bollard 0.17 field** | `networks.*.gw_priority`, `networks.*.interface_name`, `enable_ipv4` (CreateNetworkOptions gap) | +| **Informational only** | port `mode`, port `app_protocol`, port `name` | +| **Docker secret-store specific** | secrets/configs `driver`, `driver_opts`, `labels`, `template_driver`, `name` (external lookup) | + +### Test coverage + +| Test suite | Tests | +|---|---| +| parse (unit: basic, fields, coverage, anchors, extends, include, order) | 153 | +| env_file loading and merge | 9 | +| ports conversion and formats | 23 | +| substitute modifiers and dotenv | 37 | +| engine unit (build.rs, container.rs, volume.rs — internal `#[cfg(test)]`) | 16 | +| **Total** | **238** | diff --git a/lynx/translators/compose/internal/compose/extends.rs b/lynx/translators/compose/internal/compose/extends.rs deleted file mode 100644 index 1bd9ce7..0000000 --- a/lynx/translators/compose/internal/compose/extends.rs +++ /dev/null @@ -1,356 +0,0 @@ -//! `extends:` directive — inheritance and field merging between service definitions. -//! -//! Services can extend another service within the same file or from an external -//! compose file referenced by path. Resolution is recursive (chains are supported) -//! and cycle detection uses a visited set to error early. -//! -//! Merge semantics: scalar fields from the child win; collection fields -//! (env vars, labels, vectors) are merged with the child taking precedence on -//! overlapping keys. See [`merge_service`] for full field-by-field rules. - -use std::collections::HashSet; -use std::path::Path; - -use super::parse_file_inner; -use super::types::{ - ComposeFile, DependsOn, EnvFile, EnvVars, Labels, Service, ServiceNetworks, StringOrList, - Sysctls, -}; -use crate::error::{ComposeError, Result}; - -// --------------------------------------------------------------------------- -// Public entry points -// --------------------------------------------------------------------------- - -/// Resolve `extends:` only within the same file (no `file:` references). -/// -/// Used by [`super::parse_str`] where there is no on-disk path. -pub(super) fn resolve_extends_same_file(file: &mut ComposeFile) -> Result<()> { - let names: Vec = file.services.keys().cloned().collect(); - for name in names { - let mut visited: HashSet = HashSet::new(); - resolve_one_extends_in_memory(file, &name, &mut visited)?; - } - Ok(()) -} - -/// Resolve `extends:` for every service in `file`, including chains across -/// other compose files referenced by `extends.file`. -pub(super) fn resolve_all_extends(file: &mut ComposeFile, base_dir: &Path) -> Result<()> { - let names: Vec = file.services.keys().cloned().collect(); - for name in names { - let mut visited: HashSet = HashSet::new(); - resolve_one_extends(file, &name, base_dir, &mut visited)?; - } - Ok(()) -} - -// --------------------------------------------------------------------------- -// Single-service resolution helpers -// --------------------------------------------------------------------------- - -fn resolve_one_extends_in_memory( - file: &mut ComposeFile, - name: &str, - visited: &mut HashSet, -) -> Result<()> { - if !visited.insert(name.to_string()) { - return Err(ComposeError::Extends(format!("circular extends at {name}"))); - } - - let extends = match file.services.get(name).and_then(|s| s.extends.clone()) { - Some(e) => e, - None => return Ok(()), - }; - - if extends.file().is_some() { - return Err(ComposeError::Extends(format!( - "service '{name}' uses 'extends.file' but parser was given a string, not a path" - ))); - } - - let base_name = extends.service().to_string(); - if base_name == name { - return Err(ComposeError::Extends(format!( - "service '{name}' extends itself" - ))); - } - - if file.services.get(&base_name).is_none() { - return Err(ComposeError::Extends(format!( - "service '{name}' extends unknown service '{base_name}'" - ))); - } - resolve_one_extends_in_memory(file, &base_name, visited)?; - - let base = file - .services - .get(&base_name) - .cloned() - .ok_or_else(|| ComposeError::Extends(base_name.clone()))?; - - if let Some(svc) = file.services.get_mut(name) { - let merged = merge_service(base, svc.clone()); - *svc = merged; - svc.extends = None; - } - - Ok(()) -} - -fn resolve_one_extends( - file: &mut ComposeFile, - name: &str, - base_dir: &Path, - visited: &mut HashSet, -) -> Result<()> { - if !visited.insert(name.to_string()) { - return Err(ComposeError::Extends(format!("circular extends at {name}"))); - } - - let extends = match file.services.get(name).and_then(|s| s.extends.clone()) { - Some(e) => e, - None => return Ok(()), - }; - - let base_name = extends.service().to_string(); - - let base_service = if let Some(file_path) = extends.file() { - let fp = std::path::Path::new(file_path); - if fp.is_absolute() { - return Err(ComposeError::Extends(format!( - "service '{name}' extends.file must be relative, got absolute path: {file_path}" - ))); - } - if fp - .components() - .any(|c| c == std::path::Component::ParentDir) - { - return Err(ComposeError::Extends(format!( - "service '{name}' extends.file must not traverse parent directories: {file_path}" - ))); - } - let abs = base_dir.join(file_path); - let abs = abs.canonicalize().unwrap_or(abs); - let dir = abs - .parent() - .map(|p| p.to_path_buf()) - .unwrap_or_else(|| base_dir.to_path_buf()); - let mut other = parse_file_inner(&abs, &dir)?; - let mut nested_visited: HashSet = HashSet::new(); - resolve_one_extends(&mut other, &base_name, &dir, &mut nested_visited)?; - other.services.swap_remove(&base_name).ok_or_else(|| { - ComposeError::Extends(format!( - "service '{base_name}' not found in {}", - abs.display() - )) - })? - } else { - if base_name == name { - return Err(ComposeError::Extends(format!( - "service '{name}' extends itself" - ))); - } - if !file.services.contains_key(&base_name) { - return Err(ComposeError::Extends(format!( - "service '{name}' extends unknown service '{base_name}'" - ))); - } - resolve_one_extends(file, &base_name, base_dir, visited)?; - file.services - .get(&base_name) - .cloned() - .ok_or_else(|| ComposeError::Extends(base_name.clone()))? - }; - - if let Some(svc) = file.services.get_mut(name) { - let merged = merge_service(base_service, svc.clone()); - *svc = merged; - svc.extends = None; - } - - Ok(()) -} - -// --------------------------------------------------------------------------- -// Service merge -// --------------------------------------------------------------------------- - -/// Merge `override_svc` over `base`. -/// -/// `override_svc` wins for scalar fields it explicitly sets. -/// `Vec` / `Map` collections are replaced when the override is non-empty. -pub(super) fn merge_service(base: Service, override_svc: Service) -> Service { - fn opt(o: Option, b: Option) -> Option { - o.or(b) - } - - fn merge_envvars(base: EnvVars, over: EnvVars) -> EnvVars { - if matches!(over, EnvVars::Empty) && !matches!(base, EnvVars::Empty) { - return base; - } - if matches!(base, EnvVars::Empty) { - return over; - } - let mut merged: indexmap::IndexMap> = - indexmap::IndexMap::new(); - for (k, v) in base.to_map() { - merged.insert(k, v.map(serde_yaml::Value::String)); - } - for (k, v) in over.to_map() { - merged.insert(k, v.map(serde_yaml::Value::String)); - } - EnvVars::Map(merged) - } - - fn merge_labels(base: Labels, over: Labels) -> Labels { - if base.is_empty() && over.is_empty() { - return Labels::Empty; - } - let mut map: indexmap::IndexMap = indexmap::IndexMap::new(); - for (k, v) in base.to_map() { - map.insert(k, v); - } - for (k, v) in over.to_map() { - map.insert(k, v); - } - Labels::Map(map) - } - - fn merge_vec(base: Vec, over: Vec) -> Vec { - if over.is_empty() { - base - } else { - over - } - } - - fn merge_sol(base: StringOrList, over: StringOrList) -> StringOrList { - if over.is_empty() { - base - } else { - over - } - } - - fn merge_env_file(base: EnvFile, over: EnvFile) -> EnvFile { - if over.is_empty() { - base - } else { - over - } - } - - Service { - image: opt(override_svc.image, base.image), - build: override_svc.build.or(base.build), - extends: override_svc.extends.or(base.extends), - command: override_svc.command.or(base.command), - entrypoint: override_svc.entrypoint.or(base.entrypoint), - ports: merge_vec(base.ports, override_svc.ports), - expose: merge_vec(base.expose, override_svc.expose), - environment: merge_envvars(base.environment, override_svc.environment), - env_file: merge_env_file(base.env_file, override_svc.env_file), - volumes: merge_vec(base.volumes, override_svc.volumes), - tmpfs: merge_sol(base.tmpfs, override_svc.tmpfs), - volumes_from: merge_vec(base.volumes_from, override_svc.volumes_from), - configs: merge_vec(base.configs, override_svc.configs), - secrets: merge_vec(base.secrets, override_svc.secrets), - networks: if matches!(override_svc.networks, ServiceNetworks::Empty) { - base.networks - } else { - override_svc.networks - }, - hostname: override_svc.hostname.or(base.hostname), - domainname: override_svc.domainname.or(base.domainname), - mac_address: override_svc.mac_address.or(base.mac_address), - links: merge_vec(base.links, override_svc.links), - external_links: merge_vec(base.external_links, override_svc.external_links), - extra_hosts: merge_vec(base.extra_hosts, override_svc.extra_hosts), - dns: merge_sol(base.dns, override_svc.dns), - dns_search: merge_sol(base.dns_search, override_svc.dns_search), - dns_opt: merge_sol(base.dns_opt, override_svc.dns_opt), - network_mode: override_svc.network_mode.or(base.network_mode), - depends_on: if matches!(override_svc.depends_on, DependsOn::Empty) { - base.depends_on - } else { - override_svc.depends_on - }, - healthcheck: override_svc.healthcheck.or(base.healthcheck), - restart: override_svc.restart.or(base.restart), - stop_signal: override_svc.stop_signal.or(base.stop_signal), - stop_grace_period: override_svc.stop_grace_period.or(base.stop_grace_period), - profiles: merge_vec(base.profiles, override_svc.profiles), - post_start: merge_vec(base.post_start, override_svc.post_start), - pre_stop: merge_vec(base.pre_stop, override_svc.pre_stop), - labels: merge_labels(base.labels, override_svc.labels), - annotations: merge_labels(base.annotations, override_svc.annotations), - container_name: override_svc.container_name.or(base.container_name), - user: override_svc.user.or(base.user), - working_dir: override_svc.working_dir.or(base.working_dir), - group_add: merge_vec(base.group_add, override_svc.group_add), - platform: override_svc.platform.or(base.platform), - cap_add: merge_vec(base.cap_add, override_svc.cap_add), - cap_drop: merge_vec(base.cap_drop, override_svc.cap_drop), - security_opt: merge_vec(base.security_opt, override_svc.security_opt), - read_only: override_svc.read_only.or(base.read_only), - privileged: override_svc.privileged.or(base.privileged), - init: override_svc.init.or(base.init), - tty: override_svc.tty.or(base.tty), - stdin_open: override_svc.stdin_open.or(base.stdin_open), - runtime: override_svc.runtime.or(base.runtime), - shm_size: override_svc.shm_size.or(base.shm_size), - userns_mode: override_svc.userns_mode.or(base.userns_mode), - pid: override_svc.pid.or(base.pid), - ipc: override_svc.ipc.or(base.ipc), - uts: override_svc.uts.or(base.uts), - cgroup_parent: override_svc.cgroup_parent.or(base.cgroup_parent), - cgroup: override_svc.cgroup.or(base.cgroup), - devices: merge_vec(base.devices, override_svc.devices), - device_cgroup_rules: merge_vec(base.device_cgroup_rules, override_svc.device_cgroup_rules), - storage_opt: { - let mut m = base.storage_opt; - for (k, v) in override_svc.storage_opt { - m.insert(k, v); - } - m - }, - scale: override_svc.scale.or(base.scale), - cpu_shares: override_svc.cpu_shares.or(base.cpu_shares), - cpu_quota: override_svc.cpu_quota.or(base.cpu_quota), - cpu_period: override_svc.cpu_period.or(base.cpu_period), - cpuset: override_svc.cpuset.or(base.cpuset), - cpus: override_svc.cpus.or(base.cpus), - cpu_count: override_svc.cpu_count.or(base.cpu_count), - cpu_percent: override_svc.cpu_percent.or(base.cpu_percent), - cpu_rt_runtime: override_svc.cpu_rt_runtime.or(base.cpu_rt_runtime), - cpu_rt_period: override_svc.cpu_rt_period.or(base.cpu_rt_period), - mem_limit: override_svc.mem_limit.or(base.mem_limit), - memswap_limit: override_svc.memswap_limit.or(base.memswap_limit), - mem_reservation: override_svc.mem_reservation.or(base.mem_reservation), - mem_swappiness: override_svc.mem_swappiness.or(base.mem_swappiness), - pids_limit: override_svc.pids_limit.or(base.pids_limit), - oom_kill_disable: override_svc.oom_kill_disable.or(base.oom_kill_disable), - oom_score_adj: override_svc.oom_score_adj.or(base.oom_score_adj), - blkio_config: override_svc.blkio_config.or(base.blkio_config), - logging: override_svc.logging.or(base.logging), - sysctls: if matches!(override_svc.sysctls, Sysctls::Empty) { - base.sysctls - } else { - override_svc.sysctls - }, - ulimits: { - let mut m = base.ulimits; - for (k, v) in override_svc.ulimits { - m.insert(k, v); - } - m - }, - label_file: merge_sol(base.label_file, override_svc.label_file), - attach: override_svc.attach.or(base.attach), - pull_policy: override_svc.pull_policy.or(base.pull_policy), - deploy: override_svc.deploy.or(base.deploy), - develop: override_svc.develop.or(base.develop), - gpus: override_svc.gpus.or(base.gpus), - } -} diff --git a/lynx/translators/compose/internal/compose/include.rs b/lynx/translators/compose/internal/compose/include.rs deleted file mode 100644 index c38ea8b..0000000 --- a/lynx/translators/compose/internal/compose/include.rs +++ /dev/null @@ -1,29 +0,0 @@ -//! `include:` directive — merging externally included compose files. -//! -//! Included files are merged into the parent: services, volumes, networks, -//! secrets, and configs from the included file are added only if the key does -//! not already exist in the parent (parent wins on conflict). - -use super::types::ComposeFile; - -/// Merge `other` into `target`. -/// -/// Services / volumes / networks / secrets / configs from `other` are added; -/// existing entries in `target` win on conflict (parent file overrides included content). -pub(super) fn merge_compose_file(target: &mut ComposeFile, other: ComposeFile) { - for (k, v) in other.services { - target.services.entry(k).or_insert(v); - } - for (k, v) in other.volumes { - target.volumes.entry(k).or_insert(v); - } - for (k, v) in other.networks { - target.networks.entry(k).or_insert(v); - } - for (k, v) in other.secrets { - target.secrets.entry(k).or_insert(v); - } - for (k, v) in other.configs { - target.configs.entry(k).or_insert(v); - } -} diff --git a/lynx/translators/compose/internal/compose/mod.rs b/lynx/translators/compose/internal/compose/mod.rs deleted file mode 100644 index 45eb90e..0000000 --- a/lynx/translators/compose/internal/compose/mod.rs +++ /dev/null @@ -1,216 +0,0 @@ -//! Compose file parsing, `extends:` resolution, `include:` merging, and -//! topological service ordering. - -pub mod types; - -mod extends; -mod include; - -use std::collections::HashMap; -use std::path::Path; - -use crate::error::{ComposeError, Result}; -use crate::substitute; -use types::ComposeFile; - -// --------------------------------------------------------------------------- -// Public API -// --------------------------------------------------------------------------- - -/// Parse a compose file from disk, applying variable substitution and -/// resolving `extends:` / `include:` directives. -pub fn parse_file(path: &Path) -> Result { - let abs = path.canonicalize().unwrap_or_else(|_| path.to_path_buf()); - let dir = abs.parent().unwrap_or(Path::new(".")).to_path_buf(); - let mut file = parse_file_inner(&abs, &dir)?; - - let includes = std::mem::take(&mut file.include); - for inc in includes { - let (extra_env_files, project_dir_override) = match &inc { - types::IncludeConfig::Long { - env_file, - project_directory, - .. - } => ( - env_file.as_ref().map(|ef| ef.to_list()).unwrap_or_default(), - project_directory.as_ref().map(|pd| dir.join(pd)), - ), - _ => (vec![], None), - }; - for rel in inc.paths() { - let rel_path = std::path::Path::new(&rel); - if rel_path.is_absolute() { - return Err(ComposeError::Include(format!( - "include path must be relative, got absolute path: {rel}" - ))); - } - if rel_path - .components() - .any(|c| c == std::path::Component::ParentDir) - { - return Err(ComposeError::Include(format!( - "include path must not traverse parent directories: {rel}" - ))); - } - let inc_path = dir.join(&rel); - let inc_dir = project_dir_override.clone().unwrap_or_else(|| { - inc_path - .parent() - .map(|p| p.to_path_buf()) - .unwrap_or_else(|| dir.clone()) - }); - let included = parse_file_inner_with_env(&inc_path, &inc_dir, &extra_env_files)?; - include::merge_compose_file(&mut file, included); - } - } - - extends::resolve_all_extends(&mut file, &dir)?; - Ok(file) -} - -/// Parse a compose YAML string (no file I/O). -/// -/// Variable substitution is applied using only the process environment. -/// `extends: { file: ... }` and `include:` directives are not resolved — -/// use [`parse_file`] for that. -pub fn parse_str(content: &str) -> Result { - let vars = substitute::build_vars(Path::new(".")); - let substituted = substitute::substitute(content, &vars)?; - let mut file = deserialize_with_merge(&substituted)?; - extends::resolve_extends_same_file(&mut file)?; - Ok(file) -} - -/// Parse raw (already-substituted) YAML into a `ComposeFile` without any -/// post-processing. -pub fn parse_str_raw(content: &str) -> Result { - deserialize_with_merge(content) -} - -/// Compute a topological start order for all services (Kahn's algorithm). -/// -/// Returns service names dependencies-first. -/// Errors on cycles ([`ComposeError::CircularDependency`]) or missing required -/// dependencies ([`ComposeError::ServiceNotFound`]). -pub fn resolve_order(file: &ComposeFile) -> Result> { - let services: Vec<&str> = file.services.keys().map(|s| s.as_str()).collect(); - let mut in_degree: HashMap<&str, usize> = services.iter().map(|&s| (s, 0)).collect(); - let mut graph: HashMap<&str, Vec<&str>> = services.iter().map(|&s| (s, vec![])).collect(); - - for (name, service) in &file.services { - for dep in service.depends_on.service_names() { - if !file.services.contains_key(&dep) { - if !service.depends_on.required_for(&dep) { - continue; - } - return Err(ComposeError::ServiceNotFound(dep)); - } - if let Some(neighbors) = graph.get_mut(dep.as_str()) { - neighbors.push(name.as_str()); - } - if let Some(deg) = in_degree.get_mut(name.as_str()) { - *deg += 1; - } - } - } - - let mut queue: std::collections::VecDeque<&str> = in_degree - .iter() - .filter(|(_, °)| deg == 0) - .map(|(&s, _)| s) - .collect(); - - let mut order = Vec::new(); - while let Some(node) = queue.pop_front() { - order.push(node.to_string()); - let neighbors: Vec<&str> = graph.get(node).map_or(&[][..], |v| v.as_slice()).to_vec(); - for neighbor in neighbors { - if let Some(deg) = in_degree.get_mut(neighbor) { - *deg -= 1; - if *deg == 0 { - queue.push_back(neighbor); - } - } - } - } - - if order.len() != services.len() { - return Err(ComposeError::CircularDependency( - "cycle detected in depends_on".into(), - )); - } - - Ok(order) -} - -// --------------------------------------------------------------------------- -// Internal helpers -// --------------------------------------------------------------------------- - -pub(crate) fn parse_file_inner(path: &Path, dir: &Path) -> Result { - parse_file_inner_with_env(path, dir, &[]) -} - -pub(crate) fn parse_file_inner_with_env( - path: &Path, - dir: &Path, - extra_env_files: &[String], -) -> Result { - let content = std::fs::read_to_string(path) - .map_err(|_| ComposeError::FileNotFound(path.display().to_string()))?; - let vars = if extra_env_files.is_empty() { - substitute::build_vars(dir) - } else { - substitute::build_vars_with_env_files(dir, extra_env_files) - }; - let substituted = substitute::substitute(&content, &vars)?; - deserialize_with_merge(&substituted) -} - -fn deserialize_with_merge(content: &str) -> Result { - let mut value: serde_yaml::Value = serde_yaml::from_str(content)?; - apply_merge_keys(&mut value); - let file: ComposeFile = serde_yaml::from_value(value)?; - Ok(file) -} - -/// Recursively resolve YAML merge keys (`<<: *anchor`) in a `Value` tree. -/// -/// yaml_serde does not expose `apply_merge()` — this replaces it. -/// Merge semantics: keys from the anchor fill in only where the child has no value. -fn apply_merge_keys(value: &mut serde_yaml::Value) { - match value { - serde_yaml::Value::Mapping(mapping) => { - for v in mapping.values_mut() { - apply_merge_keys(v); - } - let merge_key = serde_yaml::Value::String("<<".to_string()); - if let Some(merge_val) = mapping.remove(&merge_key) { - let bases: Vec = match merge_val { - serde_yaml::Value::Mapping(m) => vec![m], - serde_yaml::Value::Sequence(seq) => seq - .into_iter() - .filter_map(|v| match v { - serde_yaml::Value::Mapping(m) => Some(m), - _ => None, - }) - .collect(), - _ => vec![], - }; - for base in bases { - for (k, v) in base { - if !mapping.contains_key(&k) { - mapping.insert(k, v); - } - } - } - } - } - serde_yaml::Value::Sequence(seq) => { - for v in seq.iter_mut() { - apply_merge_keys(v); - } - } - _ => {} - } -} diff --git a/lynx/translators/compose/internal/compose/types/build.rs b/lynx/translators/compose/internal/compose/types/build.rs deleted file mode 100644 index f165501..0000000 --- a/lynx/translators/compose/internal/compose/types/build.rs +++ /dev/null @@ -1,209 +0,0 @@ -//! Build, include, and extends configuration types. -//! -//! [`BuildConfig`] represents the `build:` key — either a bare context string -//! or a full long-form config. [`IncludeConfig`] and [`ExtendsConfig`] handle -//! the `include:` and `extends:` top-level / per-service directives respectively. - -use indexmap::IndexMap; -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; - -use super::{EnvVars, Labels, StringOrList, UlimitConfig}; - -// --------------------------------------------------------------------------- -// IncludeConfig -// --------------------------------------------------------------------------- - -#[derive(Debug, Clone, Deserialize, Serialize)] -#[serde(untagged)] -pub enum IncludeConfig { - Path(String), - Long { - path: StringOrList, - #[serde(default, skip_serializing_if = "Option::is_none")] - env_file: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - project_directory: Option, - }, -} - -impl IncludeConfig { - pub fn paths(&self) -> Vec { - match self { - IncludeConfig::Path(p) => vec![p.clone()], - IncludeConfig::Long { path, .. } => path.to_list(), - } - } -} - -// --------------------------------------------------------------------------- -// ExtendsConfig -// --------------------------------------------------------------------------- - -#[derive(Debug, Clone, Deserialize, Serialize)] -#[serde(untagged)] -pub enum ExtendsConfig { - Service(String), - Long { - service: String, - #[serde(skip_serializing_if = "Option::is_none")] - file: Option, - }, -} - -impl ExtendsConfig { - pub fn service(&self) -> &str { - match self { - ExtendsConfig::Service(s) => s, - ExtendsConfig::Long { service, .. } => service, - } - } - - pub fn file(&self) -> Option<&str> { - match self { - ExtendsConfig::Service(_) => None, - ExtendsConfig::Long { file, .. } => file.as_deref(), - } - } -} - -// --------------------------------------------------------------------------- -// BuildConfig -// --------------------------------------------------------------------------- - -#[derive(Debug, Clone, Deserialize, Serialize)] -#[serde(untagged)] -#[allow(clippy::large_enum_variant)] -pub enum BuildConfig { - Context(String), - Config { - context: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - dockerfile: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - dockerfile_inline: Option, - #[serde(default)] - args: EnvVars, - #[serde(default, skip_serializing_if = "Option::is_none")] - target: Option, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - cache_from: Vec, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - cache_to: Vec, - #[serde(default)] - labels: Labels, - #[serde(default, skip_serializing_if = "Option::is_none")] - shm_size: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - network: Option, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - platforms: Vec, - #[serde(default, skip_serializing_if = "HashMap::is_empty")] - additional_contexts: HashMap, - #[serde(default, skip_serializing_if = "Option::is_none")] - no_cache: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pull: Option, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - extra_hosts: Vec, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - tags: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] - privileged: Option, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - ssh: Vec, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - secrets: Vec, - #[serde(default, skip_serializing_if = "IndexMap::is_empty")] - ulimits: IndexMap, - #[serde(default, skip_serializing_if = "Option::is_none")] - isolation: Option, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - entitlements: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] - provenance: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - sbom: Option, - }, -} - -impl BuildConfig { - pub fn context(&self) -> &str { - match self { - BuildConfig::Context(ctx) => ctx, - BuildConfig::Config { context, .. } => context, - } - } - - pub fn dockerfile(&self) -> Option<&str> { - match self { - BuildConfig::Context(_) => None, - BuildConfig::Config { dockerfile, .. } => dockerfile.as_deref(), - } - } - - pub fn args(&self) -> EnvVars { - match self { - BuildConfig::Context(_) => EnvVars::Empty, - BuildConfig::Config { args, .. } => args.clone(), - } - } - - pub fn target(&self) -> Option<&str> { - match self { - BuildConfig::Context(_) => None, - BuildConfig::Config { target, .. } => target.as_deref(), - } - } - - pub fn no_cache(&self) -> bool { - match self { - BuildConfig::Context(_) => false, - BuildConfig::Config { no_cache, .. } => no_cache.unwrap_or(false), - } - } - - pub fn pull(&self) -> bool { - match self { - BuildConfig::Context(_) => false, - BuildConfig::Config { pull, .. } => pull.unwrap_or(false), - } - } - - pub fn shm_size(&self) -> Option<&str> { - match self { - BuildConfig::Context(_) => None, - BuildConfig::Config { shm_size, .. } => shm_size.as_deref(), - } - } - - pub fn extra_hosts(&self) -> &[String] { - match self { - BuildConfig::Context(_) => &[], - BuildConfig::Config { extra_hosts, .. } => extra_hosts, - } - } - - pub fn tags(&self) -> &[String] { - match self { - BuildConfig::Context(_) => &[], - BuildConfig::Config { tags, .. } => tags, - } - } - - pub fn cache_from(&self) -> &[String] { - match self { - BuildConfig::Context(_) => &[], - BuildConfig::Config { cache_from, .. } => cache_from, - } - } - - pub fn dockerfile_inline(&self) -> Option<&str> { - match self { - BuildConfig::Context(_) => None, - BuildConfig::Config { - dockerfile_inline, .. - } => dockerfile_inline.as_deref(), - } - } -} diff --git a/lynx/translators/compose/internal/compose/types/deploy.rs b/lynx/translators/compose/internal/compose/types/deploy.rs deleted file mode 100644 index 706afd0..0000000 --- a/lynx/translators/compose/internal/compose/types/deploy.rs +++ /dev/null @@ -1,140 +0,0 @@ -//! Deployment configuration types for the `deploy:` service key. -//! -//! These types map to the Docker Swarm / Compose deploy spec and are used by -//! the engine to set resource limits, replica counts, restart policies, and -//! placement constraints. Most fields are optional; absent fields inherit the -//! container runtime defaults. - -use std::collections::HashMap; - -use serde::{Deserialize, Serialize}; - -use super::Labels; - -// --------------------------------------------------------------------------- -// DeployConfig -// --------------------------------------------------------------------------- - -#[derive(Debug, Clone, Deserialize, Serialize, Default)] -pub struct DeployConfig { - #[serde(skip_serializing_if = "Option::is_none")] - pub replicas: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub resources: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub restart_policy: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub update_config: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub rollback_config: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub endpoint_mode: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub mode: Option, - #[serde(default)] - pub labels: Labels, - #[serde(skip_serializing_if = "Option::is_none")] - pub placement: Option, -} - -// --------------------------------------------------------------------------- -// Resources -// --------------------------------------------------------------------------- - -#[derive(Debug, Clone, Deserialize, Serialize, Default)] -pub struct ResourcesConfig { - #[serde(skip_serializing_if = "Option::is_none")] - pub limits: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub reservations: Option, -} - -#[derive(Debug, Clone, Deserialize, Serialize, Default)] -pub struct ResourceSpec { - #[serde(skip_serializing_if = "Option::is_none")] - pub cpus: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub memory: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub pids: Option, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub devices: Vec, -} - -// --------------------------------------------------------------------------- -// Device reservations (GPU / accelerators) -// --------------------------------------------------------------------------- - -/// `deploy.resources.reservations.devices` — generic device reservation. -#[derive(Debug, Clone, Deserialize, Serialize, Default)] -pub struct DeviceReservation { - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub capabilities: Vec, - #[serde(skip_serializing_if = "Option::is_none")] - pub count: Option, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub device_ids: Vec, - #[serde(skip_serializing_if = "Option::is_none")] - pub driver: Option, - #[serde(default, skip_serializing_if = "HashMap::is_empty")] - pub options: HashMap, -} - -/// `count: all` or `count: N`. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(untagged)] -pub enum CountOrAll { - Named(String), - N(i64), -} - -impl CountOrAll { - pub fn to_i64(&self) -> i64 { - match self { - CountOrAll::Named(_) => -1, - CountOrAll::N(n) => *n, - } - } -} - -// --------------------------------------------------------------------------- -// Deploy policies -// --------------------------------------------------------------------------- - -#[derive(Debug, Clone, Deserialize, Serialize, Default)] -pub struct DeployRestartPolicy { - #[serde(skip_serializing_if = "Option::is_none")] - pub condition: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub delay: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub max_attempts: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub window: Option, -} - -#[derive(Debug, Clone, Deserialize, Serialize, Default)] -pub struct DeployUpdateConfig { - #[serde(skip_serializing_if = "Option::is_none")] - pub parallelism: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub delay: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub failure_action: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub monitor: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub max_failure_ratio: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub order: Option, -} - -#[derive(Debug, Clone, Deserialize, Serialize, Default)] -pub struct DeployPlacement { - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub constraints: Vec, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub preferences: Vec, - #[serde(skip_serializing_if = "Option::is_none")] - pub max_replicas_per_node: Option, -} diff --git a/lynx/translators/compose/internal/compose/types/develop.rs b/lynx/translators/compose/internal/compose/types/develop.rs deleted file mode 100644 index fb2673f..0000000 --- a/lynx/translators/compose/internal/compose/types/develop.rs +++ /dev/null @@ -1,77 +0,0 @@ -//! Development watch configuration types for the `develop:` service key. -//! -//! [`DevelopConfig`] holds a list of [`WatchRule`]s that drive the file-watch -//! engine. Each rule specifies a host path to monitor, an [`WatchAction`] to -//! take on change (sync, rebuild, restart, or sync+exec), and optional -//! ignore/include glob filters. - -use serde::{Deserialize, Serialize}; - -// --------------------------------------------------------------------------- -// DevelopConfig -// --------------------------------------------------------------------------- - -#[derive(Debug, Clone, Deserialize, Serialize, Default)] -pub struct DevelopConfig { - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub watch: Vec, -} - -// --------------------------------------------------------------------------- -// WatchRule -// --------------------------------------------------------------------------- - -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct WatchRule { - pub path: String, - pub action: WatchAction, - #[serde(skip_serializing_if = "Option::is_none")] - pub target: Option, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub ignore: Vec, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub include: Vec, - #[serde(default)] - pub initial_sync: bool, - #[serde(skip_serializing_if = "Option::is_none")] - pub exec: Option, -} - -// --------------------------------------------------------------------------- -// WatchAction -// --------------------------------------------------------------------------- - -#[derive(Debug, Clone, Serialize, Default, PartialEq, Eq)] -pub enum WatchAction { - #[default] - Sync, - Rebuild, - Restart, - SyncAndRestart, - SyncAndExec, -} - -impl<'de> Deserialize<'de> for WatchAction { - fn deserialize>(d: D) -> Result { - let s = String::deserialize(d)?; - match s.as_str() { - "sync" => Ok(WatchAction::Sync), - "rebuild" => Ok(WatchAction::Rebuild), - "restart" => Ok(WatchAction::Restart), - "sync+restart" => Ok(WatchAction::SyncAndRestart), - "sync+exec" => Ok(WatchAction::SyncAndExec), - other => Err(serde::de::Error::custom(format!( - "unknown watch action: {other}" - ))), - } - } -} - -// --------------------------------------------------------------------------- -// WatchExec -// --------------------------------------------------------------------------- - -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct WatchExec { - pub command: Vec, -} diff --git a/lynx/translators/compose/internal/compose/types/env.rs b/lynx/translators/compose/internal/compose/types/env.rs deleted file mode 100644 index 5d47cd4..0000000 --- a/lynx/translators/compose/internal/compose/types/env.rs +++ /dev/null @@ -1,124 +0,0 @@ -//! Environment variable and env-file types for the `environment:` and `env_file:` service fields. -//! -//! [`EnvVars`] accepts list or map form. [`EnvFile`] accepts a single path, a list of paths, -//! or a list of long-form [`EnvFileEntry`] objects with optional `required:` and `format:` fields. - -use indexmap::IndexMap; -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; - -/// Environment variables as a list (`["KEY=VAL"]`) or map (`{KEY: VAL}`). -#[derive(Debug, Clone, Deserialize, Serialize, Default)] -#[serde(untagged)] -pub enum EnvVars { - #[default] - Empty, - List(Vec), - Map(IndexMap>), -} - -impl EnvVars { - pub fn to_map(&self) -> HashMap> { - match self { - EnvVars::Empty => HashMap::new(), - EnvVars::List(list) => list - .iter() - .filter_map(|s| { - let mut parts = s.splitn(2, '='); - let key = parts.next()?.to_string(); - let val = parts.next().map(|v| v.to_string()); - Some((key, val)) - }) - .collect(), - EnvVars::Map(map) => map - .iter() - .map(|(k, v)| { - let val = v.as_ref().and_then(|v| match v { - serde_yaml::Value::String(s) => Some(s.clone()), - serde_yaml::Value::Number(n) => Some(n.to_string()), - serde_yaml::Value::Bool(b) => Some(b.to_string()), - serde_yaml::Value::Null => None, - _ => None, - }); - (k.clone(), val) - }) - .collect(), - } - } - - pub fn is_empty(&self) -> bool { - match self { - EnvVars::Empty => true, - EnvVars::List(v) => v.is_empty(), - EnvVars::Map(m) => m.is_empty(), - } - } -} - -/// One entry in an `env_file:` list — either a bare path or a long-form object. -#[derive(Debug, Clone, Deserialize, Serialize)] -#[serde(untagged)] -pub enum EnvFileEntry { - Path(String), - Config { - path: String, - #[serde(skip_serializing_if = "Option::is_none")] - required: Option, - #[serde(skip_serializing_if = "Option::is_none")] - format: Option, - }, -} - -impl EnvFileEntry { - pub fn path(&self) -> &str { - match self { - EnvFileEntry::Path(p) => p, - EnvFileEntry::Config { path, .. } => path, - } - } - - /// `true` by default — missing file is an error unless `required: false`. - pub fn required(&self) -> bool { - match self { - EnvFileEntry::Path(_) => true, - EnvFileEntry::Config { required, .. } => required.unwrap_or(true), - } - } -} - -/// `env_file:` field — single path, list of paths, or list of long-form objects. -#[derive(Debug, Clone, Deserialize, Serialize, Default)] -#[serde(untagged)] -pub enum EnvFile { - #[default] - Empty, - Single(EnvFileEntry), - List(Vec), -} - -impl EnvFile { - pub fn to_entries(&self) -> Vec { - match self { - EnvFile::Empty => vec![], - EnvFile::Single(e) => vec![e.clone()], - EnvFile::List(v) => v.clone(), - } - } - - /// Return just the paths (strips `required` / `format` info). - /// Kept for test compatibility; prefer `to_entries()` in engine code. - pub fn to_list(&self) -> Vec { - self.to_entries() - .into_iter() - .map(|e| e.path().to_string()) - .collect() - } - - pub fn is_empty(&self) -> bool { - match self { - EnvFile::Empty => true, - EnvFile::Single(_) => false, - EnvFile::List(v) => v.is_empty(), - } - } -} diff --git a/lynx/translators/compose/internal/compose/types/lifecycle.rs b/lynx/translators/compose/internal/compose/types/lifecycle.rs deleted file mode 100644 index 1e3fdb0..0000000 --- a/lynx/translators/compose/internal/compose/types/lifecycle.rs +++ /dev/null @@ -1,148 +0,0 @@ -//! Lifecycle and dependency types: `depends_on:`, `healthcheck:`, `restart:`, and lifecycle hooks. -//! -//! [`DependsOn`] models the service dependency graph — either a simple name list -//! or a map with per-dependency [`ServiceCondition`] semantics. [`HealthCheck`] -//! covers the inline healthcheck definition. [`RestartPolicy`] parses the -//! `restart:` string field. [`LifecycleHook`] is used for `post_start:` and -//! `pre_stop:` hook entries. - -use indexmap::IndexMap; -use serde::{Deserialize, Serialize}; - -use super::env::EnvVars; -use super::primitives::Command; - -#[derive(Debug, Clone, Deserialize, Serialize, Default)] -#[serde(untagged)] -pub enum DependsOn { - #[default] - Empty, - List(Vec), - Map(IndexMap), -} - -impl DependsOn { - pub fn service_names(&self) -> Vec { - match self { - DependsOn::Empty => vec![], - DependsOn::List(v) => v.clone(), - DependsOn::Map(m) => m.keys().cloned().collect(), - } - } - - pub fn condition_for(&self, service: &str) -> ServiceCondition { - match self { - DependsOn::Map(m) => m - .get(service) - .map(|c| c.condition.clone()) - .unwrap_or(ServiceCondition::ServiceStarted), - _ => ServiceCondition::ServiceStarted, - } - } - - pub fn restart_for(&self, service: &str) -> bool { - match self { - DependsOn::Map(m) => m.get(service).and_then(|c| c.restart).unwrap_or(false), - _ => false, - } - } - - pub fn required_for(&self, service: &str) -> bool { - match self { - DependsOn::Map(m) => m.get(service).and_then(|c| c.required).unwrap_or(true), - _ => true, - } - } -} - -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct DependsOnCondition { - pub condition: ServiceCondition, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub restart: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub required: Option, -} - -#[derive(Debug, Clone, Deserialize, Serialize, Default, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum ServiceCondition { - #[default] - ServiceStarted, - ServiceHealthy, - ServiceCompletedSuccessfully, -} - -#[derive(Debug, Clone, Deserialize, Serialize, Default)] -pub struct HealthCheck { - #[serde(skip_serializing_if = "Option::is_none")] - pub test: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub interval: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub timeout: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub retries: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub start_period: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub start_interval: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub disable: Option, -} - -impl HealthCheck { - pub fn is_disabled(&self) -> bool { - if self.disable.unwrap_or(false) { - return true; - } - matches!(&self.test, Some(Command::Exec(v)) if v.len() == 1 && v[0].eq_ignore_ascii_case("NONE")) - } -} - -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct LifecycleHook { - pub command: Command, - #[serde(skip_serializing_if = "Option::is_none")] - pub user: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub privileged: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub working_dir: Option, - #[serde(default)] - pub environment: EnvVars, -} - -#[derive(Debug, Clone, Serialize, PartialEq, Eq)] -pub enum RestartPolicy { - No, - Always, - OnFailure { max_attempts: Option }, - UnlessStopped, -} - -impl<'de> Deserialize<'de> for RestartPolicy { - fn deserialize(deserializer: D) -> std::result::Result - where - D: serde::Deserializer<'de>, - { - let s = String::deserialize(deserializer)?; - match s.as_str() { - "no" => Ok(RestartPolicy::No), - "always" => Ok(RestartPolicy::Always), - "unless-stopped" => Ok(RestartPolicy::UnlessStopped), - "on-failure" => Ok(RestartPolicy::OnFailure { max_attempts: None }), - s if s.starts_with("on-failure:") => { - let n = s["on-failure:".len()..] - .parse::() - .map_err(serde::de::Error::custom)?; - Ok(RestartPolicy::OnFailure { - max_attempts: Some(n), - }) - } - other => Err(serde::de::Error::custom(format!( - "invalid restart policy: {other}" - ))), - } - } -} diff --git a/lynx/translators/compose/internal/compose/types/mod.rs b/lynx/translators/compose/internal/compose/types/mod.rs deleted file mode 100644 index dacd8d3..0000000 --- a/lynx/translators/compose/internal/compose/types/mod.rs +++ /dev/null @@ -1,104 +0,0 @@ -//! Docker Compose file type definitions. - -pub mod build; -pub mod deploy; -pub mod develop; -pub mod env; -pub mod lifecycle; -pub mod network; -pub mod ports; -pub mod primitives; -pub mod resources; -pub mod service; -pub mod volume; - -pub use build::*; -pub use deploy::*; -pub use develop::*; -pub use env::*; -pub use lifecycle::*; -pub use network::*; -pub use ports::*; -pub use primitives::*; -pub use resources::*; -pub use service::*; -pub use volume::*; - -use indexmap::IndexMap; -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; - -// --------------------------------------------------------------------------- -// Top-level secrets / configs -// --------------------------------------------------------------------------- - -#[derive(Debug, Clone, Deserialize, Serialize, Default)] -pub struct SecretConfig { - #[serde(skip_serializing_if = "Option::is_none")] - pub file: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub external: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub name: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub content: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub environment: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub driver: Option, - #[serde(default, skip_serializing_if = "HashMap::is_empty")] - pub driver_opts: HashMap, - #[serde(default)] - pub labels: Labels, - #[serde(skip_serializing_if = "Option::is_none")] - pub template_driver: Option, -} - -#[derive(Debug, Clone, Deserialize, Serialize, Default)] -pub struct ConfigConfig { - #[serde(skip_serializing_if = "Option::is_none")] - pub file: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub external: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub name: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub content: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub environment: Option, - #[serde(default)] - pub labels: Labels, - #[serde(skip_serializing_if = "Option::is_none")] - pub driver: Option, - #[serde(default, skip_serializing_if = "HashMap::is_empty")] - pub driver_opts: HashMap, - #[serde(skip_serializing_if = "Option::is_none")] - pub template_driver: Option, -} - -// --------------------------------------------------------------------------- -// ComposeFile (root) -// --------------------------------------------------------------------------- - -#[derive(Debug, Clone, Deserialize, Serialize, Default)] -pub struct ComposeFile { - #[serde(skip_serializing_if = "Option::is_none")] - pub version: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub name: Option, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub include: Vec, - #[serde(default)] - pub services: IndexMap, - #[serde(default)] - pub volumes: IndexMap>, - #[serde(default)] - pub networks: IndexMap>, - #[serde(default)] - pub secrets: IndexMap, - #[serde(default)] - pub configs: IndexMap, - /// Top-level `x-*` extension fields — preserved and round-tripped via `config` subcommand. - #[serde(flatten, default, skip_serializing_if = "IndexMap::is_empty")] - pub extensions: IndexMap, -} diff --git a/lynx/translators/compose/internal/compose/types/network.rs b/lynx/translators/compose/internal/compose/types/network.rs deleted file mode 100644 index 23798fe..0000000 --- a/lynx/translators/compose/internal/compose/types/network.rs +++ /dev/null @@ -1,121 +0,0 @@ -//! Network configuration types for both top-level networks and per-service attachments. -//! -//! [`NetworkConfig`] describes a named network in the `networks:` top-level block. -//! [`ServiceNetworks`] is the per-service attachment — either a bare list of names -//! or a map with [`ServiceNetworkConfig`] options (aliases, IP, priority, etc.). - -use indexmap::IndexMap; -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; - -use super::Labels; - -// --------------------------------------------------------------------------- -// ServiceNetworks -// --------------------------------------------------------------------------- - -#[derive(Debug, Clone, Deserialize, Serialize, Default)] -#[serde(untagged)] -pub enum ServiceNetworks { - #[default] - Empty, - List(Vec), - Map(IndexMap>), -} - -impl ServiceNetworks { - pub fn names(&self) -> Vec { - match self { - ServiceNetworks::Empty => vec![], - ServiceNetworks::List(v) => v.clone(), - ServiceNetworks::Map(m) => m.keys().cloned().collect(), - } - } - - pub fn config_for(&self, name: &str) -> Option<&ServiceNetworkConfig> { - match self { - ServiceNetworks::Map(m) => m.get(name).and_then(|c| c.as_ref()), - _ => None, - } - } -} - -// --------------------------------------------------------------------------- -// ServiceNetworkConfig -// --------------------------------------------------------------------------- - -#[derive(Debug, Clone, Deserialize, Serialize, Default)] -pub struct ServiceNetworkConfig { - #[serde(skip_serializing_if = "Option::is_none")] - pub aliases: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub ipv4_address: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub ipv6_address: Option, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub link_local_ips: Vec, - #[serde(skip_serializing_if = "Option::is_none")] - pub priority: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub mac_address: Option, - #[serde(default, skip_serializing_if = "HashMap::is_empty")] - pub driver_opts: HashMap, - #[serde(skip_serializing_if = "Option::is_none")] - pub gw_priority: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub interface_name: Option, -} - -// --------------------------------------------------------------------------- -// Top-level NetworkConfig -// --------------------------------------------------------------------------- - -#[derive(Debug, Clone, Deserialize, Serialize, Default)] -pub struct NetworkConfig { - #[serde(skip_serializing_if = "Option::is_none")] - pub driver: Option, - #[serde(default, skip_serializing_if = "HashMap::is_empty")] - pub driver_opts: HashMap, - #[serde(skip_serializing_if = "Option::is_none")] - pub external: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub name: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub internal: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub enable_ipv6: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub enable_ipv4: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub attachable: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub ipam: Option, - #[serde(default)] - pub labels: Labels, -} - -// --------------------------------------------------------------------------- -// IPAM -// --------------------------------------------------------------------------- - -#[derive(Debug, Clone, Deserialize, Serialize, Default)] -pub struct IpamConfig { - #[serde(skip_serializing_if = "Option::is_none")] - pub driver: Option, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub config: Vec, - #[serde(default, skip_serializing_if = "HashMap::is_empty")] - pub options: HashMap, -} - -#[derive(Debug, Clone, Deserialize, Serialize, Default)] -pub struct IpamPool { - #[serde(skip_serializing_if = "Option::is_none")] - pub subnet: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub gateway: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub ip_range: Option, - #[serde(default, skip_serializing_if = "HashMap::is_empty")] - pub aux_addresses: HashMap, -} diff --git a/lynx/translators/compose/internal/compose/types/ports.rs b/lynx/translators/compose/internal/compose/types/ports.rs deleted file mode 100644 index 53534af..0000000 --- a/lynx/translators/compose/internal/compose/types/ports.rs +++ /dev/null @@ -1,54 +0,0 @@ -//! Port mapping types used in the `ports:` service field. -//! -//! [`PortMapping`] is either a short-form string (`"8080:80"`) or a long-form -//! struct. [`StringOrU16`] handles the `published` field which the spec allows -//! as either a port number or a quoted string range like `"8080-8090"`. - -use serde::{Deserialize, Serialize}; - -/// A port value that may appear as a bare number or a quoted string in YAML. -/// -/// The spec allows `published: 8080` (integer) or `published: "8080"` (string), -/// and also string ranges like `"8080-8090"` for port range mappings. -#[derive(Debug, Clone, Deserialize, Serialize)] -#[serde(untagged)] -pub enum StringOrU16 { - String(String), - Number(u16), -} - -impl StringOrU16 { - pub fn as_str_val(&self) -> String { - match self { - StringOrU16::String(s) => s.clone(), - StringOrU16::Number(n) => n.to_string(), - } - } -} - -/// A single entry in a service's `ports:` list. -/// -/// The short form (`"host:container"`, `"ip:host:container/proto"`) is a string -/// and is parsed by [`crate::ports::parse_ports`]. The long form exposes each -/// field individually and supports all spec options including `mode`, `app_protocol`, -/// and per-port naming. -#[derive(Debug, Clone, Deserialize, Serialize)] -#[serde(untagged)] -pub enum PortMapping { - Short(String), - Long { - target: u16, - #[serde(default, skip_serializing_if = "Option::is_none")] - published: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - protocol: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - host_ip: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - mode: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - app_protocol: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - name: Option, - }, -} diff --git a/lynx/translators/compose/internal/compose/types/primitives.rs b/lynx/translators/compose/internal/compose/types/primitives.rs deleted file mode 100644 index 3ccaedb..0000000 --- a/lynx/translators/compose/internal/compose/types/primitives.rs +++ /dev/null @@ -1,148 +0,0 @@ -//! Primitive compose field types shared across multiple service keys. -//! -//! [`Command`] — shell string or exec list for `command:`/`entrypoint:`. -//! [`StringOrList`] — single string or list of strings (used in `dns:`, `cap_add:`, etc.). -//! [`Labels`] — list or map form for `labels:`. -//! [`LoggingConfig`] — `logging:` driver and options. -//! [`Sysctls`] — list or map form for `sysctls:`. - -use indexmap::IndexMap; -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; - -/// Container entrypoint / command — either a shell string or exec list. -#[derive(Debug, Clone, Deserialize, Serialize)] -#[serde(untagged)] -pub enum Command { - Shell(String), - Exec(Vec), -} - -impl Command { - pub fn to_exec(&self) -> Vec { - match self { - Command::Shell(s) => vec!["sh".into(), "-c".into(), s.clone()], - Command::Exec(v) => v.clone(), - } - } - - pub fn to_argv(&self) -> Vec { - match self { - Command::Shell(s) => vec![s.clone()], - Command::Exec(v) => v.clone(), - } - } -} - -/// A field that accepts either a single string or a list of strings. -#[derive(Debug, Clone, Deserialize, Serialize, Default)] -#[serde(untagged)] -pub enum StringOrList { - #[default] - Empty, - Single(String), - List(Vec), -} - -impl StringOrList { - pub fn to_list(&self) -> Vec { - match self { - StringOrList::Empty => vec![], - StringOrList::Single(s) => vec![s.clone()], - StringOrList::List(v) => v.clone(), - } - } - - pub fn is_empty(&self) -> bool { - match self { - StringOrList::Empty => true, - StringOrList::Single(s) => s.is_empty(), - StringOrList::List(v) => v.is_empty(), - } - } -} - -/// Labels — list or map form. -#[derive(Debug, Clone, Deserialize, Serialize, Default)] -#[serde(untagged)] -pub enum Labels { - #[default] - Empty, - List(Vec), - Map(IndexMap), -} - -impl Labels { - pub fn to_map(&self) -> HashMap { - match self { - Labels::Empty => HashMap::new(), - Labels::List(list) => list - .iter() - .filter_map(|s| { - let mut parts = s.splitn(2, '='); - Some(( - parts.next()?.to_string(), - parts.next().unwrap_or("").to_string(), - )) - }) - .collect(), - Labels::Map(m) => m.iter().map(|(k, v)| (k.clone(), v.clone())).collect(), - } - } - - pub fn is_empty(&self) -> bool { - match self { - Labels::Empty => true, - Labels::List(v) => v.is_empty(), - Labels::Map(m) => m.is_empty(), - } - } -} - -/// `logging:` configuration — driver name and driver-specific options. -#[derive(Debug, Clone, Deserialize, Serialize, Default)] -pub struct LoggingConfig { - #[serde(skip_serializing_if = "Option::is_none")] - pub driver: Option, - #[serde(default, skip_serializing_if = "HashMap::is_empty")] - pub options: HashMap, -} - -/// Kernel parameters — list (`["net.ipv4.ip_forward=1"]`) or map form. -#[derive(Debug, Clone, Deserialize, Serialize, Default)] -#[serde(untagged)] -pub enum Sysctls { - #[default] - Empty, - List(Vec), - Map(IndexMap), -} - -impl Sysctls { - pub fn to_map(&self) -> HashMap { - match self { - Sysctls::Empty => HashMap::new(), - Sysctls::List(list) => list - .iter() - .filter_map(|s| { - let mut parts = s.splitn(2, '='); - let key = parts.next()?.to_string(); - let val = parts.next().unwrap_or("").to_string(); - Some((key, val)) - }) - .collect(), - Sysctls::Map(m) => m - .iter() - .map(|(k, v)| { - let s = match v { - serde_yaml::Value::String(s) => s.clone(), - serde_yaml::Value::Number(n) => n.to_string(), - serde_yaml::Value::Bool(b) => b.to_string(), - _ => String::new(), - }; - (k.clone(), s) - }) - .collect(), - } - } -} diff --git a/lynx/translators/compose/internal/compose/types/resources.rs b/lynx/translators/compose/internal/compose/types/resources.rs deleted file mode 100644 index 171ac20..0000000 --- a/lynx/translators/compose/internal/compose/types/resources.rs +++ /dev/null @@ -1,87 +0,0 @@ -//! Resource limit and device types shared across service and deploy configuration. -//! -//! [`UlimitConfig`] maps to the `ulimits:` map — either a single value (soft==hard) -//! or an explicit soft/hard pair. [`BlkioConfig`] covers block I/O weight and rate -//! limits. [`GpuSpec`] handles the `gpus:` shorthand (`"all"` or a count). - -use serde::{Deserialize, Serialize}; - -#[derive(Debug, Clone, Deserialize, Serialize)] -#[serde(untagged)] -pub enum UlimitConfig { - Single(i64), - Pair { soft: i64, hard: i64 }, -} - -impl UlimitConfig { - pub fn soft(&self) -> i64 { - match self { - UlimitConfig::Single(n) => *n, - UlimitConfig::Pair { soft, .. } => *soft, - } - } - - pub fn hard(&self) -> i64 { - match self { - UlimitConfig::Single(n) => *n, - UlimitConfig::Pair { hard, .. } => *hard, - } - } -} - -#[derive(Debug, Clone, Deserialize, Serialize, Default)] -pub struct BlkioConfig { - #[serde(skip_serializing_if = "Option::is_none")] - pub weight: Option, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub weight_device: Vec, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub device_read_bps: Vec, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub device_write_bps: Vec, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub device_read_iops: Vec, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub device_write_iops: Vec, -} - -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct BlkioWeightDevice { - pub path: String, - pub weight: u16, -} - -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct BlkioRateDevice { - pub path: String, - pub rate: serde_yaml::Value, -} - -impl BlkioRateDevice { - /// Return rate as bytes/second (or IOPS as a plain integer). - pub fn rate_value(&self) -> i64 { - match &self.rate { - serde_yaml::Value::Number(n) => n.as_i64().unwrap_or(0), - serde_yaml::Value::String(s) => crate::size::parse_memory(s).unwrap_or(0), - _ => 0, - } - } -} - -/// `gpus: all` or `gpus: 2` top-level service field. -#[derive(Debug, Clone, Deserialize, Serialize)] -#[serde(untagged)] -pub enum GpuSpec { - Named(String), - Count(u32), -} - -impl GpuSpec { - /// -1 = all; positive = exact count. - pub fn to_count(&self) -> i64 { - match self { - GpuSpec::Named(_) => -1, - GpuSpec::Count(n) => *n as i64, - } - } -} diff --git a/lynx/translators/compose/internal/compose/types/service.rs b/lynx/translators/compose/internal/compose/types/service.rs deleted file mode 100644 index 04c9cc7..0000000 --- a/lynx/translators/compose/internal/compose/types/service.rs +++ /dev/null @@ -1,231 +0,0 @@ -//! [`Service`] struct — the central type representing a single compose service. -//! -//! Fields map 1-to-1 to the Docker Compose specification. Optional fields use -//! `Option` so that absent keys are distinguishable from explicit nulls -//! during `extends:` merging. - -use indexmap::IndexMap; -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; - -use super::build::{BuildConfig, ExtendsConfig}; -use super::deploy::DeployConfig; -use super::develop::DevelopConfig; -use super::network::ServiceNetworks; -use super::volume::{ServiceConfigRef, ServiceSecretRef, VolumeMount}; -use super::{ - BlkioConfig, Command, DependsOn, EnvFile, EnvVars, GpuSpec, HealthCheck, Labels, LifecycleHook, - LoggingConfig, PortMapping, RestartPolicy, StringOrList, Sysctls, UlimitConfig, -}; - -/// A single service definition. -#[derive(Debug, Clone, Deserialize, Serialize, Default)] -pub struct Service { - // ---------------- core ---------------- - #[serde(skip_serializing_if = "Option::is_none")] - pub image: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub build: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub extends: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub command: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub entrypoint: Option, - - // ---------------- ports / network ---------------- - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub ports: Vec, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub expose: Vec, - - // ---------------- env / mounts ---------------- - #[serde(default)] - pub environment: EnvVars, - #[serde(default)] - pub env_file: EnvFile, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub volumes: Vec, - #[serde(default)] - pub tmpfs: StringOrList, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub volumes_from: Vec, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub configs: Vec, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub secrets: Vec, - - // ---------------- networking ---------------- - #[serde(default)] - pub networks: ServiceNetworks, - #[serde(skip_serializing_if = "Option::is_none")] - pub hostname: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub domainname: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub mac_address: Option, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub links: Vec, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub external_links: Vec, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub extra_hosts: Vec, - #[serde(default)] - pub dns: StringOrList, - #[serde(default)] - pub dns_search: StringOrList, - #[serde(default)] - pub dns_opt: StringOrList, - #[serde(skip_serializing_if = "Option::is_none")] - pub network_mode: Option, - - // ---------------- ordering / health ---------------- - #[serde(default)] - pub depends_on: DependsOn, - #[serde(skip_serializing_if = "Option::is_none")] - pub healthcheck: Option, - - // ---------------- lifecycle / restart ---------------- - #[serde(skip_serializing_if = "Option::is_none")] - pub restart: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub stop_signal: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub stop_grace_period: Option, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub profiles: Vec, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub post_start: Vec, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub pre_stop: Vec, - - // ---------------- identity / labels ---------------- - #[serde(default)] - pub labels: Labels, - #[serde(default)] - pub annotations: Labels, - #[serde(skip_serializing_if = "Option::is_none")] - pub container_name: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub user: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub working_dir: Option, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub group_add: Vec, - #[serde(skip_serializing_if = "Option::is_none")] - pub platform: Option, - - // ---------------- security / capabilities ---------------- - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub cap_add: Vec, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub cap_drop: Vec, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub security_opt: Vec, - #[serde(skip_serializing_if = "Option::is_none")] - pub read_only: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub privileged: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub init: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub tty: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub stdin_open: Option, - - // ---------------- runtime / namespaces ---------------- - #[serde(skip_serializing_if = "Option::is_none")] - pub runtime: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub shm_size: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub userns_mode: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub pid: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub ipc: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub uts: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub cgroup_parent: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub cgroup: Option, - - // ---------------- devices / filesystem ---------------- - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub devices: Vec, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub device_cgroup_rules: Vec, - #[serde(default, skip_serializing_if = "HashMap::is_empty")] - pub storage_opt: HashMap, - - // ---------------- resources / limits (top-level) ---------------- - #[serde(skip_serializing_if = "Option::is_none")] - pub scale: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub cpu_shares: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub cpu_quota: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub cpu_period: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub cpuset: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub cpus: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub cpu_count: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub cpu_percent: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub cpu_rt_runtime: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub cpu_rt_period: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub mem_limit: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub memswap_limit: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub mem_reservation: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub mem_swappiness: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub pids_limit: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub oom_kill_disable: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub oom_score_adj: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub blkio_config: Option, - - // ---------------- logging / sysctl / ulimit ---------------- - #[serde(skip_serializing_if = "Option::is_none")] - pub logging: Option, - #[serde(default)] - pub sysctls: Sysctls, - #[serde(default, skip_serializing_if = "IndexMap::is_empty")] - pub ulimits: IndexMap, - - // ---------------- labels / annotations ---------------- - #[serde(default)] - pub label_file: StringOrList, - - // ---------------- attach / log collection ---------------- - #[serde(skip_serializing_if = "Option::is_none")] - pub attach: Option, - - // ---------------- pull policy ---------------- - #[serde(skip_serializing_if = "Option::is_none")] - pub pull_policy: Option, - - // ---------------- deploy ---------------- - #[serde(skip_serializing_if = "Option::is_none")] - pub deploy: Option, - - // ---------------- develop (file-watch) ---------------- - #[serde(skip_serializing_if = "Option::is_none")] - pub develop: Option, - - // ---------------- gpu shorthand ---------------- - #[serde(skip_serializing_if = "Option::is_none")] - pub gpus: Option, -} diff --git a/lynx/translators/compose/internal/compose/types/volume.rs b/lynx/translators/compose/internal/compose/types/volume.rs deleted file mode 100644 index 7c13cd6..0000000 --- a/lynx/translators/compose/internal/compose/types/volume.rs +++ /dev/null @@ -1,199 +0,0 @@ -//! Volume, secret, and config mount types. -//! -//! [`VolumeMount`] covers the `volumes:` list on a service (short and long forms). -//! [`VolumeConfig`] describes top-level named volume definitions. -//! [`ServiceSecretRef`] and [`ServiceConfigRef`] are the per-service `secrets:` / -//! `configs:` attachment points (short = just the name, long = full options). - -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; - -use super::Labels; - -// --------------------------------------------------------------------------- -// VolumeType -// --------------------------------------------------------------------------- - -#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] -#[serde(rename_all = "lowercase")] -pub enum VolumeType { - Volume, - Bind, - Tmpfs, - Npipe, - Cluster, -} - -// --------------------------------------------------------------------------- -// Mount options -// --------------------------------------------------------------------------- - -#[derive(Debug, Clone, Deserialize, Serialize, Default)] -pub struct BindOptions { - #[serde(skip_serializing_if = "Option::is_none")] - pub propagation: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub create_host_path: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub selinux: Option, -} - -#[derive(Debug, Clone, Deserialize, Serialize, Default)] -pub struct VolumeOptions { - #[serde(skip_serializing_if = "Option::is_none")] - pub nocopy: Option, - #[serde(default)] - pub labels: Labels, - #[serde(skip_serializing_if = "Option::is_none")] - pub driver_config: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub subpath: Option, -} - -#[derive(Debug, Clone, Deserialize, Serialize, Default)] -pub struct DriverConfig { - #[serde(skip_serializing_if = "Option::is_none")] - pub name: Option, - #[serde(default, skip_serializing_if = "HashMap::is_empty")] - pub options: HashMap, -} - -#[derive(Debug, Clone, Deserialize, Serialize, Default)] -pub struct TmpfsOptions { - #[serde(skip_serializing_if = "Option::is_none")] - pub size: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub mode: Option, -} - -// --------------------------------------------------------------------------- -// VolumeMount -// --------------------------------------------------------------------------- - -#[derive(Debug, Clone, Deserialize, Serialize)] -#[serde(untagged)] -#[allow(clippy::large_enum_variant)] -pub enum VolumeMount { - Short(String), - Long { - #[serde(rename = "type")] - volume_type: VolumeType, - #[serde(default, skip_serializing_if = "Option::is_none")] - source: Option, - target: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - read_only: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - bind: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - volume: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - tmpfs: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - consistency: Option, - }, -} - -impl VolumeMount { - pub fn target(&self) -> &str { - match self { - VolumeMount::Short(s) => { - let parts: Vec<&str> = s.splitn(3, ':').collect(); - if parts.len() >= 2 { - parts[1] - } else { - parts[0] - } - } - VolumeMount::Long { target, .. } => target, - } - } -} - -// --------------------------------------------------------------------------- -// Top-level VolumeConfig -// --------------------------------------------------------------------------- - -#[derive(Debug, Clone, Deserialize, Serialize, Default)] -pub struct VolumeConfig { - #[serde(skip_serializing_if = "Option::is_none")] - pub driver: Option, - #[serde(default, skip_serializing_if = "HashMap::is_empty")] - pub driver_opts: HashMap, - #[serde(skip_serializing_if = "Option::is_none")] - pub external: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub name: Option, - #[serde(default)] - pub labels: Labels, -} - -// --------------------------------------------------------------------------- -// Service-level config / secret references -// --------------------------------------------------------------------------- - -#[derive(Debug, Clone, Deserialize, Serialize)] -#[serde(untagged)] -pub enum ServiceConfigRef { - Short(String), - Long { - source: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - target: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - uid: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - gid: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - mode: Option, - }, -} - -impl ServiceConfigRef { - pub fn source(&self) -> &str { - match self { - ServiceConfigRef::Short(s) => s, - ServiceConfigRef::Long { source, .. } => source, - } - } - - pub fn target(&self) -> Option<&str> { - match self { - ServiceConfigRef::Short(_) => None, - ServiceConfigRef::Long { target, .. } => target.as_deref(), - } - } -} - -#[derive(Debug, Clone, Deserialize, Serialize)] -#[serde(untagged)] -pub enum ServiceSecretRef { - Short(String), - Long { - source: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - target: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - uid: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - gid: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - mode: Option, - }, -} - -impl ServiceSecretRef { - pub fn source(&self) -> &str { - match self { - ServiceSecretRef::Short(s) => s, - ServiceSecretRef::Long { source, .. } => source, - } - } - - pub fn target(&self) -> Option<&str> { - match self { - ServiceSecretRef::Short(_) => None, - ServiceSecretRef::Long { target, .. } => target.as_deref(), - } - } -} diff --git a/lynx/translators/compose/internal/engine/build.rs b/lynx/translators/compose/internal/engine/build.rs deleted file mode 100644 index 49a8794..0000000 --- a/lynx/translators/compose/internal/engine/build.rs +++ /dev/null @@ -1,467 +0,0 @@ -//! Image build and pull operations. -//! -//! [`Engine::pull_image`] fetches a pre-built image from a registry. -//! [`Engine::build_service`] compiles a build context tar, passes it to the -//! Podman/Docker API, and applies any extra tags. Inline Dockerfiles and -//! multi-stage `--target` trimming are handled before the tar is assembled. - -use std::path::Path; - -use bollard::body_full; -use bollard::query_parameters::{BuildImageOptions, CreateImageOptions, TagImageOptions}; -use bytes::Bytes; -use flate2::write::GzEncoder; -use flate2::Compression; -use futures::StreamExt; -use tracing::{debug, info, warn}; -use walkdir::WalkDir; - -use crate::compose::types::{BuildConfig, Service}; -use crate::error::{ComposeError, Result}; -use crate::size; - -use super::Engine; - -impl Engine { - pub(super) async fn pull_image(&self, service: &Service) -> Result<()> { - let image = match &service.image { - Some(img) => img.clone(), - None => return Ok(()), - }; - - info!("pulling {image}"); - - let mut stream = self.docker.create_image( - Some(CreateImageOptions { - from_image: Some(image.clone()), - platform: service.platform.clone().unwrap_or_default(), - ..Default::default() - }), - None, - None, - ); - - while let Some(result) = stream.next().await { - match result { - Ok(info) => { - if let Some(status) = info.status { - debug!("{status}"); - } - } - Err(e) => warn!("pull warning: {e}"), - } - } - - Ok(()) - } - - pub(super) async fn build_service(&self, service_name: &str, service: &Service) -> Result<()> { - let build = match &service.build { - Some(b) => b, - None => return Ok(()), - }; - - let context_path = self.base_dir.join(build.context()); - let tag = service - .image - .clone() - .unwrap_or_else(|| format!("{}:latest", service_name)); - - info!("building {tag} from {}", context_path.display()); - - let (tar_bytes, dockerfile_name) = if let Some(inline) = build.dockerfile_inline() { - build_context_tar_with_inline(&context_path, inline)? - } else { - let df = build.dockerfile().unwrap_or("Dockerfile"); - if let Some(target) = build.target() { - build_context_tar_with_target(&context_path, df, target)? - } else { - (build_context_tar(&context_path, df)?, df.to_string()) - } - }; - - let arg_map = build.args().to_map(); - let mut build_args: std::collections::HashMap = - std::collections::HashMap::new(); - for (k, v) in arg_map { - let value = match v { - Some(val) => val, - None => std::env::var(&k).unwrap_or_default(), - }; - build_args.insert(k, value); - } - - let mut labels: std::collections::HashMap = - std::collections::HashMap::new(); - if let BuildConfig::Config { labels: l, .. } = build { - labels.extend(l.to_map()); - } - - let network_owned = if let BuildConfig::Config { - network: Some(n), .. - } = build - { - n.clone() - } else { - String::new() - }; - let platform_owned = if let BuildConfig::Config { platforms, .. } = build { - platforms.first().cloned().unwrap_or_default() - } else { - String::new() - }; - let shmsize = build - .shm_size() - .and_then(size::parse_memory) - .map(|s| s as u64) - .unwrap_or(0); - let extrahosts = build.extra_hosts().join(","); - - let options = BuildImageOptions { - dockerfile: dockerfile_name, - t: Some(tag.clone()), - rm: true, - nocache: build.no_cache(), - pull: if build.pull() { - Some("1".to_string()) - } else { - None - }, - buildargs: if build_args.is_empty() { - None - } else { - Some(build_args) - }, - labels: if labels.is_empty() { - None - } else { - Some(labels) - }, - networkmode: if network_owned.is_empty() { - None - } else { - Some(network_owned) - }, - platform: platform_owned, - shmsize: if shmsize > 0 { - Some(shmsize as i32) - } else { - None - }, - extrahosts: if extrahosts.is_empty() { - None - } else { - Some(extrahosts) - }, - cachefrom: if build.cache_from().is_empty() { - None - } else { - Some(build.cache_from().to_vec()) - }, - ..Default::default() - }; - - let body = Bytes::from(tar_bytes); - let mut stream = self - .docker - .build_image(options, None, Some(body_full(body))); - - while let Some(result) = stream.next().await { - match result { - Ok(info) => { - if let Some(stream_msg) = info.stream { - print!("{stream_msg}"); - } - if let Some(err) = info.error_detail.and_then(|e| e.message) { - return Err(ComposeError::Build(err)); - } - } - Err(e) => return Err(ComposeError::Podman(e)), - } - } - - // Apply additional tags. - for extra_tag in build.tags() { - let (repo, tag_str) = extra_tag - .rsplit_once(':') - .map(|(r, t)| (r.to_string(), t.to_string())) - .unwrap_or_else(|| (extra_tag.clone(), "latest".to_string())); - if let Err(e) = self - .docker - .tag_image( - &tag, - Some(TagImageOptions { - repo: Some(repo), - tag: Some(tag_str), - }), - ) - .await - { - warn!("failed to apply extra tag {extra_tag}: {e}"); - } - } - - Ok(()) - } -} - -// --------------------------------------------------------------------------- -// Build context tar -// --------------------------------------------------------------------------- - -/// Write inline Dockerfile content into the context tar as `.dockerfile-inline`. -fn build_context_tar_with_inline(context: &Path, inline: &str) -> Result<(Vec, String)> { - let inline_name = ".dockerfile-inline"; - let ignore_patterns = read_dockerignore(context); - - let encoder = GzEncoder::new(Vec::new(), Compression::default()); - let mut tar = tar::Builder::new(encoder); - - // Inline Dockerfile first. - let mut header = tar::Header::new_gnu(); - header.set_size(inline.len() as u64); - header.set_mode(0o644); - header.set_cksum(); - tar.append_data(&mut header, inline_name, inline.as_bytes()) - .map_err(|e| ComposeError::Build(e.to_string()))?; - - for entry in WalkDir::new(context).follow_links(false) { - let entry = entry.map_err(|e| ComposeError::Io(e.into()))?; - let abs = entry.path(); - let rel = abs - .strip_prefix(context) - .map_err(|_| ComposeError::Build("path strip error".into()))?; - if rel.as_os_str().is_empty() { - continue; - } - let rel_str = rel.to_string_lossy(); - if is_ignored(&rel_str, &ignore_patterns) { - continue; - } - if abs.is_dir() { - tar.append_dir(rel, abs) - .map_err(|e| ComposeError::Build(e.to_string()))?; - } else { - tar.append_path_with_name(abs, rel) - .map_err(|e| ComposeError::Build(e.to_string()))?; - } - } - - let gz = tar - .into_inner() - .map_err(|e| ComposeError::Build(e.to_string()))?; - let bytes = gz - .finish() - .map_err(|e| ComposeError::Build(e.to_string()))?; - Ok((bytes, inline_name.to_string())) -} - -pub(crate) fn build_context_tar(context: &Path, _dockerfile: &str) -> Result> { - let ignore_patterns = read_dockerignore(context); - - let encoder = GzEncoder::new(Vec::new(), Compression::default()); - let mut tar = tar::Builder::new(encoder); - - for entry in WalkDir::new(context).follow_links(false) { - let entry = entry.map_err(|e| ComposeError::Io(e.into()))?; - let abs = entry.path(); - let rel = abs - .strip_prefix(context) - .map_err(|_| ComposeError::Build("path strip error".into()))?; - - if rel.as_os_str().is_empty() { - continue; - } - - let rel_str = rel.to_string_lossy(); - if is_ignored(&rel_str, &ignore_patterns) { - continue; - } - - if abs.is_dir() { - tar.append_dir(rel, abs) - .map_err(|e| ComposeError::Build(e.to_string()))?; - } else { - tar.append_path_with_name(abs, rel) - .map_err(|e| ComposeError::Build(e.to_string()))?; - } - } - - let gz = tar - .into_inner() - .map_err(|e| ComposeError::Build(e.to_string()))?; - let bytes = gz - .finish() - .map_err(|e| ComposeError::Build(e.to_string()))?; - - Ok(bytes) -} - -/// Build a context tar with the Dockerfile truncated to stages up to `target`. -/// -/// Achieves the same result as `docker build --target=` without requiring -/// bollard API support for the target parameter. -fn build_context_tar_with_target( - context: &Path, - dockerfile: &str, - target: &str, -) -> Result<(Vec, String)> { - let df_path = context.join(dockerfile); - let df_content = std::fs::read_to_string(&df_path).map_err(ComposeError::Io)?; - let truncated = truncate_dockerfile_to_target(&df_content, target); - - let ignore_patterns = read_dockerignore(context); - let encoder = GzEncoder::new(Vec::new(), Compression::default()); - let mut tar = tar::Builder::new(encoder); - - // Write truncated Dockerfile first. - let df_bytes = truncated.as_bytes(); - let mut header = tar::Header::new_gnu(); - header.set_size(df_bytes.len() as u64); - header.set_mode(0o644); - header.set_cksum(); - tar.append_data(&mut header, dockerfile, df_bytes) - .map_err(|e| ComposeError::Build(e.to_string()))?; - - // Add context, skipping the original Dockerfile (already wrote truncated version). - for entry in WalkDir::new(context).follow_links(false) { - let entry = entry.map_err(|e| ComposeError::Io(e.into()))?; - let abs = entry.path(); - let rel = abs - .strip_prefix(context) - .map_err(|_| ComposeError::Build("path strip error".into()))?; - if rel.as_os_str().is_empty() { - continue; - } - let rel_str = rel.to_string_lossy(); - if is_ignored(&rel_str, &ignore_patterns) { - continue; - } - if rel_str == dockerfile { - continue; // Replaced by truncated version above. - } - if abs.is_dir() { - tar.append_dir(rel, abs) - .map_err(|e| ComposeError::Build(e.to_string()))?; - } else { - tar.append_path_with_name(abs, rel) - .map_err(|e| ComposeError::Build(e.to_string()))?; - } - } - - let gz = tar - .into_inner() - .map_err(|e| ComposeError::Build(e.to_string()))?; - let bytes = gz - .finish() - .map_err(|e| ComposeError::Build(e.to_string()))?; - Ok((bytes, dockerfile.to_string())) -} - -/// Truncate a Dockerfile to only include stages up to and including `target`. -/// -/// Stages after `target` are dropped, making the target stage the effective -/// final output — equivalent to `docker build --target=`. -pub(crate) fn truncate_dockerfile_to_target(content: &str, target: &str) -> String { - let target_lower = target.to_lowercase(); - let mut lines: Vec<&str> = Vec::new(); - let mut found_target = false; - - for line in content.lines() { - let trimmed = line.trim().to_ascii_lowercase(); - - if trimmed.starts_with("from ") { - if found_target { - // First FROM after our target stage — stop here. - break; - } - lines.push(line); - if let Some(as_idx) = trimmed.find(" as ") { - let stage = trimmed[as_idx + 4..].trim().to_string(); - if stage == target_lower { - found_target = true; - } - } - } else { - lines.push(line); - } - } - - if !found_target { - tracing::warn!( - "build.target '{target}' not found as a named stage in Dockerfile — using full Dockerfile" - ); - return content.to_string(); - } - - lines.join("\n") -} - -fn read_dockerignore(context: &Path) -> Vec { - let path = context.join(".dockerignore"); - let Ok(content) = std::fs::read_to_string(path) else { - return Vec::new(); - }; - content - .lines() - .map(|l| l.trim().to_string()) - .filter(|l| !l.is_empty() && !l.starts_with('#')) - .collect() -} - -fn is_ignored(path: &str, patterns: &[String]) -> bool { - for pattern in patterns { - if pattern.ends_with('/') { - if path.starts_with(pattern.as_str()) { - return true; - } - } else if path == pattern.as_str() - || (path.starts_with(pattern.as_str()) - && path.as_bytes().get(pattern.len()) == Some(&b'/')) - { - return true; - } - } - false -} - -// --------------------------------------------------------------------------- -// Unit tests -// --------------------------------------------------------------------------- - -#[cfg(test)] -mod tests { - use super::truncate_dockerfile_to_target; - - #[test] - fn truncate_drops_stages_after_target() { - let df = "FROM base AS builder\nRUN build\nFROM builder AS production\nRUN run\nFROM production AS final\nRUN finalize\n"; - let result = truncate_dockerfile_to_target(df, "production"); - assert!(result.contains("FROM base AS builder")); - assert!(result.contains("FROM builder AS production")); - assert!(!result.contains("FROM production AS final")); - } - - #[test] - fn truncate_unknown_target_returns_full() { - let df = "FROM alpine\nRUN echo hi\n"; - let result = truncate_dockerfile_to_target(df, "nonexistent"); - assert_eq!(result, df); - } - - #[test] - fn truncate_case_insensitive_target() { - let df = "FROM base AS Builder\nRUN step\nFROM Builder AS Next\nRUN other\n"; - let result = truncate_dockerfile_to_target(df, "builder"); - assert!(result.contains("AS Builder")); - assert!(!result.contains("AS Next")); - } - - #[test] - fn truncate_single_stage_target() { - let df = "FROM alpine AS app\nRUN echo done\n"; - let result = truncate_dockerfile_to_target(df, "app"); - assert!(result.contains("FROM alpine AS app")); - assert!(result.contains("echo done")); - } -} diff --git a/lynx/translators/compose/internal/engine/container.rs b/lynx/translators/compose/internal/engine/container.rs deleted file mode 100644 index 803ee91..0000000 --- a/lynx/translators/compose/internal/engine/container.rs +++ /dev/null @@ -1,703 +0,0 @@ -//! Container creation, configuration, and lifecycle management. -//! -//! [`Engine::create_and_start`] is the main entry point: it assembles the full -//! bollard `Config` from a [`Service`] definition (env vars, mounts, ports, -//! resource limits, networking) and starts the container. Helper functions -//! (`build_env`, `build_mounts`, `resolve_resources`, etc.) each own one -//! slice of the config to keep the mapping manageable. - -use std::collections::HashMap; -use std::path::Path; - -use bollard::models::{ - ContainerCreateBody, DeviceMapping, DeviceRequest, HealthConfig, HostConfig, - HostConfigLogConfig, NetworkingConfig, ResourcesBlkioWeightDevice, ResourcesUlimits, - RestartPolicy as BollardRestart, RestartPolicyNameEnum, ThrottleDevice, -}; -use bollard::query_parameters::{ - CreateContainerOptions, RemoveContainerOptions, StartContainerOptions, -}; -use tracing::warn; - -use crate::compose::types::{ - Command as ComposeCommand, ComposeFile, HealthCheck, LoggingConfig, - RestartPolicy as ComposeRestart, Service, VolumeMount, VolumeType, -}; -use crate::error::{ComposeError, Result}; -use crate::{env_file, ports, size}; - -use super::network::{build_endpoint_settings, resolve_network_mode}; -use super::volume::{build_binds, build_mounts}; -use super::Engine; - -impl Engine { - pub(super) async fn create_and_start( - &self, - container_name: &str, - service_name: &str, - service: &Service, - file: &ComposeFile, - ) -> Result<()> { - let image = service - .image - .as_deref() - .ok_or_else(|| ComposeError::NoImageOrBuild(service_name.into()))?; - - let env = build_env(service, &self.base_dir)?; - - let binds = build_binds(service, &self.base_dir); - let secret_binds = self.build_secret_binds(service, file)?; - let config_binds = self.build_config_binds(service, file)?; - let all_binds: Vec = binds - .into_iter() - .chain(secret_binds) - .chain(config_binds) - .collect(); - - let parsed_ports = ports::parse_ports(&service.ports)?; - let (port_bindings, exposed_ports_map) = ports::to_bollard(&parsed_ports); - - let mut exposed_port_keys: Vec = exposed_ports_map.into_keys().collect(); - for raw in &service.expose { - let key = if raw.contains('/') { - raw.clone() - } else { - format!("{raw}/tcp") - }; - if !exposed_port_keys.contains(&key) { - exposed_port_keys.push(key); - } - } - - let restart_policy = build_restart_policy(service); - let log_config = build_log_config(service.logging.as_ref()); - let (network_mode, first_network) = resolve_network_mode(service, file); - let label_file_labels = build_label_file_labels(service, &self.base_dir); - - let mut labels = service.labels.to_map(); - // Merge label_file labels (lower priority than inline labels). - for (k, v) in label_file_labels { - labels.entry(k).or_insert(v); - } - // Merge deploy.labels (lower priority than service.labels). - if let Some(deploy) = &service.deploy { - for (k, v) in deploy.labels.to_map() { - labels.entry(k).or_insert(v); - } - } - for (k, v) in service.annotations.to_map() { - labels.insert(format!("annotation.{k}"), v); - } - labels.insert("lynx.compose.project".to_string(), self.project.clone()); - labels.insert("lynx.compose.service".to_string(), service_name.to_string()); - - let ulimits: Vec = service - .ulimits - .iter() - .map(|(name, cfg)| ResourcesUlimits { - name: Some(name.clone()), - soft: Some(cfg.soft()), - hard: Some(cfg.hard()), - }) - .collect(); - - let sysctls: HashMap = service.sysctls.to_map(); - let extra_hosts: Vec = service.extra_hosts.clone(); - let dns = service.dns.to_list(); - let dns_search = service.dns_search.to_list(); - let dns_opt = service.dns_opt.to_list(); - - let devices: Vec = service - .devices - .iter() - .map(|s| parse_device(s.as_str())) - .collect(); - - let device_requests = build_device_requests(service); - - let tmpfs_list = service.tmpfs.to_list(); - let mut tmpfs_map: HashMap = - tmpfs_list.into_iter().map(|p| (p, String::new())).collect(); - for v in &service.volumes { - if let VolumeMount::Long { - volume_type: VolumeType::Tmpfs, - target, - tmpfs, - .. - } = v - { - let opts = tmpfs_options_to_string(tmpfs.as_ref()); - tmpfs_map.insert(target.clone(), opts); - } - } - - let ( - mem_limit, - mem_reservation, - memswap, - nano_cpus, - cpu_quota_eff, - cpu_period_eff, - pids_limit, - ) = resolve_resources(service); - - let blkio = build_blkio_config(service); - - let mut all_links: Vec = service.links.clone(); - all_links.extend_from_slice(&service.external_links); - - let mounts = build_mounts(service); - - let host_config = HostConfig { - binds: opt_vec(all_binds), - mounts: if mounts.is_empty() { - None - } else { - Some(mounts) - }, - network_mode: network_mode.clone(), - restart_policy, - port_bindings: opt_map(port_bindings), - cap_add: opt_vec(service.cap_add.clone()), - cap_drop: opt_vec(service.cap_drop.clone()), - sysctls: opt_map(sysctls), - ulimits: if ulimits.is_empty() { - None - } else { - Some(ulimits) - }, - extra_hosts: opt_vec(extra_hosts), - dns: opt_vec(dns), - dns_search: opt_vec(dns_search), - dns_options: opt_vec(dns_opt), - init: service.init, - privileged: service.privileged, - log_config, - pid_mode: service.pid.clone(), - ipc_mode: service.ipc.clone(), - uts_mode: service.uts.clone(), - cgroup_parent: service.cgroup_parent.clone(), - cgroupns_mode: service.cgroup.as_deref().and_then(|v| v.parse().ok()), - shm_size: service.shm_size.as_deref().and_then(size::parse_memory), - userns_mode: service.userns_mode.clone(), - security_opt: opt_vec(service.security_opt.clone()), - readonly_rootfs: service.read_only, - devices: opt_vec(devices), - device_cgroup_rules: opt_vec(service.device_cgroup_rules.clone()), - tmpfs: opt_map(tmpfs_map), - volumes_from: opt_vec(service.volumes_from.clone()), - links: opt_vec(all_links), - runtime: service.runtime.clone(), - memory: mem_limit, - memory_reservation: mem_reservation, - memory_swap: memswap, - memory_swappiness: service.mem_swappiness, - nano_cpus, - cpu_shares: service.cpu_shares.map(|s| s as i64), - cpu_quota: cpu_quota_eff, - cpu_period: cpu_period_eff, - cpuset_cpus: service.cpuset.clone(), - pids_limit, - cpu_count: service.cpu_count, - cpu_percent: service.cpu_percent, - cpu_realtime_period: service.cpu_rt_period, - cpu_realtime_runtime: service.cpu_rt_runtime, - oom_kill_disable: service.oom_kill_disable, - oom_score_adj: service.oom_score_adj, - storage_opt: opt_map(service.storage_opt.clone()), - group_add: opt_vec(service.group_add.clone()), - blkio_weight: blkio.as_ref().and_then(|b| b.weight), - blkio_weight_device: blkio.as_ref().and_then(|b| b.weight_device.clone()), - blkio_device_read_bps: blkio.as_ref().and_then(|b| b.device_read_bps.clone()), - blkio_device_write_bps: blkio.as_ref().and_then(|b| b.device_write_bps.clone()), - blkio_device_read_iops: blkio.as_ref().and_then(|b| b.device_read_iops.clone()), - blkio_device_write_iops: blkio.as_ref().and_then(|b| b.device_write_iops.clone()), - device_requests: if device_requests.is_empty() { - None - } else { - Some(device_requests) - }, - annotations: opt_map(service.annotations.to_map()), - ..Default::default() - }; - - let cmd = service.command.as_ref().map(|c| c.to_exec()); - let entrypoint = service.entrypoint.as_ref().map(|c| c.to_exec()); - - let networking_config = first_network.as_ref().map(|net| { - let mut endpoints = HashMap::new(); - let svc_net_cfg = service.networks.config_for(net); - endpoints.insert(net.clone(), build_endpoint_settings(svc_net_cfg, file)); - NetworkingConfig { - endpoints_config: Some(endpoints), - } - }); - - let healthcheck = service.healthcheck.as_ref().map(build_healthcheck); - - let config = ContainerCreateBody { - image: Some(image.to_string()), - env: opt_vec(env), - cmd, - entrypoint, - host_config: Some(host_config), - labels: opt_map(labels), - exposed_ports: opt_vec(exposed_port_keys), - tty: service.tty, - open_stdin: service.stdin_open, - user: service.user.clone(), - working_dir: service.working_dir.clone(), - stop_signal: service.stop_signal.clone(), - stop_timeout: service - .stop_grace_period - .as_deref() - .and_then(size::parse_duration_secs) - .map(|s| s as i64), - hostname: service.hostname.clone(), - domainname: service.domainname.clone(), - networking_config, - healthcheck, - ..Default::default() - }; - - // Remove any pre-existing container with the same name. - let _ = self - .docker - .remove_container( - container_name, - Some(RemoveContainerOptions { - force: true, - ..Default::default() - }), - ) - .await; - - self.docker - .create_container( - Some(CreateContainerOptions { - name: Some(container_name.to_string()), - platform: service.platform.clone().unwrap_or_default(), - }), - config, - ) - .await?; - - self.docker - .start_container(container_name, None::) - .await?; - - Ok(()) - } -} - -// --------------------------------------------------------------------------- -// Container config helpers -// --------------------------------------------------------------------------- - -fn build_env(service: &Service, base_dir: &Path) -> Result> { - let entries = service.env_file.to_entries(); - let env_file_vars = if !entries.is_empty() { - env_file::load_env_file_entries(&entries, base_dir)? - } else { - HashMap::new() - }; - Ok(env_file::merge_env( - service.environment.to_map(), - env_file_vars, - )) -} - -pub(crate) fn build_restart_policy(service: &Service) -> Option { - if let Some(r) = &service.restart { - return Some(match r { - ComposeRestart::No => BollardRestart { - name: Some(RestartPolicyNameEnum::NO), - maximum_retry_count: None, - }, - ComposeRestart::Always => BollardRestart { - name: Some(RestartPolicyNameEnum::ALWAYS), - maximum_retry_count: None, - }, - ComposeRestart::OnFailure { max_attempts } => BollardRestart { - name: Some(RestartPolicyNameEnum::ON_FAILURE), - maximum_retry_count: max_attempts.map(|n| n as i64), - }, - ComposeRestart::UnlessStopped => BollardRestart { - name: Some(RestartPolicyNameEnum::UNLESS_STOPPED), - maximum_retry_count: None, - }, - }); - } - // Fall back to deploy.restart_policy when service.restart is absent. - // delay/window are Swarm-specific and have no container API equivalent. - if let Some(drp) = service - .deploy - .as_ref() - .and_then(|d| d.restart_policy.as_ref()) - { - let name = match drp.condition.as_deref().unwrap_or("any") { - "none" => RestartPolicyNameEnum::NO, - "on-failure" => RestartPolicyNameEnum::ON_FAILURE, - _ => RestartPolicyNameEnum::UNLESS_STOPPED, - }; - return Some(BollardRestart { - name: Some(name), - maximum_retry_count: drp.max_attempts.map(|n| n as i64), - }); - } - None -} - -fn build_log_config(logging: Option<&LoggingConfig>) -> Option { - logging.map(|l| HostConfigLogConfig { - typ: l.driver.clone(), - config: if l.options.is_empty() { - None - } else { - Some(l.options.clone()) - }, - }) -} - -fn build_healthcheck(hc: &HealthCheck) -> HealthConfig { - if hc.is_disabled() { - return HealthConfig { - test: Some(vec!["NONE".to_string()]), - ..Default::default() - }; - } - let test = hc.test.as_ref().map(|cmd| match cmd { - ComposeCommand::Shell(s) => vec!["CMD-SHELL".to_string(), s.clone()], - ComposeCommand::Exec(v) => v.clone(), - }); - HealthConfig { - test, - interval: hc.interval.as_deref().and_then(size::parse_duration_nanos), - timeout: hc.timeout.as_deref().and_then(size::parse_duration_nanos), - retries: hc.retries.map(|r| r as i64), - start_period: hc - .start_period - .as_deref() - .and_then(size::parse_duration_nanos), - start_interval: hc - .start_interval - .as_deref() - .and_then(size::parse_duration_nanos), - } -} - -#[allow(clippy::type_complexity)] -fn resolve_resources( - service: &Service, -) -> ( - Option, - Option, - Option, - Option, - Option, - Option, - Option, -) { - let mut memory = service.mem_limit.as_deref().and_then(size::parse_memory); - let mut mem_reservation = service - .mem_reservation - .as_deref() - .and_then(size::parse_memory); - let memswap = service - .memswap_limit - .as_deref() - .and_then(size::parse_memory); - let mut nano_cpus = service.cpus.as_deref().and_then(size::parse_cpus); - let cpu_quota = service.cpu_quota; - let cpu_period = service.cpu_period.map(|p| p as i64); - let mut pids_limit = service.pids_limit; - - if let Some(deploy) = &service.deploy { - if let Some(res) = &deploy.resources { - if let Some(limits) = &res.limits { - if memory.is_none() { - memory = limits.memory.as_deref().and_then(size::parse_memory); - } - if nano_cpus.is_none() { - nano_cpus = limits.cpus.as_deref().and_then(size::parse_cpus); - } - if pids_limit.is_none() { - pids_limit = limits.pids.map(|p| p as i64); - } - } - if let Some(reserv) = &res.reservations { - if mem_reservation.is_none() { - mem_reservation = reserv.memory.as_deref().and_then(size::parse_memory); - } - } - } - } - - ( - memory, - mem_reservation, - memswap, - nano_cpus, - cpu_quota, - cpu_period, - pids_limit, - ) -} - -pub(crate) fn parse_device(s: &str) -> DeviceMapping { - let parts: Vec<&str> = s.splitn(3, ':').collect(); - let host = parts.first().copied().unwrap_or("").to_string(); - let cont = parts - .get(1) - .copied() - .map(|c| c.to_string()) - .unwrap_or_else(|| host.clone()); - let perm = parts.get(2).copied().unwrap_or("rwm").to_string(); - DeviceMapping { - path_on_host: Some(host), - path_in_container: Some(cont), - cgroup_permissions: Some(perm), - } -} - -pub(crate) fn tmpfs_options_to_string( - opts: Option<&crate::compose::types::TmpfsOptions>, -) -> String { - let opts = match opts { - Some(o) => o, - None => return String::new(), - }; - let mut parts: Vec = Vec::new(); - if let Some(size) = opts.size { - parts.push(format!("size={size}")); - } - if let Some(mode) = opts.mode { - parts.push(format!("mode={mode:o}")); - } - parts.join(",") -} - -pub(crate) fn opt_vec(v: Vec) -> Option> { - if v.is_empty() { - None - } else { - Some(v) - } -} - -pub(crate) fn opt_map(m: HashMap) -> Option> { - if m.is_empty() { - None - } else { - Some(m) - } -} - -fn build_label_file_labels(service: &Service, base_dir: &Path) -> HashMap { - let mut labels = HashMap::new(); - for path in service.label_file.to_list() { - let full = if std::path::Path::new(&path).is_absolute() { - std::path::PathBuf::from(&path) - } else { - base_dir.join(&path) - }; - let Ok(content) = std::fs::read_to_string(&full) else { - warn!("label_file: cannot read {}", full.display()); - continue; - }; - for line in content.lines() { - let trimmed = line.trim(); - if trimmed.is_empty() || trimmed.starts_with('#') { - continue; - } - let mut parts = trimmed.splitn(2, '='); - let key = parts.next().unwrap_or("").trim().to_string(); - let val = parts.next().unwrap_or("").to_string(); - if !key.is_empty() { - labels.insert(key, val); - } - } - } - labels -} - -struct BlkioHostConfig { - weight: Option, - weight_device: Option>, - device_read_bps: Option>, - device_write_bps: Option>, - device_read_iops: Option>, - device_write_iops: Option>, -} - -fn build_device_requests(service: &Service) -> Vec { - use crate::compose::types::CountOrAll; - - let mut requests: Vec = Vec::new(); - - // Top-level `gpus:` shorthand. - if let Some(gpus) = &service.gpus { - requests.push(DeviceRequest { - driver: Some("".into()), - count: Some(gpus.to_count()), - device_ids: None, - capabilities: Some(vec![vec!["gpu".into()]]), - options: None, - }); - } - - // `deploy.resources.reservations.devices`. - if let Some(deploy) = &service.deploy { - if let Some(resources) = &deploy.resources { - if let Some(reservations) = &resources.reservations { - for dev in &reservations.devices { - if dev.capabilities.is_empty() { - continue; - } - - let count = if !dev.device_ids.is_empty() { - None - } else { - Some( - dev.count - .as_ref() - .map(|c: &CountOrAll| c.to_i64()) - .unwrap_or(-1), - ) - }; - - let device_ids = if dev.device_ids.is_empty() { - None - } else { - Some(dev.device_ids.clone()) - }; - - requests.push(DeviceRequest { - driver: dev.driver.clone().or(Some("".into())), - count, - device_ids, - capabilities: Some(vec![dev.capabilities.clone()]), - options: if dev.options.is_empty() { - None - } else { - Some(dev.options.clone()) - }, - }); - } - } - } - } - - requests -} - -fn build_blkio_config(service: &Service) -> Option { - use crate::compose::types::BlkioConfig; - let cfg: &BlkioConfig = service.blkio_config.as_ref()?; - - let weight_device = if cfg.weight_device.is_empty() { - None - } else { - Some( - cfg.weight_device - .iter() - .map(|d| ResourcesBlkioWeightDevice { - path: Some(d.path.clone()), - weight: Some(d.weight as usize), - }) - .collect(), - ) - }; - - let to_throttle = |devs: &[crate::compose::types::BlkioRateDevice]| { - if devs.is_empty() { - None - } else { - Some( - devs.iter() - .map(|d| ThrottleDevice { - path: Some(d.path.clone()), - rate: Some(d.rate_value()), - }) - .collect(), - ) - } - }; - - Some(BlkioHostConfig { - weight: cfg.weight, - weight_device, - device_read_bps: to_throttle(&cfg.device_read_bps), - device_write_bps: to_throttle(&cfg.device_write_bps), - device_read_iops: to_throttle(&cfg.device_read_iops), - device_write_iops: to_throttle(&cfg.device_write_iops), - }) -} - -// --------------------------------------------------------------------------- -// Unit tests -// --------------------------------------------------------------------------- - -#[cfg(test)] -mod tests { - use super::{parse_device, tmpfs_options_to_string}; - use crate::compose::types::TmpfsOptions; - - #[test] - fn parse_device_host_container_perm() { - let d = parse_device("/dev/sda:/dev/xvda:rwm"); - assert_eq!(d.path_on_host.as_deref(), Some("/dev/sda")); - assert_eq!(d.path_in_container.as_deref(), Some("/dev/xvda")); - assert_eq!(d.cgroup_permissions.as_deref(), Some("rwm")); - } - - #[test] - fn parse_device_default_perm() { - let d = parse_device("/dev/null:/dev/null"); - assert_eq!(d.cgroup_permissions.as_deref(), Some("rwm")); - } - - #[test] - fn parse_device_same_path_both_sides() { - let d = parse_device("/dev/dri"); - assert_eq!(d.path_on_host.as_deref(), Some("/dev/dri")); - assert_eq!(d.path_in_container.as_deref(), Some("/dev/dri")); - } - - #[test] - fn tmpfs_options_empty() { - let s = tmpfs_options_to_string(None); - assert!(s.is_empty()); - } - - #[test] - fn tmpfs_options_size_only() { - let opts = TmpfsOptions { - size: Some(67108864), - mode: None, - }; - let s = tmpfs_options_to_string(Some(&opts)); - assert_eq!(s, "size=67108864"); - } - - #[test] - fn tmpfs_options_mode_only() { - let opts = TmpfsOptions { - size: None, - mode: Some(0o1755), - }; - let s = tmpfs_options_to_string(Some(&opts)); - assert_eq!(s, "mode=1755"); - } - - #[test] - fn tmpfs_options_size_and_mode() { - let opts = TmpfsOptions { - size: Some(1024), - mode: Some(0o755), - }; - let s = tmpfs_options_to_string(Some(&opts)); - assert!(s.contains("size=1024")); - assert!(s.contains("mode=755")); - } -} diff --git a/lynx/translators/compose/internal/engine/health.rs b/lynx/translators/compose/internal/engine/health.rs deleted file mode 100644 index 2cfad5b..0000000 --- a/lynx/translators/compose/internal/engine/health.rs +++ /dev/null @@ -1,79 +0,0 @@ -//! Health and completion polling for service dependency ordering. -//! -//! [`Engine::wait_healthy`] polls until the container reports `healthy` (used when -//! a dependent service declares `condition: service_healthy`). -//! [`Engine::wait_completed`] polls until the container exits with code 0 (used for -//! `condition: service_completed_successfully`). - -use crate::compose::types::Service; -use crate::error::{ComposeError, Result}; - -use super::Engine; - -impl Engine { - /// Poll a container until its health status is `healthy` or timeout. - /// - /// Uses `healthcheck.retries` (default 30) with a 2 s interval between probes. - pub(super) async fn wait_healthy(&self, container_name: &str, service: &Service) -> Result<()> { - use bollard::models::HealthStatusEnum; - - let retries = service - .healthcheck - .as_ref() - .and_then(|h| h.retries) - .unwrap_or(30); - - for _ in 0..retries { - let info = match self.docker.inspect_container(container_name, None).await { - Ok(i) => i, - Err(e) => { - // Podman uses "stopped" for exited containers; Bollard can't - // deserialize it. Treat any inspect error as "not healthy yet". - tracing::debug!("inspect_container error (will retry): {e}"); - tokio::time::sleep(std::time::Duration::from_secs(2)).await; - continue; - } - }; - if let Some(state) = info.state { - if let Some(health) = state.health { - if health.status == Some(HealthStatusEnum::HEALTHY) { - return Ok(()); - } - } - } - tokio::time::sleep(std::time::Duration::from_secs(2)).await; - } - - Err(ComposeError::HealthCheckTimeout(container_name.into())) - } - - /// Poll a container until it exits with status 0. - /// - /// Tries for up to 600 seconds (1 s interval). Errors if the container - /// exits with a non-zero code or if the deadline is exceeded. - pub(super) async fn wait_completed(&self, container_name: &str) -> Result<()> { - for _ in 0..600 { - let info = match self.docker.inspect_container(container_name, None).await { - Ok(i) => i, - Err(e) => { - tracing::debug!("inspect_container error (will retry): {e}"); - tokio::time::sleep(std::time::Duration::from_secs(1)).await; - continue; - } - }; - if let Some(state) = info.state { - let status = state.status.map(|s| format!("{s:?}").to_lowercase()); - if status.as_deref() == Some("exited") { - if state.exit_code.unwrap_or(-1) == 0 { - return Ok(()); - } - return Err(ComposeError::HealthCheckTimeout(format!( - "{container_name} exited with non-zero status" - ))); - } - } - tokio::time::sleep(std::time::Duration::from_secs(1)).await; - } - Err(ComposeError::HealthCheckTimeout(container_name.into())) - } -} diff --git a/lynx/translators/compose/internal/engine/mod.rs b/lynx/translators/compose/internal/engine/mod.rs deleted file mode 100644 index 627a981..0000000 --- a/lynx/translators/compose/internal/engine/mod.rs +++ /dev/null @@ -1,644 +0,0 @@ -//! Container orchestration engine. -//! -//! Translates a parsed [`ComposeFile`] into Podman API calls via bollard. - -mod build; -mod container; -mod health; -mod network; -mod profiles; -mod volume; -mod watch; - -use std::collections::HashMap; -use std::path::PathBuf; - -use bollard::container::LogOutput; -use bollard::exec::{CreateExecOptions, StartExecResults}; -use bollard::query_parameters::{ - ListContainersOptions, LogsOptions, RemoveContainerOptions, StartContainerOptions, - StopContainerOptions, -}; -use bollard::Docker; -use futures::StreamExt; -use tracing::info; - -use crate::compose::types::{ComposeFile, LifecycleHook, Service, ServiceCondition}; -use crate::error::{ComposeError, Result}; - -use profiles::{active_profiles_set, service_in_profiles}; - -// --------------------------------------------------------------------------- -// Engine -// --------------------------------------------------------------------------- - -pub struct Engine { - docker: Docker, - project: String, - base_dir: PathBuf, -} - -impl Engine { - pub fn new(docker: Docker, project: String) -> Self { - Self { - docker, - project, - base_dir: std::env::current_dir().unwrap_or_default(), - } - } - - pub fn with_base_dir(docker: Docker, project: String, base_dir: PathBuf) -> Self { - Self { - docker, - project, - base_dir, - } - } - - // ----------------------------------------------------------------------- - // Public commands - // ----------------------------------------------------------------------- - - pub async fn up(&self, file: &ComposeFile) -> Result<()> { - self.up_with_options(file, false, &[], &[], false).await - } - - pub async fn up_with_options( - &self, - file: &ComposeFile, - _detach: bool, - active_profiles: &[String], - target_services: &[String], - no_recreate: bool, - ) -> Result<()> { - let order = crate::compose::resolve_order(file)?; - let active = active_profiles_set(active_profiles); - - // When target_services is non-empty, restrict the start set to those - // services plus their transitive dependencies. - let target_set: Option> = if target_services.is_empty() { - None - } else { - let mut set = std::collections::HashSet::new(); - let mut stack: Vec = target_services.to_vec(); - while let Some(name) = stack.pop() { - if !set.insert(name.clone()) { - continue; - } - if let Some(service) = file.services.get(&name) { - for dep in service.depends_on.service_names() { - if !set.contains(&dep) { - stack.push(dep); - } - } - } - } - Some(set) - }; - - self.create_networks(file).await?; - self.create_volumes(file).await?; - - for name in &order { - if let Some(ref set) = target_set { - if !set.contains(name) { - continue; - } - } - let service = &file.services[name]; - - if !service_in_profiles(service, &active) { - tracing::debug!("skipping {name}: no active profile match"); - continue; - } - - for dep in service.depends_on.service_names() { - let condition = service.depends_on.condition_for(&dep); - let dep_service = match file.services.get(&dep) { - Some(s) => s, - None => continue, - }; - if !service_in_profiles(dep_service, &active) { - continue; - } - let dep_container = self.container_name(&dep, dep_service); - - match condition { - ServiceCondition::ServiceStarted => {} - ServiceCondition::ServiceHealthy => { - if dep_service - .healthcheck - .as_ref() - .map(|h| !h.is_disabled()) - .unwrap_or(false) - { - self.wait_healthy(&dep_container, dep_service).await?; - } else { - tracing::debug!( - "{dep} requested service_healthy but has no healthcheck — skipping wait" - ); - } - } - ServiceCondition::ServiceCompletedSuccessfully => { - self.wait_completed(&dep_container).await?; - } - } - } - - let policy = service.pull_policy.as_deref().unwrap_or("missing"); - match (service.build.is_some(), policy) { - (true, _) => self.build_service(name, service).await?, - (false, "never") => {} - (false, "always") => self.pull_image(service).await?, - (false, _) => self.pull_image(service).await?, - } - - let replicas = service - .scale - .or(service.deploy.as_ref().and_then(|d| d.replicas)) - .unwrap_or(1) as usize; - - for i in 1..=replicas { - let container_name = if replicas == 1 { - self.container_name(name, service) - } else { - format!("{}-{i}", self.container_name(name, service)) - }; - if no_recreate && self.is_container_running(&container_name).await { - info!("{container_name} already running — skipping recreate"); - continue; - } - self.create_and_start(&container_name, name, service, file) - .await?; - self.connect_extra_networks(&container_name, service, file) - .await?; - info!("started {container_name}"); - - // Execute post_start lifecycle hooks. - for hook in &service.post_start { - self.run_lifecycle_hook(&container_name, hook).await?; - } - } - } - - Ok(()) - } - - pub async fn down(&self, file: &ComposeFile) -> Result<()> { - self.down_with_options(file, false).await - } - - pub async fn down_with_options(&self, file: &ComposeFile, remove_volumes: bool) -> Result<()> { - let mut order = crate::compose::resolve_order(file)?; - order.reverse(); - - for name in &order { - let service = &file.services[name]; - for container_name in self.replica_names(name, service) { - // Execute pre_stop lifecycle hooks before stopping. - for hook in &service.pre_stop { - let _ = self.run_lifecycle_hook(&container_name, hook).await; - } - - let _ = self - .docker - .stop_container( - &container_name, - Some(StopContainerOptions { - t: Some(10), - ..Default::default() - }), - ) - .await; - - let _ = self - .docker - .remove_container( - &container_name, - Some(RemoveContainerOptions { - force: true, - v: remove_volumes, - ..Default::default() - }), - ) - .await; - - info!("removed {container_name}"); - } - } - - self.cleanup_temp_dir(); - Ok(()) - } - - pub async fn ps(&self, _file: &ComposeFile) -> Result<()> { - let label = format!("lynx.compose.project={}", self.project); - let mut filters: HashMap> = HashMap::new(); - filters.insert("label".to_string(), vec![label]); - - let containers = self - .docker - .list_containers(Some(ListContainersOptions { - all: true, - filters: Some(filters), - ..Default::default() - })) - .await?; - - println!("{:<40} {:<30} {:<20}", "NAME", "IMAGE", "STATUS"); - for c in containers { - let names = c - .names - .unwrap_or_default() - .join(", ") - .trim_start_matches('/') - .to_string(); - let image = c.image.unwrap_or_default(); - let status = c.status.unwrap_or_default(); - let ports = c - .ports - .unwrap_or_default() - .iter() - .map(|p| { - format!( - "{}:{}->{}", - p.ip.as_deref().unwrap_or(""), - p.public_port.unwrap_or(0), - p.private_port - ) - }) - .collect::>() - .join(", "); - println!("{names:<40} {image:<30} {status:<20} {ports}"); - } - - Ok(()) - } - - pub async fn logs( - &self, - file: &ComposeFile, - service_name: Option<&str>, - follow: bool, - ) -> Result<()> { - let targets: Vec = if let Some(svc) = service_name { - let service = file - .services - .get(svc) - .ok_or_else(|| ComposeError::ServiceNotFound(svc.into()))?; - vec![self.container_name(svc, service)] - } else { - file.services - .iter() - .map(|(n, s)| self.container_name(n, s)) - .collect() - }; - - for container_name in targets { - let mut stream = self.docker.logs( - &container_name, - Some(LogsOptions { - stdout: true, - stderr: true, - follow, - ..Default::default() - }), - ); - - while let Some(msg) = stream.next().await { - match msg? { - LogOutput::StdOut { message } => { - print!("{}", String::from_utf8_lossy(&message)); - } - LogOutput::StdErr { message } => { - eprint!("{}", String::from_utf8_lossy(&message)); - } - _ => {} - } - } - } - - Ok(()) - } - - pub async fn exec( - &self, - file: &ComposeFile, - service_name: &str, - cmd: Vec, - ) -> Result<()> { - let service = file - .services - .get(service_name) - .ok_or_else(|| ComposeError::ServiceNotFound(service_name.into()))?; - let container_name = self.container_name(service_name, service); - - let exec_id = self - .docker - .create_exec( - &container_name, - CreateExecOptions:: { - cmd: Some(cmd), - attach_stdout: Some(true), - attach_stderr: Some(true), - attach_stdin: Some(true), - tty: Some(true), - ..Default::default() - }, - ) - .await? - .id; - - match self.docker.start_exec(&exec_id, None).await? { - StartExecResults::Attached { mut output, .. } => { - while let Some(msg) = output.next().await { - match msg? { - LogOutput::StdOut { message } => { - print!("{}", String::from_utf8_lossy(&message)); - } - LogOutput::StdErr { message } => { - eprint!("{}", String::from_utf8_lossy(&message)); - } - _ => {} - } - } - } - StartExecResults::Detached => {} - } - - Ok(()) - } - - /// Stream logs from all attached services until Ctrl+C. - /// - /// Services with `attach: false` are excluded. Respects the compose spec: - /// when not detaching, all attached services have their output forwarded. - pub async fn attach_logs(&self, file: &ComposeFile) -> Result<()> { - use bollard::query_parameters::LogsOptions; - use futures::StreamExt; - - let attached: Vec<(String, String)> = file - .services - .iter() - .filter(|(_, s)| s.attach.unwrap_or(true)) - .map(|(name, s)| (name.clone(), self.container_name(name, s))) - .collect(); - - if attached.is_empty() { - return Ok(()); - } - - let streams: Vec<_> = attached - .iter() - .map(|(name, cname)| { - let prefix = name.clone(); - let mut stream = self.docker.logs( - cname, - Some(LogsOptions { - stdout: true, - stderr: true, - follow: true, - ..Default::default() - }), - ); - async move { - while let Some(msg) = stream.next().await { - match msg { - Ok(LogOutput::StdOut { message }) => { - print!("{prefix} | {}", String::from_utf8_lossy(&message)); - } - Ok(LogOutput::StdErr { message }) => { - eprint!("{prefix} | {}", String::from_utf8_lossy(&message)); - } - _ => {} - } - } - } - }) - .collect(); - - tokio::select! { - _ = futures::future::join_all(streams) => {} - _ = tokio::signal::ctrl_c() => {} - } - - Ok(()) - } - - /// Remove containers that belong to this project but are no longer in the compose file. - pub async fn remove_orphans(&self, file: &ComposeFile) -> Result<()> { - let label = format!("lynx.compose.project={}", self.project); - let mut filters: HashMap> = HashMap::new(); - filters.insert("label".to_string(), vec![label]); - - let running = self - .docker - .list_containers(Some(ListContainersOptions { - all: true, - filters: Some(filters), - ..Default::default() - })) - .await?; - - let known: std::collections::HashSet = file - .services - .iter() - .flat_map(|(n, s)| self.replica_names(n, s)) - .collect(); - - for c in running { - let names = c.names.unwrap_or_default(); - for raw in &names { - let name = raw.trim_start_matches('/'); - if !known.contains(name) { - tracing::info!("removing orphan container {name}"); - let _ = self - .docker - .remove_container( - name, - Some(RemoveContainerOptions { - force: true, - ..Default::default() - }), - ) - .await; - } - } - } - Ok(()) - } - - pub async fn pull(&self, file: &ComposeFile) -> Result<()> { - let futs: Vec<_> = file - .services - .values() - .filter(|s| s.image.is_some()) - .map(|s| self.pull_image(s)) - .collect(); - - let results = futures::future::join_all(futs).await; - for r in results { - r?; - } - Ok(()) - } - - pub async fn restart(&self, file: &ComposeFile, service_name: Option<&str>) -> Result<()> { - let names: Vec = if let Some(svc) = service_name { - if !file.services.contains_key(svc) { - return Err(ComposeError::ServiceNotFound(svc.into())); - } - vec![svc.to_string()] - } else { - file.services.keys().cloned().collect() - }; - - for name in &names { - let service = &file.services[name]; - let container_name = self.container_name(name, service); - - let _ = self - .docker - .stop_container( - &container_name, - Some(StopContainerOptions { - t: Some(10), - ..Default::default() - }), - ) - .await; - - self.docker - .start_container(&container_name, None::) - .await?; - - info!("restarted {container_name}"); - - // Cascade restart to dependents with depends_on.restart: true. - for (dep_name, dep_service) in &file.services { - if dep_service.depends_on.restart_for(name) { - let dep_container = self.container_name(dep_name, dep_service); - let _ = self - .docker - .stop_container( - &dep_container, - Some(StopContainerOptions { - t: Some(10), - ..Default::default() - }), - ) - .await; - if let Err(e) = self - .docker - .start_container(&dep_container, None::) - .await - { - tracing::warn!("cascade restart of {dep_name} failed: {e}"); - } else { - info!("cascade-restarted {dep_container} (depends_on.restart)"); - } - } - } - } - - Ok(()) - } - - // ----------------------------------------------------------------------- - // Internal - // ----------------------------------------------------------------------- - - async fn run_lifecycle_hook(&self, container_name: &str, hook: &LifecycleHook) -> Result<()> { - use bollard::exec::{CreateExecOptions, StartExecResults}; - - let cmd = hook.command.to_exec(); - let env: Option> = { - let m = hook.environment.to_map(); - if m.is_empty() { - None - } else { - Some( - m.into_iter() - .filter_map(|(k, v)| v.map(|v| format!("{k}={v}"))) - .collect(), - ) - } - }; - - let exec_id = self - .docker - .create_exec( - container_name, - CreateExecOptions:: { - cmd: Some(cmd), - user: hook.user.clone(), - privileged: hook.privileged, - working_dir: hook.working_dir.clone(), - env, - attach_stdout: Some(true), - attach_stderr: Some(true), - ..Default::default() - }, - ) - .await? - .id; - - match self.docker.start_exec(&exec_id, None).await? { - StartExecResults::Attached { mut output, .. } => { - use bollard::container::LogOutput; - use futures::StreamExt; - while let Some(msg) = output.next().await { - match msg? { - LogOutput::StdOut { message } => { - print!("{}", String::from_utf8_lossy(&message)); - } - LogOutput::StdErr { message } => { - eprint!("{}", String::from_utf8_lossy(&message)); - } - _ => {} - } - } - } - StartExecResults::Detached => {} - } - - Ok(()) - } - - async fn is_container_running(&self, container_name: &str) -> bool { - // Use list_containers (not inspect_container) to avoid Bollard - // deserialization failures when Podman returns "stopped" state. - let mut filters = HashMap::new(); - filters.insert("name".to_string(), vec![container_name.to_string()]); - self.docker - .list_containers(Some(ListContainersOptions { - all: false, - filters: Some(filters), - ..Default::default() - })) - .await - .map(|v| !v.is_empty()) - .unwrap_or(false) - } - - fn container_name(&self, service_name: &str, service: &Service) -> String { - service - .container_name - .clone() - .unwrap_or_else(|| format!("{}-{}", self.project, service_name)) - } - - /// Return one container name per replica (indexed when scale > 1). - fn replica_names(&self, service_name: &str, service: &Service) -> Vec { - let replicas = service - .scale - .or(service.deploy.as_ref().and_then(|d| d.replicas)) - .unwrap_or(1) as usize; - let base = self.container_name(service_name, service); - if replicas == 1 { - vec![base] - } else { - (1..=replicas).map(|i| format!("{base}-{i}")).collect() - } - } -} diff --git a/lynx/translators/compose/internal/engine/network.rs b/lynx/translators/compose/internal/engine/network.rs deleted file mode 100644 index a0416a1..0000000 --- a/lynx/translators/compose/internal/engine/network.rs +++ /dev/null @@ -1,212 +0,0 @@ -//! Network creation and service attachment. -//! -//! [`Engine::create_networks`] creates all non-external networks declared in -//! the compose file before any containers start. [`Engine::connect_extra_networks`] -//! attaches a running container to any additional networks beyond its primary -//! one (Docker API creates containers connected to only one network; extras need -//! a separate `ConnectNetwork` call). - -use std::collections::HashMap; - -use bollard::models::{ - EndpointIpamConfig, EndpointSettings, Ipam, IpamConfig as BollardIpamConfig, - NetworkConnectRequest, NetworkCreateRequest, -}; -use tracing::{debug, info}; - -use crate::compose::types::{ComposeFile, IpamConfig, Service, ServiceNetworkConfig}; -use crate::error::{ComposeError, Result}; - -use super::Engine; - -impl Engine { - pub(super) async fn create_networks(&self, file: &ComposeFile) -> Result<()> { - for (name, config) in &file.networks { - let network_name = config - .as_ref() - .and_then(|c| c.name.as_deref()) - .unwrap_or(name); - - let external = config.as_ref().and_then(|c| c.external).unwrap_or(false); - if external { - continue; - } - - let driver = config - .as_ref() - .and_then(|c| c.driver.clone()) - .unwrap_or_else(|| "bridge".into()); - - let mut labels: HashMap = config - .as_ref() - .map(|c| c.labels.to_map()) - .unwrap_or_default(); - labels.insert("lynx.compose.project".to_string(), self.project.clone()); - - let driver_opts: HashMap = config - .as_ref() - .map(|c| c.driver_opts.clone()) - .unwrap_or_default(); - - let ipam = config - .as_ref() - .and_then(|c| c.ipam.as_ref()) - .map(build_ipam); - - let request = NetworkCreateRequest { - name: network_name.to_string(), - driver: Some(driver.clone()), - internal: config.as_ref().and_then(|c| c.internal), - attachable: config.as_ref().and_then(|c| c.attachable), - enable_ipv6: config.as_ref().and_then(|c| c.enable_ipv6), - options: if driver_opts.is_empty() { - None - } else { - Some(driver_opts) - }, - labels: if labels.is_empty() { - None - } else { - Some(labels) - }, - ipam, - ..Default::default() - }; - - match self.docker.create_network(request).await { - Ok(_) => info!("created network {network_name}"), - Err(bollard::errors::Error::DockerResponseServerError { - status_code: 409, .. - }) => {} - Err(e) => return Err(ComposeError::Podman(e)), - } - } - Ok(()) - } - - pub(super) async fn connect_extra_networks( - &self, - container_name: &str, - service: &Service, - file: &ComposeFile, - ) -> Result<()> { - if service.network_mode.is_some() { - return Ok(()); - } - - let network_names = service.networks.names(); - for network in network_names.iter().skip(1) { - let full_name = resolve_network_name(network, file); - let endpoint_config = - build_endpoint_settings(service.networks.config_for(network), file); - self.docker - .connect_network( - &full_name, - NetworkConnectRequest { - container: container_name.to_string(), - endpoint_config: Some(endpoint_config), - }, - ) - .await?; - debug!("connected {container_name} to network {full_name}"); - } - - Ok(()) - } -} - -// --------------------------------------------------------------------------- -// Free helpers (pub(super) so container.rs can call them) -// --------------------------------------------------------------------------- - -pub(super) fn build_endpoint_settings( - cfg: Option<&ServiceNetworkConfig>, - _file: &ComposeFile, -) -> EndpointSettings { - let mut settings = EndpointSettings::default(); - if let Some(c) = cfg { - if let Some(aliases) = &c.aliases { - settings.aliases = Some(aliases.clone()); - } - if c.ipv4_address.is_some() || c.ipv6_address.is_some() || !c.link_local_ips.is_empty() { - settings.ipam_config = Some(EndpointIpamConfig { - ipv4_address: c.ipv4_address.clone(), - ipv6_address: c.ipv6_address.clone(), - link_local_ips: if c.link_local_ips.is_empty() { - None - } else { - Some(c.link_local_ips.clone()) - }, - }); - } - if c.mac_address.is_some() { - settings.mac_address = c.mac_address.clone(); - } - if let Some(prio) = c.priority { - let mut m = HashMap::new(); - m.insert("priority".to_string(), prio.to_string()); - settings.driver_opts = Some(m); - } - } - settings -} - -/// Determine `network_mode` and the first named network for `NetworkingConfig`. -/// -/// Returns `(Option, Option)`. -pub(super) fn resolve_network_mode( - service: &Service, - file: &ComposeFile, -) -> (Option, Option) { - if let Some(mode) = &service.network_mode { - return (Some(mode.clone()), None); - } - let networks = service.networks.names(); - if networks.is_empty() { - (None, None) - } else { - let first = resolve_network_name(&networks[0], file); - (None, Some(first)) - } -} - -pub(super) fn resolve_network_name(network: &str, file: &ComposeFile) -> String { - file.networks - .get(network) - .and_then(|c| c.as_ref()) - .and_then(|c| c.name.as_deref()) - .unwrap_or(network) - .to_string() -} - -fn build_ipam(ipam: &IpamConfig) -> Ipam { - let config = if ipam.config.is_empty() { - None - } else { - Some( - ipam.config - .iter() - .map(|pool| BollardIpamConfig { - subnet: pool.subnet.clone(), - gateway: pool.gateway.clone(), - ip_range: pool.ip_range.clone(), - auxiliary_addresses: if pool.aux_addresses.is_empty() { - None - } else { - Some(pool.aux_addresses.clone()) - }, - }) - .collect(), - ) - }; - - Ipam { - driver: ipam.driver.clone(), - config, - options: if ipam.options.is_empty() { - None - } else { - Some(ipam.options.clone()) - }, - } -} diff --git a/lynx/translators/compose/internal/engine/profiles.rs b/lynx/translators/compose/internal/engine/profiles.rs deleted file mode 100644 index a6bf0c4..0000000 --- a/lynx/translators/compose/internal/engine/profiles.rs +++ /dev/null @@ -1,31 +0,0 @@ -//! Profile filtering — determines which services run given the active profile set. - -use std::collections::HashSet; - -use crate::compose::types::Service; - -/// Build the active-profile set, falling back to `COMPOSE_PROFILES` env var. -pub(super) fn active_profiles_set(active: &[String]) -> HashSet { - if !active.is_empty() { - return active.iter().cloned().collect(); - } - std::env::var("COMPOSE_PROFILES") - .ok() - .map(|s| { - s.split(',') - .map(|p| p.trim().to_string()) - .filter(|p| !p.is_empty()) - .collect() - }) - .unwrap_or_default() -} - -/// True if the service should be started given the active profile set. -/// -/// Services with no profiles always start. -pub(super) fn service_in_profiles(service: &Service, active: &HashSet) -> bool { - if service.profiles.is_empty() { - return true; - } - service.profiles.iter().any(|p| active.contains(p)) -} diff --git a/lynx/translators/compose/internal/engine/volume.rs b/lynx/translators/compose/internal/engine/volume.rs deleted file mode 100644 index 9d55d72..0000000 --- a/lynx/translators/compose/internal/engine/volume.rs +++ /dev/null @@ -1,580 +0,0 @@ -//! Volume and secret/config mount helpers. -//! -//! [`Engine::create_volumes`] pre-creates named volumes before containers start. -//! [`build_binds`] and [`build_mounts`] convert `volumes:` entries to bollard's -//! bind-string and Mount-API formats respectively (tmpfs and volumes with -//! subpath/labels require the Mount API; simple bind/volume mounts use strings). -//! [`Engine::build_secret_binds`] and [`Engine::build_config_binds`] materialise -//! inline secrets/configs to a restricted temp directory and return bind strings. - -use std::collections::HashMap; -use std::path::Path; - -use bollard::models::{ - Mount, MountBindOptions, MountTmpfsOptions, MountType, MountVolumeOptions, - MountVolumeOptionsDriverConfig, VolumeCreateRequest, -}; -use tracing::info; - -use crate::compose::types::{ - BindOptions, ComposeFile, ConfigConfig, SecretConfig, Service, ServiceConfigRef, - ServiceSecretRef, VolumeMount, VolumeOptions, VolumeType, -}; -use crate::error::{ComposeError, Result}; - -use super::Engine; - -impl Engine { - pub(super) async fn create_volumes(&self, file: &ComposeFile) -> Result<()> { - for (name, config) in &file.volumes { - let external = config.as_ref().and_then(|c| c.external).unwrap_or(false); - if external { - continue; - } - - let volume_name = config - .as_ref() - .and_then(|c| c.name.as_deref()) - .unwrap_or(name); - - let mut labels: HashMap = config - .as_ref() - .map(|c| c.labels.to_map()) - .unwrap_or_default(); - labels.insert("lynx.compose.project".to_string(), self.project.clone()); - - let driver = config - .as_ref() - .and_then(|c| c.driver.clone()) - .unwrap_or_else(|| "local".into()); - - let driver_opts: HashMap = config - .as_ref() - .map(|c| c.driver_opts.clone()) - .unwrap_or_default(); - - let options = VolumeCreateRequest { - name: Some(volume_name.to_string()), - driver: Some(driver.clone()), - driver_opts: if driver_opts.is_empty() { - None - } else { - Some(driver_opts) - }, - labels: if labels.is_empty() { - None - } else { - Some(labels) - }, - ..Default::default() - }; - - match self.docker.create_volume(options).await { - Ok(_) => info!("created volume {volume_name}"), - Err(bollard::errors::Error::DockerResponseServerError { - status_code: 409, .. - }) => {} - Err(e) => return Err(ComposeError::Podman(e)), - } - } - Ok(()) - } -} - -// --------------------------------------------------------------------------- -// Free helpers (pub(super) for container.rs) -// --------------------------------------------------------------------------- - -pub(crate) fn build_binds(service: &Service, base_dir: &Path) -> Vec { - let mut out = Vec::new(); - for v in &service.volumes { - match v { - VolumeMount::Short(s) => out.push(s.clone()), - VolumeMount::Long { - volume_type, - source, - target, - read_only, - bind, - volume, - .. - } => { - if matches!(volume_type, VolumeType::Tmpfs) { - continue; - } - // Volumes with subpath/labels/driver_config go through the Mount API. - if needs_mount_api(volume) { - continue; - } - let src = source.as_deref().unwrap_or(""); - - if matches!(volume_type, VolumeType::Bind) { - if let Some(b) = bind { - if b.create_host_path.unwrap_or(false) && !src.is_empty() { - let abs = if Path::new(src).is_absolute() { - std::path::PathBuf::from(src) - } else { - base_dir.join(src) - }; - if let Err(e) = std::fs::create_dir_all(&abs) { - tracing::warn!( - "create_host_path: failed to create {}: {e}", - abs.display() - ); - } - } - } - } - - let mut opts: Vec = Vec::new(); - if read_only.unwrap_or(false) { - opts.push("ro".into()); - } else { - opts.push("rw".into()); - } - if let Some(b) = bind { - extend_bind_opts(&mut opts, b); - } - if let Some(vol) = volume { - extend_volume_opts(&mut opts, vol); - } - out.push(format!("{src}:{target}:{}", opts.join(","))); - } - } - } - out -} - -fn needs_mount_api(volume: &Option) -> bool { - volume - .as_ref() - .is_some_and(|v| v.subpath.is_some() || !v.labels.is_empty() || v.driver_config.is_some()) -} - -pub(crate) fn build_mounts(service: &Service) -> Vec { - let mut out = Vec::new(); - for v in &service.volumes { - if let VolumeMount::Long { - volume_type, - source, - target, - read_only, - bind, - volume, - tmpfs, - consistency, - } = v - { - if matches!(volume_type, VolumeType::Tmpfs) { - // Tmpfs via Mount API. - let tmpfs_options = tmpfs.as_ref().map(|t| MountTmpfsOptions { - size_bytes: t.size.map(|s| s as i64), - mode: t.mode.map(|m| m as i64), - options: None, - }); - out.push(Mount { - target: Some(target.clone()), - source: source.clone(), - typ: Some(MountType::TMPFS), - read_only: *read_only, - consistency: consistency.clone(), - tmpfs_options, - ..Default::default() - }); - continue; - } - if !needs_mount_api(volume) { - continue; - } - let mount_type = match volume_type { - VolumeType::Bind => MountType::BIND, - VolumeType::Volume => MountType::VOLUME, - VolumeType::Npipe => MountType::NPIPE, - VolumeType::Cluster => MountType::CLUSTER, - VolumeType::Tmpfs => unreachable!(), - }; - let bind_options = bind.as_ref().map(|b| MountBindOptions { - propagation: b.propagation.as_deref().and_then(|p| p.parse().ok()), - ..Default::default() - }); - let volume_options = volume.as_ref().map(|v| { - let labels = if v.labels.is_empty() { - None - } else { - Some(v.labels.to_map()) - }; - let driver_config = - v.driver_config - .as_ref() - .map(|dc| MountVolumeOptionsDriverConfig { - name: dc.name.clone(), - options: if dc.options.is_empty() { - None - } else { - Some(dc.options.clone()) - }, - }); - MountVolumeOptions { - no_copy: v.nocopy, - labels, - driver_config, - subpath: v.subpath.clone(), - } - }); - out.push(Mount { - target: Some(target.clone()), - source: source.clone(), - typ: Some(mount_type), - read_only: *read_only, - consistency: consistency.clone(), - bind_options, - volume_options, - ..Default::default() - }); - } - } - out -} - -fn extend_bind_opts(opts: &mut Vec, b: &BindOptions) { - if let Some(p) = &b.propagation { - opts.push(p.clone()); - } - if let Some(s) = &b.selinux { - opts.push(s.clone()); - } -} - -fn extend_volume_opts(opts: &mut Vec, v: &VolumeOptions) { - if v.nocopy.unwrap_or(false) { - opts.push("nocopy".into()); - } -} - -impl Engine { - pub(super) fn build_secret_binds( - &self, - service: &Service, - file: &ComposeFile, - ) -> Result> { - let mut binds = Vec::new(); - for secret_ref in &service.secrets { - let (name, target_override, ref_mode, ref_uid, ref_gid) = match secret_ref { - ServiceSecretRef::Short(s) => (s.clone(), None, None, None, None), - ServiceSecretRef::Long { - source, - target, - mode, - uid, - gid, - } => ( - source.clone(), - target.clone(), - *mode, - uid.clone(), - gid.clone(), - ), - }; - if let Some(config) = file.secrets.get(&name) { - let target = target_override.unwrap_or_else(|| format!("/run/secrets/{name}")); - match config { - SecretConfig { - file: Some(host_path), - .. - } => { - binds.push(format!("{host_path}:{target}:ro")); - } - SecretConfig { - content: Some(content), - .. - } => { - let path = self.materialize_inline_full( - "secrets", - &name, - content.as_bytes(), - ref_mode, - ref_uid.as_deref(), - ref_gid.as_deref(), - )?; - binds.push(format!("{}:{target}:ro", path.display())); - } - SecretConfig { - environment: Some(env_var), - .. - } => { - let value = std::env::var(env_var).unwrap_or_default(); - let path = self.materialize_inline_full( - "secrets", - &name, - value.as_bytes(), - ref_mode, - ref_uid.as_deref(), - ref_gid.as_deref(), - )?; - binds.push(format!("{}:{target}:ro", path.display())); - } - SecretConfig { - external: Some(true), - .. - } => { - tracing::debug!("external secret {name} — relying on runtime injection"); - } - _ => {} - } - } - } - Ok(binds) - } - - pub(super) fn build_config_binds( - &self, - service: &Service, - file: &ComposeFile, - ) -> Result> { - let mut binds = Vec::new(); - for config_ref in &service.configs { - let (name, target_override, ref_mode, ref_uid, ref_gid) = match config_ref { - ServiceConfigRef::Short(s) => (s.clone(), None, None, None, None), - ServiceConfigRef::Long { - source, - target, - mode, - uid, - gid, - } => ( - source.clone(), - target.clone(), - *mode, - uid.clone(), - gid.clone(), - ), - }; - if let Some(cfg) = file.configs.get(&name) { - let target = target_override.unwrap_or_else(|| format!("/{name}")); - match cfg { - ConfigConfig { - file: Some(host_path), - .. - } => { - binds.push(format!("{host_path}:{target}:ro")); - } - ConfigConfig { - content: Some(content), - .. - } => { - let path = self.materialize_inline_full( - "configs", - &name, - content.as_bytes(), - ref_mode, - ref_uid.as_deref(), - ref_gid.as_deref(), - )?; - binds.push(format!("{}:{target}:ro", path.display())); - } - ConfigConfig { - environment: Some(env_var), - .. - } => { - let value = std::env::var(env_var).unwrap_or_default(); - let path = self.materialize_inline_full( - "configs", - &name, - value.as_bytes(), - ref_mode, - ref_uid.as_deref(), - ref_gid.as_deref(), - )?; - binds.push(format!("{}:{target}:ro", path.display())); - } - ConfigConfig { - external: Some(true), - .. - } => { - tracing::debug!("external config {name} — relying on runtime injection"); - } - _ => {} - } - } - } - Ok(binds) - } - - /// Write `content` to a per-project temp file and return its path. - /// - fn materialize_inline_full( - &self, - kind: &str, - name: &str, - content: &[u8], - mode: Option, - uid: Option<&str>, - gid: Option<&str>, - ) -> Result { - use std::io::Write; - use std::os::unix::fs::{DirBuilderExt, OpenOptionsExt, PermissionsExt}; - - // Reject names that could escape the temp dir (path traversal). - if std::path::Path::new(name) - .components() - .any(|c| !matches!(c, std::path::Component::Normal(_))) - { - return Err(ComposeError::Unsupported(format!( - "{kind} name must not contain path separators or '..': {name}" - ))); - } - - let dir = std::env::temp_dir() - .join(format!("lynx-compose-{}", self.project)) - .join(kind); - - // Create dir with 0o700 so only the owning user can list/enter it. - std::fs::DirBuilder::new() - .recursive(true) - .mode(0o700) - .create(&dir) - .map_err(ComposeError::Io)?; - - let path = dir.join(name); - - // Create file with 0o600 atomically — no world-readable window before chmod. - let mut file = std::fs::OpenOptions::new() - .write(true) - .create(true) - .truncate(true) - .mode(0o600) - .open(&path) - .map_err(ComposeError::Io)?; - file.write_all(content).map_err(ComposeError::Io)?; - drop(file); - - if let Some(m) = mode { - std::fs::set_permissions(&path, std::fs::Permissions::from_mode(m)) - .map_err(ComposeError::Io)?; - } - - // Best-effort chown — succeeds in rootful Podman, no-op in rootless. - let uid_val: libc::uid_t = uid.and_then(|s| s.parse().ok()).unwrap_or(u32::MAX); - let gid_val: libc::gid_t = gid.and_then(|s| s.parse().ok()).unwrap_or(u32::MAX); - if uid_val != u32::MAX || gid_val != u32::MAX { - use std::ffi::CString; - use std::os::unix::ffi::OsStrExt; - if let Ok(p) = CString::new(path.as_os_str().as_bytes()) { - let rc = unsafe { libc::chown(p.as_ptr(), uid_val, gid_val) }; - if rc != 0 { - tracing::warn!( - "chown failed for {}: {}", - path.display(), - std::io::Error::last_os_error() - ); - } - } - } - - Ok(path) - } - - /// Remove the per-project temp directory created by `materialize_inline`. - pub(super) fn cleanup_temp_dir(&self) { - let dir = std::env::temp_dir().join(format!("lynx-compose-{}", self.project)); - let _ = std::fs::remove_dir_all(dir); - } -} - -// --------------------------------------------------------------------------- -// Unit tests -// --------------------------------------------------------------------------- - -#[cfg(test)] -mod tests { - use super::build_binds; - use crate::compose::types::{BindOptions, Service, VolumeMount, VolumeOptions, VolumeType}; - use std::path::Path; - - fn svc_with_volumes(vols: Vec) -> Service { - Service { - volumes: vols, - ..Default::default() - } - } - - #[test] - fn short_form_passthrough() { - let svc = svc_with_volumes(vec![VolumeMount::Short("./data:/app/data".into())]); - let binds = build_binds(&svc, Path::new("/base")); - assert_eq!(binds, vec!["./data:/app/data"]); - } - - #[test] - fn long_form_bind_read_only() { - let svc = svc_with_volumes(vec![VolumeMount::Long { - volume_type: VolumeType::Bind, - source: Some("/host/path".into()), - target: "/container/path".into(), - read_only: Some(true), - bind: None, - volume: None, - tmpfs: None, - consistency: None, - }]); - let binds = build_binds(&svc, Path::new("/base")); - assert_eq!(binds.len(), 1); - assert!(binds[0].contains("ro")); - assert!(binds[0].contains("/host/path:/container/path")); - } - - #[test] - fn long_form_bind_with_propagation() { - let svc = svc_with_volumes(vec![VolumeMount::Long { - volume_type: VolumeType::Bind, - source: Some("/host".into()), - target: "/cont".into(), - read_only: Some(false), - bind: Some(BindOptions { - propagation: Some("rshared".into()), - create_host_path: None, - selinux: None, - }), - volume: None, - tmpfs: None, - consistency: None, - }]); - let binds = build_binds(&svc, Path::new("/base")); - assert!(binds[0].contains("rshared")); - } - - #[test] - fn long_form_volume_nocopy() { - let svc = svc_with_volumes(vec![VolumeMount::Long { - volume_type: VolumeType::Volume, - source: Some("myvolume".into()), - target: "/data".into(), - read_only: None, - bind: None, - volume: Some(VolumeOptions { - nocopy: Some(true), - ..Default::default() - }), - tmpfs: None, - consistency: None, - }]); - let binds = build_binds(&svc, Path::new("/base")); - assert!(binds[0].contains("nocopy")); - } - - #[test] - fn tmpfs_type_excluded_from_binds() { - let svc = svc_with_volumes(vec![VolumeMount::Long { - volume_type: VolumeType::Tmpfs, - source: None, - target: "/run".into(), - read_only: None, - bind: None, - volume: None, - tmpfs: None, - consistency: None, - }]); - let binds = build_binds(&svc, Path::new("/base")); - assert!(binds.is_empty()); - } -} diff --git a/lynx/translators/compose/internal/engine/watch.rs b/lynx/translators/compose/internal/engine/watch.rs deleted file mode 100644 index 5cca7ea..0000000 --- a/lynx/translators/compose/internal/engine/watch.rs +++ /dev/null @@ -1,379 +0,0 @@ -//! File-watch engine for `develop: watch:` rules. -//! -//! [`Engine::watch`] sets up an `inotify`/`kqueue` watcher via `notify`, then -//! dispatches each change event to the matching [`WatchRule`]. Debouncing -//! collapses rapid bursts into a single action. Actions: -//! - `sync` — tar the changed file and upload it into the container -//! - `rebuild` — stop container, rebuild image, restart -//! - `restart` — stop and start the container without rebuilding -//! - `sync+restart` — sync first, then restart -//! - `sync+exec` — sync, then run the rule's `exec` command inside the container - -use std::path::{Path, PathBuf}; -use std::time::Duration; - -use bollard::body_full; -use bollard::container::LogOutput; -use bollard::exec::{CreateExecOptions, StartExecResults}; -use bollard::query_parameters::{ - StartContainerOptions, StopContainerOptions, UploadToContainerOptions, -}; -use bytes::Bytes; -use flate2::write::GzEncoder; -use flate2::Compression; -use futures::StreamExt; -use notify::{RecommendedWatcher, RecursiveMode, Watcher}; -use tokio::sync::mpsc; -use tracing::{debug, info, warn}; - -use crate::compose::types::{ComposeFile, WatchAction, WatchRule}; -use crate::error::{ComposeError, Result}; - -use super::Engine; - -// --------------------------------------------------------------------------- -// Rule tracking -// --------------------------------------------------------------------------- - -/// Pre-resolved watch rule: service identity + rule + absolute host path for fast matching. -struct RuleEntry { - service_name: String, - container_name: String, - rule: WatchRule, - abs_path: PathBuf, -} - -// --------------------------------------------------------------------------- -// Public watch command -// --------------------------------------------------------------------------- - -impl Engine { - pub async fn watch(&self, file: &ComposeFile) -> Result<()> { - let mut rule_entries: Vec = Vec::new(); - - for (name, service) in &file.services { - if let Some(dev) = &service.develop { - for rule in &dev.watch { - let abs = self.base_dir.join(&rule.path); - rule_entries.push(RuleEntry { - service_name: name.clone(), - container_name: self.container_name(name, service), - rule: rule.clone(), - abs_path: abs, - }); - } - } - } - - if rule_entries.is_empty() { - info!("no develop.watch rules found"); - return Ok(()); - } - - // Initial sync. - for entry in &rule_entries { - if entry.rule.initial_sync { - if let Some(target) = &entry.rule.target { - info!("initial sync {} -> {target}", entry.abs_path.display()); - if let Err(e) = self - .sync_to_container(&entry.container_name, &entry.abs_path, target) - .await - { - warn!("initial sync failed: {e}"); - } - } - } - } - - // Notify watcher with tokio channel bridge. - let (tx, mut rx) = mpsc::unbounded_channel::>(); - let mut watcher = RecommendedWatcher::new( - move |res| { - let _ = tx.send(res); - }, - notify::Config::default(), - ) - .map_err(|e| ComposeError::Watch(e.to_string()))?; - - for entry in &rule_entries { - if entry.abs_path.exists() { - watcher - .watch(&entry.abs_path, RecursiveMode::Recursive) - .map_err(|e| ComposeError::Watch(e.to_string()))?; - } else { - warn!("watch path not found: {}", entry.abs_path.display()); - } - } - - info!("watching {} rule(s) — Ctrl+C to stop", rule_entries.len()); - - let debounce = Duration::from_millis(100); - - loop { - let event = tokio::select! { - ev = rx.recv() => match ev { - Some(Ok(e)) => e, - Some(Err(e)) => { warn!("notify error: {e}"); continue; } - None => break, - }, - _ = tokio::signal::ctrl_c() => break, - }; - - // Drain events within debounce window. - let mut paths = event.paths; - let deadline = tokio::time::Instant::now() + debounce; - while let Ok(Some(Ok(e))) = tokio::time::timeout_at(deadline, rx.recv()).await { - paths.extend(e.paths); - } - - // Dispatch each changed path. - 'outer: for path in &paths { - for entry in &rule_entries { - if !path.starts_with(&entry.abs_path) { - continue; - } - - let rel = path.strip_prefix(&self.base_dir).unwrap_or(path.as_path()); - let rel_str = rel.to_string_lossy(); - - if is_ignored(&rel_str, &entry.rule.ignore) { - continue; - } - if !entry.rule.include.is_empty() && !is_included(&rel_str, &entry.rule.include) - { - continue; - } - - debug!("dispatch {:?} for {}", entry.rule.action, path.display()); - - if let Err(e) = self.dispatch_action(file, path, entry).await { - warn!("watch action failed: {e}"); - } - - continue 'outer; - } - } - } - - Ok(()) - } - - async fn dispatch_action( - &self, - file: &ComposeFile, - path: &Path, - entry: &RuleEntry, - ) -> Result<()> { - match &entry.rule.action { - WatchAction::Sync => { - if let Some(target) = &entry.rule.target { - self.sync_to_container(&entry.container_name, path, target) - .await?; - } - } - WatchAction::Rebuild => { - self.watch_rebuild(file, &entry.service_name).await?; - } - WatchAction::Restart => { - self.watch_restart(&entry.container_name).await?; - } - WatchAction::SyncAndRestart => { - if let Some(target) = &entry.rule.target { - self.sync_to_container(&entry.container_name, path, target) - .await?; - } - self.watch_restart(&entry.container_name).await?; - } - WatchAction::SyncAndExec => { - if let Some(target) = &entry.rule.target { - self.sync_to_container(&entry.container_name, path, target) - .await?; - } - if let Some(exec) = &entry.rule.exec { - self.watch_exec(&entry.container_name, exec.command.clone()) - .await?; - } - } - } - Ok(()) - } - - // ----------------------------------------------------------------------- - // Action helpers - // ----------------------------------------------------------------------- - - async fn sync_to_container(&self, container: &str, src: &Path, target: &str) -> Result<()> { - let tar_bytes = build_sync_tar(src)?; - - let dest_dir = if target.ends_with('/') { - target.to_string() - } else { - Path::new(target) - .parent() - .map(|p| p.to_string_lossy().into_owned()) - .filter(|s| !s.is_empty()) - .unwrap_or_else(|| "/".to_string()) - }; - - self.docker - .upload_to_container( - container, - Some(UploadToContainerOptions { - path: dest_dir, - no_overwrite_dir_non_dir: None, - copy_uidgid: None, - }), - body_full(Bytes::from(tar_bytes)), - ) - .await - .map_err(ComposeError::Podman)?; - - info!("synced {} -> {target}", src.display()); - Ok(()) - } - - async fn watch_rebuild(&self, file: &ComposeFile, service_name: &str) -> Result<()> { - let service = match file.services.get(service_name) { - Some(s) => s, - None => return Ok(()), - }; - info!("rebuilding {service_name}"); - self.build_service(service_name, service).await?; - let container_name = self.container_name(service_name, service); - self.create_and_start(&container_name, service_name, service, file) - .await - } - - async fn watch_restart(&self, container_name: &str) -> Result<()> { - info!("restarting {container_name}"); - let _ = self - .docker - .stop_container( - container_name, - Some(StopContainerOptions { - t: Some(5), - ..Default::default() - }), - ) - .await; - self.docker - .start_container(container_name, None::) - .await?; - Ok(()) - } - - async fn watch_exec(&self, container_name: &str, cmd: Vec) -> Result<()> { - let exec_id = self - .docker - .create_exec( - container_name, - CreateExecOptions:: { - cmd: Some(cmd), - attach_stdout: Some(true), - attach_stderr: Some(true), - ..Default::default() - }, - ) - .await? - .id; - - match self.docker.start_exec(&exec_id, None).await? { - StartExecResults::Attached { mut output, .. } => { - while let Some(msg) = output.next().await { - match msg? { - LogOutput::StdOut { message } => { - print!("{}", String::from_utf8_lossy(&message)); - } - LogOutput::StdErr { message } => { - eprint!("{}", String::from_utf8_lossy(&message)); - } - _ => {} - } - } - } - StartExecResults::Detached => {} - } - Ok(()) - } -} - -// --------------------------------------------------------------------------- -// Tar builder for sync -// --------------------------------------------------------------------------- - -fn build_sync_tar(src: &Path) -> Result> { - let encoder = GzEncoder::new(Vec::new(), Compression::default()); - let mut tar = tar::Builder::new(encoder); - - if src.is_dir() { - for entry in walkdir::WalkDir::new(src).follow_links(false) { - let entry = entry.map_err(|e| ComposeError::Io(e.into()))?; - let abs = entry.path(); - let rel = abs - .strip_prefix(src) - .map_err(|_| ComposeError::Build("path strip".into()))?; - if rel.as_os_str().is_empty() { - continue; - } - if abs.is_dir() { - tar.append_dir(rel, abs) - .map_err(|e| ComposeError::Build(e.to_string()))?; - } else { - tar.append_path_with_name(abs, rel) - .map_err(|e| ComposeError::Build(e.to_string()))?; - } - } - } else if let Some(name) = src.file_name() { - tar.append_path_with_name(src, name) - .map_err(|e| ComposeError::Build(e.to_string()))?; - } - - let gz = tar - .into_inner() - .map_err(|e| ComposeError::Build(e.to_string()))?; - let bytes = gz - .finish() - .map_err(|e| ComposeError::Build(e.to_string()))?; - Ok(bytes) -} - -// --------------------------------------------------------------------------- -// Filter helpers -// --------------------------------------------------------------------------- - -fn is_ignored(path: &str, patterns: &[String]) -> bool { - for pat in patterns { - if pat.ends_with('/') { - if path.starts_with(pat.as_str()) { - return true; - } - } else if path == pat.as_str() - || (path.starts_with(pat.as_str()) && path.as_bytes().get(pat.len()) == Some(&b'/')) - { - return true; - } - } - false -} - -fn is_included(path: &str, patterns: &[String]) -> bool { - for pat in patterns { - if pat.starts_with("*.") { - let ext = &pat[1..]; - if path.ends_with(ext) { - return true; - } - } else if pat.ends_with('/') { - if path.starts_with(pat.as_str()) { - return true; - } - } else if path == pat.as_str() - || (path.len() > pat.len() + 1 - && path.as_bytes()[path.len() - pat.len() - 1] == b'/' - && path.ends_with(pat.as_str())) - { - return true; - } - } - false -} diff --git a/lynx/translators/compose/internal/env_file.rs b/lynx/translators/compose/internal/env_file.rs deleted file mode 100644 index 571ca9e..0000000 --- a/lynx/translators/compose/internal/env_file.rs +++ /dev/null @@ -1,106 +0,0 @@ -//! `env_file:` loading for services. -//! -//! Reads KEY=VALUE pairs from files listed in a service's `env_file:` field. -//! Service-level `environment:` takes precedence over `env_file:` values. - -use std::collections::HashMap; -use std::path::Path; - -use crate::compose::types::EnvFileEntry; -use crate::error::{ComposeError, Result}; - -/// Load all `env_file` paths relative to `base_dir`. -/// -/// Returns a merged map. If the same key appears in multiple files, the -/// first file wins (earlier entries in the list have higher priority). -/// `env_file:` never overrides service-level `environment:`. -/// -/// Returns [`ComposeError::FileNotFound`] when an env file does not exist. -pub fn load_env_files(paths: &[String], base_dir: &Path) -> Result> { - let entries: Vec = paths - .iter() - .map(|p| EnvFileEntry::Path(p.clone())) - .collect(); - load_env_file_entries(&entries, base_dir) -} - -/// Load env_file entries supporting both short and long-form (with `required` and `format`). -/// -/// When `required: false`, a missing file is silently skipped instead of returning an error. -pub fn load_env_file_entries( - entries: &[EnvFileEntry], - base_dir: &Path, -) -> Result> { - let mut result: HashMap = HashMap::new(); - - for entry in entries { - if let EnvFileEntry::Config { - format: Some(fmt), .. - } = entry - { - if fmt != "dotenv" { - return Err(ComposeError::Unsupported(format!( - "env_file format '{fmt}' not supported (only 'dotenv')" - ))); - } - } - - let abs = base_dir.join(entry.path()); - let content = match std::fs::read_to_string(&abs) { - Ok(c) => c, - Err(e) if e.kind() == std::io::ErrorKind::NotFound => { - if entry.required() { - return Err(ComposeError::FileNotFound(abs.display().to_string())); - } else { - continue; - } - } - Err(e) => return Err(ComposeError::Io(e)), - }; - - for line in content.lines() { - let trimmed = line.trim(); - if trimmed.is_empty() || trimmed.starts_with('#') { - continue; - } - - let (key, value) = if let Some(eq) = trimmed.find('=') { - let k = trimmed[..eq].trim().to_string(); - let v = trimmed[eq + 1..].to_string(); - (k, v) - } else { - (trimmed.to_string(), String::new()) - }; - - if key.is_empty() { - continue; - } - - result.entry(key).or_insert(value); - } - } - - Ok(result) -} - -/// Merge env_file values with service environment. -/// -/// `service_env` takes precedence: only keys not already in `service_env` are added. -pub fn merge_env( - service_env: HashMap>, - env_file_vars: HashMap, -) -> Vec { - // Start with service env (higher priority), fill gaps from env_file. - let mut merged = service_env; - for (k, v) in env_file_vars { - merged.entry(k).or_insert(Some(v)); - } - - merged - .into_iter() - .map(|(k, v)| match v { - Some(val) => format!("{k}={val}"), - None => k, - }) - .collect() -} diff --git a/lynx/translators/compose/internal/error.rs b/lynx/translators/compose/internal/error.rs deleted file mode 100644 index aa65532..0000000 --- a/lynx/translators/compose/internal/error.rs +++ /dev/null @@ -1,57 +0,0 @@ -//! Error types for the lynx-compose library. -//! -//! All fallible operations return [`Result`], which is an alias for -//! `std::result::Result`. - -use thiserror::Error; - -/// All errors produced by lynx-compose. -#[derive(Debug, Error)] -pub enum ComposeError { - #[error("failed to parse compose file: {0}")] - Parse(#[from] serde_yaml::Error), - - #[error("compose file not found: {0}")] - FileNotFound(String), - - #[error("io error: {0}")] - Io(#[from] std::io::Error), - - #[error("podman error: {0}")] - Podman(#[from] bollard::errors::Error), - - #[error("service '{0}' not found")] - ServiceNotFound(String), - - #[error("circular dependency detected: {0}")] - CircularDependency(String), - - #[error("service '{0}' has no image or build config")] - NoImageOrBuild(String), - - #[error("required variable '{var}' is not set: {msg}")] - RequiredVarNotSet { var: String, msg: String }, - - #[error("health check timeout for container '{0}'")] - HealthCheckTimeout(String), - - #[error("invalid port mapping: {0}")] - InvalidPort(String), - - #[error("build error: {0}")] - Build(String), - - #[error("extends error: {0}")] - Extends(String), - - #[error("include error: {0}")] - Include(String), - - #[error("watch error: {0}")] - Watch(String), - - #[error("unsupported feature: {0}")] - Unsupported(String), -} - -pub type Result = std::result::Result; diff --git a/lynx/translators/compose/internal/lib.rs b/lynx/translators/compose/internal/lib.rs deleted file mode 100644 index 5949356..0000000 --- a/lynx/translators/compose/internal/lib.rs +++ /dev/null @@ -1,18 +0,0 @@ -//! `lynx-compose` — docker-compose → Podman translator library. -//! -//! Provides parsing, variable substitution, topological ordering, and an -//! async engine that drives container lifecycle via Podman's Docker-compatible -//! REST API (bollard). - -pub mod compose; -pub mod engine; -pub mod env_file; -pub mod error; -pub mod podman; -pub mod ports; -pub mod size; -pub mod substitute; - -pub use compose::{parse_file, parse_str, parse_str_raw, resolve_order}; -pub use engine::Engine; -pub use error::{ComposeError, Result}; diff --git a/lynx/translators/compose/internal/main.rs b/lynx/translators/compose/internal/main.rs deleted file mode 100644 index 5c7c07b..0000000 --- a/lynx/translators/compose/internal/main.rs +++ /dev/null @@ -1,150 +0,0 @@ -//! `lynx-compose` — docker-compose to Podman translator CLI. - -use clap::{Parser, Subcommand}; -use std::path::PathBuf; -use tracing_subscriber::EnvFilter; - -#[derive(Parser)] -#[command( - name = "lynx-compose", - version, - about = "docker-compose translator for Podman" -)] -struct Cli { - /// Path to the compose file. - #[arg(short, long, default_value = "docker-compose.yml")] - file: PathBuf, - - /// Project name (used as a prefix for container names). - #[arg(short, long, default_value = "lynx")] - project: String, - - /// Podman socket path (overrides auto-detection and PODMAN_SOCKET env). - #[arg(long, env = "PODMAN_SOCKET")] - socket: Option, - - /// Active profiles (comma-separated). May also be set via `COMPOSE_PROFILES`. - #[arg(long, value_delimiter = ',', global = true)] - profile: Vec, - - #[command(subcommand)] - command: Commands, -} - -#[derive(Subcommand)] -enum Commands { - /// Create and start all services. - Up { - /// Run containers in the background. - #[arg(short, long)] - detach: bool, - /// Watch for file changes and sync/rebuild/restart per develop.watch rules. - #[arg(short, long)] - watch: bool, - /// Remove containers for services not defined in the compose file. - #[arg(long)] - remove_orphans: bool, - /// Do not recreate containers that are already running. - #[arg(long)] - no_recreate: bool, - /// Bring up only these services (and their transitive depends_on). - /// If omitted, brings up every service in the compose file. - #[arg(trailing_var_arg = true)] - services: Vec, - }, - /// Stop and remove containers. - Down { - /// Also remove named volumes declared in the compose file. - #[arg(short = 'v', long)] - volumes: bool, - }, - /// List containers. - Ps, - /// View output from containers. - Logs { - /// Only show logs for this service. - service: Option, - /// Follow log output. - #[arg(short, long)] - follow: bool, - }, - /// Execute a command in a running service container. - Exec { - /// Service name. - service: String, - /// Command (and arguments) to execute. - #[arg(trailing_var_arg = true, allow_hyphen_values = true)] - cmd: Vec, - }, - /// Pull images for all services. - Pull, - /// Restart services. - Restart { - /// Only restart this service. - service: Option, - }, - /// Print the resolved compose file (after substitution / extends / include). - Config, - /// Watch for file changes and sync/rebuild/restart as configured by develop.watch. - Watch, -} - -#[tokio::main] -async fn main() -> anyhow::Result<()> { - tracing_subscriber::fmt() - .with_env_filter(EnvFilter::from_default_env()) - .init(); - - let cli = Cli::parse(); - - let file = lynx_compose::parse_file(&cli.file)?; - - // The `config` command does not need a Podman connection. - if matches!(cli.command, Commands::Config) { - let yaml = serde_yaml::to_string(&file)?; - println!("{yaml}"); - return Ok(()); - } - - let docker = lynx_compose::podman::connect(cli.socket.as_deref())?; - let base_dir = cli - .file - .parent() - .map(|p| p.to_path_buf()) - .unwrap_or_default(); - let engine = lynx_compose::Engine::with_base_dir(docker, cli.project, base_dir); - - match cli.command { - Commands::Up { - detach, - watch, - remove_orphans, - no_recreate, - services, - } => { - if remove_orphans { - engine.remove_orphans(&file).await?; - } - engine - .up_with_options(&file, detach, &cli.profile, &services, no_recreate) - .await?; - if watch { - engine.watch(&file).await?; - } else if !detach { - engine.attach_logs(&file).await?; - } - } - Commands::Down { volumes } => engine.down_with_options(&file, volumes).await?, - Commands::Ps => engine.ps(&file).await?, - Commands::Logs { service, follow } => { - engine.logs(&file, service.as_deref(), follow).await? - } - Commands::Exec { service, cmd } => engine.exec(&file, &service, cmd).await?, - Commands::Pull => engine.pull(&file).await?, - Commands::Restart { service } => engine.restart(&file, service.as_deref()).await?, - Commands::Config => unreachable!("handled above"), - Commands::Watch => engine.watch(&file).await?, - } - - Ok(()) -} diff --git a/lynx/translators/compose/internal/podman/mod.rs b/lynx/translators/compose/internal/podman/mod.rs deleted file mode 100644 index 5dc446e..0000000 --- a/lynx/translators/compose/internal/podman/mod.rs +++ /dev/null @@ -1,38 +0,0 @@ -//! Podman socket connection helpers. - -use crate::error::Result; -use bollard::Docker; - -const DEFAULT_PODMAN_SOCKET: &str = "/run/podman/podman.sock"; - -/// Connect to Podman's Docker-compatible socket. -/// -/// Priority: -/// 1. `socket_path` if provided. -/// 2. `/run/podman/podman.sock` when running as root. -/// 3. `/run/user//podman/podman.sock` for non-root users. -pub fn connect(socket_path: Option<&str>) -> Result { - let default_path = default_socket_path(); - let path = socket_path.unwrap_or(&default_path); - let client = Docker::connect_with_unix(path, 120, bollard::API_DEFAULT_VERSION)?; - Ok(client) -} - -fn default_socket_path() -> String { - let uid = unsafe { libc::getuid() }; - if uid == 0 { - DEFAULT_PODMAN_SOCKET.to_string() - } else { - format!("/run/user/{uid}/podman/podman.sock") - } -} - -/// Connect using `PODMAN_SOCKET` or `DOCKER_HOST` environment variables. -pub fn connect_from_env() -> Result { - let socket = std::env::var("PODMAN_SOCKET") - .or_else(|_| std::env::var("DOCKER_HOST")) - .ok(); - - let path = socket.as_deref().and_then(|s| s.strip_prefix("unix://")); - connect(path) -} diff --git a/lynx/translators/compose/internal/ports.rs b/lynx/translators/compose/internal/ports.rs deleted file mode 100644 index bcbce8c..0000000 --- a/lynx/translators/compose/internal/ports.rs +++ /dev/null @@ -1,259 +0,0 @@ -//! Port mapping parser. -//! -//! Handles all docker-compose port format variants and converts them to -//! bollard's `PortBinding` structures. - -use bollard::models::PortBinding; -use std::collections::HashMap; - -use crate::compose::types::{PortMapping, StringOrU16}; -use crate::error::{ComposeError, Result}; - -/// A parsed, normalized port binding. -#[derive(Debug, Clone)] -pub struct ParsedPort { - /// Container port number. - pub container_port: u16, - /// Protocol (`tcp`, `udp`, `sctp`). - pub protocol: String, - /// Host IP (may be empty to mean all interfaces). - pub host_ip: String, - /// Host port (`None` means random / ephemeral; `Some(0)` means runtime-assigned). - pub host_port: Option, -} - -/// Parse all port mappings in a service, expanding ranges. -pub fn parse_ports(ports: &[PortMapping]) -> Result> { - let mut result = Vec::new(); - for mapping in ports { - result.extend(parse_one(mapping)?); - } - Ok(result) -} - -/// Convert parsed ports into bollard's `PortBindings` and `ExposedPorts` maps. -/// -/// Returns `(port_bindings, exposed_ports)`. Port 0 is encoded as an empty -/// host_port string per the Docker API convention for "auto-assign". -#[allow(clippy::type_complexity)] -pub fn to_bollard( - ports: &[ParsedPort], -) -> ( - HashMap>>, - HashMap>, -) { - let mut port_bindings: HashMap>> = HashMap::new(); - let mut exposed_ports: HashMap> = HashMap::new(); - - for p in ports { - let key = format!("{}/{}", p.container_port, p.protocol); - let host_ip = if p.host_ip.is_empty() { - "0.0.0.0".to_string() - } else { - p.host_ip.clone() - }; - let host_port = match p.host_port { - Some(0) => Some(String::new()), - Some(n) => Some(n.to_string()), - None => None, - }; - let binding = PortBinding { - host_ip: Some(host_ip), - host_port, - }; - let bindings = port_bindings - .entry(key.clone()) - .or_insert_with(|| Some(Vec::new())); - if let Some(v) = bindings { - v.push(binding); - } - exposed_ports.entry(key).or_default(); - } - - (port_bindings, exposed_ports) -} - -// --------------------------------------------------------------------------- -// Internal -// --------------------------------------------------------------------------- - -fn parse_one(mapping: &PortMapping) -> Result> { - match mapping { - PortMapping::Short(s) => parse_short(s), - PortMapping::Long { - target, - published, - protocol, - host_ip, - .. - } => { - let proto = protocol.clone().unwrap_or_else(|| "tcp".into()); - let hip = host_ip.clone().unwrap_or_default(); - let host_port = published - .as_ref() - .map(|p| match p { - StringOrU16::Number(n) => Ok(*n), - StringOrU16::String(s) => s.parse::().map_err(|_| { - ComposeError::InvalidPort(format!("invalid published port: {s}")) - }), - }) - .transpose()?; - Ok(vec![ParsedPort { - container_port: *target, - protocol: proto, - host_ip: hip, - host_port, - }]) - } - } -} - -/// Parse a short-form port string. -/// -/// Formats: -/// - `container` -/// - `container/proto` -/// - `host:container` -/// - `host:container/proto` -/// - `ip:host:container` (ip may be IPv4 or `[ipv6]`) -/// - `ip:host:container/proto` -/// - `host_start-host_end:container_start-container_end` -fn parse_short(s: &str) -> Result> { - // Split off protocol suffix. - let (rest, proto) = if let Some(idx) = s.rfind('/') { - (&s[..idx], s[idx + 1..].to_string()) - } else { - (s, "tcp".to_string()) - }; - - // IPv6 form: `[::1]:host:container` or `[::1]:container`. - if let Some(rest) = rest.strip_prefix('[') { - let close = rest - .find(']') - .ok_or_else(|| ComposeError::InvalidPort(format!("unclosed `[` in {s}")))?; - let ip = &rest[..close]; - let after = &rest[close + 1..]; - let after = after.strip_prefix(':').unwrap_or(after); - return parse_with_ip(ip, after, &proto, s); - } - - // Count colons to determine format. - let colon_count = rest.chars().filter(|&c| c == ':').count(); - - match colon_count { - 0 => { - // Just container port (possibly a range). - let ports = expand_port_range(rest)?; - Ok(ports - .into_iter() - .map(|cp| ParsedPort { - container_port: cp, - protocol: proto.clone(), - host_ip: String::new(), - host_port: None, - }) - .collect()) - } - 1 => { - let (left, right) = split_last_colon(rest); - let host_ports = expand_port_range(left)?; - let container_ports = expand_port_range(right)?; - if host_ports.len() != container_ports.len() && host_ports.len() != 1 { - return Err(ComposeError::InvalidPort(format!( - "port range mismatch: {s}" - ))); - } - Ok(host_ports - .into_iter() - .zip(container_ports) - .map(|(hp, cp)| ParsedPort { - container_port: cp, - protocol: proto.clone(), - host_ip: String::new(), - host_port: Some(hp), - }) - .collect()) - } - _ => { - let parts: Vec<&str> = rest.splitn(3, ':').collect(); - if parts.len() < 3 { - return Err(ComposeError::InvalidPort(format!("invalid port spec: {s}"))); - } - parse_with_ip(parts[0], &format!("{}:{}", parts[1], parts[2]), &proto, s) - } - } -} - -/// Parse the `host[:container]` portion when an explicit IP prefix is present. -fn parse_with_ip(ip: &str, after: &str, proto: &str, full: &str) -> Result> { - if let Some((left, right)) = after.split_once(':') { - let host_ports = expand_port_range(left)?; - let container_ports = expand_port_range(right)?; - if host_ports.len() != container_ports.len() && host_ports.len() != 1 { - return Err(ComposeError::InvalidPort(format!( - "port range mismatch: {full}" - ))); - } - Ok(host_ports - .into_iter() - .zip(container_ports) - .map(|(hp, cp)| ParsedPort { - container_port: cp, - protocol: proto.to_string(), - host_ip: ip.to_string(), - host_port: Some(hp), - }) - .collect()) - } else { - let cp: u16 = after - .parse() - .map_err(|_| ComposeError::InvalidPort(format!("bad port: {full}")))?; - Ok(vec![ParsedPort { - container_port: cp, - protocol: proto.to_string(), - host_ip: ip.to_string(), - host_port: None, - }]) - } -} - -/// Split at the LAST colon (to avoid splitting IPv6 addresses incorrectly). -fn split_last_colon(s: &str) -> (&str, &str) { - if let Some(idx) = s.rfind(':') { - (&s[..idx], &s[idx + 1..]) - } else { - ("", s) - } -} - -const MAX_PORT_RANGE: usize = 1024; - -/// Expand `start-end` or a single port string. -fn expand_port_range(s: &str) -> Result> { - let s = s.trim(); - if let Some(idx) = s.find('-') { - let start: u16 = s[..idx] - .parse() - .map_err(|_| ComposeError::InvalidPort(format!("bad port: {s}")))?; - let end: u16 = s[idx + 1..] - .parse() - .map_err(|_| ComposeError::InvalidPort(format!("bad port: {s}")))?; - if start > end { - return Err(ComposeError::InvalidPort(format!( - "start > end in range: {s}" - ))); - } - let count = (end as usize) - (start as usize) + 1; - if count > MAX_PORT_RANGE { - return Err(ComposeError::InvalidPort(format!( - "port range too large ({count} ports, max {MAX_PORT_RANGE}): {s}" - ))); - } - Ok((start..=end).collect()) - } else { - let p: u16 = s - .parse() - .map_err(|_| ComposeError::InvalidPort(format!("bad port: {s}")))?; - Ok(vec![p]) - } -} diff --git a/lynx/translators/compose/internal/size.rs b/lynx/translators/compose/internal/size.rs deleted file mode 100644 index 9d3c845..0000000 --- a/lynx/translators/compose/internal/size.rs +++ /dev/null @@ -1,101 +0,0 @@ -//! Memory and CPU value parsers shared by the engine and tests. - -/// Parse a memory string like `128m`, `1G`, `1024k`, `512b`, or a bare -/// number-of-bytes. -/// -/// Recognised suffixes (case-insensitive): `b`, `k`/`kb`, `m`/`mb`, -/// `g`/`gb`, `t`/`tb`. Returns `None` for unparseable values, and `Some(-1)` -/// for the special string `"-1"` (commonly used to disable swap limits). -pub fn parse_memory(s: &str) -> Option { - let trimmed = s.trim(); - if trimmed.is_empty() { - return None; - } - if trimmed == "-1" { - return Some(-1); - } - - // Find where the numeric portion ends. - let split_at = trimmed - .find(|c: char| !(c.is_ascii_digit() || c == '.' || c == '-')) - .unwrap_or(trimmed.len()); - - let (num_part, suffix) = trimmed.split_at(split_at); - let num_part = num_part.trim(); - let suffix = suffix.trim().to_ascii_lowercase(); - - let num: f64 = num_part.parse().ok()?; - if num < 0.0 { - return None; - } - - let multiplier: u64 = match suffix.as_str() { - "" | "b" => 1, - "k" | "kb" => 1024, - "m" | "mb" => 1024 * 1024, - "g" | "gb" => 1024 * 1024 * 1024, - "t" | "tb" => 1024_u64 * 1024 * 1024 * 1024, - _ => return None, - }; - - let bytes = num * multiplier as f64; - if bytes > i64::MAX as f64 { - return None; - } - Some(bytes as i64) -} - -/// Parse a CPU count string like `"0.5"`, `"1"`, `"2.5"` into nano-CPUs -/// (1 CPU = 1_000_000_000 nano-CPUs). -pub fn parse_cpus(s: &str) -> Option { - s.trim().parse::().ok().map(|f| (f * 1e9) as i64) -} - -/// Parse a duration like `5s`, `200ms`, `1m`, `1h` into seconds (rounded down). -/// -/// This is a best-effort parser used when the engine needs an integer -/// seconds value (e.g. Docker API `start_period`). -pub fn parse_duration_secs(s: &str) -> Option { - let trimmed = s.trim(); - if trimmed.is_empty() { - return None; - } - let split_at = trimmed - .find(|c: char| !(c.is_ascii_digit() || c == '.')) - .unwrap_or(trimmed.len()); - let (num_part, suffix) = trimmed.split_at(split_at); - let num: f64 = num_part.parse().ok()?; - let secs = match suffix.trim() { - "" | "s" => num, - "ms" => num / 1000.0, - "us" | "µs" => num / 1_000_000.0, - "ns" => num / 1_000_000_000.0, - "m" => num * 60.0, - "h" => num * 3600.0, - _ => return None, - }; - Some(secs as u64) -} - -/// Parse a duration into nanoseconds (used by Docker healthcheck APIs). -pub fn parse_duration_nanos(s: &str) -> Option { - let trimmed = s.trim(); - if trimmed.is_empty() { - return None; - } - let split_at = trimmed - .find(|c: char| !(c.is_ascii_digit() || c == '.')) - .unwrap_or(trimmed.len()); - let (num_part, suffix) = trimmed.split_at(split_at); - let num: f64 = num_part.parse().ok()?; - let nanos = match suffix.trim() { - "" | "s" => num * 1e9, - "ms" => num * 1e6, - "us" | "µs" => num * 1e3, - "ns" => num, - "m" => num * 60.0 * 1e9, - "h" => num * 3600.0 * 1e9, - _ => return None, - }; - Some(nanos as i64) -} diff --git a/lynx/translators/compose/internal/substitute.rs b/lynx/translators/compose/internal/substitute.rs deleted file mode 100644 index 68e722a..0000000 --- a/lynx/translators/compose/internal/substitute.rs +++ /dev/null @@ -1,319 +0,0 @@ -//! Docker Compose variable substitution. -//! -//! Applies `${VAR}` / `$VAR` substitution to raw YAML text before parsing. -//! Handles all compose-spec modifier forms: `:-`, `-`, `:+`, `+`, `:?`, `?`. - -use std::collections::HashMap; -use std::path::Path; - -use crate::error::{ComposeError, Result}; - -// --------------------------------------------------------------------------- -// Public API -// --------------------------------------------------------------------------- - -/// Substitute all `$VAR` / `${VAR}` references in `input` using `vars`. -/// -/// `vars` should contain both the process environment and the `.env` file -/// entries (process environment takes precedence). -pub fn substitute(input: &str, vars: &HashMap) -> Result { - let mut out = String::with_capacity(input.len()); - let mut chars = input.chars().peekable(); - - while let Some(ch) = chars.next() { - if ch != '$' { - out.push(ch); - continue; - } - - match chars.peek() { - None => { - // Trailing bare `$` — keep as-is. - out.push('$'); - } - Some('$') => { - // `$$` → literal `$` - chars.next(); - out.push('$'); - } - Some('{') => { - // Consume `{` - chars.next(); - let (var, modifier) = parse_braced_var(&mut chars)?; - let value = resolve_modifier(var, modifier, vars)?; - out.push_str(&value); - } - Some(c) if is_var_start(*c) => { - // Bare `$VAR` - let var = collect_var_name(&mut chars); - let value = vars.get(&var).cloned().unwrap_or_default(); - out.push_str(&value); - } - Some(_) => { - // Not a variable — keep the `$` - out.push('$'); - } - } - } - - Ok(out) -} - -/// Load a `.env` file from `dir`. -/// -/// - Lines starting with `#` are comments and are skipped. -/// - Empty / whitespace-only lines are skipped. -/// - `KEY=VALUE` sets KEY to VALUE (quotes are preserved as-is, matching compose-spec). -/// - `KEY` without `=` sets KEY to empty string. -/// - Process environment variables take precedence: if a key already exists in -/// the current process env it will *not* be overridden by the `.env` file. -pub fn load_dotenv(dir: &Path) -> HashMap { - let path = dir.join(".env"); - let Ok(content) = std::fs::read_to_string(&path) else { - return HashMap::new(); - }; - - let mut map = HashMap::new(); - for line in content.lines() { - let trimmed = line.trim(); - if trimmed.is_empty() || trimmed.starts_with('#') { - continue; - } - let (key, value) = if let Some(eq) = trimmed.find('=') { - let k = trimmed[..eq].trim().to_string(); - let v = trimmed[eq + 1..].to_string(); - (k, v) - } else { - (trimmed.to_string(), String::new()) - }; - - if key.is_empty() { - continue; - } - - // Process env takes precedence. - if std::env::var(&key).is_ok() { - continue; - } - - map.insert(key, value); - } - - map -} - -/// Build the full variable map: process env + dotenv (process env wins). -pub fn build_vars(dir: &Path) -> HashMap { - let mut vars: HashMap = std::env::vars().collect(); - // Merge dotenv; only insert keys not already present. - for (k, v) in load_dotenv(dir) { - vars.entry(k).or_insert(v); - } - vars -} - -/// Build vars additionally loading explicit env files (process env + dotenv + extra files). -/// -/// Extra files are loaded after dotenv; process env still wins for all keys. -pub fn build_vars_with_env_files(dir: &Path, extra: &[String]) -> HashMap { - let mut vars = build_vars(dir); - for path in extra { - let abs = if std::path::Path::new(path).is_absolute() { - std::path::PathBuf::from(path) - } else { - dir.join(path) - }; - let Ok(content) = std::fs::read_to_string(&abs) else { - continue; - }; - for line in content.lines() { - let trimmed = line.trim(); - if trimmed.is_empty() || trimmed.starts_with('#') { - continue; - } - let (key, value) = if let Some(eq) = trimmed.find('=') { - ( - trimmed[..eq].trim().to_string(), - trimmed[eq + 1..].to_string(), - ) - } else { - (trimmed.to_string(), String::new()) - }; - if !key.is_empty() { - vars.entry(key).or_insert(value); - } - } - } - vars -} - -// --------------------------------------------------------------------------- -// Internal helpers -// --------------------------------------------------------------------------- - -fn is_var_start(c: char) -> bool { - c.is_alphabetic() || c == '_' -} - -fn is_var_char(c: char) -> bool { - c.is_alphanumeric() || c == '_' -} - -/// Collect a bare variable name (alphanumeric + `_`). -fn collect_var_name(chars: &mut std::iter::Peekable>) -> String { - let mut name = String::new(); - while let Some(&c) = chars.peek() { - if is_var_char(c) { - name.push(c); - chars.next(); - } else { - break; - } - } - name -} - -#[derive(Debug)] -enum Modifier { - None, - /// `${VAR:-default}` — use default if unset or empty - DefaultIfUnsetOrEmpty(String), - /// `${VAR-default}` — use default if unset (empty value is OK) - DefaultIfUnset(String), - /// `${VAR:+value}` — use value if set and non-empty - AltIfSetAndNonEmpty(String), - /// `${VAR+value}` — use value if set (even if empty) - AltIfSet(String), - /// `${VAR:?error}` — error if unset or empty - ErrorIfUnsetOrEmpty(String), - /// `${VAR?error}` — error if unset - ErrorIfUnset(String), -} - -/// Parse the content inside `${…}`. The opening `{` has already been consumed. -/// -/// Returns `(variable_name, Modifier)`. -fn parse_braced_var( - chars: &mut std::iter::Peekable>, -) -> Result<(String, Modifier)> { - let mut name = String::new(); - - // Read until we hit `}`, `:`, `+`, `-`, `?`, or end-of-input. - loop { - match chars.peek() { - None => { - // Unclosed brace — treat as literal. - return Ok((name, Modifier::None)); - } - Some('}') => { - chars.next(); - return Ok((name, Modifier::None)); - } - Some(':') => { - chars.next(); - // Peek at what follows `:`. - let modifier = match chars.peek() { - Some('-') => { - chars.next(); - Modifier::DefaultIfUnsetOrEmpty(collect_until_close(chars)) - } - Some('+') => { - chars.next(); - Modifier::AltIfSetAndNonEmpty(collect_until_close(chars)) - } - Some('?') => { - chars.next(); - Modifier::ErrorIfUnsetOrEmpty(collect_until_close(chars)) - } - _ => { - // `${VAR:` with unknown char — collect default anyway. - Modifier::DefaultIfUnsetOrEmpty(collect_until_close(chars)) - } - }; - return Ok((name, modifier)); - } - Some('-') => { - chars.next(); - return Ok((name, Modifier::DefaultIfUnset(collect_until_close(chars)))); - } - Some('+') => { - chars.next(); - return Ok((name, Modifier::AltIfSet(collect_until_close(chars)))); - } - Some('?') => { - chars.next(); - return Ok((name, Modifier::ErrorIfUnset(collect_until_close(chars)))); - } - Some(&c) => { - name.push(c); - chars.next(); - } - } - } -} - -/// Collect everything until `}` (exclusive), consuming the `}`. -fn collect_until_close(chars: &mut std::iter::Peekable>) -> String { - let mut buf = String::new(); - for c in chars.by_ref() { - if c == '}' { - break; - } - buf.push(c); - } - buf -} - -fn resolve_modifier( - var: String, - modifier: Modifier, - vars: &HashMap, -) -> Result { - let value = vars.get(&var); - - match modifier { - Modifier::None => Ok(value.cloned().unwrap_or_default()), - - Modifier::DefaultIfUnsetOrEmpty(default) => { - // Use default when unset OR empty. - match value { - Some(v) if !v.is_empty() => Ok(v.clone()), - _ => Ok(default), - } - } - - Modifier::DefaultIfUnset(default) => { - // Use default only when unset. - match value { - Some(v) => Ok(v.clone()), - None => Ok(default), - } - } - - Modifier::AltIfSetAndNonEmpty(alt) => { - // Use alt when set and non-empty; else empty. - match value { - Some(v) if !v.is_empty() => Ok(alt), - _ => Ok(String::new()), - } - } - - Modifier::AltIfSet(alt) => { - // Use alt when set (even if empty); else empty. - match value { - Some(_) => Ok(alt), - None => Ok(String::new()), - } - } - - Modifier::ErrorIfUnsetOrEmpty(msg) => match value { - Some(v) if !v.is_empty() => Ok(v.clone()), - _ => Err(ComposeError::RequiredVarNotSet { var, msg }), - }, - - Modifier::ErrorIfUnset(msg) => match value { - Some(v) => Ok(v.clone()), - None => Err(ComposeError::RequiredVarNotSet { var, msg }), - }, - } -} diff --git a/lynx/translators/compose/tests/env_file.rs b/lynx/translators/compose/tests/env_file.rs deleted file mode 100644 index 17ea695..0000000 --- a/lynx/translators/compose/tests/env_file.rs +++ /dev/null @@ -1,4 +0,0 @@ -#[path = "env_file/loading.rs"] -mod loading; -#[path = "env_file/merge.rs"] -mod merge; diff --git a/lynx/translators/compose/tests/env_file/loading.rs b/lynx/translators/compose/tests/env_file/loading.rs deleted file mode 100644 index e44fcdf..0000000 --- a/lynx/translators/compose/tests/env_file/loading.rs +++ /dev/null @@ -1,77 +0,0 @@ -use lynx_compose::env_file::load_env_files; -use lynx_compose::ComposeError; -use std::io::Write; - -#[test] -fn basic_key_value() { - let dir = tempfile::tempdir().unwrap(); - let mut f = std::fs::File::create(dir.path().join("app.env")).unwrap(); - writeln!(f, "# comment").unwrap(); - writeln!(f).unwrap(); - writeln!(f, "DB_HOST=localhost").unwrap(); - writeln!(f, "PORT=5432").unwrap(); - writeln!(f, "NOVALUE").unwrap(); - - let map = load_env_files(&["app.env".to_string()], dir.path()).unwrap(); - assert_eq!(map["DB_HOST"], "localhost"); - assert_eq!(map["PORT"], "5432"); - assert_eq!(map["NOVALUE"], ""); -} - -#[test] -fn string_or_list_single() { - use lynx_compose::compose::types::StringOrList; - assert_eq!( - StringOrList::Single("file.env".to_string()).to_list(), - vec!["file.env"] - ); -} - -#[test] -fn string_or_list_many() { - use lynx_compose::compose::types::StringOrList; - let sol = StringOrList::List(vec!["a.env".to_string(), "b.env".to_string()]); - assert_eq!(sol.to_list().len(), 2); -} - -#[test] -fn first_file_wins_for_duplicate_keys() { - let dir = tempfile::tempdir().unwrap(); - - let mut a = std::fs::File::create(dir.path().join("a.env")).unwrap(); - writeln!(a, "KEY=from_a").unwrap(); - - let mut b = std::fs::File::create(dir.path().join("b.env")).unwrap(); - writeln!(b, "KEY=from_b").unwrap(); - - let map = load_env_files(&["a.env".to_string(), "b.env".to_string()], dir.path()).unwrap(); - assert_eq!(map["KEY"], "from_a"); -} - -#[test] -fn quoted_values_preserved_as_is() { - let dir = tempfile::tempdir().unwrap(); - let mut f = std::fs::File::create(dir.path().join("q.env")).unwrap(); - writeln!(f, r#"KEY="value with spaces""#).unwrap(); - - let map = load_env_files(&["q.env".to_string()], dir.path()).unwrap(); - // Per compose-spec, quotes are preserved literally. - assert_eq!(map["KEY"], "\"value with spaces\""); -} - -#[test] -fn value_with_equals_sign() { - let dir = tempfile::tempdir().unwrap(); - let mut f = std::fs::File::create(dir.path().join("eq.env")).unwrap(); - writeln!(f, "KEY=val=ue=more").unwrap(); - - let map = load_env_files(&["eq.env".to_string()], dir.path()).unwrap(); - assert_eq!(map["KEY"], "val=ue=more"); -} - -#[test] -fn missing_env_file_errors() { - let dir = tempfile::tempdir().unwrap(); - let result = load_env_files(&["does_not_exist.env".to_string()], dir.path()); - assert!(matches!(result, Err(ComposeError::FileNotFound(_)))); -} diff --git a/lynx/translators/compose/tests/env_file/merge.rs b/lynx/translators/compose/tests/env_file/merge.rs deleted file mode 100644 index a3bb2b8..0000000 --- a/lynx/translators/compose/tests/env_file/merge.rs +++ /dev/null @@ -1,34 +0,0 @@ -use lynx_compose::env_file::merge_env; -use std::collections::HashMap; - -#[test] -fn service_env_wins_over_env_file() { - let mut service_env = HashMap::new(); - service_env.insert("KEY".to_string(), Some("from-service".to_string())); - - let mut env_file_vars = HashMap::new(); - env_file_vars.insert("KEY".to_string(), "from-file".to_string()); - env_file_vars.insert("EXTRA".to_string(), "extra".to_string()); - - let merged = merge_env(service_env, env_file_vars); - let map: HashMap<_, _> = merged - .iter() - .filter_map(|s| { - let mut it = s.splitn(2, '='); - Some((it.next()?.to_string(), it.next()?.to_string())) - }) - .collect(); - - assert_eq!(map["KEY"], "from-service"); - assert_eq!(map["EXTRA"], "extra"); -} - -#[test] -fn env_file_only_key_included() { - let service_env = HashMap::new(); - let mut env_file_vars = HashMap::new(); - env_file_vars.insert("FROM_FILE".to_string(), "yes".to_string()); - - let merged = merge_env(service_env, env_file_vars); - assert!(merged.iter().any(|s| s.starts_with("FROM_FILE="))); -} diff --git a/lynx/translators/compose/tests/parse.rs b/lynx/translators/compose/tests/parse.rs deleted file mode 100644 index 494ab0b..0000000 --- a/lynx/translators/compose/tests/parse.rs +++ /dev/null @@ -1,14 +0,0 @@ -#[path = "parse/anchors.rs"] -mod anchors; -#[path = "parse/basic.rs"] -mod basic; -#[path = "parse/coverage.rs"] -mod coverage; -#[path = "parse/extends.rs"] -mod extends; -#[path = "parse/fields.rs"] -mod fields; -#[path = "parse/include.rs"] -mod include; -#[path = "parse/order.rs"] -mod order; diff --git a/lynx/translators/compose/tests/parse/anchors.rs b/lynx/translators/compose/tests/parse/anchors.rs deleted file mode 100644 index 5a481fc..0000000 --- a/lynx/translators/compose/tests/parse/anchors.rs +++ /dev/null @@ -1,140 +0,0 @@ -use lynx_compose::parse_file; -use lynx_compose::parse_str; -use std::io::Write; - -#[test] -fn yaml_anchor_and_alias() { - let yaml = r#" -x-common: &common - image: alpine - restart: always - environment: - LOG_LEVEL: info - -services: - web: - <<: *common - api: - <<: *common - image: node:20 -"#; - let file = parse_str(yaml).unwrap(); - - // web inherits image and environment from anchor. - assert_eq!(file.services["web"].image.as_deref(), Some("alpine")); - assert!(file.services["web"] - .environment - .to_map() - .contains_key("LOG_LEVEL")); - - // api overrides image, keeps environment. - assert_eq!(file.services["api"].image.as_deref(), Some("node:20")); - assert!(file.services["api"] - .environment - .to_map() - .contains_key("LOG_LEVEL")); -} - -#[test] -fn yaml_anchor_passthrough_for_environment() { - let yaml = r#" -x-env: &env - NODE_ENV: production - PORT: "3000" - -services: - app: - image: node - environment: *env -"#; - let file = parse_str(yaml).unwrap(); - let env = file.services["app"].environment.to_map(); - assert_eq!( - env.get("NODE_ENV").and_then(|v| v.clone()).as_deref(), - Some("production") - ); - assert_eq!( - env.get("PORT").and_then(|v| v.clone()).as_deref(), - Some("3000") - ); -} - -#[test] -fn yaml_merge_key_sequence_of_anchors() { - let yaml = r#" -x-a: &a - restart: always -x-b: &b - environment: - FROM_B: "yes" - -services: - app: - image: alpine - <<: [*a, *b] -"#; - let file = parse_str(yaml).unwrap(); - assert_eq!( - file.services["app"] - .restart - .as_ref() - .map(|r| format!("{r:?}")), - Some("Always".to_string()) - ); - assert!(file.services["app"] - .environment - .to_map() - .contains_key("FROM_B")); -} - -#[test] -fn include_absolute_path_rejected() { - let dir = tempfile::tempdir().unwrap(); - let main = dir.path().join("docker-compose.yml"); - writeln!( - std::fs::File::create(&main).unwrap(), - "{}", - "include:\n - /etc/passwd\nservices:\n app:\n image: alpine" - ) - .unwrap(); - assert!(parse_file(&main).is_err()); -} - -#[test] -fn include_parent_traversal_rejected() { - let dir = tempfile::tempdir().unwrap(); - let main = dir.path().join("docker-compose.yml"); - writeln!( - std::fs::File::create(&main).unwrap(), - "{}", - "include:\n - ../../secret.yml\nservices:\n app:\n image: alpine" - ) - .unwrap(); - assert!(parse_file(&main).is_err()); -} - -#[test] -fn extends_file_absolute_path_rejected() { - let dir = tempfile::tempdir().unwrap(); - let main = dir.path().join("docker-compose.yml"); - writeln!( - std::fs::File::create(&main).unwrap(), - "{}", - "services:\n app:\n extends:\n service: base\n file: /etc/shadow" - ) - .unwrap(); - assert!(parse_file(&main).is_err()); -} - -#[test] -fn extends_file_parent_traversal_rejected() { - let dir = tempfile::tempdir().unwrap(); - let main = dir.path().join("docker-compose.yml"); - writeln!( - std::fs::File::create(&main).unwrap(), - "{}", - "services:\n app:\n extends:\n service: base\n file: ../../other.yml" - ) - .unwrap(); - assert!(parse_file(&main).is_err()); -} diff --git a/lynx/translators/compose/tests/parse/basic.rs b/lynx/translators/compose/tests/parse/basic.rs deleted file mode 100644 index 87a2171..0000000 --- a/lynx/translators/compose/tests/parse/basic.rs +++ /dev/null @@ -1,413 +0,0 @@ -use lynx_compose::compose::types::*; -use lynx_compose::parse_str; - -#[test] -fn minimal() { - let file = parse_str("services:\n web:\n image: nginx:alpine").unwrap(); - assert!(file.services.contains_key("web")); - assert_eq!(file.services["web"].image.as_deref(), Some("nginx:alpine")); -} - -#[test] -fn empty_services() { - assert!(parse_str("services: {}").unwrap().services.is_empty()); -} - -#[test] -fn invalid_yaml() { - assert!(parse_str("services: [invalid: yaml: here").is_err()); -} - -#[test] -fn env_as_list() { - let yaml = r#" -services: - app: - image: node:20 - environment: - - NODE_ENV=production - - PORT=3000 - - SECRET -"#; - let env = parse_str(yaml).unwrap().services["app"] - .environment - .to_map(); - assert_eq!(env["NODE_ENV"].as_deref(), Some("production")); - assert_eq!(env["PORT"].as_deref(), Some("3000")); - assert!(env.contains_key("SECRET")); -} - -#[test] -fn env_as_map() { - let yaml = r#" -services: - app: - image: node:20 - environment: - NODE_ENV: production - PORT: 3000 -"#; - let env = parse_str(yaml).unwrap().services["app"] - .environment - .to_map(); - assert_eq!(env["NODE_ENV"].as_deref(), Some("production")); - assert_eq!(env["PORT"].as_deref(), Some("3000")); -} - -#[test] -fn command_shell() { - let yaml = r#" -services: - app: - image: node:20 - command: "node server.js --port 3000" -"#; - let exec = parse_str(yaml).unwrap().services["app"] - .command - .as_ref() - .unwrap() - .to_exec(); - assert_eq!(exec[0], "sh"); - assert_eq!(exec[1], "-c"); -} - -#[test] -fn command_exec() { - let yaml = r#" -services: - app: - image: node:20 - command: ["node", "server.js"] -"#; - let exec = parse_str(yaml).unwrap().services["app"] - .command - .as_ref() - .unwrap() - .to_exec(); - assert_eq!(exec, vec!["node", "server.js"]); -} - -#[test] -fn entrypoint_shell() { - let yaml = r#" -services: - app: - image: alpine - entrypoint: "/usr/local/bin/init.sh --foo" -"#; - let ep = parse_str(yaml).unwrap().services["app"] - .entrypoint - .as_ref() - .unwrap() - .to_exec(); - assert_eq!(ep[0], "sh"); - assert_eq!(ep[1], "-c"); - assert!(ep[2].contains("init.sh")); -} - -#[test] -fn entrypoint_exec() { - let yaml = r#" -services: - app: - image: alpine - entrypoint: ["/bin/init", "--foo"] -"#; - let ep = parse_str(yaml).unwrap().services["app"] - .entrypoint - .as_ref() - .unwrap() - .to_exec(); - assert_eq!(ep, vec!["/bin/init", "--foo"]); -} - -#[test] -fn ports_short() { - let yaml = r#" -services: - web: - image: nginx - ports: ["80:80", "443:443"] -"#; - assert_eq!(parse_str(yaml).unwrap().services["web"].ports.len(), 2); -} - -#[test] -fn volumes_short() { - let yaml = r#" -services: - db: - image: postgres:17 - volumes: - - ./data:/var/lib/postgresql/data - - pgdata:/var/lib/postgresql/data2 -volumes: - pgdata: -"#; - let file = parse_str(yaml).unwrap(); - assert_eq!(file.services["db"].volumes.len(), 2); - assert!(file.volumes.contains_key("pgdata")); -} - -#[test] -fn networks_list() { - let yaml = r#" -services: - web: - image: nginx - networks: [frontend] -networks: - frontend: - driver: bridge -"#; - let file = parse_str(yaml).unwrap(); - assert!(file.networks.contains_key("frontend")); - assert_eq!(file.services["web"].networks.names(), vec!["frontend"]); -} - -#[test] -fn healthcheck() { - let yaml = r#" -services: - db: - image: postgres:17 - healthcheck: - test: ["CMD-SHELL", "pg_isready -U postgres"] - interval: 5s - timeout: 3s - retries: 10 -"#; - let hc = parse_str(yaml).unwrap().services["db"] - .healthcheck - .as_ref() - .unwrap() - .retries; - assert_eq!(hc, Some(10)); -} - -#[test] -fn depends_on_list() { - let yaml = r#" -services: - app: - image: node:20 - depends_on: [db, redis] - db: - image: postgres:17 - redis: - image: redis:8 -"#; - let deps = parse_str(yaml).unwrap().services["app"] - .depends_on - .service_names(); - assert!(deps.contains(&"db".to_string())); - assert!(deps.contains(&"redis".to_string())); -} - -#[test] -fn depends_on_map_with_condition() { - let yaml = r#" -services: - app: - image: node:20 - depends_on: - db: - condition: service_healthy - db: - image: postgres:17 -"#; - let file = parse_str(yaml).unwrap(); - let condition = file.services["app"].depends_on.condition_for("db"); - assert!(matches!(condition, ServiceCondition::ServiceHealthy)); -} - -#[test] -fn secrets_top_level() { - let yaml = r#" -secrets: - db_password: - file: ./secrets/db_password.txt - jwt_secret: - external: true -"#; - let file = parse_str(yaml).unwrap(); - assert_eq!( - file.secrets["db_password"].file.as_deref(), - Some("./secrets/db_password.txt") - ); - assert_eq!(file.secrets["jwt_secret"].external, Some(true)); -} - -#[test] -fn hostname_and_domainname() { - let yaml = - "services:\n app:\n image: alpine\n hostname: web1\n domainname: example.com\n"; - let file = parse_str(yaml).unwrap(); - assert_eq!(file.services["app"].hostname.as_deref(), Some("web1")); - assert_eq!( - file.services["app"].domainname.as_deref(), - Some("example.com") - ); -} - -#[test] -fn mac_address() { - let yaml = "services:\n app:\n image: alpine\n mac_address: 02:42:ac:11:00:01\n"; - let file = parse_str(yaml).unwrap(); - assert_eq!( - file.services["app"].mac_address.as_deref(), - Some("02:42:ac:11:00:01") - ); -} - -#[test] -fn read_only_root() { - let yaml = "services:\n app:\n image: alpine\n read_only: true\n"; - assert_eq!( - parse_str(yaml).unwrap().services["app"].read_only, - Some(true) - ); -} - -#[test] -fn expose_list() { - let yaml = r#" -services: - app: - image: alpine - expose: ["80", "443"] -"#; - let file = parse_str(yaml).unwrap(); - assert_eq!(file.services["app"].expose, vec!["80", "443"]); -} - -#[test] -fn volumes_from_list() { - let yaml = r#" -services: - app: - image: alpine - volumes_from: - - service:ro - - container:mycontainer -"#; - let file = parse_str(yaml).unwrap(); - assert_eq!(file.services["app"].volumes_from.len(), 2); -} - -#[test] -fn tmpfs_as_string() { - let yaml = "services:\n app:\n image: alpine\n tmpfs: /run\n"; - assert_eq!( - parse_str(yaml).unwrap().services["app"].tmpfs.to_list(), - vec!["/run"] - ); -} - -#[test] -fn tmpfs_as_list() { - let yaml = r#" -services: - app: - image: alpine - tmpfs: - - /run - - /tmp -"#; - assert_eq!( - parse_str(yaml).unwrap().services["app"] - .tmpfs - .to_list() - .len(), - 2 - ); -} - -#[test] -fn security_opt_list() { - let yaml = r#" -services: - app: - image: alpine - security_opt: - - "no-new-privileges:true" - - "label=disable" -"#; - let so = &parse_str(yaml).unwrap().services["app"].security_opt; - assert_eq!(so.len(), 2); - assert!(so.iter().any(|s| s.contains("no-new-privileges"))); -} - -#[test] -fn devices_list() { - let yaml = r#" -services: - app: - image: alpine - devices: - - "/dev/sda:/dev/xvda:rwm" - - "/dev/null:/dev/null" -"#; - assert_eq!(parse_str(yaml).unwrap().services["app"].devices.len(), 2); -} - -#[test] -fn group_add_list() { - let yaml = r#" -services: - app: - image: alpine - group_add: [audio, video, "1000"] -"#; - let g = &parse_str(yaml).unwrap().services["app"].group_add; - assert_eq!(g.len(), 3); - assert!(g.contains(&"audio".to_string())); -} - -#[test] -fn userns_mode() { - let yaml = - "services:\n app:\n image: alpine\n userns_mode: \"keep-id:uid=1000,gid=1000\"\n"; - assert_eq!( - parse_str(yaml).unwrap().services["app"] - .userns_mode - .as_deref(), - Some("keep-id:uid=1000,gid=1000") - ); -} - -#[test] -fn shm_size() { - let yaml = "services:\n app:\n image: alpine\n shm_size: 128m\n"; - assert_eq!( - parse_str(yaml).unwrap().services["app"].shm_size.as_deref(), - Some("128m") - ); -} - -#[test] -fn cgroup_parent_field() { - let yaml = "services:\n app:\n image: alpine\n cgroup_parent: my-cgroup\n"; - assert_eq!( - parse_str(yaml).unwrap().services["app"] - .cgroup_parent - .as_deref(), - Some("my-cgroup") - ); -} - -#[test] -fn cpu_fields() { - let yaml = r#" -services: - app: - image: alpine - cpu_shares: 512 - cpuset: "0-1" - mem_limit: 256m -"#; - let svc = &parse_str(yaml).unwrap().services["app"]; - assert_eq!(svc.cpu_shares, Some(512)); - assert_eq!(svc.cpuset.as_deref(), Some("0-1")); - assert_eq!(svc.mem_limit.as_deref(), Some("256m")); -} diff --git a/lynx/translators/compose/tests/parse/coverage.rs b/lynx/translators/compose/tests/parse/coverage.rs deleted file mode 100644 index 828c19d..0000000 --- a/lynx/translators/compose/tests/parse/coverage.rs +++ /dev/null @@ -1,1231 +0,0 @@ -/// Parse tests for features present in the type system but not previously covered. -use lynx_compose::compose::types::*; -use lynx_compose::parse_str; - -// --------------------------------------------------------------------------- -// blkio_config -// --------------------------------------------------------------------------- - -#[test] -fn blkio_weight() { - let yaml = r#" -services: - app: - image: alpine - blkio_config: - weight: 300 -"#; - let svc = &parse_str(yaml).unwrap().services["app"]; - let bc = svc.blkio_config.as_ref().unwrap(); - assert_eq!(bc.weight, Some(300)); -} - -#[test] -fn blkio_weight_device() { - let yaml = r#" -services: - app: - image: alpine - blkio_config: - weight_device: - - path: /dev/sda - weight: 400 -"#; - let svc = &parse_str(yaml).unwrap().services["app"]; - let bc = svc.blkio_config.as_ref().unwrap(); - assert_eq!(bc.weight_device.len(), 1); - assert_eq!(bc.weight_device[0].path, "/dev/sda"); - assert_eq!(bc.weight_device[0].weight, 400); -} - -#[test] -fn blkio_device_read_bps() { - let yaml = r#" -services: - app: - image: alpine - blkio_config: - device_read_bps: - - path: /dev/sda - rate: "12mb" - device_write_bps: - - path: /dev/sda - rate: "1024k" -"#; - let svc = &parse_str(yaml).unwrap().services["app"]; - let bc = svc.blkio_config.as_ref().unwrap(); - assert_eq!(bc.device_read_bps.len(), 1); - assert_eq!(bc.device_read_bps[0].path, "/dev/sda"); - assert_eq!(bc.device_write_bps.len(), 1); -} - -#[test] -fn blkio_device_read_write_iops() { - let yaml = r#" -services: - app: - image: alpine - blkio_config: - device_read_iops: - - path: /dev/sda - rate: 100 - device_write_iops: - - path: /dev/sda - rate: 200 -"#; - let svc = &parse_str(yaml).unwrap().services["app"]; - let bc = svc.blkio_config.as_ref().unwrap(); - assert_eq!(bc.device_read_iops.len(), 1); - assert_eq!(bc.device_write_iops.len(), 1); - assert_eq!(bc.device_read_iops[0].rate_value(), 100); - assert_eq!(bc.device_write_iops[0].rate_value(), 200); -} - -// --------------------------------------------------------------------------- -// gpus shorthand -// --------------------------------------------------------------------------- - -#[test] -fn gpus_all() { - let yaml = r#" -services: - app: - image: alpine - gpus: all -"#; - let svc = &parse_str(yaml).unwrap().services["app"]; - let gpus = svc.gpus.as_ref().unwrap(); - assert_eq!(gpus.to_count(), -1); -} - -#[test] -fn gpus_count() { - let yaml = r#" -services: - app: - image: alpine - gpus: 2 -"#; - let svc = &parse_str(yaml).unwrap().services["app"]; - let gpus = svc.gpus.as_ref().unwrap(); - assert_eq!(gpus.to_count(), 2); -} - -// --------------------------------------------------------------------------- -// deploy.resources.reservations.devices (GPU reservation) -// --------------------------------------------------------------------------- - -#[test] -fn deploy_gpu_device_reservation() { - let yaml = r#" -services: - app: - image: alpine - deploy: - resources: - reservations: - devices: - - capabilities: [gpu] - count: 1 - - capabilities: [gpu] - count: all - device_ids: ["GPU-0", "GPU-1"] -"#; - let svc = &parse_str(yaml).unwrap().services["app"]; - let deploy = svc.deploy.as_ref().unwrap(); - let devs = &deploy - .resources - .as_ref() - .unwrap() - .reservations - .as_ref() - .unwrap() - .devices; - assert_eq!(devs.len(), 2); - assert!(devs[0].capabilities.contains(&"gpu".to_string())); - assert_eq!(devs[1].device_ids.len(), 2); -} - -// --------------------------------------------------------------------------- -// develop.watch -// --------------------------------------------------------------------------- - -#[test] -fn develop_watch_sync() { - let yaml = r#" -services: - app: - image: alpine - develop: - watch: - - path: ./src - action: sync - target: /app/src - ignore: - - node_modules -"#; - let svc = &parse_str(yaml).unwrap().services["app"]; - let dev = svc.develop.as_ref().unwrap(); - assert_eq!(dev.watch.len(), 1); - let rule = &dev.watch[0]; - assert_eq!(rule.path, "./src"); - assert_eq!(rule.action, WatchAction::Sync); - assert_eq!(rule.target.as_deref(), Some("/app/src")); - assert_eq!(rule.ignore.len(), 1); -} - -#[test] -fn develop_watch_rebuild() { - let yaml = r#" -services: - app: - image: alpine - develop: - watch: - - path: ./Dockerfile - action: rebuild -"#; - let svc = &parse_str(yaml).unwrap().services["app"]; - let rule = &svc.develop.as_ref().unwrap().watch[0]; - assert_eq!(rule.action, WatchAction::Rebuild); -} - -#[test] -fn develop_watch_sync_restart() { - let yaml = r#" -services: - app: - image: alpine - develop: - watch: - - path: ./config - action: sync+restart - target: /etc/app - initial_sync: true -"#; - let file = parse_str(yaml).unwrap(); - let rule = &file.services["app"].develop.as_ref().unwrap().watch[0]; - assert_eq!(rule.action, WatchAction::SyncAndRestart); - assert!(rule.initial_sync); -} - -// --------------------------------------------------------------------------- -// post_start / pre_stop lifecycle hooks -// --------------------------------------------------------------------------- - -#[test] -fn post_start_exec_list() { - let yaml = r#" -services: - app: - image: alpine - post_start: - - command: ["/scripts/init.sh", "--quiet"] -"#; - let svc = &parse_str(yaml).unwrap().services["app"]; - assert_eq!(svc.post_start.len(), 1); - let cmd = svc.post_start[0].command.to_exec(); - assert_eq!(cmd[0], "/scripts/init.sh"); - assert_eq!(cmd[1], "--quiet"); -} - -#[test] -fn pre_stop_with_env_and_user() { - let yaml = r#" -services: - app: - image: alpine - pre_stop: - - command: ["/scripts/cleanup.sh"] - user: "1000" - privileged: false - working_dir: /app - environment: - CLEANUP: "true" -"#; - let hook = &parse_str(yaml).unwrap().services["app"].pre_stop[0]; - assert_eq!(hook.user.as_deref(), Some("1000")); - assert_eq!(hook.privileged, Some(false)); - assert_eq!(hook.working_dir.as_deref(), Some("/app")); - let env = hook.environment.to_map(); - assert_eq!( - env.get("CLEANUP").and_then(|v| v.clone()).as_deref(), - Some("true") - ); -} - -// --------------------------------------------------------------------------- -// env_file long-form -// --------------------------------------------------------------------------- - -#[test] -fn env_file_long_form_with_required() { - let yaml = r#" -services: - app: - image: alpine - env_file: - - path: .env.prod - required: false - - path: .env.local - required: true -"#; - let entries = parse_str(yaml).unwrap().services["app"] - .env_file - .to_entries(); - assert_eq!(entries.len(), 2); - assert_eq!(entries[0].path(), ".env.prod"); - assert!(!entries[0].required()); - assert_eq!(entries[1].path(), ".env.local"); - assert!(entries[1].required()); -} - -#[test] -fn env_file_long_form_with_format() { - let yaml = r#" -services: - app: - image: alpine - env_file: - - path: .env - format: dotenv -"#; - let entries = parse_str(yaml).unwrap().services["app"] - .env_file - .to_entries(); - assert_eq!(entries.len(), 1); - assert_eq!(entries[0].path(), ".env"); -} - -#[test] -fn env_file_mixed_short_and_long() { - let yaml = r#" -services: - app: - image: alpine - env_file: - - .env - - path: .env.local - required: false -"#; - let entries = parse_str(yaml).unwrap().services["app"] - .env_file - .to_entries(); - assert_eq!(entries.len(), 2); - assert!(entries[0].required()); // short form defaults to required - assert!(!entries[1].required()); -} - -// --------------------------------------------------------------------------- -// secrets top-level: content and environment -// --------------------------------------------------------------------------- - -#[test] -fn secret_content_inline() { - let yaml = r#" -secrets: - db_password: - content: "s3cr3t_password" -"#; - let file = parse_str(yaml).unwrap(); - assert_eq!( - file.secrets["db_password"].content.as_deref(), - Some("s3cr3t_password") - ); -} - -#[test] -fn secret_from_environment() { - let yaml = r#" -secrets: - api_key: - environment: MY_API_KEY -"#; - let file = parse_str(yaml).unwrap(); - assert_eq!( - file.secrets["api_key"].environment.as_deref(), - Some("MY_API_KEY") - ); -} - -#[test] -fn secret_with_driver_and_labels() { - let yaml = r#" -secrets: - db_pass: - driver: vault - driver_opts: - path: secret/db - labels: - team: backend -"#; - let s = &parse_str(yaml).unwrap().secrets["db_pass"]; - assert_eq!(s.driver.as_deref(), Some("vault")); - assert_eq!( - s.driver_opts.get("path").map(|s| s.as_str()), - Some("secret/db") - ); - assert!(!s.labels.to_map().is_empty()); -} - -#[test] -fn secret_long_form_uid_gid_mode() { - let yaml = r#" -secrets: - app_cert: - file: ./cert.pem -services: - app: - image: alpine - secrets: - - source: app_cert - target: /run/secrets/cert.pem - uid: "1000" - gid: "1000" - mode: 256 -"#; - let file = parse_str(yaml).unwrap(); - let sref = &file.services["app"].secrets[0]; - assert_eq!(sref.source(), "app_cert"); - assert_eq!(sref.target(), Some("/run/secrets/cert.pem")); - match sref { - ServiceSecretRef::Long { uid, gid, mode, .. } => { - assert_eq!(uid.as_deref(), Some("1000")); - assert_eq!(gid.as_deref(), Some("1000")); - assert_eq!(*mode, Some(256)); - } - _ => panic!("expected long-form secret ref"), - } -} - -// --------------------------------------------------------------------------- -// configs: content and environment -// --------------------------------------------------------------------------- - -#[test] -fn config_content_multiline() { - let yaml = r#" -configs: - app_conf: - content: | - [server] - port = 8080 -"#; - let content = parse_str(yaml).unwrap().configs["app_conf"] - .content - .clone() - .unwrap(); - assert!(content.contains("port = 8080")); -} - -#[test] -fn config_from_environment() { - let yaml = r#" -configs: - token: - environment: AUTH_TOKEN -"#; - let file = parse_str(yaml).unwrap(); - assert_eq!( - file.configs["token"].environment.as_deref(), - Some("AUTH_TOKEN") - ); -} - -#[test] -fn config_long_form_uid_gid_mode() { - let yaml = r#" -configs: - app_cfg: - file: ./app.conf -services: - app: - image: alpine - configs: - - source: app_cfg - target: /etc/app.conf - uid: "500" - gid: "500" - mode: 292 -"#; - let file = parse_str(yaml).unwrap(); - let cref = &file.services["app"].configs[0]; - assert_eq!(cref.source(), "app_cfg"); - assert_eq!(cref.target(), Some("/etc/app.conf")); - match cref { - ServiceConfigRef::Long { uid, gid, mode, .. } => { - assert_eq!(uid.as_deref(), Some("500")); - assert_eq!(gid.as_deref(), Some("500")); - assert_eq!(*mode, Some(292)); - } - _ => panic!("expected long-form config ref"), - } -} - -// --------------------------------------------------------------------------- -// IPAM network config -// --------------------------------------------------------------------------- - -#[test] -fn network_ipam_subnet_gateway() { - let yaml = r#" -networks: - backend: - driver: bridge - ipam: - driver: default - config: - - subnet: 192.168.90.0/24 - gateway: 192.168.90.1 - ip_range: 192.168.90.128/25 -"#; - let file = parse_str(yaml).unwrap(); - let net = file.networks["backend"].as_ref().unwrap(); - let ipam = net.ipam.as_ref().unwrap(); - assert_eq!(ipam.driver.as_deref(), Some("default")); - assert_eq!(ipam.config.len(), 1); - assert_eq!(ipam.config[0].subnet.as_deref(), Some("192.168.90.0/24")); - assert_eq!(ipam.config[0].gateway.as_deref(), Some("192.168.90.1")); - assert_eq!( - ipam.config[0].ip_range.as_deref(), - Some("192.168.90.128/25") - ); -} - -#[test] -fn network_ipam_aux_addresses() { - let yaml = r#" -networks: - mynet: - ipam: - config: - - subnet: 172.16.238.0/24 - aux_addresses: - host1: 172.16.238.5 -"#; - let file = parse_str(yaml).unwrap(); - let net = file.networks["mynet"].as_ref().unwrap(); - let pool = &net.ipam.as_ref().unwrap().config[0]; - assert_eq!( - pool.aux_addresses.get("host1").map(|s| s.as_str()), - Some("172.16.238.5") - ); -} - -// --------------------------------------------------------------------------- -// pids_limit (top-level service field) -// --------------------------------------------------------------------------- - -#[test] -fn pids_limit_service_field() { - let yaml = "services:\n app:\n image: alpine\n pids_limit: 256\n"; - assert_eq!( - parse_str(yaml).unwrap().services["app"].pids_limit, - Some(256) - ); -} - -// --------------------------------------------------------------------------- -// Deploy: restart_policy, update_config, rollback_config, placement -// --------------------------------------------------------------------------- - -#[test] -fn deploy_restart_policy() { - let yaml = r#" -services: - app: - image: alpine - deploy: - restart_policy: - condition: on-failure - delay: 5s - max_attempts: 3 - window: 120s -"#; - let file = parse_str(yaml).unwrap(); - let rp = file.services["app"] - .deploy - .as_ref() - .unwrap() - .restart_policy - .as_ref() - .unwrap(); - assert_eq!(rp.condition.as_deref(), Some("on-failure")); - assert_eq!(rp.max_attempts, Some(3)); -} - -#[test] -fn deploy_update_config() { - let yaml = r#" -services: - app: - image: alpine - deploy: - update_config: - parallelism: 2 - delay: 10s - failure_action: rollback - order: start-first -"#; - let file = parse_str(yaml).unwrap(); - let uc = file.services["app"] - .deploy - .as_ref() - .unwrap() - .update_config - .as_ref() - .unwrap(); - assert_eq!(uc.parallelism, Some(2)); - assert_eq!(uc.failure_action.as_deref(), Some("rollback")); -} - -#[test] -fn deploy_rollback_config() { - let yaml = r#" -services: - app: - image: alpine - deploy: - rollback_config: - parallelism: 1 - delay: 5s -"#; - let file = parse_str(yaml).unwrap(); - let rc = file.services["app"] - .deploy - .as_ref() - .unwrap() - .rollback_config - .as_ref() - .unwrap(); - assert_eq!(rc.parallelism, Some(1)); -} - -#[test] -fn deploy_placement_constraints() { - let yaml = r#" -services: - app: - image: alpine - deploy: - placement: - constraints: - - "node.role==manager" - - "node.labels.datacenter==east" - max_replicas_per_node: 3 -"#; - let file = parse_str(yaml).unwrap(); - let pl = file.services["app"] - .deploy - .as_ref() - .unwrap() - .placement - .as_ref() - .unwrap(); - assert_eq!(pl.constraints.len(), 2); - assert_eq!(pl.max_replicas_per_node, Some(3)); -} - -#[test] -fn deploy_mode_and_endpoint_mode() { - let yaml = r#" -services: - app: - image: alpine - deploy: - mode: replicated - endpoint_mode: vip -"#; - let file = parse_str(yaml).unwrap(); - let deploy = file.services["app"].deploy.as_ref().unwrap(); - assert_eq!(deploy.mode.as_deref(), Some("replicated")); - assert_eq!(deploy.endpoint_mode.as_deref(), Some("vip")); -} - -// --------------------------------------------------------------------------- -// Build: no_cache, pull, tags, privileged, extra_hosts, additional_contexts, -// dockerfile_inline, ssh, secrets, ulimits -// --------------------------------------------------------------------------- - -#[test] -fn build_no_cache_and_pull() { - let yaml = r#" -services: - app: - build: - context: . - no_cache: true - pull: true -"#; - let file = parse_str(yaml).unwrap(); - let build = file.services["app"].build.as_ref().unwrap(); - assert!(build.no_cache()); - assert!(build.pull()); -} - -#[test] -fn build_tags() { - let yaml = r#" -services: - app: - build: - context: . - tags: - - myregistry.io/myapp:v1.2.3 - - myregistry.io/myapp:latest -"#; - let file = parse_str(yaml).unwrap(); - let build = file.services["app"].build.as_ref().unwrap(); - assert_eq!(build.tags().len(), 2); - assert!(build.tags()[0].contains("v1.2.3")); -} - -#[test] -fn build_privileged() { - let yaml = r#" -services: - app: - build: - context: . - privileged: true -"#; - match parse_str(yaml).unwrap().services["app"] - .build - .as_ref() - .unwrap() - { - BuildConfig::Config { privileged, .. } => assert_eq!(*privileged, Some(true)), - _ => panic!("expected long-form build"), - } -} - -#[test] -fn build_extra_hosts() { - let yaml = r#" -services: - app: - build: - context: . - extra_hosts: - - "somehost:162.242.195.82" -"#; - let file = parse_str(yaml).unwrap(); - let build = file.services["app"].build.as_ref().unwrap(); - assert_eq!(build.extra_hosts().len(), 1); -} - -#[test] -fn build_additional_contexts() { - let yaml = r#" -services: - app: - build: - context: . - additional_contexts: - mylib: /path/to/mylib -"#; - match parse_str(yaml).unwrap().services["app"] - .build - .as_ref() - .unwrap() - { - BuildConfig::Config { - additional_contexts, - .. - } => { - assert_eq!( - additional_contexts.get("mylib").map(|s| s.as_str()), - Some("/path/to/mylib") - ); - } - _ => panic!("expected long-form build"), - } -} - -#[test] -fn build_dockerfile_inline() { - let yaml = r#" -services: - app: - build: - context: . - dockerfile_inline: | - FROM alpine - RUN echo hello -"#; - let file = parse_str(yaml).unwrap(); - let build = file.services["app"].build.as_ref().unwrap(); - let inline = build.dockerfile_inline().unwrap(); - assert!(inline.contains("FROM alpine")); -} - -#[test] -fn build_ssh_and_secrets() { - let yaml = r#" -services: - app: - build: - context: . - ssh: - - default - secrets: - - server-certificate -"#; - match parse_str(yaml).unwrap().services["app"] - .build - .as_ref() - .unwrap() - { - BuildConfig::Config { ssh, secrets, .. } => { - assert_eq!(ssh.len(), 1); - assert_eq!(secrets.len(), 1); - assert_eq!(secrets[0], "server-certificate"); - } - _ => panic!("expected long-form build"), - } -} - -// --------------------------------------------------------------------------- -// Network service config: gw_priority, mac_address, link_local_ips, -// interface_name -// --------------------------------------------------------------------------- - -#[test] -fn network_service_gw_priority() { - let yaml = r#" -networks: - frontend: -services: - app: - image: alpine - networks: - frontend: - gw_priority: 100 -"#; - let file = parse_str(yaml).unwrap(); - let cfg = file.services["app"] - .networks - .config_for("frontend") - .unwrap(); - assert_eq!(cfg.gw_priority, Some(100)); -} - -#[test] -fn network_service_mac_address() { - let yaml = r#" -networks: - net: -services: - app: - image: alpine - networks: - net: - mac_address: "02:42:ac:11:00:02" -"#; - let file = parse_str(yaml).unwrap(); - let cfg = file.services["app"].networks.config_for("net").unwrap(); - assert_eq!(cfg.mac_address.as_deref(), Some("02:42:ac:11:00:02")); -} - -#[test] -fn network_service_link_local_ips() { - let yaml = r#" -networks: - net: -services: - app: - image: alpine - networks: - net: - link_local_ips: - - 169.254.8.1 -"#; - let file = parse_str(yaml).unwrap(); - let cfg = file.services["app"].networks.config_for("net").unwrap(); - assert_eq!(cfg.link_local_ips.len(), 1); - assert_eq!(cfg.link_local_ips[0], "169.254.8.1"); -} - -#[test] -fn network_service_interface_name() { - let yaml = r#" -networks: - net: -services: - app: - image: alpine - networks: - net: - interface_name: eth0 -"#; - let file = parse_str(yaml).unwrap(); - let cfg = file.services["app"].networks.config_for("net").unwrap(); - assert_eq!(cfg.interface_name.as_deref(), Some("eth0")); -} - -// --------------------------------------------------------------------------- -// Volume: consistency, driver_config, subpath, labels -// --------------------------------------------------------------------------- - -#[test] -fn volume_consistency() { - let yaml = r#" -services: - app: - image: alpine - volumes: - - type: volume - source: data - target: /data - consistency: cached -"#; - let v = &parse_str(yaml).unwrap().services["app"].volumes[0]; - match v { - VolumeMount::Long { consistency, .. } => { - assert_eq!(consistency.as_deref(), Some("cached")); - } - _ => panic!("expected long-form volume"), - } -} - -#[test] -fn volume_options_driver_config() { - let yaml = r#" -services: - app: - image: alpine - volumes: - - type: volume - source: data - target: /data - volume: - nocopy: true - driver_config: - name: nfs - options: - addr: "nfs.example.com" -"#; - let v = &parse_str(yaml).unwrap().services["app"].volumes[0]; - match v { - VolumeMount::Long { - volume: Some(vo), .. - } => { - let dc = vo.driver_config.as_ref().unwrap(); - assert_eq!(dc.name.as_deref(), Some("nfs")); - assert_eq!( - dc.options.get("addr").map(|s| s.as_str()), - Some("nfs.example.com") - ); - } - _ => panic!("expected long-form volume with options"), - } -} - -#[test] -fn volume_options_subpath() { - let yaml = r#" -services: - app: - image: alpine - volumes: - - type: volume - source: data - target: /data - volume: - subpath: subdir/nested -"#; - let v = &parse_str(yaml).unwrap().services["app"].volumes[0]; - match v { - VolumeMount::Long { - volume: Some(vo), .. - } => { - assert_eq!(vo.subpath.as_deref(), Some("subdir/nested")); - } - _ => panic!("expected long-form volume"), - } -} - -#[test] -fn volume_options_labels() { - let yaml = r#" -services: - app: - image: alpine - volumes: - - type: volume - source: data - target: /data - volume: - labels: - backup: daily -"#; - let v = &parse_str(yaml).unwrap().services["app"].volumes[0]; - match v { - VolumeMount::Long { - volume: Some(vo), .. - } => { - let labels = vo.labels.to_map(); - assert_eq!(labels.get("backup").map(|s| s.as_str()), Some("daily")); - } - _ => panic!("expected long-form volume"), - } -} - -// --------------------------------------------------------------------------- -// CPU realtime / cpu_count / cpu_percent fields -// --------------------------------------------------------------------------- - -#[test] -fn cpu_count_and_percent() { - let yaml = r#" -services: - app: - image: alpine - cpu_count: 4 - cpu_percent: 75 -"#; - let svc = &parse_str(yaml).unwrap().services["app"]; - assert_eq!(svc.cpu_count, Some(4)); - assert_eq!(svc.cpu_percent, Some(75)); -} - -#[test] -fn cpu_rt_runtime_and_period() { - let yaml = r#" -services: - app: - image: alpine - cpu_rt_runtime: 950000 - cpu_rt_period: 1000000 -"#; - let svc = &parse_str(yaml).unwrap().services["app"]; - assert_eq!(svc.cpu_rt_runtime, Some(950000)); - assert_eq!(svc.cpu_rt_period, Some(1000000)); -} - -// --------------------------------------------------------------------------- -// label_file and attach -// --------------------------------------------------------------------------- - -#[test] -fn label_file_single() { - let yaml = "services:\n app:\n image: alpine\n label_file: ./labels.properties\n"; - let list = parse_str(yaml).unwrap().services["app"] - .label_file - .to_list(); - assert_eq!(list, vec!["./labels.properties"]); -} - -#[test] -fn label_file_list() { - let yaml = r#" -services: - app: - image: alpine - label_file: - - ./labels.properties - - ./extra.labels -"#; - let list = parse_str(yaml).unwrap().services["app"] - .label_file - .to_list(); - assert_eq!(list.len(), 2); -} - -#[test] -fn attach_field() { - let yaml = "services:\n app:\n image: alpine\n attach: false\n"; - assert_eq!(parse_str(yaml).unwrap().services["app"].attach, Some(false)); -} - -// --------------------------------------------------------------------------- -// uts, cgroup namespace -// --------------------------------------------------------------------------- - -#[test] -fn uts_host() { - let yaml = "services:\n app:\n image: alpine\n uts: host\n"; - assert_eq!( - parse_str(yaml).unwrap().services["app"].uts.as_deref(), - Some("host") - ); -} - -#[test] -fn cgroup_field() { - let yaml = "services:\n app:\n image: alpine\n cgroup: host\n"; - assert_eq!( - parse_str(yaml).unwrap().services["app"].cgroup.as_deref(), - Some("host") - ); -} - -// --------------------------------------------------------------------------- -// Build: isolation, entitlements, provenance, sbom -// --------------------------------------------------------------------------- - -#[test] -fn build_isolation() { - let yaml = r#" -services: - app: - build: - context: . - isolation: hyperv -"#; - match parse_str(yaml).unwrap().services["app"] - .build - .as_ref() - .unwrap() - { - BuildConfig::Config { isolation, .. } => assert_eq!(isolation.as_deref(), Some("hyperv")), - _ => panic!("expected long-form build"), - } -} - -#[test] -fn build_entitlements() { - let yaml = r#" -services: - app: - build: - context: . - entitlements: - - network.host - - security.insecure -"#; - match parse_str(yaml).unwrap().services["app"] - .build - .as_ref() - .unwrap() - { - BuildConfig::Config { entitlements, .. } => { - assert_eq!(entitlements.len(), 2); - assert!(entitlements.contains(&"network.host".to_string())); - } - _ => panic!("expected long-form build"), - } -} - -#[test] -fn build_sbom() { - let yaml = r#" -services: - app: - build: - context: . - sbom: true -"#; - match parse_str(yaml).unwrap().services["app"] - .build - .as_ref() - .unwrap() - { - BuildConfig::Config { sbom, .. } => assert_eq!(*sbom, Some(true)), - _ => panic!("expected long-form build"), - } -} - -// --------------------------------------------------------------------------- -// Networks: ipam options, multiple pools -// --------------------------------------------------------------------------- - -#[test] -fn network_ipam_options() { - let yaml = r#" -networks: - mynet: - ipam: - driver: custom - options: - foo: bar -"#; - let file = parse_str(yaml).unwrap(); - let ipam = file.networks["mynet"] - .as_ref() - .unwrap() - .ipam - .as_ref() - .unwrap(); - assert_eq!(ipam.driver.as_deref(), Some("custom")); - assert_eq!(ipam.options.get("foo").map(|s| s.as_str()), Some("bar")); -} - -// --------------------------------------------------------------------------- -// deploy.labels -// --------------------------------------------------------------------------- - -#[test] -fn deploy_labels_as_list() { - let yaml = r#" -services: - app: - image: alpine - deploy: - labels: - - "com.example.description=API service" - - "com.example.tier=backend" -"#; - let file = parse_str(yaml).unwrap(); - let deploy = file.services["app"].deploy.as_ref().unwrap(); - let labels = deploy.labels.to_map(); - assert_eq!( - labels.get("com.example.description").map(|s| s.as_str()), - Some("API service") - ); -} - -#[test] -fn deploy_labels_as_map() { - let yaml = r#" -services: - app: - image: alpine - deploy: - labels: - app.version: "1.0" -"#; - let file = parse_str(yaml).unwrap(); - let deploy = file.services["app"].deploy.as_ref().unwrap(); - let labels = deploy.labels.to_map(); - assert_eq!(labels.get("app.version").map(|s| s.as_str()), Some("1.0")); -} - -// --------------------------------------------------------------------------- -// service.dns_search (coverage of StringOrList as list form) -// --------------------------------------------------------------------------- - -#[test] -fn dns_search_list() { - let yaml = r#" -services: - app: - image: alpine - dns_search: - - example.com - - internal.local -"#; - let list = parse_str(yaml).unwrap().services["app"] - .dns_search - .to_list(); - assert!(list.contains(&"example.com".to_string())); -} - -// --------------------------------------------------------------------------- -// Devices: cgroup rules -// --------------------------------------------------------------------------- - -#[test] -fn device_cgroup_rules() { - let yaml = r#" -services: - app: - image: alpine - device_cgroup_rules: - - "c 1:3 mr" - - "b 7:* rmw" -"#; - let rules = &parse_str(yaml).unwrap().services["app"].device_cgroup_rules; - assert_eq!(rules.len(), 2); - assert!(rules[0].contains("c 1:3")); -} diff --git a/lynx/translators/compose/tests/parse/extends.rs b/lynx/translators/compose/tests/parse/extends.rs deleted file mode 100644 index a23bfb1..0000000 --- a/lynx/translators/compose/tests/parse/extends.rs +++ /dev/null @@ -1,167 +0,0 @@ -use lynx_compose::{parse_file, parse_str}; -use std::io::Write; - -#[test] -fn extends_same_file() { - let yaml = r#" -services: - base: - image: alpine - environment: - LOG: info - app: - extends: base -"#; - let file = parse_str(yaml).unwrap(); - assert_eq!(file.services["app"].image.as_deref(), Some("alpine")); -} - -#[test] -fn extends_merges_environment() { - let yaml = r#" -services: - base: - image: alpine - environment: - LOG: info - KEEP: yes - app: - extends: - service: base - environment: - LOG: debug - EXTRA: ok -"#; - let file = parse_str(yaml).unwrap(); - let env = file.services["app"].environment.to_map(); - // Override wins for LOG, base value preserved for KEEP, override-only EXTRA present. - assert_eq!( - env.get("LOG").and_then(|v| v.clone()).as_deref(), - Some("debug") - ); - assert_eq!( - env.get("KEEP").and_then(|v| v.clone()).as_deref(), - Some("yes") - ); - assert_eq!( - env.get("EXTRA").and_then(|v| v.clone()).as_deref(), - Some("ok") - ); -} - -#[test] -fn extends_override_image_wins() { - let yaml = r#" -services: - base: - image: alpine - app: - extends: base - image: nginx:alpine -"#; - let file = parse_str(yaml).unwrap(); - assert_eq!(file.services["app"].image.as_deref(), Some("nginx:alpine")); -} - -#[test] -fn extends_chains_through_multiple_levels() { - let yaml = r#" -services: - grand: - image: alpine - environment: - A: 1 - parent: - extends: grand - environment: - B: 2 - child: - extends: parent - environment: - C: 3 -"#; - let file = parse_str(yaml).unwrap(); - let env = file.services["child"].environment.to_map(); - assert!(env.contains_key("A")); - assert!(env.contains_key("B")); - assert!(env.contains_key("C")); - assert_eq!(file.services["child"].image.as_deref(), Some("alpine")); -} - -#[test] -fn extends_circular_errors() { - let yaml = r#" -services: - a: - image: alpine - extends: b - b: - image: alpine - extends: a -"#; - assert!(parse_str(yaml).is_err()); -} - -#[test] -fn extends_self_errors() { - let yaml = r#" -services: - a: - image: alpine - extends: a -"#; - assert!(parse_str(yaml).is_err()); -} - -#[test] -fn extends_unknown_service_errors() { - let yaml = r#" -services: - a: - image: alpine - extends: missing -"#; - assert!(parse_str(yaml).is_err()); -} - -#[test] -fn extends_with_external_file() { - let dir = tempfile::tempdir().unwrap(); - let common_path = dir.path().join("common.yml"); - let mut f = std::fs::File::create(&common_path).unwrap(); - writeln!( - f, - "{}", - r#" -services: - base: - image: alpine - environment: - FROM_BASE: yes -"# - ) - .unwrap(); - - let main_path = dir.path().join("docker-compose.yml"); - let mut m = std::fs::File::create(&main_path).unwrap(); - writeln!( - m, - "{}", - r#" -services: - app: - extends: - service: base - file: ./common.yml - environment: - FROM_APP: yes -"# - ) - .unwrap(); - - let file = parse_file(&main_path).unwrap(); - assert_eq!(file.services["app"].image.as_deref(), Some("alpine")); - let env = file.services["app"].environment.to_map(); - assert!(env.contains_key("FROM_BASE")); - assert!(env.contains_key("FROM_APP")); -} diff --git a/lynx/translators/compose/tests/parse/fields.rs b/lynx/translators/compose/tests/parse/fields.rs deleted file mode 100644 index d8cf159..0000000 --- a/lynx/translators/compose/tests/parse/fields.rs +++ /dev/null @@ -1,675 +0,0 @@ -use lynx_compose::compose::types::*; -use lynx_compose::parse_str; - -#[test] -fn sysctls_as_list() { - let yaml = r#" -services: - app: - image: alpine - sysctls: - - net.core.somaxconn=1024 - - net.ipv4.ip_forward=1 -"#; - let sc = parse_str(yaml).unwrap().services["app"].sysctls.to_map(); - assert_eq!( - sc.get("net.core.somaxconn").map(|s| s.as_str()), - Some("1024") - ); - assert_eq!(sc.get("net.ipv4.ip_forward").map(|s| s.as_str()), Some("1")); -} - -#[test] -fn sysctls_as_map() { - let yaml = r#" -services: - app: - image: alpine - sysctls: - net.core.somaxconn: "1024" -"#; - let sc = parse_str(yaml).unwrap().services["app"].sysctls.to_map(); - assert_eq!( - sc.get("net.core.somaxconn").map(|s| s.as_str()), - Some("1024") - ); -} - -#[test] -fn sysctls_as_map_with_int() { - let yaml = r#" -services: - app: - image: alpine - sysctls: - net.ipv4.ip_forward: 1 -"#; - let sc = parse_str(yaml).unwrap().services["app"].sysctls.to_map(); - assert_eq!(sc.get("net.ipv4.ip_forward").map(|s| s.as_str()), Some("1")); -} - -#[test] -fn ulimits_as_number() { - let yaml = r#" -services: - app: - image: alpine - ulimits: - nofile: 1024 -"#; - let ul = parse_str(yaml).unwrap(); - let ul = &ul.services["app"].ulimits["nofile"]; - assert_eq!(ul.soft(), 1024); - assert_eq!(ul.hard(), 1024); -} - -#[test] -fn ulimits_as_object() { - let yaml = r#" -services: - app: - image: alpine - ulimits: - nofile: - soft: 1024 - hard: 65536 -"#; - let file = parse_str(yaml).unwrap(); - let ul = &file.services["app"].ulimits["nofile"]; - assert_eq!(ul.soft(), 1024); - assert_eq!(ul.hard(), 65536); -} - -#[test] -fn logging_config() { - let yaml = r#" -services: - app: - image: alpine - logging: - driver: json-file - options: - max-size: 10m - max-file: "3" -"#; - let file = parse_str(yaml).unwrap(); - let logging = file.services["app"].logging.as_ref().unwrap(); - assert_eq!(logging.driver.as_deref(), Some("json-file")); - assert_eq!( - logging.options.get("max-size").map(|s| s.as_str()), - Some("10m") - ); -} - -#[test] -fn deploy_config() { - let yaml = r#" -services: - app: - image: alpine - deploy: - replicas: 3 - resources: - limits: - cpus: "0.5" - memory: 128M -"#; - let file = parse_str(yaml).unwrap(); - let deploy = file.services["app"].deploy.as_ref().unwrap(); - assert_eq!(deploy.replicas, Some(3)); - let limits = deploy.resources.as_ref().unwrap().limits.as_ref().unwrap(); - assert_eq!(limits.cpus.as_deref(), Some("0.5")); - assert_eq!(limits.memory.as_deref(), Some("128M")); -} - -#[test] -fn network_mode_host() { - let yaml = "services:\n app:\n image: alpine\n network_mode: host\n"; - assert_eq!( - parse_str(yaml).unwrap().services["app"] - .network_mode - .as_deref(), - Some("host") - ); -} - -#[test] -fn profiles() { - let yaml = "services:\n debug:\n image: alpine\n profiles: [debug, dev]\n"; - let profiles = &parse_str(yaml).unwrap().services["debug"].profiles; - assert!(profiles.contains(&"debug".to_string())); - assert!(profiles.contains(&"dev".to_string())); -} - -#[test] -fn secrets_on_service() { - let yaml = r#" -services: - app: - image: alpine - secrets: [my_secret, ext_secret] -secrets: - my_secret: - file: ./secret.txt - ext_secret: - external: true -"#; - let file = parse_str(yaml).unwrap(); - assert_eq!(file.services["app"].secrets.len(), 2); - assert_eq!(file.services["app"].secrets[0].source(), "my_secret"); - assert_eq!( - file.secrets["my_secret"].file.as_deref(), - Some("./secret.txt") - ); -} - -#[test] -fn env_file_string() { - let yaml = "services:\n app:\n image: alpine\n env_file: .env\n"; - assert_eq!( - parse_str(yaml).unwrap().services["app"].env_file.to_list(), - vec![".env"] - ); -} - -#[test] -fn env_file_list() { - let yaml = r#" -services: - app: - image: alpine - env_file: [.env, .env.local] -"#; - let list = parse_str(yaml).unwrap().services["app"].env_file.to_list(); - assert_eq!(list.len(), 2); - assert!(list.contains(&".env.local".to_string())); -} - -#[test] -fn restart_policies() { - for policy in &["no", "always", "on-failure", "unless-stopped"] { - let yaml = format!("services:\n app:\n image: alpine\n restart: {policy}\n"); - assert!(parse_str(&yaml).unwrap().services["app"].restart.is_some()); - } -} - -#[test] -fn restart_on_failure_with_count() { - let yaml = "services:\n app:\n image: alpine\n restart: on-failure:5\n"; - let r = parse_str(yaml).unwrap().services["app"] - .restart - .clone() - .unwrap(); - match r { - RestartPolicy::OnFailure { max_attempts } => assert_eq!(max_attempts, Some(5)), - other => panic!("expected OnFailure, got {other:?}"), - } -} - -#[test] -fn labels_as_list() { - let yaml = r#" -services: - app: - image: alpine - labels: - - "com.example.env=prod" -"#; - let labels = parse_str(yaml).unwrap().services["app"].labels.to_map(); - assert_eq!( - labels.get("com.example.env").map(|s| s.as_str()), - Some("prod") - ); -} - -#[test] -fn labels_as_map() { - let yaml = "services:\n app:\n image: alpine\n labels:\n com.example.env: prod\n"; - let labels = parse_str(yaml).unwrap().services["app"].labels.to_map(); - assert_eq!( - labels.get("com.example.env").map(|s| s.as_str()), - Some("prod") - ); -} - -#[test] -fn extra_hosts() { - let yaml = r#" -services: - app: - image: alpine - extra_hosts: - - "somehost:162.242.195.82" - - "otherhost:50.31.209.229" -"#; - assert_eq!( - parse_str(yaml).unwrap().services["app"].extra_hosts.len(), - 2 - ); -} - -#[test] -fn tty_and_stdin_open() { - let yaml = "services:\n app:\n image: alpine\n tty: true\n stdin_open: true\n"; - let file = parse_str(yaml).unwrap(); - assert_eq!(file.services["app"].tty, Some(true)); - assert_eq!(file.services["app"].stdin_open, Some(true)); -} - -#[test] -fn privileged_and_init() { - let yaml = "services:\n app:\n image: alpine\n privileged: true\n init: true\n"; - let file = parse_str(yaml).unwrap(); - assert_eq!(file.services["app"].privileged, Some(true)); - assert_eq!(file.services["app"].init, Some(true)); -} - -#[test] -fn stop_signal() { - let yaml = "services:\n app:\n image: alpine\n stop_signal: SIGTERM\n"; - assert_eq!( - parse_str(yaml).unwrap().services["app"] - .stop_signal - .as_deref(), - Some("SIGTERM") - ); -} - -#[test] -fn dns() { - let yaml = r#" -services: - app: - image: alpine - dns: [8.8.8.8, 8.8.4.4] -"#; - assert!(parse_str(yaml).unwrap().services["app"] - .dns - .to_list() - .contains(&"8.8.8.8".to_string())); -} - -#[test] -fn cap_add_and_drop() { - let yaml = - "services:\n app:\n image: alpine\n cap_add: [NET_ADMIN]\n cap_drop: [ALL]\n"; - let file = parse_str(yaml).unwrap(); - assert!(file.services["app"] - .cap_add - .contains(&"NET_ADMIN".to_string())); - assert!(file.services["app"].cap_drop.contains(&"ALL".to_string())); -} - -// --------------------------------------------------------------------------- -// New: extends, configs, annotations, scale, complex volumes, build extras, -// network maps, healthcheck disable, podman network modes -// --------------------------------------------------------------------------- - -#[test] -fn extends_short_form() { - let yaml = r#" -services: - base: - image: alpine - environment: - LOG: info - app: - extends: base -"#; - let file = parse_str(yaml).unwrap(); - let app = &file.services["app"]; - assert_eq!(app.image.as_deref(), Some("alpine")); - let env = app.environment.to_map(); - assert_eq!( - env.get("LOG").and_then(|v| v.clone()).as_deref(), - Some("info") - ); -} - -#[test] -fn extends_long_form_no_file() { - let yaml = r#" -services: - base: - image: alpine - app: - extends: - service: base -"#; - let file = parse_str(yaml).unwrap(); - assert_eq!(file.services["app"].image.as_deref(), Some("alpine")); -} - -#[test] -fn extends_with_file_field_parses() { - // Just verify that extends with file is parsed (resolution requires parse_file). - let yaml = r#" -services: - app: - image: alpine - extends: - service: base - file: ./common.yml -"#; - // parse_str does not resolve external files; the field should still parse, - // but extends must remain unresolved (we expect an error from parse_str). - let res = parse_str(yaml); - assert!( - res.is_err(), - "parse_str should reject extends.file references" - ); -} - -#[test] -fn configs_top_level() { - let yaml = r#" -configs: - app_cfg: - file: ./app.conf - inline_cfg: - content: "hello world" -services: - app: - image: alpine - configs: - - app_cfg - - source: inline_cfg - target: /etc/inline.conf - mode: 420 -"#; - let file = parse_str(yaml).unwrap(); - assert!(file.configs.contains_key("app_cfg")); - assert_eq!(file.configs["app_cfg"].file.as_deref(), Some("./app.conf")); - assert_eq!( - file.configs["inline_cfg"].content.as_deref(), - Some("hello world") - ); - assert_eq!(file.services["app"].configs.len(), 2); - assert_eq!(file.services["app"].configs[0].source(), "app_cfg"); - assert_eq!( - file.services["app"].configs[1].target(), - Some("/etc/inline.conf") - ); -} - -#[test] -fn annotations_as_list_and_map() { - let yaml_list = r#" -services: - a: - image: alpine - annotations: - - "io.k8s.example=foo" -"#; - let yaml_map = r#" -services: - a: - image: alpine - annotations: - io.k8s.example: foo -"#; - for yaml in &[yaml_list, yaml_map] { - let m = parse_str(yaml).unwrap().services["a"].annotations.to_map(); - assert_eq!(m.get("io.k8s.example").map(|s| s.as_str()), Some("foo")); - } -} - -#[test] -fn scale_field() { - let yaml = "services:\n app:\n image: alpine\n scale: 3\n"; - assert_eq!(parse_str(yaml).unwrap().services["app"].scale, Some(3)); -} - -#[test] -fn volume_long_form_bind_propagation() { - let yaml = r#" -services: - app: - image: alpine - volumes: - - type: bind - source: /host - target: /cont - bind: - propagation: rshared - create_host_path: true - selinux: z -"#; - let file = parse_str(yaml).unwrap(); - let v = &file.services["app"].volumes[0]; - match v { - VolumeMount::Long { bind: Some(b), .. } => { - assert_eq!(b.propagation.as_deref(), Some("rshared")); - assert_eq!(b.create_host_path, Some(true)); - assert_eq!(b.selinux.as_deref(), Some("z")); - } - _ => panic!("expected long-form bind mount"), - } -} - -#[test] -fn volume_long_form_tmpfs_size_mode() { - let yaml = r#" -services: - app: - image: alpine - volumes: - - type: tmpfs - target: /run - tmpfs: - size: 67108864 - mode: 1023 -"#; - let v = &parse_str(yaml).unwrap().services["app"].volumes[0]; - match v { - VolumeMount::Long { tmpfs: Some(t), .. } => { - assert_eq!(t.size, Some(67108864)); - assert_eq!(t.mode, Some(1023)); - } - _ => panic!("expected tmpfs mount"), - } -} - -#[test] -fn volume_long_form_volume_nocopy() { - let yaml = r#" -services: - app: - image: alpine - volumes: - - type: volume - source: data - target: /data - volume: - nocopy: true -"#; - let v = &parse_str(yaml).unwrap().services["app"].volumes[0]; - match v { - VolumeMount::Long { - volume: Some(vo), .. - } => assert_eq!(vo.nocopy, Some(true)), - _ => panic!("expected long-form volume mount"), - } -} - -#[test] -fn build_cache_from() { - let yaml = r#" -services: - app: - build: - context: . - cache_from: - - registry/example:latest -"#; - let svc = &parse_str(yaml).unwrap().services["app"]; - match svc.build.as_ref().unwrap() { - BuildConfig::Config { cache_from, .. } => assert_eq!(cache_from.len(), 1), - _ => panic!("expected long-form build"), - } -} - -#[test] -fn build_shm_size() { - let yaml = r#" -services: - app: - build: - context: . - shm_size: 128m -"#; - let svc = &parse_str(yaml).unwrap().services["app"]; - match svc.build.as_ref().unwrap() { - BuildConfig::Config { shm_size, .. } => assert_eq!(shm_size.as_deref(), Some("128m")), - _ => panic!(""), - } -} - -#[test] -fn build_network() { - let yaml = r#" -services: - app: - build: - context: . - network: host -"#; - let svc = &parse_str(yaml).unwrap().services["app"]; - match svc.build.as_ref().unwrap() { - BuildConfig::Config { network, .. } => assert_eq!(network.as_deref(), Some("host")), - _ => panic!(""), - } -} - -#[test] -fn build_platforms() { - let yaml = r#" -services: - app: - build: - context: . - platforms: - - linux/amd64 - - linux/arm64 -"#; - let svc = &parse_str(yaml).unwrap().services["app"]; - match svc.build.as_ref().unwrap() { - BuildConfig::Config { platforms, .. } => assert_eq!(platforms.len(), 2), - _ => panic!(""), - } -} - -#[test] -fn network_per_service_map_form() { - let yaml = r#" -networks: - frontend: - backend: -services: - app: - image: alpine - networks: - frontend: - aliases: [web, www] - ipv4_address: 172.16.238.10 - ipv6_address: 2001:db8::10 - backend: ~ -"#; - let file = parse_str(yaml).unwrap(); - let names = file.services["app"].networks.names(); - assert!(names.contains(&"frontend".to_string())); - assert!(names.contains(&"backend".to_string())); - let cfg = file.services["app"] - .networks - .config_for("frontend") - .expect("frontend cfg"); - assert_eq!(cfg.aliases.as_ref().unwrap().len(), 2); - assert_eq!(cfg.ipv4_address.as_deref(), Some("172.16.238.10")); - assert_eq!(cfg.ipv6_address.as_deref(), Some("2001:db8::10")); -} - -#[test] -fn healthcheck_disable_via_test_none() { - let yaml = r#" -services: - app: - image: alpine - healthcheck: - test: ["NONE"] -"#; - let hc = parse_str(yaml).unwrap().services["app"] - .healthcheck - .as_ref() - .unwrap() - .clone(); - assert!(hc.is_disabled()); -} - -#[test] -fn healthcheck_disable_explicit() { - let yaml = r#" -services: - app: - image: alpine - healthcheck: - disable: true -"#; - let hc = parse_str(yaml).unwrap().services["app"] - .healthcheck - .as_ref() - .unwrap() - .clone(); - assert!(hc.is_disabled()); -} - -#[test] -fn healthcheck_start_interval() { - let yaml = r#" -services: - app: - image: alpine - healthcheck: - test: ["CMD", "true"] - start_interval: 1s -"#; - let hc = parse_str(yaml).unwrap().services["app"] - .healthcheck - .as_ref() - .unwrap() - .clone(); - assert_eq!(hc.start_interval.as_deref(), Some("1s")); -} - -#[test] -fn network_mode_slirp4netns() { - let yaml = "services:\n app:\n image: alpine\n network_mode: slirp4netns\n"; - assert_eq!( - parse_str(yaml).unwrap().services["app"] - .network_mode - .as_deref(), - Some("slirp4netns") - ); -} - -#[test] -fn network_mode_pasta() { - let yaml = "services:\n app:\n image: alpine\n network_mode: pasta\n"; - assert_eq!( - parse_str(yaml).unwrap().services["app"] - .network_mode - .as_deref(), - Some("pasta") - ); -} - -#[test] -fn network_mode_ns_path() { - let yaml = - "services:\n app:\n image: alpine\n network_mode: \"ns:/run/netns/mynamespace\"\n"; - assert_eq!( - parse_str(yaml).unwrap().services["app"] - .network_mode - .as_deref(), - Some("ns:/run/netns/mynamespace") - ); -} diff --git a/lynx/translators/compose/tests/parse/include.rs b/lynx/translators/compose/tests/parse/include.rs deleted file mode 100644 index 56684a3..0000000 --- a/lynx/translators/compose/tests/parse/include.rs +++ /dev/null @@ -1,113 +0,0 @@ -use lynx_compose::parse_file; -use std::io::Write; - -#[test] -fn include_string_form_merges_services() { - let dir = tempfile::tempdir().unwrap(); - - let included = dir.path().join("services.yml"); - writeln!( - std::fs::File::create(&included).unwrap(), - "{}", - r#" -services: - helper: - image: alpine -"# - ) - .unwrap(); - - let main = dir.path().join("docker-compose.yml"); - writeln!( - std::fs::File::create(&main).unwrap(), - "{}", - r#" -include: - - ./services.yml - -services: - app: - image: nginx -"# - ) - .unwrap(); - - let file = parse_file(&main).unwrap(); - assert!(file.services.contains_key("app")); - assert!(file.services.contains_key("helper")); -} - -#[test] -fn include_long_form_parses() { - let dir = tempfile::tempdir().unwrap(); - - let inc = dir.path().join("inc.yml"); - writeln!( - std::fs::File::create(&inc).unwrap(), - "{}", - r#" -services: - inc_svc: - image: alpine -"# - ) - .unwrap(); - - let main = dir.path().join("docker-compose.yml"); - writeln!( - std::fs::File::create(&main).unwrap(), - "{}", - r#" -include: - - path: ./inc.yml - -services: - main_svc: - image: alpine -"# - ) - .unwrap(); - - let file = parse_file(&main).unwrap(); - assert!(file.services.contains_key("inc_svc")); - assert!(file.services.contains_key("main_svc")); -} - -#[test] -fn parent_overrides_included_service() { - let dir = tempfile::tempdir().unwrap(); - - let inc = dir.path().join("inc.yml"); - writeln!( - std::fs::File::create(&inc).unwrap(), - "{}", - r#" -services: - shared: - image: alpine:included -"# - ) - .unwrap(); - - let main = dir.path().join("docker-compose.yml"); - writeln!( - std::fs::File::create(&main).unwrap(), - "{}", - r#" -include: - - ./inc.yml - -services: - shared: - image: alpine:override -"# - ) - .unwrap(); - - let file = parse_file(&main).unwrap(); - // Parent file definition wins. - assert_eq!( - file.services["shared"].image.as_deref(), - Some("alpine:override") - ); -} diff --git a/lynx/translators/compose/tests/parse/order.rs b/lynx/translators/compose/tests/parse/order.rs deleted file mode 100644 index 68d5d78..0000000 --- a/lynx/translators/compose/tests/parse/order.rs +++ /dev/null @@ -1,132 +0,0 @@ -use lynx_compose::compose::types::ServiceCondition; -use lynx_compose::{parse_str, resolve_order}; - -#[test] -fn no_deps() { - let yaml = - "services:\n a:\n image: alpine\n b:\n image: alpine\n c:\n image: alpine\n"; - assert_eq!(resolve_order(&parse_str(yaml).unwrap()).unwrap().len(), 3); -} - -#[test] -fn linear_chain() { - let yaml = r#" -services: - c: - image: alpine - depends_on: [b] - b: - image: alpine - depends_on: [a] - a: - image: alpine -"#; - let order = resolve_order(&parse_str(yaml).unwrap()).unwrap(); - let pos = |s: &str| order.iter().position(|x| x == s).unwrap(); - assert!(pos("a") < pos("b")); - assert!(pos("b") < pos("c")); -} - -#[test] -fn diamond() { - let yaml = r#" -services: - app: - image: alpine - depends_on: [api, worker] - api: - image: alpine - depends_on: [db] - worker: - image: alpine - depends_on: [db] - db: - image: alpine -"#; - let order = resolve_order(&parse_str(yaml).unwrap()).unwrap(); - let pos = |s: &str| order.iter().position(|x| x == s).unwrap(); - assert!(pos("db") < pos("api")); - assert!(pos("db") < pos("worker")); - assert!(pos("api") < pos("app")); - assert!(pos("worker") < pos("app")); -} - -#[test] -fn circular_dependency_error() { - let yaml = r#" -services: - a: - image: alpine - depends_on: [b] - b: - image: alpine - depends_on: [a] -"#; - assert!(resolve_order(&parse_str(yaml).unwrap()).is_err()); -} - -#[test] -fn missing_dependency_error() { - let yaml = "services:\n a:\n image: alpine\n depends_on: [nonexistent]\n"; - assert!(resolve_order(&parse_str(yaml).unwrap()).is_err()); -} - -#[test] -fn service_completed_successfully_parses() { - let yaml = r#" -services: - app: - image: alpine - depends_on: - seed: - condition: service_completed_successfully - seed: - image: alpine -"#; - let file = parse_str(yaml).unwrap(); - let cond = file.services["app"].depends_on.condition_for("seed"); - assert_eq!(cond, ServiceCondition::ServiceCompletedSuccessfully); -} - -#[test] -fn depends_on_with_required_false_skipped() { - let yaml = r#" -services: - app: - image: alpine - depends_on: - missing: - condition: service_started - required: false -"#; - // Without `required: false` this would fail; with it, the missing dep is ignored. - let file = parse_str(yaml).unwrap(); - let order = resolve_order(&file); - assert!(order.is_ok()); -} - -// --------------------------------------------------------------------------- -// Profile filtering relies on engine logic, but we can verify the parser -// preserves the active set properly. -// --------------------------------------------------------------------------- - -#[test] -fn services_with_profiles_are_listed() { - let yaml = r#" -services: - always: - image: alpine - debug: - image: alpine - profiles: [debug] - monitoring: - image: alpine - profiles: [monitoring, full] -"#; - let file = parse_str(yaml).unwrap(); - assert!(file.services["always"].profiles.is_empty()); - assert_eq!(file.services["debug"].profiles, vec!["debug"]); - assert!(file.services["monitoring"] - .profiles - .contains(&"full".to_string())); -} diff --git a/lynx/translators/compose/tests/ports.rs b/lynx/translators/compose/tests/ports.rs deleted file mode 100644 index 4cccd93..0000000 --- a/lynx/translators/compose/tests/ports.rs +++ /dev/null @@ -1,4 +0,0 @@ -#[path = "ports/conversion.rs"] -mod conversion; -#[path = "ports/formats.rs"] -mod formats; diff --git a/lynx/translators/compose/tests/ports/conversion.rs b/lynx/translators/compose/tests/ports/conversion.rs deleted file mode 100644 index aafb885..0000000 --- a/lynx/translators/compose/tests/ports/conversion.rs +++ /dev/null @@ -1,32 +0,0 @@ -use lynx_compose::compose::types::PortMapping; -use lynx_compose::ports::{parse_ports, to_bollard}; - -fn short(s: &str) -> PortMapping { - PortMapping::Short(s.to_string()) -} - -#[test] -fn bollard_keys_and_bindings() { - let ports = parse_ports(&[short("8080:80")]).unwrap(); - let (bindings, exposed) = to_bollard(&ports); - assert!(bindings.contains_key("80/tcp")); - assert!(exposed.contains_key("80/tcp")); - let b = bindings["80/tcp"].as_ref().unwrap(); - assert_eq!(b[0].host_port.as_deref(), Some("8080")); - assert_eq!(b[0].host_ip.as_deref(), Some("0.0.0.0")); -} - -#[test] -fn bollard_udp_key() { - let ports = parse_ports(&[short("514:514/udp")]).unwrap(); - let (bindings, _) = to_bollard(&ports); - assert!(bindings.contains_key("514/udp")); -} - -#[test] -fn bollard_range_produces_multiple_keys() { - let ports = parse_ports(&[short("8000-8001:8000-8001")]).unwrap(); - let (bindings, _) = to_bollard(&ports); - assert!(bindings.contains_key("8000/tcp")); - assert!(bindings.contains_key("8001/tcp")); -} diff --git a/lynx/translators/compose/tests/ports/formats.rs b/lynx/translators/compose/tests/ports/formats.rs deleted file mode 100644 index f22dbf1..0000000 --- a/lynx/translators/compose/tests/ports/formats.rs +++ /dev/null @@ -1,109 +0,0 @@ -use lynx_compose::compose::types::PortMapping; -use lynx_compose::ports::parse_ports; - -fn short(s: &str) -> PortMapping { - PortMapping::Short(s.to_string()) -} - -#[test] -fn container_only() { - let ports = parse_ports(&[short("80")]).unwrap(); - assert_eq!(ports[0].container_port, 80); - assert_eq!(ports[0].protocol, "tcp"); - assert!(ports[0].host_port.is_none()); -} - -#[test] -fn host_colon_container() { - let ports = parse_ports(&[short("8080:80")]).unwrap(); - assert_eq!(ports[0].container_port, 80); - assert_eq!(ports[0].host_port, Some(8080)); -} - -#[test] -fn ip_host_container() { - let ports = parse_ports(&[short("127.0.0.1:8080:80")]).unwrap(); - assert_eq!(ports[0].container_port, 80); - assert_eq!(ports[0].host_port, Some(8080)); - assert_eq!(ports[0].host_ip, "127.0.0.1"); -} - -#[test] -fn udp_protocol() { - assert_eq!( - parse_ports(&[short("514:514/udp")]).unwrap()[0].protocol, - "udp" - ); -} - -#[test] -fn container_only_udp() { - let ports = parse_ports(&[short("53/udp")]).unwrap(); - assert_eq!(ports[0].container_port, 53); - assert_eq!(ports[0].protocol, "udp"); - assert!(ports[0].host_port.is_none()); -} - -#[test] -fn range_expansion() { - let ports = parse_ports(&[short("8000-8002:8000-8002")]).unwrap(); - assert_eq!(ports.len(), 3); - assert_eq!(ports[0].container_port, 8000); - assert_eq!(ports[1].container_port, 8001); - assert_eq!(ports[2].container_port, 8002); -} - -#[test] -fn ipv6_host_ip() { - let ports = parse_ports(&[short("[::1]:8080:80")]).unwrap(); - assert_eq!(ports[0].host_ip, "::1"); - assert_eq!(ports[0].host_port, Some(8080)); - assert_eq!(ports[0].container_port, 80); -} - -#[test] -fn random_host_port_via_zero() { - let ports = parse_ports(&[short("0:80")]).unwrap(); - assert_eq!(ports[0].container_port, 80); - assert_eq!(ports[0].host_port, Some(0)); -} - -#[test] -fn sctp_protocol() { - let ports = parse_ports(&[short("80:80/sctp")]).unwrap(); - assert_eq!(ports[0].protocol, "sctp"); -} - -#[test] -fn range_with_udp() { - let ports = parse_ports(&[short("5000-5010:5000-5010/udp")]).unwrap(); - assert_eq!(ports.len(), 11); - assert!(ports.iter().all(|p| p.protocol == "udp")); -} - -#[test] -fn range_too_large_rejected() { - assert!(parse_ports(&[short("1-1025")]).is_err()); - assert!(parse_ports(&[short("1-65535")]).is_err()); -} - -#[test] -fn range_at_limit_accepted() { - let ports = parse_ports(&[short("1-1024")]).unwrap(); - assert_eq!(ports.len(), 1024); -} - -#[test] -fn long_form_invalid_published_string_rejected() { - use lynx_compose::compose::types::StringOrU16; - let mapping = lynx_compose::compose::types::PortMapping::Long { - target: 80, - published: Some(StringOrU16::String("invalid".to_string())), - protocol: None, - host_ip: None, - mode: None, - app_protocol: None, - name: None, - }; - assert!(parse_ports(&[mapping]).is_err()); -} diff --git a/lynx/translators/compose/tests/size.rs b/lynx/translators/compose/tests/size.rs deleted file mode 100644 index 2fff903..0000000 --- a/lynx/translators/compose/tests/size.rs +++ /dev/null @@ -1,75 +0,0 @@ -//! Tests for the memory / cpu / duration parser helpers. - -use lynx_compose::size::{parse_cpus, parse_duration_nanos, parse_duration_secs, parse_memory}; - -#[test] -fn memory_bytes() { - assert_eq!(parse_memory("1024"), Some(1024)); - assert_eq!(parse_memory("1024b"), Some(1024)); - assert_eq!(parse_memory("1024B"), Some(1024)); -} - -#[test] -fn memory_kilobytes() { - assert_eq!(parse_memory("1k"), Some(1024)); - assert_eq!(parse_memory("1K"), Some(1024)); - assert_eq!(parse_memory("2KB"), Some(2 * 1024)); -} - -#[test] -fn memory_megabytes() { - assert_eq!(parse_memory("128m"), Some(128 * 1024 * 1024)); - assert_eq!(parse_memory("128M"), Some(128 * 1024 * 1024)); - assert_eq!(parse_memory("128MB"), Some(128 * 1024 * 1024)); -} - -#[test] -fn memory_gigabytes() { - assert_eq!(parse_memory("1g"), Some(1024 * 1024 * 1024)); - assert_eq!(parse_memory("1G"), Some(1024 * 1024 * 1024)); -} - -#[test] -fn memory_terabytes() { - assert_eq!(parse_memory("1t"), Some(1024_i64 * 1024 * 1024 * 1024)); -} - -#[test] -fn memory_negative_one() { - assert_eq!(parse_memory("-1"), Some(-1)); -} - -#[test] -fn memory_invalid() { - assert!(parse_memory("xyz").is_none()); - assert!(parse_memory("12xb").is_none()); -} - -#[test] -fn memory_overflow_returns_none() { - // Values that overflow i64::MAX must return None, not wrap around. - assert!(parse_memory("99999999t").is_none()); - assert!(parse_memory("9999999999g").is_none()); -} - -#[test] -fn cpus_fractional() { - assert_eq!(parse_cpus("0.5"), Some(500_000_000)); - assert_eq!(parse_cpus("1"), Some(1_000_000_000)); - assert_eq!(parse_cpus("2.5"), Some(2_500_000_000)); -} - -#[test] -fn duration_seconds() { - assert_eq!(parse_duration_secs("5"), Some(5)); - assert_eq!(parse_duration_secs("5s"), Some(5)); - assert_eq!(parse_duration_secs("1m"), Some(60)); - assert_eq!(parse_duration_secs("1h"), Some(3600)); -} - -#[test] -fn duration_nanos() { - assert_eq!(parse_duration_nanos("1s"), Some(1_000_000_000)); - assert_eq!(parse_duration_nanos("1ms"), Some(1_000_000)); - assert_eq!(parse_duration_nanos("500ms"), Some(500_000_000)); -} diff --git a/lynx/translators/compose/tests/substitute.rs b/lynx/translators/compose/tests/substitute.rs deleted file mode 100644 index 440b74b..0000000 --- a/lynx/translators/compose/tests/substitute.rs +++ /dev/null @@ -1,4 +0,0 @@ -#[path = "substitute/dotenv.rs"] -mod dotenv; -#[path = "substitute/modifiers.rs"] -mod modifiers; diff --git a/lynx/translators/compose/tests/substitute/dotenv.rs b/lynx/translators/compose/tests/substitute/dotenv.rs deleted file mode 100644 index a66112d..0000000 --- a/lynx/translators/compose/tests/substitute/dotenv.rs +++ /dev/null @@ -1,48 +0,0 @@ -use lynx_compose::substitute::{build_vars, load_dotenv}; -use std::io::Write; - -#[test] -fn basic_key_value() { - let dir = tempfile::tempdir().unwrap(); - let mut f = std::fs::File::create(dir.path().join(".env")).unwrap(); - writeln!(f, "# comment").unwrap(); - writeln!(f).unwrap(); - writeln!(f, "KEY=value").unwrap(); - writeln!(f, "EMPTY=").unwrap(); - writeln!(f, "NOVALUE").unwrap(); - - let map = load_dotenv(dir.path()); - assert_eq!(map.get("KEY").map(|s| s.as_str()), Some("value")); - assert_eq!(map.get("EMPTY").map(|s| s.as_str()), Some("")); - assert_eq!(map.get("NOVALUE").map(|s| s.as_str()), Some("")); - assert!(!map.contains_key("# comment")); -} - -#[test] -fn missing_file_returns_empty() { - let dir = tempfile::tempdir().unwrap(); - assert!(load_dotenv(dir.path()).is_empty()); -} - -#[test] -fn process_env_takes_precedence() { - let dir = tempfile::tempdir().unwrap(); - let mut f = std::fs::File::create(dir.path().join(".env")).unwrap(); - writeln!(f, "PATH=/should/not/override").unwrap(); - - let map = load_dotenv(dir.path()); - assert!(!map.contains_key("PATH")); -} - -#[test] -fn build_vars_includes_dotenv() { - let dir = tempfile::tempdir().unwrap(); - let mut f = std::fs::File::create(dir.path().join(".env")).unwrap(); - writeln!(f, "LYNX_TEST_DOTENV_KEY=from_file").unwrap(); - - let vars = build_vars(dir.path()); - assert_eq!( - vars.get("LYNX_TEST_DOTENV_KEY").map(|s| s.as_str()), - Some("from_file") - ); -} diff --git a/lynx/translators/compose/tests/substitute/modifiers.rs b/lynx/translators/compose/tests/substitute/modifiers.rs deleted file mode 100644 index 6fdde39..0000000 --- a/lynx/translators/compose/tests/substitute/modifiers.rs +++ /dev/null @@ -1,239 +0,0 @@ -use lynx_compose::substitute::substitute; -use lynx_compose::ComposeError; -use std::collections::HashMap; - -fn vars(pairs: &[(&str, &str)]) -> HashMap { - pairs - .iter() - .map(|(k, v)| (k.to_string(), v.to_string())) - .collect() -} - -#[test] -fn bare_dollar_sign() { - assert_eq!(substitute("cost: $5", &vars(&[])).unwrap(), "cost: $5"); -} - -#[test] -fn double_dollar_escape() { - assert_eq!( - substitute("$$VAR", &vars(&[("VAR", "hello")])).unwrap(), - "$VAR" - ); -} - -#[test] -fn bare_var_set() { - assert_eq!( - substitute("$FOO bar", &vars(&[("FOO", "hello")])).unwrap(), - "hello bar" - ); -} - -#[test] -fn bare_var_unset() { - assert_eq!(substitute("$MISSING", &vars(&[])).unwrap(), ""); -} - -#[test] -fn braced_var_set() { - assert_eq!( - substitute("${FOO}", &vars(&[("FOO", "world")])).unwrap(), - "world" - ); -} - -#[test] -fn braced_var_unset() { - assert_eq!(substitute("${MISSING}", &vars(&[])).unwrap(), ""); -} - -#[test] -fn default_if_unset_or_empty_nonempty() { - assert_eq!( - substitute("${FOO:-def}", &vars(&[("FOO", "bar")])).unwrap(), - "bar" - ); -} - -#[test] -fn default_if_unset_or_empty_empty() { - assert_eq!( - substitute("${FOO:-def}", &vars(&[("FOO", "")])).unwrap(), - "def" - ); -} - -#[test] -fn default_if_unset_or_empty_unset() { - assert_eq!(substitute("${FOO:-def}", &vars(&[])).unwrap(), "def"); -} - -#[test] -fn default_if_unset_set_empty() { - assert_eq!(substitute("${FOO-def}", &vars(&[("FOO", "")])).unwrap(), ""); -} - -#[test] -fn default_if_unset_unset() { - assert_eq!(substitute("${FOO-def}", &vars(&[])).unwrap(), "def"); -} - -#[test] -fn default_if_unset_set_nonempty() { - assert_eq!( - substitute("${FOO-def}", &vars(&[("FOO", "bar")])).unwrap(), - "bar" - ); -} - -#[test] -fn alt_if_set_and_nonempty() { - assert_eq!( - substitute("${FOO:+alt}", &vars(&[("FOO", "bar")])).unwrap(), - "alt" - ); -} - -#[test] -fn alt_if_set_empty_value() { - assert_eq!( - substitute("${FOO:+alt}", &vars(&[("FOO", "")])).unwrap(), - "" - ); -} - -#[test] -fn alt_if_set_unset() { - assert_eq!(substitute("${FOO:+alt}", &vars(&[])).unwrap(), ""); -} - -#[test] -fn alt_if_set_counts_empty() { - assert_eq!( - substitute("${FOO+alt}", &vars(&[("FOO", "")])).unwrap(), - "alt" - ); -} - -#[test] -fn alt_if_set_unset_returns_empty() { - assert_eq!(substitute("${FOO+alt}", &vars(&[])).unwrap(), ""); -} - -#[test] -fn error_if_unset_or_empty_nonempty() { - assert_eq!( - substitute("${FOO:?err}", &vars(&[("FOO", "bar")])).unwrap(), - "bar" - ); -} - -#[test] -fn error_if_unset_or_empty_unset() { - let result = substitute("${FOO:?err msg}", &vars(&[])); - assert!( - matches!(result, Err(ComposeError::RequiredVarNotSet { ref var, ref msg }) if var == "FOO" && msg == "err msg") - ); -} - -#[test] -fn error_if_unset_or_empty_empty() { - assert!(substitute("${FOO:?err msg}", &vars(&[("FOO", "")])).is_err()); -} - -#[test] -fn error_if_unset_set_empty_ok() { - assert_eq!(substitute("${FOO?err}", &vars(&[("FOO", "")])).unwrap(), ""); -} - -#[test] -fn error_if_unset_unset() { - assert!(substitute("${FOO?err}", &vars(&[])).is_err()); -} - -#[test] -fn chained() { - let v = vars(&[("A", "hello"), ("B", "world")]); - assert_eq!(substitute("$A ${B}", &v).unwrap(), "hello world"); -} - -#[test] -fn yaml_default_in_string() { - assert_eq!( - substitute("image: myapp:${TAG:-latest}", &vars(&[])).unwrap(), - "image: myapp:latest" - ); -} - -// --------------------------------------------------------------------------- -// New: substitution in compose-style positions -// --------------------------------------------------------------------------- - -#[test] -fn substitution_in_image_name() { - let v = vars(&[("VERSION", "1.2.3")]); - assert_eq!( - substitute("image: myapp:${VERSION:-dev}", &v).unwrap(), - "image: myapp:1.2.3" - ); -} - -#[test] -fn substitution_in_volume_path() { - let v = vars(&[]); - assert_eq!( - substitute("- ${DATA_DIR:-./data}:/app/data", &v).unwrap(), - "- ./data:/app/data" - ); -} - -#[test] -fn substitution_in_port() { - let v = vars(&[]); - assert_eq!( - substitute("- \"${HOST_PORT:-8080}:80\"", &v).unwrap(), - "- \"8080:80\"" - ); -} - -#[test] -fn empty_vs_unset_for_colon_dash() { - // :- treats both unset and empty as "use default" - assert_eq!(substitute("${V:-def}", &vars(&[])).unwrap(), "def"); - assert_eq!(substitute("${V:-def}", &vars(&[("V", "")])).unwrap(), "def"); -} - -#[test] -fn empty_vs_unset_for_dash() { - // - treats empty as "use the empty string" — different from :- - assert_eq!(substitute("${V-def}", &vars(&[])).unwrap(), "def"); - assert_eq!(substitute("${V-def}", &vars(&[("V", "")])).unwrap(), ""); -} - -#[test] -fn special_chars_in_default_with_spaces() { - assert_eq!( - substitute("${V:-hello world}", &vars(&[])).unwrap(), - "hello world" - ); -} - -#[test] -fn special_chars_in_default_with_url() { - assert_eq!( - substitute("${V:-http://example.com}", &vars(&[])).unwrap(), - "http://example.com" - ); -} - -#[test] -fn nested_like_two_subs_in_one_string() { - let v = vars(&[("V1", "hello"), ("V2", "world")]); - assert_eq!(substitute("${V1}_${V2}", &v).unwrap(), "hello_world"); -} - -#[test] -fn trailing_dollar_preserved() { - assert_eq!(substitute("price: $", &vars(&[])).unwrap(), "price: $"); -} diff --git a/scripts/lint.sh b/scripts/lint.sh index 58812bb..54e3364 100644 --- a/scripts/lint.sh +++ b/scripts/lint.sh @@ -44,8 +44,6 @@ SCRIPTS=( "$SCRIPT_DIR/install-nftables.sh" "$ROOT_DIR/lynx/dashboard/setup-dashboard.sh" "$ROOT_DIR/lynx/dashboard/update-dashboard.sh" - "$ROOT_DIR/lynx/agent/setup-agent.sh" - "$ROOT_DIR/lynx/agent/update-agent.sh" ) # -----------------------------------------------------------------------------