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