diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 9a55c29..e7577e5 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,6 +1,26 @@ version: 2 updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "daily" + labels: + - "dependencies" + - "ci" + open-pull-requests-limit: 10 + target-branch: "develop" + + - package-ecosystem: "cargo" + directory: "/lynx/translators/compose" + schedule: + interval: "weekly" + labels: + - "dependencies" + - "translator/compose" + open-pull-requests-limit: 5 + target-branch: "develop" + - package-ecosystem: "bun" directory: "/lynx/dashboard/ui" schedule: @@ -8,7 +28,7 @@ updates: labels: - "dependencies" - "dashboard" - open-pull-requests-limit: 5 + open-pull-requests-limit: 10 target-branch: "develop" groups: types: diff --git a/.github/workflows/agent.yml b/.github/workflows/agent.yml new file mode 100644 index 0000000..84d62d3 --- /dev/null +++ b/.github/workflows/agent.yml @@ -0,0 +1,156 @@ +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] + paths: + - "lynx/agent/**" + - "lynx/translators/**" + - "lynx/Cargo.toml" + - "lynx/Cargo.lock" + - ".github/workflows/agent.yml" + +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@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + + - uses: dtolnay/rust-toolchain@3c5f7ea28cd621ae0bf5283f0e981fb97b8a7af9 # stable + with: + toolchain: stable + components: rustfmt + + - name: fmt + run: cargo fmt --package lynx-agent -- --check + + audit: + name: Security audit + runs-on: ubuntu-latest + defaults: + run: + working-directory: lynx + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + + - 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@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + + - 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@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + + - 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 + run: cargo test --package lynx-agent --bin lynx-agent + + # 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 new file mode 100644 index 0000000..29d5cf7 --- /dev/null +++ b/.github/workflows/compose.yml @@ -0,0 +1,64 @@ +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" + +env: + CARGO_TERM_COLOR: always + RUST_BACKTRACE: 1 + +jobs: + check: + name: Check & Lint + runs-on: ubuntu-latest + defaults: + run: + working-directory: lynx + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + + - 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@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + + - 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/dashboard-server.yml b/.github/workflows/dashboard-server.yml new file mode 100644 index 0000000..06ad4ab --- /dev/null +++ b/.github/workflows/dashboard-server.yml @@ -0,0 +1,157 @@ +name: dashboard-server + +on: + push: + branches: [main, develop] + paths: + - "lynx/dashboard/server/**" + - "lynx/Cargo.toml" + - "lynx/Cargo.lock" + - ".github/workflows/dashboard-server.yml" + pull_request: + branches: [main] + paths: + - "lynx/dashboard/server/**" + - "lynx/Cargo.toml" + - "lynx/Cargo.lock" + - ".github/workflows/dashboard-server.yml" + +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@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + + - uses: dtolnay/rust-toolchain@3c5f7ea28cd621ae0bf5283f0e981fb97b8a7af9 # stable + with: + toolchain: stable + components: rustfmt + + - name: fmt + run: cargo fmt --package lynx-dashboard-server -- --check + + audit: + name: Security audit + runs-on: ubuntu-latest + defaults: + run: + working-directory: lynx + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + + - 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@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + + - 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 migrations + run: sqlx migrate run --source dashboard/server/migrations + + - name: check + run: cargo check --package lynx-dashboard-server + + - name: clippy + run: cargo clippy --package lynx-dashboard-server -- -D warnings + + test: + name: Test + 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 + redis: + image: redis:8-alpine + ports: + - 6379:6379 + options: >- + --health-cmd "redis-cli ping" + --health-interval 5s + --health-timeout 5s + --health-retries 10 + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + + - 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 + run: sqlx migrate run --source dashboard/server/migrations + env: + DATABASE_URL: postgres://lynx_ci:lynx_ci@localhost:5432/lynx_ci + + - name: test + run: cargo test --package lynx-dashboard-server + env: + DATABASE_URL: postgres://lynx_ci:lynx_ci@localhost:5432/lynx_ci + REDIS_URL: redis://localhost:6379 diff --git a/.github/workflows/dashboard-ui.yml b/.github/workflows/dashboard-ui.yml new file mode 100644 index 0000000..833193e --- /dev/null +++ b/.github/workflows/dashboard-ui.yml @@ -0,0 +1,90 @@ +name: dashboard-ui + +on: + push: + branches: [main, develop] + paths: + - "lynx/dashboard/ui/**" + - ".github/workflows/dashboard-ui.yml" + pull_request: + branches: [main] + paths: + - "lynx/dashboard/ui/**" + - ".github/workflows/dashboard-ui.yml" + +jobs: + typecheck: + name: TypeScript + runs-on: ubuntu-latest + defaults: + run: + working-directory: lynx/dashboard/ui + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version: latest + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: typecheck + run: bun run typecheck + + test: + name: Unit tests (Vitest) + runs-on: ubuntu-latest + defaults: + run: + working-directory: lynx/dashboard/ui + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version: latest + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: test + run: bun run test + + e2e: + name: E2E tests (Playwright) + runs-on: ubuntu-latest + defaults: + run: + working-directory: lynx/dashboard/ui + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version: latest + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Install Playwright browsers + run: bunx playwright install chromium --with-deps + + - name: Build + run: bun run build + env: + BACKEND_URL: http://localhost:8080 + + - name: Run E2E tests + run: bun run test:e2e + env: + CI: "true" + BACKEND_URL: http://localhost:8080 + + - name: Upload Playwright report + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + if: failure() + with: + name: playwright-report + path: lynx/dashboard/ui/playwright-report/ + retention-days: 7 diff --git a/.github/workflows/lint-shell.yml b/.github/workflows/lint-shell.yml index f3fbcdf..173396a 100644 --- a/.github/workflows/lint-shell.yml +++ b/.github/workflows/lint-shell.yml @@ -2,12 +2,13 @@ name: Lint shell scripts on: push: + branches: + - develop paths: - "**.sh" pull_request: branches: - main - - develop paths: - "**.sh" diff --git a/.github/workflows/validate-pr-source.yml b/.github/workflows/validate-pr-source.yml new file mode 100644 index 0000000..9c7acd0 --- /dev/null +++ b/.github/workflows/validate-pr-source.yml @@ -0,0 +1,17 @@ +name: Validate PR source branch + +on: + pull_request: + branches: + - main + +jobs: + check-source: + name: check-source-branch + runs-on: ubuntu-latest + steps: + - name: Verify PR comes from develop + if: github.head_ref != 'develop' + run: | + echo "PRs to main must come from develop, not '${{ github.head_ref }}'." + exit 1 diff --git a/.gitignore b/.gitignore index d530506..800828e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,36 @@ -CLAUDE.md +# Personal dev scratch space dev/ + +# Rust build artifacts +lynx/target/ +target/ +*.rs.bk + +# macOS +.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 +.env.*.local + +# Editor +*.swp +*.swo diff --git a/lynx/Cargo.lock b/lynx/Cargo.lock new file mode 100644 index 0000000..ce4975f --- /dev/null +++ b/lynx/Cargo.lock @@ -0,0 +1,4684 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "arc-swap" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207" +dependencies = [ + "rustversion", +] + +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures 0.2.17", + "password-hash", +] + +[[package]] +name = "asn1-rs" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5493c3bedbacf7fd7382c6346bbd66687d12bbaad3a89a2d2c303ee6cf20b048" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", + "thiserror 1.0.69", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "965c2d33e53cb6b267e148a4cb0760bc01f4904c1cd4bb4002a085bb016d1490" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "aws-lc-rs" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "axum-macros", + "base64", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sha1", + "sync_wrapper", + "tokio", + "tokio-tungstenite 0.29.0", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-macros" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aa268c23bfbbd2c4363b9cd302a4f504fb2a9dfe7e3451d66f35dd392e20aca" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "axum-test" +version = "20.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a86bfe2ef15bee102ac34912f7f4542b0bb37dc464fa55461763999c4d625e7" +dependencies = [ + "anyhow", + "axum", + "bytes", + "bytesize", + "cookie", + "expect-json", + "http", + "http-body-util", + "hyper", + "hyper-util", + "mime", + "pretty_assertions", + "reserve-port", + "rust-multipart-rfc7578_2", + "serde", + "serde_json", + "serde_urlencoded", + "tokio", + "tower", + "url", +] + +[[package]] +name = "backon" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cffb0e931875b666fc4fcb20fee52e9bbd1ef836fd9e9e04ec21555f9f85f7ef" +dependencies = [ + "fastrand", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +dependencies = [ + "serde_core", +] + +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bollard" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d41711ad46fda47cd701f6908e59d1bd6b9a2b7464c0d0aeab95c6d37096ff8a" +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_repr", + "serde_urlencoded", + "thiserror 1.0.69", + "tokio", + "tokio-util", + "tower-service", + "url", + "winapi", +] + +[[package]] +name = "bollard-stubs" +version = "1.45.0-rc.26.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d7c5415e3a6bc6d3e99eff6268e488fd4ee25e7b28c10f08fa6760bd9de16e4" +dependencies = [ + "serde", + "serde_repr", + "serde_with", +] + +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "bytesize" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bd91ee7b2422bcb158d90ef4d14f75ef67f340943fc4149891dcce8f8b972a3" + +[[package]] +name = "cc" +version = "1.2.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chacha20" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "futures-core", + "memchr", + "pin-project-lite", + "tokio", + "tokio-util", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "time", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "typenum", +] + +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "der-parser" +version = "9.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cd0a5c643689626bec213c4d8bd4d96acc8ffdb4ad4bb6bc16abf27d5f4b553" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom", + "num-bigint", + "num-traits", + "rusticata-macros", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", + "serde_core", +] + +[[package]] +name = "diff" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "subtle", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "serde", + "sha2", + "subtle", + "zeroize", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +dependencies = [ + "serde", +] + +[[package]] +name = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" +dependencies = [ + "serde", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "erased-serde" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec" +dependencies = [ + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "etcetera" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" +dependencies = [ + "cfg-if", + "home", + "windows-sys 0.48.0", +] + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "expect-json" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "869f97f4abe8e78fc812a94ad6b721d72c4fb5532877c79610f2c238d7ccf6c4" +dependencies = [ + "chrono", + "email_address", + "expect-json-macros", + "num", + "regex", + "serde", + "serde_json", + "thiserror 2.0.18", + "typetag", + "uuid", +] + +[[package]] +name = "expect-json-macros" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e6fdf550180a6c29a28cb9aac262dc0064c25735641d2317f670075e9a469d9" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "fiat-crypto" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "flume" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs_extra" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +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", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasip2", + "wasip3", +] + +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots 1.0.7", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2 0.6.3", + "tokio", + "tower-service", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "inotify" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8069d3ec154eb856955c1c0fbffefbf5f3c40a104ec912d4797314c1801abff" +dependencies = [ + "bitflags 1.3.2", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "inventory" +version = "0.3.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4f0c30c76f2f4ccee3fe55a2435f691ca00c0e4bd87abe4f4a851b1d4dac39b" +dependencies = [ + "rustversion", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "ipnetwork" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf466541e9d546596ee94f9f69590f89473455f88372423e0008fc1a7daf100e" +dependencies = [ + "serde", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "josekit" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a808e078330e6af222eb0044b71d4b1ff981bfef43e7bc8133a88234e0c86a0c" +dependencies = [ + "anyhow", + "base64", + "flate2", + "openssl", + "regex", + "serde", + "serde_json", + "thiserror 2.0.18", + "time", +] + +[[package]] +name = "js-sys" +version = "0.3.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "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 2.11.1", + "libc", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" +dependencies = [ + "bitflags 2.11.1", + "libc", + "plain", + "redox_syscall 0.7.5", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "pkg-config", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "lynx-agent" +version = "0.1.0" +dependencies = [ + "anyhow", + "axum", + "base64ct", + "chrono", + "clap", + "ed25519-dalek", + "futures-util", + "hex", + "hyper", + "hyper-util", + "lynx-compose", + "nix", + "rand 0.8.6", + "rcgen", + "reqwest", + "rustls", + "serde", + "serde_json", + "sha2", + "sqlx", + "subtle", + "thiserror 2.0.18", + "tokio", + "tokio-rustls", + "tokio-tungstenite 0.24.0", + "tower", + "tower-http", + "tracing", + "tracing-subscriber", + "uuid", + "zeroize", +] + +[[package]] +name = "lynx-compose" +version = "0.2.0" +dependencies = [ + "anyhow", + "bollard", + "bytes", + "clap", + "flate2", + "futures", + "indexmap 2.14.0", + "libc", + "notify", + "serde", + "tar", + "tempfile", + "thiserror 2.0.18", + "tokio", + "tracing", + "tracing-subscriber", + "walkdir", + "yaml_serde", +] + +[[package]] +name = "lynx-dashboard-server" +version = "0.1.0" +dependencies = [ + "aes-gcm", + "anyhow", + "argon2", + "axum", + "axum-test", + "base64ct", + "chrono", + "clap", + "ed25519-dalek", + "futures-util", + "josekit", + "rand 0.8.6", + "rcgen", + "redis", + "reqwest", + "rustls", + "serde", + "serde_json", + "sha2", + "sqlx", + "subtle", + "thiserror 2.0.18", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tracing", + "tracing-subscriber", + "url", + "uuid", + "x25519-dalek", + "x509-parser", + "zeroize", +] + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.48.0", +] + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.11.1", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "notify" +version = "6.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6205bd8bb1e454ad2e27422015fb5e4f2bcc7e08fa8f27058670d208324a4d2d" +dependencies = [ + "bitflags 2.11.1", + "crossbeam-channel", + "filetime", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio 0.8.11", + "walkdir", + "windows-sys 0.48.0", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.6", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "oid-registry" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8d8034d9489cdaf79228eb9f6a3b8d7bb32ba00d6645ebd48eef4077ceb5bd9" +dependencies = [ + "asn1-rs", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "openssl" +version = "0.10.80" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967" +dependencies = [ + "bitflags 2.11.1", + "cfg-if", + "foreign-types", + "libc", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-sys" +version = "0.9.116" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28a22dc7140cda5f096e5e7724a6962ca81a7f8bfd2979f9b18c11af56318c4" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "pretty_assertions" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d" +dependencies = [ + "diff", + "yansi", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2 0.6.3", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.4", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2 0.6.3", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +dependencies = [ + "chacha20", + "getrandom 0.4.2", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rcgen" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2" +dependencies = [ + "pem", + "ring", + "rustls-pki-types", + "time", + "x509-parser", + "yasna", +] + +[[package]] +name = "redis" +version = "0.27.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09d8f99a4090c89cc489a94833c901ead69bfbf3877b4867d5482e321ee875bc" +dependencies = [ + "arc-swap", + "async-trait", + "backon", + "bytes", + "combine", + "futures", + "futures-util", + "itertools", + "itoa", + "num-bigint", + "percent-encoding", + "pin-project-lite", + "ryu", + "sha1_smol", + "socket2 0.5.10", + "tokio", + "tokio-util", + "url", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.11.1", +] + +[[package]] +name = "redox_syscall" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4666a1a60d8412eab19d94f6d13dcc9cea0a5ef4fdf6a5db306537413c661b1b" +dependencies = [ + "bitflags 2.11.1", +] + +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "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", +] + +[[package]] +name = "reserve-port" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94070964579245eb2f76e62a7668fe87bd9969ed6c41256f3bf614e3323dd3cc" +dependencies = [ + "thiserror 2.0.18", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "signature", + "spki", + "subtle", + "zeroize", +] + +[[package]] +name = "rust-multipart-rfc7578_2" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00bdaa068902270ca7fa8619775e1838e23a63620abac0947ce0f715819b8cec" +dependencies = [ + "bytes", + "futures-core", + "futures-util", + "http", + "mime", + "rand 0.10.1", + "thiserror 2.0.18", +] + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.11.1", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "aws-lc-rs", + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +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 = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[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 2.11.1", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "indexmap 2.14.0", + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_with" +version = "3.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e72c1c2cb7b223fafb600a619537a871c2818583d619401b785e7c0b746ccde2" +dependencies = [ + "base64", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.0", + "schemars 0.9.0", + "schemars 1.2.1", + "serde_core", + "serde_json", + "time", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "sha1_smol" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core 0.6.4", +] + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +dependencies = [ + "serde", +] + +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "sqlx" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" +dependencies = [ + "base64", + "bytes", + "chrono", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashbrown 0.15.5", + "hashlink", + "indexmap 2.14.0", + "ipnetwork", + "log", + "memchr", + "once_cell", + "percent-encoding", + "rustls", + "serde", + "serde_json", + "sha2", + "smallvec", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "tracing", + "url", + "uuid", + "webpki-roots 0.26.11", +] + +[[package]] +name = "sqlx-macros" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" +dependencies = [ + "dotenvy", + "either", + "heck", + "hex", + "once_cell", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2", + "sqlx-core", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", + "syn", + "tokio", + "url", +] + +[[package]] +name = "sqlx-mysql" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" +dependencies = [ + "atoi", + "base64", + "bitflags 2.11.1", + "byteorder", + "bytes", + "chrono", + "crc", + "digest", + "dotenvy", + "either", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "generic-array", + "hex", + "hkdf", + "hmac", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "percent-encoding", + "rand 0.8.6", + "rsa", + "serde", + "sha1", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 2.0.18", + "tracing", + "uuid", + "whoami", +] + +[[package]] +name = "sqlx-postgres" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" +dependencies = [ + "atoi", + "base64", + "bitflags 2.11.1", + "byteorder", + "chrono", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-util", + "hex", + "hkdf", + "hmac", + "home", + "ipnetwork", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "rand 0.8.6", + "serde", + "serde_json", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 2.0.18", + "tracing", + "uuid", + "whoami", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" +dependencies = [ + "atoi", + "chrono", + "flume", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "serde_urlencoded", + "sqlx-core", + "thiserror 2.0.18", + "tracing", + "url", + "uuid", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tar" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973" +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.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio 1.2.0", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2 0.6.3", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9" +dependencies = [ + "futures-util", + "log", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tungstenite 0.24.0", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite 0.29.0", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51" +dependencies = [ + "bitflags 2.11.1", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "tracing", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a" +dependencies = [ + "byteorder", + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.8.6", + "rustls", + "rustls-pki-types", + "sha1", + "thiserror 1.0.69", + "utf-8", +] + +[[package]] +name = "tungstenite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.9.4", + "sha1", + "thiserror 2.0.18", +] + +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + +[[package]] +name = "typenum" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" + +[[package]] +name = "typetag" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be2212c8a9b9bcfca32024de14998494cf9a5dfa59ea1b829de98bac374b86bf" +dependencies = [ + "erased-serde", + "inventory", + "once_cell", + "serde", + "typetag-impl", +] + +[[package]] +name = "typetag-impl" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27a7a9b72ba121f6f1f6c3632b85604cac41aedb5ddc70accbebb6cac83de846" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.71" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap 2.14.0", + "wasm-encoder", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.11.1", + "hashbrown 0.15.5", + "indexmap 2.14.0", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.7", +] + +[[package]] +name = "webpki-roots" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "whoami" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" +dependencies = [ + "libredox", + "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.61.2", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap 2.14.0", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.11.1", + "indexmap 2.14.0", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap 2.14.0", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "x25519-dalek" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" +dependencies = [ + "curve25519-dalek", + "rand_core 0.6.4", + "serde", + "zeroize", +] + +[[package]] +name = "x509-parser" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcbc162f30700d6f3f82a24bf7cc62ffe7caea42c0b2cba8bf7f3ae50cf51f69" +dependencies = [ + "asn1-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom", + "oid-registry", + "ring", + "rusticata-macros", + "thiserror 1.0.69", + "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 = "yaml_serde" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c7c1b1a6a7c8a6b2741a6c21a4f8918e51899b111cfa08d1288202656e3975" +dependencies = [ + "indexmap 2.14.0", + "itoa", + "libyaml-rs", + "ryu", + "serde", +] + +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + +[[package]] +name = "yasna" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd" +dependencies = [ + "time", +] + +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/lynx/Cargo.toml b/lynx/Cargo.toml new file mode 100644 index 0000000..cc76433 --- /dev/null +++ b/lynx/Cargo.toml @@ -0,0 +1,61 @@ +[workspace] +members = [ + "agent", + "translators/compose", + "dashboard/server", +] +resolver = "2" + +[workspace.dependencies] +tokio = { version = "1", features = ["full"] } +clap = { version = "4", features = ["derive"] } +url = "2" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +thiserror = "2" +anyhow = "1" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +axum = { version = "0.8", features = ["macros", "ws"] } +hyper = { version = "1", features = ["server", "http1"] } +hyper-util = { version = "0.1", features = ["tokio"] } +tower = "0.5" +tower-http = { version = "0.6", features = ["trace", "cors", "set-header", "limit"] } + +# database +sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio-rustls", "postgres", "uuid", "chrono", "migrate", "ipnetwork", "macros"] } + +# cache +redis = { version = "0.27", features = ["tokio-comp", "connection-manager"] } + +# auth +argon2 = "0.5" +josekit = "0.10" +subtle = "2" +zeroize = { version = "1", features = ["derive"] } + +# crypto primitives +rand = "0.8" +sha2 = "0.10" +aes-gcm = "0.10" +base64ct = { version = "1", features = ["alloc"] } +ed25519-dalek = { version = "2", features = ["pkcs8", "zeroize"] } +x25519-dalek = { version = "2", features = ["static_secrets", "zeroize"] } +rcgen = { version = "0.13", features = ["x509-parser"] } +x509-parser = "0.16" +rustls = { version = "0.23", default-features = false, features = ["ring"] } +tokio-rustls = "0.26" + +# types +uuid = { version = "1", features = ["v7", "serde"] } +chrono = { version = "0.4", features = ["serde"] } + +# WebSocket (agent client) +tokio-tungstenite = { version = "0.24", features = ["rustls-tls-native-roots"] } +futures-util = "0.3" + +[profile.release] +opt-level = 3 +lto = "thin" +codegen-units = 1 +strip = true diff --git a/lynx/agent/.env.example b/lynx/agent/.env.example new file mode 100644 index 0000000..7faa426 --- /dev/null +++ b/lynx/agent/.env.example @@ -0,0 +1,14 @@ +# 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 public key from dashboard CA +# 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 new file mode 100644 index 0000000..c793a92 --- /dev/null +++ b/lynx/agent/.gitignore @@ -0,0 +1,15 @@ +# 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/Cargo.toml b/lynx/agent/Cargo.toml index e69de29..36ee8dd 100644 --- a/lynx/agent/Cargo.toml +++ b/lynx/agent/Cargo.toml @@ -0,0 +1,54 @@ +[package] +name = "lynx-agent" +version = "0.1.0" +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 } + +# 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.29", 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 new file mode 100644 index 0000000..68795d4 --- /dev/null +++ b/lynx/agent/migrations/001_init.sql @@ -0,0 +1,26 @@ +-- 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 new file mode 100644 index 0000000..1617438 --- /dev/null +++ b/lynx/agent/migrations/002_sync_cursor.sql @@ -0,0 +1,9 @@ +-- 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 new file mode 100644 index 0000000..db429fa --- /dev/null +++ b/lynx/agent/migrations/003_nginx_configs.sql @@ -0,0 +1,5 @@ +CREATE TABLE nginx_configs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + 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 new file mode 100644 index 0000000..5482b21 --- /dev/null +++ b/lynx/agent/migrations/004_nftables_state.sql @@ -0,0 +1,14 @@ +-- 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/setup-agent.sh b/lynx/agent/setup-agent.sh new file mode 100644 index 0000000..3f0974f --- /dev/null +++ b/lynx/agent/setup-agent.sh @@ -0,0 +1,744 @@ +#!/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="10.100.0.2" +DASHBOARD_WG_IP="10.100.0.1" +WG_SUBNET="10.100.0.0/24" +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/library/postgres@sha256:bfae840554bdbd4e9f8d097d8e23ffda8aac82866e04ea0d6bc09647234dd359" +PG_DB="lynx_agent" +# Agent UUID v7 — generated on first install, persists across updates +AGENT_ID="" + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +BINARY_PATH="/usr/local/bin/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 + + # 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 + podman rm -f "$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 unit + 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 + + rm -rf "$LYNX_DIR" + 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)" + +# --- 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 — only block the legacy binary, not the nftables compat layer (iptables-nft) +if command -v iptables &>/dev/null && ! iptables --version 2>/dev/null | grep -q 'nf_tables'; then + _incompatible_found=true + log_warn "Removing incompatible: iptables (legacy binary, not nftables-compat)" + log_info " Reason: ${_REASON_FW}" + case "$DISTRO" in + debian) apt-get purge -y iptables 2>/dev/null || true ;; + rhel) { dnf remove -y iptables 2>/dev/null || yum remove -y iptables 2>/dev/null; } || true ;; + *) log_warn "Unknown distro — remove iptables manually" ;; + esac + log_ok "Removed: iptables (legacy)" +fi + +if $_incompatible_found; then + if command -v iptables-legacy &>/dev/null; then + iptables-legacy -F 2>/dev/null || true + iptables-legacy -X 2>/dev/null || true + iptables-legacy -t nat -F 2>/dev/null || true + iptables-legacy -t nat -X 2>/dev/null || true + iptables-legacy -t mangle -F 2>/dev/null || true + iptables-legacy -t mangle -X 2>/dev/null || true + fi + log_ok "Incompatible software removed — residual firewall rules cleared" +else + log_ok "No incompatible software found" +fi + +unset _REASON_DOCKER _REASON_CTR _REASON_FW + +# --- Detect existing installation ------------------------------------------- + +log_section "Checking for existing installation" + +existing=false +if [[ -d "$LYNX_DIR" ]] || id "$LYNX_AGENT_USER" &>/dev/null || \ + systemctl list-unit-files lynx-agent.service &>/dev/null 2>&1 | grep -q lynx-agent; 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} Reinstall clean → destroys all agent data" + echo "" + read -rp "Choice [1/2]: " choice + choice="${choice:-1}" + + case "$choice" in + 2) + 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 + _cleanup_existing + ;; + *) + log_info "Aborting. No changes made." + exit 0 + ;; + esac +fi + +# --- Check dependencies ----------------------------------------------------- + +log_section "Checking system dependencies" + +_require_cmd() { + if ! command -v "$1" &>/dev/null; then + log_error "Required: $1 ($2)" + exit 1 + fi + log_ok "$1" +} + +_require_cmd podman "apt install podman" +_require_cmd openssl "apt install openssl" +_require_cmd nft "apt install nftables" +_require_cmd wg "apt install wireguard-tools" +_require_cmd wg-quick "apt install wireguard-tools" +_require_cmd systemctl "systemd required" +_require_cmd free "procps required" + +# --- 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 + +# --- Collect dashboard bootstrap data --------------------------------------- + +log_section "Dashboard connection setup" + +echo "" +echo -e "${YELLOW}You need the values shown at the end of the dashboard install.${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 "" + +if [[ -z "$DASHBOARD_ENDPOINT" || -z "$DASHBOARD_PUBKEY" || -z "$PSK" ]]; then + log_error "All three values are required." + exit 1 +fi + +DASHBOARD_IP="${DASHBOARD_ENDPOINT%%:*}" +DASHBOARD_WG_LISTEN="${DASHBOARD_ENDPOINT##*:}" + +# --- Create directories ----------------------------------------------------- + +log_section "Creating directories" + +mkdir -p "$LYNX_DIR" +chmod 700 "$LYNX_DIR" +log_ok "$LYNX_DIR" + +# --- Generate agent UUID ---------------------------------------------------- + +log_section "Generating agent identity" + +# UUIDv7: time-ordered. Generate with Python or fall back to uuidgen. +if python3 -c "import uuid; print(uuid.uuid7())" &>/dev/null 2>&1; then + AGENT_ID=$(python3 -c "import uuid; print(uuid.uuid7())") +elif command -v uuidgen &>/dev/null && uuidgen --version 2>&1 | grep -q "2\.4[0-9]"; then + AGENT_ID=$(uuidgen --time) +else + # Construct a v7-like UUID using current time + random bytes + TS_MS=$(date +%s%3N) + TS_HEX=$(printf '%012x' "$TS_MS") + RAND=$(openssl rand -hex 10) + AGENT_ID="${TS_HEX:0:8}-${TS_HEX:8:4}-7${RAND:0:3}-$(printf '%x' $((0x80 | (0x$(openssl rand -hex 1) & 0x3f)))${RAND:4:2})-${RAND:6:12}" +fi +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_UID=$(id -u "$LYNX_AGENT_USER") + +# 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=$(openssl rand -hex 32) + printf '%s' "$PG_ROOT" | podman secret create lynx-agent-pg-root - + PG_ROOT="$(openssl rand -hex 32)" +) + +log_info "PostgreSQL app password + database URL..." +mkdir -p /etc/lynx/credentials +chmod 700 /etc/lynx/credentials +( + PG_PASS=$(openssl rand -hex 32) + DB_URL="postgresql://lynx_agent_app:${PG_PASS}@localhost:5434/${PG_DB}" + printf '%s' "$PG_PASS" | podman secret create lynx-agent-pg-pass - + printf '%s' "$DB_URL" | podman secret create lynx-agent-database-url - + # Write credential file now — only moment we have the URL in memory + printf '%s' "$DB_URL" > /etc/lynx/credentials/database-url + chmod 600 /etc/lynx/credentials/database-url + PG_PASS="$(openssl rand -hex 32)" + DB_URL="$(openssl rand -hex 32)" +) + +log_info "Internal bearer token..." +INTERNAL_TOKEN=$(openssl rand -hex 32) +printf '%s' "$INTERNAL_TOKEN" | podman secret create lynx-agent-internal-token - + +log_ok "Agent secrets generated" + +# --- 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" + log_ok "Network created: $PG_NETWORK" +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 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; +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" + +podman run -d \ + --name "$PG_CONTAINER" \ + --network "$PG_NETWORK" \ + --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 \ + -p 127.0.0.1:5434:5432 \ + -v lynx-agent-pg-data:/var/lib/postgresql/data \ + -v "$PG_INIT_DIR:/docker-entrypoint-initdb.d:ro" \ + --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 "$PG_CONTAINER" --tail 30 + exit 1 + fi + sleep 2 +done + +# --- Install agent binary --------------------------------------------------- + +log_section "Installing lynx-agent binary" + +if [[ -f "$SCRIPT_DIR/target/release/lynx-agent" ]]; then + install -m 755 "$SCRIPT_DIR/target/release/lynx-agent" "$BINARY_PATH" + log_ok "Installed from local build: $BINARY_PATH" +elif [[ -f "$SCRIPT_DIR/target/debug/lynx-agent" ]]; then + install -m 755 "$SCRIPT_DIR/target/debug/lynx-agent" "$BINARY_PATH" + log_warn "Installed debug build — not for production" +else + log_error "lynx-agent binary not found. Build with: cargo build --release" + log_error "Expected at: $SCRIPT_DIR/target/release/lynx-agent" + exit 1 +fi + +# --- Write agent env file --------------------------------------------------- + +log_section "Writing agent configuration" + +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 +LISTEN_ADDR=127.0.0.1:${AGENT_PORT} +RUST_LOG=info +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="$(openssl rand -hex 32)" +unset INTERNAL_TOKEN + +# --- Create systemd service ------------------------------------------------- + +log_section "Installing systemd service" + +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 ${PG_CONTAINER}-container.service +Requires=network.target + +[Service] +Type=simple +User=${LYNX_AGENT_USER} +Group=${LYNX_AGENT_USER} +EnvironmentFile=${AGENT_CONF} +ExecStart=${BINARY_PATH} +Restart=on-failure +RestartSec=5s +TimeoutStopSec=30s + +# Capabilities required for nftables, Podman tenant management +AmbientCapabilities=CAP_NET_ADMIN CAP_SYS_ADMIN CAP_SETUID CAP_SETGID +CapabilityBoundingSet=CAP_NET_ADMIN CAP_SYS_ADMIN CAP_SETUID CAP_SETGID + +# Systemd credentials (tmpfs — never touches disk) +LoadCredential=database-url:/etc/lynx/credentials/database-url +LoadCredential=internal-token:/etc/lynx/credentials/internal-token + +# Security hardening +NoNewPrivileges=no +ProtectSystem=strict +ProtectHome=yes +PrivateTmp=yes +ReadWritePaths=/run/containers /var/lib/containers /home + +[Install] +WantedBy=multi-user.target +EOF + +systemctl daemon-reload +systemctl enable lynx-agent.service +log_ok "Service installed: lynx-agent.service" + +# --- WireGuard agent side --------------------------------------------------- + +log_section "Configuring WireGuard tunnel (agent ↔ dashboard)" + +# Generate agent keypair +AGENT_PRIV=$(wg genkey) +AGENT_PUB=$(printf '%s' "$AGENT_PRIV" | wg pubkey) + +# --- 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 -sf --max-time 5 https://ifconfig.me 2>/dev/null || \ + curl -sf --max-time 5 https://api.ipify.org 2>/dev/null || true) + +NAT_DETECTED=false +KEEPALIVE_LINE="" + +if [[ -n "$LOCAL_IFACE_IP" && -n "$PUBLIC_IP" && "$LOCAL_IFACE_IP" != "$PUBLIC_IP" ]]; then + NAT_DETECTED=true + 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 + NAT_DETECTED=true + 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}/24 + +${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" + +AGENT_PRIV="$(openssl rand -hex 32)" # overwrite +PSK="$(openssl rand -hex 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)" + +cat > /etc/nftables-lynx-agent.conf << EOF +table inet lynx-agent { + chain input { + type filter hook input priority 0; policy drop; + + # Loopback + iifname "lo" accept + + # Established / related + ct state established,related accept + + # ICMP + ip protocol icmp accept + ip6 nexthdr icmpv6 accept + + # SSH — emergency access only + tcp dport 22 accept + + # WireGuard inbound (dashboard connects here) + udp dport ${WG_PORT} accept + + # Agent API — only from WireGuard interface + iifname "${WG_IFACE}" tcp dport ${AGENT_PORT} accept + + drop + } + + chain forward { + type filter hook forward priority 0; policy drop; + + # Allow forwarding within lynx org networks + iifname "lynx-org-*" oifname "lynx-org-*" accept + } + + chain 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 new file mode 100644 index 0000000..4b46c69 --- /dev/null +++ b/lynx/agent/src/audit/mod.rs @@ -0,0 +1,79 @@ +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, + Failed, +} + +impl AuditResult { + fn as_str(self) -> &'static str { + match self { + AuditResult::Success => "success", + AuditResult::Rejected => "rejected", + AuditResult::Failed => "failed", + } + } +} + +/// Append an immutable audit log entry with hash chaining. +/// +/// Each entry hashes (prev_hash || id || agent_id || command_type || result || created_at). +/// Tampering with any field breaks the chain. +pub async fn append(db: &PgPool, entry: AuditEntry<'_>) -> Result<()> { + let id = Uuid::now_v7(); + let result_str = entry.result.as_str(); + + // Get last entry hash for chain + let prev_hash: String = + sqlx::query_scalar!("SELECT entry_hash FROM audit_log ORDER BY created_at DESC LIMIT 1") + .fetch_optional(db) + .await + .context("fetch prev audit hash")? + .unwrap_or_else(|| "genesis".to_string()); + + // Compute this entry's hash + let mut hasher = Sha256::new(); + hasher.update(prev_hash.as_bytes()); + hasher.update(id.as_bytes()); + hasher.update(entry.agent_id.as_bytes()); + hasher.update(entry.command_type.as_bytes()); + hasher.update(result_str.as_bytes()); + let hash = hex::encode(hasher.finalize()); + + 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(()) +} diff --git a/lynx/agent/src/auth/mod.rs b/lynx/agent/src/auth/mod.rs new file mode 100644 index 0000000..947f242 --- /dev/null +++ b/lynx/agent/src/auth/mod.rs @@ -0,0 +1,268 @@ +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. +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) + 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()); + } + + #[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 new file mode 100644 index 0000000..0350522 --- /dev/null +++ b/lynx/agent/src/cert.rs @@ -0,0 +1,67 @@ +//! 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 new file mode 100644 index 0000000..c7326b7 --- /dev/null +++ b/lynx/agent/src/config.rs @@ -0,0 +1,110 @@ +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>, +} + +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")?; + + let dashboard_verify_key = load_key32_or_dev("DASHBOARD_VERIFY_KEY")?; + 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"); + + 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, + }) + } +} + +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) +} + +fn load_key32_or_dev(env: &str) -> Result<[u8; 32]> { + if std::env::var(env).is_err() && std::env::var(format!("{env}_FILE")).is_err() { + tracing::warn!("{env} not configured — using ephemeral dev key (INSECURE)"); + let mut key = [0u8; 32]; + rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut key); + return Ok(key); + } + load_key32(env) +} diff --git a/lynx/agent/src/conflict.rs b/lynx/agent/src/conflict.rs new file mode 100644 index 0000000..598e5fe --- /dev/null +++ b/lynx/agent/src/conflict.rs @@ -0,0 +1,208 @@ +use crate::state::AppState; +use std::{process::Command, sync::atomic::Ordering, 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.lockdown.store(true, Ordering::SeqCst); + return; + } + } + } + } +} + +fn is_present(sw: &IncompatibleSoftware) -> bool { + // 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; + } + } + + // Special case: iptables — only flag if the direct (non-nft compat) iptables binary + // is present and pointing to a non-nftables backend. + if sw.name == "iptables" { + return is_legacy_iptables(); + } + + 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. + 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) +} + +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 new file mode 100644 index 0000000..6fe3da1 --- /dev/null +++ b/lynx/agent/src/error.rs @@ -0,0 +1,39 @@ +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 new file mode 100644 index 0000000..2b4e77f --- /dev/null +++ b/lynx/agent/src/handlers/containers.rs @@ -0,0 +1,119 @@ +use crate::{ + auth::{PermissionLevel, VerifiedCommand}, + error::AgentError, + podman, +}; +use serde_json::{json, Value}; + +pub fn handle_container_list(cmd: &VerifiedCommand) -> std::result::Result { + let tenant_id = require_str(&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_str(&cmd.command, "tenant_id")?; + podman::ensure_tenant_user(&tenant_id)?; + Ok(json!({ "ok": true, "tenant_id": tenant_id })) +} + +pub fn handle_container_deploy(cmd: &VerifiedCommand) -> std::result::Result { + if cmd.permission == PermissionLevel::Read { + return Err(AgentError::Forbidden( + "container.deploy requires write permission", + )); + } + let tenant_id = require_str(&cmd.command, "tenant_id")?; + let project_id = require_str(&cmd.command, "project_id")?; + let compose_yaml = require_str(&cmd.command, "compose_yaml")?; + + podman::compose_deploy(podman::DeployOptions { + tenant_id: &tenant_id, + project_id: &project_id, + compose_yaml: &compose_yaml, + })?; + + 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_str(&cmd.command, "tenant_id")?; + let name = require_str(&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_str(&cmd.command, "tenant_id")?; + let name = require_str(&cmd.command, "name")?; + podman::container_stop(&tenant_id, &name)?; + 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_str(&cmd.command, "tenant_id")?; + let name = require_str(&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_str(&cmd.command, "tenant_id")?; + let name = require_str(&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_str(&cmd.command, "tenant_id")?; + let name = require_str(&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)) +} diff --git a/lynx/agent/src/handlers/metrics.rs b/lynx/agent/src/handlers/metrics.rs new file mode 100644 index 0000000..776a543 --- /dev/null +++ b/lynx/agent/src/handlers/metrics.rs @@ -0,0 +1,74 @@ +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 new file mode 100644 index 0000000..70fe737 --- /dev/null +++ b/lynx/agent/src/handlers/mod.rs @@ -0,0 +1,9 @@ +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 new file mode 100644 index 0000000..6bc3954 --- /dev/null +++ b/lynx/agent/src/handlers/nftables.rs @@ -0,0 +1,121 @@ +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: { chain: "lynx-global"|"lynx-local", rules: "..." } + 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()), + _ => { + return Err(AgentError::BadRequest( + "unknown chain: must be lynx-global or lynx-local", + )) + } + } + + let result = apply_current_ruleset(state)?; + // Persist chain body so the agent can re-apply after reboot. + 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)?; + // Persist wg_port for both chains. + 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(), + org_networks: vec![], + global_body: state.nft_global_body(), + local_body: state.nft_local_body(), + }; + + let rendered = nftables::apply(&ruleset)?; + // Checksum from live kernel state — must match current_checksum() in divergence checker. + let checksum = nftables::current_checksum()?; + state.set_nft_checksum(checksum); + 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); + + 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 new file mode 100644 index 0000000..18e9707 --- /dev/null +++ b/lynx/agent/src/handlers/nginx_cmd.rs @@ -0,0 +1,249 @@ +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")?; + 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 })) +} + +/// 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 new file mode 100644 index 0000000..6845a2e --- /dev/null +++ b/lynx/agent/src/handlers/system.rs @@ -0,0 +1,371 @@ +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_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_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::Rejected, + error: Some("rejected_rate_limit".to_string()), + }, + ) + .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(cmd), + "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.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, + 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; + let new_pass: String = rand::thread_rng() + .sample_iter(&rand::distributions::Alphanumeric) + .take(32) + .map(char::from) + .collect(); + + 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"); + } + + tracing::info!("agent PostgreSQL password rotated"); + Ok(json!({ "ok": true })) +} + +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 new file mode 100644 index 0000000..39b871f --- /dev/null +++ b/lynx/agent/src/handlers/wireguard.rs @@ -0,0 +1,179 @@ +use crate::{ + auth::{PermissionLevel, VerifiedCommand}, + error::AgentError, +}; +use serde_json::{json, Value}; +use std::io::Write; + +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 = require_str(&cmd.command, "new_psk")?; + + 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)); + } + } + + 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")?; + let iface_suffix = tunnel_id.replace('-', ""); + let iface_suffix = &iface_suffix[..iface_suffix.len().min(8)]; + let interface = format!("wg-lynx-dp-{iface_suffix}"); + + let local_privkey = require_str(&cmd.command, "private_key")?; + let local_ip_cidr = require_str(&cmd.command, "local_ip")?; + let peer_pubkey = require_str(&cmd.command, "peer_pubkey")?; + let psk = require_str(&cmd.command, "psk")?; + 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 = format!( + "[Interface]\nPrivateKey = {local_privkey}\nAddress = {local_ip_cidr}\nListenPort = {wg_port}\n\n[Peer]\nPublicKey = {peer_pubkey}\nPresharedKey = {psk}\nAllowedIPs = {peer_allowed}\n{endpoint_line}" + ); + + let mut f = std::fs::File::create(&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 = tunnel_id.replace('-', ""); + let iface_suffix = &iface_suffix[..iface_suffix.len().min(8)]; + 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 })) +} diff --git a/lynx/agent/src/main.rs b/lynx/agent/src/main.rs new file mode 100644 index 0000000..0123f02 --- /dev/null +++ b/lynx/agent/src/main.rs @@ -0,0 +1,386 @@ +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; +mod update; +mod ws_client; + +use anyhow::Context; +use axum::{ + extract::State, + http::{HeaderMap, 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, + }, +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + tracing_subscriber::fmt() + .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) + .init(); + + let cli = Cli::parse(); + + if let Some(AgentCommand::Logs { + follow, + errors, + since, + }) = cli.command + { + return agent_logs(follow, errors, since); + } + + 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 last_heartbeat = Arc::new(std::sync::Mutex::new(std::time::Instant::now())); + + let state = AppState { + db, + config: Arc::new(config), + lockdown: lockdown.clone(), + nft_checksum: Arc::new(std::sync::Mutex::new(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_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)), + }; + + // 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 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(), + _ => {} + } + wg_port = row.wg_port as u16; + } + + state.set_nft_global_body(global_body); + state.set_nft_local_body(local_body); + state.set_nft_wg_port(wg_port); + + let ruleset = nftables::Ruleset { + wireguard_port: wg_port, + org_networks: vec![], + global_body: state.nft_global_body(), + local_body: state.nft_local_body(), + }; + + match nftables::apply(&ruleset) { + Ok(rendered) => { + if let Ok(checksum) = nftables::current_checksum() { + state.set_nft_checksum(checksum); + } + 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") + } + } + } + } + + // 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; + } + }); + } + + // WebSocket client — persistent connection to dashboard + tokio::spawn(ws_client::run_ws_client(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 lockdown_clone = lockdown.clone(); + let heartbeat_clone = last_heartbeat.clone(); + tokio::spawn(async move { + let mut ticker = interval(Duration::from_secs(30)); + loop { + ticker.tick().await; + let elapsed = heartbeat_clone.lock().unwrap().elapsed().as_secs(); + if elapsed > HEARTBEAT_TIMEOUT_SECS && !lockdown_clone.load(Ordering::SeqCst) { + tracing::warn!(elapsed_secs = elapsed, "heartbeat lost — entering lockdown"); + lockdown_clone.store(true, Ordering::SeqCst); + } + } + }); + + // Build TLS acceptor before moving state into router. + let tls_acceptor = build_tls_acceptor(&state.config); + + // Pass last_heartbeat to the heartbeat route via extension + let hb = last_heartbeat.clone(); + 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(move |State(state): State, headers: HeaderMap| { + let hb = hb.clone(); + async move { heartbeat_handler(state, headers, hb).await } + }), + ) + .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: AppState, + headers: HeaderMap, + hb: Arc>, +) -> Response { + let token = headers + .get(axum::http::header::AUTHORIZATION) + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.strip_prefix("Bearer ")) + .unwrap_or(""); + + if !auth::verify_bearer(token, &state.config.internal_token) { + return StatusCode::UNAUTHORIZED.into_response(); + } + + *hb.lock().unwrap() = std::time::Instant::now(); + let is_lockdown = state.lockdown.load(Ordering::SeqCst); + state.lockdown.store(false, Ordering::SeqCst); + + 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(()) +} diff --git a/lynx/agent/src/metrics/mod.rs b/lynx/agent/src/metrics/mod.rs new file mode 100644 index 0000000..b0cafff --- /dev/null +++ b/lynx/agent/src/metrics/mod.rs @@ -0,0 +1,198 @@ +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) +} + +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 new file mode 100644 index 0000000..c89eb91 --- /dev/null +++ b/lynx/agent/src/nftables/divergence.rs @@ -0,0 +1,134 @@ +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 + .lockdown + .store(true, std::sync::atomic::Ordering::SeqCst); + } 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 { + // We don't store per-chain expected checksums, so approximate by checking + // if the chain is accessible. If nft fails (chain deleted), that's divergence. + // For base specifically, any table-level divergence implies base was touched + // if global/local weren't modified — conservative assumption. + super::chain_checksum(chain).is_err() +} + +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 checksum to match what we just applied. + let checksum = super::current_checksum()?; + state.set_nft_checksum(checksum); + 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 new file mode 100644 index 0000000..6e5f855 --- /dev/null +++ b/lynx/agent/src/nftables/mod.rs @@ -0,0 +1,172 @@ +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 rules. +pub struct Ruleset { + /// WireGuard UDP port for management plane + pub wireguard_port: u16, + /// Per-org blocked subnets (org isolation — inter-org traffic blocked) + pub org_networks: Vec, + /// Rules body for the lynx-global chain (dashboard-pushed, applies to all agents) + pub global_body: String, + /// Rules body for the lynx-local chain (dashboard-pushed, this agent only) + pub local_body: String, +} + +pub struct OrgNetwork { + pub org_id: String, + pub subnet: 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")?; + 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") +} + +/// 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<()> { + let emergency = r#" +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; + } +} +"#; + run_nft(emergency).context("nftables apply_emergency") +} + +/// 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 { + let out = Command::new("nft") + .arg("-j") + .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 mut out = format!( + r#" +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 + + # WireGuard management plane — dashboard VPS only + udp dport {wg} accept + + # Dashboard backend port (on WG interface only) + ip saddr 10.100.0.1 accept + + # Run global and local rule chains + jump lynx-global + jump lynx-local + + drop + }} + + # Dashboard global rules — apply to all agents + chain lynx-global {{ +{global} + }} + + # Dashboard local rules — apply to this agent only + chain lynx-local {{ +{local} + }} + + chain lynx-forward {{ + type filter hook forward priority 0; policy drop; +"#, + TABLE = TABLE, + wg = r.wireguard_port, + 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( + " }\n\n chain lynx-output {\n type filter hook output priority 0; policy accept;\n }\n}\n", + ); + out +} + +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 new file mode 100644 index 0000000..0aec260 --- /dev/null +++ b/lynx/agent/src/nginx.rs @@ -0,0 +1,188 @@ +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 new file mode 100644 index 0000000..9fe60ea --- /dev/null +++ b/lynx/agent/src/podman/mod.rs @@ -0,0 +1,283 @@ +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}"); + + // Check if user already exists + let exists = Command::new("id") + .arg(&username) + .status() + .context("run id")? + .success(); + + if !exists { + // Create system user (no login shell, no home) + let status = Command::new("useradd") + .args([ + "--system", + "--no-create-home", + "--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 so the systemd user instance starts at boot, + // allowing rootless containers to survive without an active login session. + let _ = Command::new("loginctl") + .args(["enable-linger", &username]) + .status(); + } + + Ok(()) +} + +/// Run a Podman command as a specific tenant user via `runuser`. +pub fn podman_as_tenant(tenant_id: &str, args: &[&str]) -> Result { + let username = format!("lynx-tenant-{tenant_id}"); + Command::new("runuser") + .args(["-l", &username, "-c"]) + .arg(format!("podman {}", args.join(" "))) + .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`. +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(()) +} + +/// Tear down a project's compose stack. +#[allow(dead_code)] +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<()> { + // Each tenant gets a 65536-ID range. + // usermod --add-subuids / --add-subgids auto-assigns from available pool. + for flag in ["--add-subuids", "--add-subgids"] { + let status = Command::new("usermod") + .args([flag, "65536", username]) + .status() + .context("usermod subid")?; + if !status.success() { + anyhow::bail!("usermod {flag} failed for {username}"); + } + } + Ok(()) +} diff --git a/lynx/agent/src/state.rs b/lynx/agent/src/state.rs new file mode 100644 index 0000000..ab2b7f0 --- /dev/null +++ b/lynx/agent/src/state.rs @@ -0,0 +1,111 @@ +use crate::config::Config; +use sqlx::PgPool; +use std::sync::{ + atomic::{AtomicBool, AtomicU64, Ordering}, + Arc, Mutex, +}; + +#[derive(Clone)] +pub struct AppState { + pub db: PgPool, + pub config: Arc, + /// Set to true when heartbeat is lost — agent enters lockdown + pub lockdown: Arc, + /// Last known-good nftables checksum after apply(). None = no ruleset applied yet. + pub nft_checksum: Arc>>, + /// Rendered nft ruleset from last successful apply() — used for restore. + pub nft_last_ruleset: Arc>>, + /// Body of the lynx-global chain (managed by dashboard global rules). + pub nft_global_body: Arc>, + /// Body of the lynx-local chain (managed by dashboard local rules for this agent). + pub nft_local_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, +} + +impl AppState { + pub fn is_locked_down(&self) -> bool { + self.lockdown.load(Ordering::SeqCst) + } + + /// 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() + } + + 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() + } +} diff --git a/lynx/agent/src/sync/mod.rs b/lynx/agent/src/sync/mod.rs new file mode 100644 index 0000000..c74d319 --- /dev/null +++ b/lynx/agent/src/sync/mod.rs @@ -0,0 +1,108 @@ +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().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(()) +} diff --git a/lynx/agent/src/update/mod.rs b/lynx/agent/src/update/mod.rs new file mode 100644 index 0000000..5ab3411 --- /dev/null +++ b/lynx/agent/src/update/mod.rs @@ -0,0 +1,124 @@ +use anyhow::{Context, Result}; +use ed25519_dalek::{Signature, Verifier, VerifyingKey}; +use std::path::{Path, PathBuf}; + +/// Download new binary, verify Ed25519 signature, atomic swap, then exec into new process. +/// +/// The public key used for signature verification is the same Ed25519 key +/// the dashboard uses to sign commands (DASHBOARD_VERIFY_KEY env var). +/// Release binaries are signed with the corresponding private key at release time. +pub async fn perform_update(version: &str, download_url: &str, sig_url: &str) -> Result<()> { + tracing::info!(version, "starting self-update"); + + let client = reqwest::Client::builder() + .user_agent(format!("lynx-agent/{}", env!("CARGO_PKG_VERSION"))) + .timeout(std::time::Duration::from_secs(300)) + .build() + .context("build HTTP client")?; + + // Download binary + let binary_bytes = download_bytes(&client, download_url) + .await + .context("download binary")?; + + // Download signature + let sig_bytes = download_bytes(&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"); + + // Write new binary to a temp path beside the current executable + let current_exe = std::env::current_exe().context("resolve current exe")?; + let tmp_path = tmp_path(¤t_exe); + + std::fs::write(&tmp_path, &binary_bytes).with_context(|| format!("write to {tmp_path:?}"))?; + + // Make it executable + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perms = std::fs::metadata(&tmp_path)?.permissions(); + perms.set_mode(0o755); + std::fs::set_permissions(&tmp_path, perms)?; + } + + // Atomic rename: tmp → current exe path (POSIX atomic on same filesystem) + std::fs::rename(&tmp_path, ¤t_exe) + .with_context(|| format!("rename {tmp_path:?} → {current_exe:?}"))?; + + tracing::info!(version, "binary swapped — restarting via systemd"); + + // Systemd will restart us because the unit has Restart=on-failure (or always). + // We exit with code 0 so systemd treats it as a clean restart. + std::process::exit(0); +} + +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") +} + +fn load_verify_key() -> Result<[u8; 32]> { + use base64ct::{Base64, Encoding}; + + // Reuse DASHBOARD_VERIFY_KEY env / file — same key signs commands and release binaries. + let raw = if let Ok(path) = std::env::var("DASHBOARD_VERIFY_KEY_FILE") { + std::fs::read_to_string(&path) + .with_context(|| format!("read DASHBOARD_VERIFY_KEY_FILE={path}"))? + } else { + std::env::var("DASHBOARD_VERIFY_KEY").context("DASHBOARD_VERIFY_KEY not configured")? + }; + + let bytes = Base64::decode_vec(raw.trim()).context("base64 decode DASHBOARD_VERIFY_KEY")?; + bytes + .try_into() + .map_err(|_| anyhow::anyhow!("DASHBOARD_VERIFY_KEY must be 32 bytes")) +} + +fn tmp_path(exe: &Path) -> PathBuf { + let mut p = exe.to_path_buf(); + let name = exe + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("lynx-agent"); + p.set_file_name(format!("{name}.new")); + p +} diff --git a/lynx/agent/src/ws_client.rs b/lynx/agent/src/ws_client.rs new file mode 100644 index 0000000..0be452b --- /dev/null +++ b/lynx/agent/src/ws_client.rs @@ -0,0 +1,200 @@ +use crate::{auth::SignedCommand, handlers::run_verified_command, metrics, state::AppState}; +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"); + 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)).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)).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)).await.is_err() { + break; + } + } + msg = stream.next() => { + #[allow(clippy::collapsible_match)] + match msg { + Some(Ok(Message::Text(text))) => { + 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)).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 heartbeat_payload(state: &AppState) -> Value { + json!({ + "type": "heartbeat", + "agent_id": state.config.agent_id, + "version": state.config.version, + "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()?; + + if 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, + } +} diff --git a/lynx/audit.toml b/lynx/audit.toml new file mode 100644 index 0000000..0e38db7 --- /dev/null +++ b/lynx/audit.toml @@ -0,0 +1,5 @@ +[advisories] +# RUSTSEC-2023-0071: Marvin Attack in rsa crate (via sqlx-mysql). +# sqlx-mysql is an unselected optional dependency — not compiled into any binary. +# Verified: `cargo tree --workspace` does not include sqlx-mysql or rsa. +ignore = ["RUSTSEC-2023-0071"] diff --git a/lynx/dashboard/.env.example b/lynx/dashboard/.env.example new file mode 100644 index 0000000..09d8c24 --- /dev/null +++ b/lynx/dashboard/.env.example @@ -0,0 +1,21 @@ +# Non-secret config (safe to commit) +POSTGRES_USER=lynx +POSTGRES_DB=lynx_dashboard +RUST_LOG=info + +# --- Dev-only env vars (never commit the real .env) ------------------------- +# Copy to .env and fill in values for local development. +# In production these are Podman secrets mounted at /run/secrets/ via *_FILE vars. + +# DATABASE_URL=postgresql://lynx_dashboard_app:@localhost:5433/lynx_dashboard +# REDIS_URL=redis://:@localhost:6379 +# INTERNAL_API_TOKEN= # openssl rand -hex 32 +# KEK= # openssl rand -base64 32 | tr -d '\n' +# PEPPER= # openssl rand -hex 32 +# JWT_SIGN_PRIVATE_KEY= +# JWT_SIGN_PUBLIC_KEY= +# JWT_ENC_PRIVATE_KEY= +# JWT_ENC_PUBLIC_KEY= +# CA_PRIVATE_KEY= +# CA_PUBLIC_KEY= +# SETUP_TOKEN= # openssl rand -hex 32 — one-time bootstrap token diff --git a/lynx/dashboard/docker-compose.dev.yml b/lynx/dashboard/docker-compose.dev.yml new file mode 100644 index 0000000..14cef6f --- /dev/null +++ b/lynx/dashboard/docker-compose.dev.yml @@ -0,0 +1,30 @@ +services: + postgres: + image: docker.io/library/postgres@sha256:bfae840554bdbd4e9f8d097d8e23ffda8aac82866e04ea0d6bc09647234dd359 + environment: + - POSTGRES_USER=lynx + - POSTGRES_DB=lynx_dashboard + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-lynx_dev} + ports: + - "5433:5432" + volumes: + - postgres_dev_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U lynx -d lynx_dashboard"] + interval: 5s + timeout: 3s + retries: 10 + + redis: + image: docker.io/library/redis@sha256:02c2454cd3b6277389101b9d42d009f8518255930f8b06ae33624e324f8c6455 + command: redis-server --save "" --appendonly no + ports: + - "6379:6379" + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 3s + retries: 10 + +volumes: + postgres_dev_data: diff --git a/lynx/dashboard/docker-compose.yml b/lynx/dashboard/docker-compose.yml new file mode 100644 index 0000000..c5ed49d --- /dev/null +++ b/lynx/dashboard/docker-compose.yml @@ -0,0 +1,152 @@ +services: + frontend: + container_name: lynx-dashboard-frontend + build: + context: ./ui + dockerfile: Dockerfile + target: runner + ports: + - "19443:3000" + environment: + - NODE_ENV=production + - BACKEND_URL=http://lynx-dashboard-backend:8080 + depends_on: + backend: + condition: service_healthy + restart: unless-stopped + networks: + - lynx-dashboard-app + + backend: + container_name: lynx-dashboard-backend + build: + context: ./server + dockerfile: Dockerfile + environment: + - DATABASE_URL_FILE=/run/secrets/lynx-dashboard-database-url + - REDIS_URL_FILE=/run/secrets/lynx-dashboard-redis-url + - INTERNAL_API_TOKEN_FILE=/run/secrets/lynx-dashboard-api-token + - KEK_FILE=/run/secrets/lynx-dashboard-kek + - PEPPER_FILE=/run/secrets/lynx-dashboard-pepper + - JWT_SIGN_PRIVATE_KEY_FILE=/run/secrets/lynx-dashboard-jwt-sign-private + - JWT_SIGN_PUBLIC_KEY_FILE=/run/secrets/lynx-dashboard-jwt-sign-public + - JWT_ENC_PRIVATE_KEY_FILE=/run/secrets/lynx-dashboard-jwt-enc-private + - JWT_ENC_PUBLIC_KEY_FILE=/run/secrets/lynx-dashboard-jwt-enc-public + - CA_PRIVATE_KEY_FILE=/run/secrets/lynx-dashboard-ca-private + - CA_PUBLIC_KEY_FILE=/run/secrets/lynx-dashboard-ca-public + - SETUP_TOKEN_FILE=/run/secrets/lynx-dashboard-setup-token + - RUST_LOG=${RUST_LOG:-info} + secrets: + - lynx-dashboard-database-url + - lynx-dashboard-redis-url + - lynx-dashboard-api-token + - lynx-dashboard-kek + - lynx-dashboard-pepper + - lynx-dashboard-jwt-sign-private + - lynx-dashboard-jwt-sign-public + - lynx-dashboard-jwt-enc-private + - lynx-dashboard-jwt-enc-public + - lynx-dashboard-ca-private + - lynx-dashboard-ca-public + - lynx-dashboard-setup-token + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8080/health"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 15s + restart: unless-stopped + networks: + - lynx-dashboard-db + - lynx-dashboard-cache + - lynx-dashboard-app + + postgres: + container_name: lynx-dashboard-postgres + image: docker.io/library/postgres@sha256:bfae840554bdbd4e9f8d097d8e23ffda8aac82866e04ea0d6bc09647234dd359 + environment: + - POSTGRES_USER=postgres + - POSTGRES_DB=lynx_dashboard + - POSTGRES_PASSWORD_FILE=/run/secrets/lynx-dashboard-pg-root + secrets: + - lynx-dashboard-pg-root + - lynx-dashboard-pg-pass + volumes: + - postgres_data:/var/lib/postgresql/data + - ./server/db/init:/docker-entrypoint-initdb.d:ro + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres -d lynx_dashboard"] + interval: 5s + timeout: 3s + retries: 10 + restart: unless-stopped + networks: + - lynx-dashboard-db + + redis: + container_name: lynx-dashboard-redis + image: docker.io/library/redis@sha256:02c2454cd3b6277389101b9d42d009f8518255930f8b06ae33624e324f8c6455 + command: + - sh + - -c + - 'redis-server --save "" --appendonly no --requirepass "$(cat /run/secrets/lynx-dashboard-redis-pass)"' + secrets: + - lynx-dashboard-redis-pass + healthcheck: + test: + - CMD-SHELL + - 'redis-cli -a "$(cat /run/secrets/lynx-dashboard-redis-pass)" ping' + interval: 5s + timeout: 3s + retries: 10 + restart: unless-stopped + networks: + - lynx-dashboard-cache + +volumes: + postgres_data: + +networks: + lynx-dashboard-db: + external: true + lynx-dashboard-cache: + external: true + lynx-dashboard-app: + external: true + +secrets: + lynx-dashboard-pg-root: + external: true + lynx-dashboard-pg-pass: + external: true + lynx-dashboard-redis-pass: + external: true + lynx-dashboard-database-url: + external: true + lynx-dashboard-redis-url: + external: true + lynx-dashboard-api-token: + external: true + lynx-dashboard-kek: + external: true + lynx-dashboard-pepper: + external: true + lynx-dashboard-jwt-sign-private: + external: true + lynx-dashboard-jwt-sign-public: + external: true + lynx-dashboard-jwt-enc-private: + external: true + lynx-dashboard-jwt-enc-public: + external: true + lynx-dashboard-ca-private: + external: true + lynx-dashboard-ca-public: + external: true + lynx-dashboard-setup-token: + external: true diff --git a/lynx/dashboard/secrets/.gitignore b/lynx/dashboard/secrets/.gitignore new file mode 100644 index 0000000..f7af49b --- /dev/null +++ b/lynx/dashboard/secrets/.gitignore @@ -0,0 +1,4 @@ +# Ignore everything in this directory — only .gitignore itself is tracked. +# This prevents accidental commits of secrets, keys, or certificates. +* +!.gitignore diff --git a/lynx/dashboard/server/.gitignore b/lynx/dashboard/server/.gitignore new file mode 100644 index 0000000..c793a92 --- /dev/null +++ b/lynx/dashboard/server/.gitignore @@ -0,0 +1,15 @@ +# 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/dashboard/server/Cargo.toml b/lynx/dashboard/server/Cargo.toml new file mode 100644 index 0000000..342b8e8 --- /dev/null +++ b/lynx/dashboard/server/Cargo.toml @@ -0,0 +1,58 @@ +[package] +name = "lynx-dashboard-server" +version = "0.1.0" +edition = "2021" + +[[bin]] +name = "lynx-dashboard-server" +path = "src/main.rs" + +[dependencies] +tokio = { workspace = true } +clap = { workspace = true } +url = { 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 } +tower = { workspace = true } +tower-http = { workspace = true } + +# database +sqlx = { workspace = true } + +# cache +redis = { workspace = true } + +# auth +argon2 = { workspace = true } +josekit = { workspace = true } +subtle = { workspace = true } +zeroize = { workspace = true } + +# crypto primitives +rand = { workspace = true } +sha2 = { workspace = true } +aes-gcm = { workspace = true } +base64ct = { workspace = true } +ed25519-dalek = { workspace = true } +x25519-dalek = { workspace = true } +rcgen = { workspace = true } +x509-parser = { workspace = true } +rustls = { workspace = true } +tokio-rustls = { workspace = true } + +# HTTP client (command relay to agents) +reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false } + +# types +uuid = { workspace = true } +chrono = { workspace = true } + +[dev-dependencies] +axum-test = "20" +tokio = { version = "1", features = ["macros", "rt-multi-thread"] } +futures-util = { workspace = true } diff --git a/lynx/dashboard/server/Dockerfile b/lynx/dashboard/server/Dockerfile new file mode 100644 index 0000000..101dfa2 --- /dev/null +++ b/lynx/dashboard/server/Dockerfile @@ -0,0 +1,27 @@ +FROM rust:1.87-alpine AS builder +WORKDIR /app + +RUN apk add --no-cache musl-dev pkgconfig openssl-dev + +COPY Cargo.toml Cargo.lock ./ +RUN mkdir src && echo "fn main() {}" > src/main.rs +RUN cargo build --release +RUN rm src/main.rs + +COPY src ./src +RUN touch src/main.rs && cargo build --release + +FROM alpine:3.21 AS runner +RUN apk add --no-cache ca-certificates curl + +RUN addgroup -S lynx && adduser -S lynx -G lynx + +COPY --from=builder /app/target/release/lynx-dashboard-server /usr/local/bin/server + +USER lynx +EXPOSE 8080 + +HEALTHCHECK --interval=10s --timeout=5s --retries=5 \ + CMD curl -f http://localhost:8080/health || exit 1 + +CMD ["/usr/local/bin/server"] diff --git a/lynx/dashboard/server/db/init/01-init.sql b/lynx/dashboard/server/db/init/01-init.sql new file mode 100644 index 0000000..64db0e7 --- /dev/null +++ b/lynx/dashboard/server/db/init/01-init.sql @@ -0,0 +1,17 @@ +-- Creates isolated app user with minimal privileges. +-- Runs once on first PostgreSQL container startup via /docker-entrypoint-initdb.d/. +-- LYNX_APP_PASS is substituted by the init wrapper using the mounted secret. + +\set app_pass `cat /run/secrets/lynx-dashboard-pg-pass` + +CREATE USER lynx_dashboard_app WITH PASSWORD :'app_pass' NOSUPERUSER NOCREATEDB NOCREATEROLE; + +GRANT CONNECT ON DATABASE lynx_dashboard TO lynx_dashboard_app; + +\connect lynx_dashboard + +GRANT USAGE ON SCHEMA public TO lynx_dashboard_app; +ALTER DEFAULT PRIVILEGES IN SCHEMA public + GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO lynx_dashboard_app; +ALTER DEFAULT PRIVILEGES IN SCHEMA public + GRANT USAGE, SELECT ON SEQUENCES TO lynx_dashboard_app; diff --git a/lynx/dashboard/server/migrations/001_users.sql b/lynx/dashboard/server/migrations/001_users.sql new file mode 100644 index 0000000..17e0e47 --- /dev/null +++ b/lynx/dashboard/server/migrations/001_users.sql @@ -0,0 +1,13 @@ +CREATE TABLE users ( + id UUID PRIMARY KEY, + username TEXT NOT NULL UNIQUE, + email_hash TEXT NOT NULL UNIQUE, + email_encrypted BYTEA NOT NULL, + password_hash TEXT NOT NULL, + dek_encrypted BYTEA NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_users_username ON users(username); +CREATE INDEX idx_users_email_hash ON users(email_hash); diff --git a/lynx/dashboard/server/migrations/002_sessions.sql b/lynx/dashboard/server/migrations/002_sessions.sql new file mode 100644 index 0000000..ed3232d --- /dev/null +++ b/lynx/dashboard/server/migrations/002_sessions.sql @@ -0,0 +1,14 @@ +CREATE TABLE sessions ( + id UUID PRIMARY KEY, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + ip TEXT NOT NULL, + user_agent TEXT, + refresh_token_hash TEXT NOT NULL UNIQUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_used_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + expires_at TIMESTAMPTZ NOT NULL +); + +CREATE INDEX idx_sessions_user_id ON sessions(user_id); +CREATE INDEX idx_sessions_refresh_token_hash ON sessions(refresh_token_hash); +CREATE INDEX idx_sessions_expires_at ON sessions(expires_at); diff --git a/lynx/dashboard/server/migrations/003_session_logs.sql b/lynx/dashboard/server/migrations/003_session_logs.sql new file mode 100644 index 0000000..24d3f79 --- /dev/null +++ b/lynx/dashboard/server/migrations/003_session_logs.sql @@ -0,0 +1,9 @@ +-- Append-only. No FK to sessions — logs outlive sessions. +CREATE TABLE session_logs ( + id UUID PRIMARY KEY, + session_id UUID NOT NULL, + reason TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_session_logs_session_id ON session_logs(session_id); diff --git a/lynx/dashboard/server/migrations/004_agents.sql b/lynx/dashboard/server/migrations/004_agents.sql new file mode 100644 index 0000000..e59db52 --- /dev/null +++ b/lynx/dashboard/server/migrations/004_agents.sql @@ -0,0 +1,35 @@ +-- Agents registered with this dashboard instance. +-- One row per agent VPS. + +CREATE TABLE agents ( + id UUID PRIMARY KEY, -- UUID v7, set by agent + name TEXT NOT NULL, + wg_pubkey TEXT NOT NULL UNIQUE, + wg_ip TEXT NOT NULL UNIQUE, -- e.g. "10.100.0.2" + wg_endpoint TEXT, -- agent VPS public IP:port (optional) + api_port INTEGER NOT NULL DEFAULT 9090, + status TEXT NOT NULL DEFAULT 'offline' + CHECK (status IN ('online', 'lockdown', 'offline')), + version TEXT, + last_heartbeat TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_agents_status ON agents(status); + +-- Per CLAUDE.md: agent connection/state events (dashboard-side) +CREATE TABLE agent_events ( + id UUID PRIMARY KEY, + agent_id UUID NOT NULL REFERENCES agents(id) ON DELETE CASCADE, + event TEXT NOT NULL + CHECK (event IN ( + 'connected', 'disconnected', 'lockdown', + 'heartbeat_lost', 'update_applied', + 'nftables_divergence', 'bootstrap_completed' + )), + detail TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_agent_events_agent_id ON agent_events(agent_id); +CREATE INDEX idx_agent_events_created_at ON agent_events(created_at); diff --git a/lynx/dashboard/server/migrations/005_central_audit_log.sql b/lynx/dashboard/server/migrations/005_central_audit_log.sql new file mode 100644 index 0000000..674e1be --- /dev/null +++ b/lynx/dashboard/server/migrations/005_central_audit_log.sql @@ -0,0 +1,24 @@ +-- Central audit log — aggregated from all agents. +-- Hash chain integrity is verified on the dashboard side during sync. + +CREATE TABLE audit_log ( + id UUID PRIMARY KEY, + agent_id UUID NOT NULL REFERENCES agents(id) ON DELETE CASCADE, + 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, + synced_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); +CREATE INDEX idx_audit_log_user_id ON audit_log(user_id) WHERE user_id IS NOT NULL; + +-- Per-agent sync token (SHA-256 hash, for agent→dashboard audit sync auth) +ALTER TABLE agents + ADD COLUMN sync_token_hash TEXT; diff --git a/lynx/dashboard/server/migrations/006_organizations.sql b/lynx/dashboard/server/migrations/006_organizations.sql new file mode 100644 index 0000000..0d43536 --- /dev/null +++ b/lynx/dashboard/server/migrations/006_organizations.sql @@ -0,0 +1,35 @@ +-- Organizations and projects managed by this Lynx instance. + +CREATE TABLE organizations ( + id UUID PRIMARY KEY, + name TEXT NOT NULL, + slug TEXT NOT NULL UNIQUE, + owner_id UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_organizations_owner ON organizations(owner_id); + +-- Organization members (users can belong to multiple orgs) +CREATE TABLE organization_members ( + organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + role TEXT NOT NULL DEFAULT 'member' + CHECK (role IN ('owner', 'admin', 'member', 'viewer')), + joined_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (organization_id, user_id) +); + +-- Projects belong to an organization and target an agent +CREATE TABLE projects ( + id UUID PRIMARY KEY, + organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + agent_id UUID NOT NULL REFERENCES agents(id) ON DELETE RESTRICT, + name TEXT NOT NULL, + slug TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (organization_id, slug) +); + +CREATE INDEX idx_projects_organization ON projects(organization_id); +CREATE INDEX idx_projects_agent ON projects(agent_id); diff --git a/lynx/dashboard/server/migrations/007_rotation_log.sql b/lynx/dashboard/server/migrations/007_rotation_log.sql new file mode 100644 index 0000000..83f490f --- /dev/null +++ b/lynx/dashboard/server/migrations/007_rotation_log.sql @@ -0,0 +1,12 @@ +-- Records all key rotation events (manual + automatic). +-- Per CLAUDE.md: triggered_by null = automatic (update/scheduled) + +CREATE TABLE rotation_log ( + id UUID PRIMARY KEY, + triggered_by UUID REFERENCES users(id) ON DELETE SET NULL, + reason TEXT NOT NULL CHECK (reason IN ('update', 'manual', 'scheduled', 'emergency')), + scope TEXT NOT NULL CHECK (scope IN ('jwt_keys', 'wireguard_psks', 'all', 'certificates')), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_rotation_log_created_at ON rotation_log(created_at DESC); diff --git a/lynx/dashboard/server/migrations/008_update_log.sql b/lynx/dashboard/server/migrations/008_update_log.sql new file mode 100644 index 0000000..db5049e --- /dev/null +++ b/lynx/dashboard/server/migrations/008_update_log.sql @@ -0,0 +1,16 @@ +-- Tracks all auto-update and manual update events across agents. + +CREATE TABLE update_log ( + id UUID PRIMARY KEY, + triggered_by UUID REFERENCES users(id) ON DELETE SET NULL, + version TEXT NOT NULL, + channel TEXT NOT NULL DEFAULT 'stable' CHECK (channel IN ('stable', 'edge')), + scope TEXT NOT NULL CHECK (scope IN ('dashboard', 'agent', 'all')), + agent_id UUID REFERENCES agents(id) ON DELETE SET NULL, + status TEXT NOT NULL CHECK (status IN ('pending', 'success', 'failed')), + error TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_update_log_created_at ON update_log(created_at DESC); +CREATE INDEX idx_update_log_agent_id ON update_log(agent_id); diff --git a/lynx/dashboard/server/migrations/009_agent_certs.sql b/lynx/dashboard/server/migrations/009_agent_certs.sql new file mode 100644 index 0000000..adec405 --- /dev/null +++ b/lynx/dashboard/server/migrations/009_agent_certs.sql @@ -0,0 +1,7 @@ +-- Agent certificates issued by dashboard CA. +-- Stored as serialized SignedCert JSON so agents can verify on first connection. + +ALTER TABLE agents + ADD COLUMN cert_payload TEXT, + ADD COLUMN cert_signature TEXT, + ADD COLUMN cert_expires_at TIMESTAMPTZ; diff --git a/lynx/dashboard/server/migrations/010_white_label.sql b/lynx/dashboard/server/migrations/010_white_label.sql new file mode 100644 index 0000000..fbfd7cb --- /dev/null +++ b/lynx/dashboard/server/migrations/010_white_label.sql @@ -0,0 +1,14 @@ +-- White-label branding stored in PostgreSQL. +-- Single row (id=1 constraint). Updated via dashboard admin UI. + +CREATE TABLE white_label ( + id INTEGER PRIMARY KEY DEFAULT 1 CHECK (id = 1), + company_name TEXT NOT NULL DEFAULT 'Lynx', + logo_url TEXT, + primary_color TEXT NOT NULL DEFAULT '#0f172a', + secondary_color TEXT NOT NULL DEFAULT '#38bdf8', + accent_color TEXT NOT NULL DEFAULT '#6366f1', + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +INSERT INTO white_label (id) VALUES (1); diff --git a/lynx/dashboard/server/migrations/011_data_plane_tunnels.sql b/lynx/dashboard/server/migrations/011_data_plane_tunnels.sql new file mode 100644 index 0000000..03bfa77 --- /dev/null +++ b/lynx/dashboard/server/migrations/011_data_plane_tunnels.sql @@ -0,0 +1,27 @@ +-- Data-plane WireGuard tunnels between agents for cross-agent horizontal scaling. +-- Each row represents a tunnel between two agents for a specific project. +-- Distinct from the management plane (dashboard <-> agent). + +CREATE TABLE data_plane_tunnels ( + id UUID PRIMARY KEY, + project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + agent_a_id UUID NOT NULL REFERENCES agents(id) ON DELETE CASCADE, + agent_b_id UUID NOT NULL REFERENCES agents(id) ON DELETE CASCADE, + -- WireGuard keypairs (public keys only stored; private keys live on agents) + agent_a_pubkey TEXT NOT NULL, + agent_b_pubkey TEXT NOT NULL, + -- WireGuard IPs for the data-plane tunnel (distinct address space) + agent_a_wg_ip TEXT NOT NULL, + agent_b_wg_ip TEXT NOT NULL, + wg_port INTEGER NOT NULL DEFAULT 51821, + -- Replica count on agent_b for this project + replica_count INTEGER NOT NULL DEFAULT 1, + status TEXT NOT NULL DEFAULT 'pending' + CHECK (status IN ('pending', 'active', 'error', 'torn_down')), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (project_id, agent_b_id) +); + +CREATE INDEX idx_data_plane_project ON data_plane_tunnels(project_id); +CREATE INDEX idx_data_plane_agents ON data_plane_tunnels(agent_a_id, agent_b_id); diff --git a/lynx/dashboard/server/migrations/012_domain_config.sql b/lynx/dashboard/server/migrations/012_domain_config.sql new file mode 100644 index 0000000..4ff7cf8 --- /dev/null +++ b/lynx/dashboard/server/migrations/012_domain_config.sql @@ -0,0 +1,18 @@ +-- Singleton row for dashboard domain configuration. +-- id is forced to 1 via CHECK to ensure exactly one row. + +CREATE TABLE domain_config ( + id INTEGER PRIMARY KEY DEFAULT 1 CHECK (id = 1), + domain TEXT, + cert_type TEXT NOT NULL DEFAULT 'self_signed' + CHECK (cert_type IN ('self_signed', 'lets_encrypt')), + cert_expires_at TIMESTAMPTZ, + hsts_enabled BOOLEAN NOT NULL DEFAULT false, + port_19443_open BOOLEAN NOT NULL DEFAULT true, + status TEXT NOT NULL DEFAULT 'unconfigured' + CHECK (status IN ('unconfigured', 'pending', 'active', 'error')), + error_message TEXT, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +INSERT INTO domain_config DEFAULT VALUES; diff --git a/lynx/dashboard/server/migrations/013_migration_state.sql b/lynx/dashboard/server/migrations/013_migration_state.sql new file mode 100644 index 0000000..e9e70f7 --- /dev/null +++ b/lynx/dashboard/server/migrations/013_migration_state.sql @@ -0,0 +1,23 @@ +-- Tracks dashboard-to-dashboard migration state. +-- Only one migration can be active at a time (singleton row). + +CREATE TABLE migration_state ( + id INTEGER PRIMARY KEY DEFAULT 1 CHECK (id = 1), + -- 'idle' | 'preparing' | 'transferring' | 'notifying_agents' | 'waiting_agents' + -- | 'completed' | 'aborted' | 'error' + status TEXT NOT NULL DEFAULT 'idle', + role TEXT NOT NULL DEFAULT 'none' + CHECK (role IN ('none', 'source', 'target')), + -- VPS-A (source): URL of VPS-B + target_url TEXT, + -- VPS-B (target): one-time token shown to admin; stored as hash + migration_token_hash TEXT, + agents_total INTEGER NOT NULL DEFAULT 0, + agents_confirmed INTEGER NOT NULL DEFAULT 0, + error_message TEXT, + started_at TIMESTAMPTZ, + completed_at TIMESTAMPTZ, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +INSERT INTO migration_state DEFAULT VALUES; diff --git a/lynx/dashboard/server/migrations/014_sessions_jti_force_pw.sql b/lynx/dashboard/server/migrations/014_sessions_jti_force_pw.sql new file mode 100644 index 0000000..2774049 --- /dev/null +++ b/lynx/dashboard/server/migrations/014_sessions_jti_force_pw.sql @@ -0,0 +1,3 @@ +ALTER TABLE sessions ADD COLUMN last_jti UUID; + +ALTER TABLE users ADD COLUMN force_password_change BOOLEAN NOT NULL DEFAULT FALSE; diff --git a/lynx/dashboard/server/migrations/015_roles_permissions.sql b/lynx/dashboard/server/migrations/015_roles_permissions.sql new file mode 100644 index 0000000..f9d163f --- /dev/null +++ b/lynx/dashboard/server/migrations/015_roles_permissions.sql @@ -0,0 +1,58 @@ +-- Permissions: fixed set, seeded here, never editable by users +CREATE TABLE permissions ( + id UUID PRIMARY KEY, + key TEXT NOT NULL UNIQUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Roles: created by admins (except the bootstrap admin role) +CREATE TABLE roles ( + id UUID PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + created_by UUID REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Many-to-many: which permissions belong to a role +CREATE TABLE role_permissions ( + id UUID PRIMARY KEY, + role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE, + permission_id UUID NOT NULL REFERENCES permissions(id) ON DELETE CASCADE, + created_by UUID REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(role_id, permission_id) +); + +-- Many-to-many: which roles a user has +CREATE TABLE user_roles ( + id UUID PRIMARY KEY, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE, + created_by UUID REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(user_id, role_id) +); + +CREATE INDEX idx_role_permissions_role_id ON role_permissions(role_id); +CREATE INDEX idx_user_roles_user_id ON user_roles(user_id); +CREATE INDEX idx_user_roles_role_id ON user_roles(role_id); + +-- Seed all permissions (fixed — never change these keys) +INSERT INTO permissions (id, key) VALUES + (gen_random_uuid(), 'vps:read'), + (gen_random_uuid(), 'vps:create'), + (gen_random_uuid(), 'vps:edit'), + (gen_random_uuid(), 'vps:delete'), + (gen_random_uuid(), 'vps:*'), + (gen_random_uuid(), 'org:read'), + (gen_random_uuid(), 'org:create'), + (gen_random_uuid(), 'org:edit'), + (gen_random_uuid(), 'org:delete'), + (gen_random_uuid(), 'org:*'), + (gen_random_uuid(), 'project:read'), + (gen_random_uuid(), 'project:create'), + (gen_random_uuid(), 'project:edit'), + (gen_random_uuid(), 'project:delete'), + (gen_random_uuid(), 'project:*'), + (gen_random_uuid(), '*:*'); diff --git a/lynx/dashboard/server/migrations/016_agent_events_types.sql b/lynx/dashboard/server/migrations/016_agent_events_types.sql new file mode 100644 index 0000000..2f1f7e5 --- /dev/null +++ b/lynx/dashboard/server/migrations/016_agent_events_types.sql @@ -0,0 +1,22 @@ +-- Extend agent_events with event types discovered during implementation. +-- The original CHECK constraint was too narrow; ALTER TABLE ... ADD CONSTRAINT is the +-- cleanest way to add values to an existing CHECK without data migration. + +ALTER TABLE agent_events DROP CONSTRAINT IF EXISTS agent_events_event_check; + +ALTER TABLE agent_events ADD CONSTRAINT agent_events_event_check CHECK (event IN ( + 'connected', + 'disconnected', + 'lockdown', + 'heartbeat_lost', + 'rebooting', + 'update_applied', + 'nftables_divergence', + 'bootstrap_completed', + 'conflicting_software_detected', + 'nginx_unexpected_stop', + 'nginx_config_tampered', + 'mtls_cert_expired', + 'audit_integrity_failure', + 'wg_offline' +)); diff --git a/lynx/dashboard/server/migrations/017_system_config.sql b/lynx/dashboard/server/migrations/017_system_config.sql new file mode 100644 index 0000000..4cdc375 --- /dev/null +++ b/lynx/dashboard/server/migrations/017_system_config.sql @@ -0,0 +1,7 @@ +-- Key-value store for system-level metadata (e.g. setup_token_issued_at). +CREATE TABLE system_config ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); diff --git a/lynx/dashboard/server/migrations/018_ip_pool.sql b/lynx/dashboard/server/migrations/018_ip_pool.sql new file mode 100644 index 0000000..6afef5b --- /dev/null +++ b/lynx/dashboard/server/migrations/018_ip_pool.sql @@ -0,0 +1,13 @@ +-- WireGuard management plane IP pool (10.100.0.0/16). +-- ip = NULL agent_id → free; filled → assigned to that agent. +CREATE TABLE ip_pool ( + ip INET PRIMARY KEY, + agent_id UUID REFERENCES agents(id) ON DELETE SET NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Pre-populate the usable range 10.100.0.2 – 10.100.0.254 (first /24). +-- The dashboard is always 10.100.0.1; extend with additional INSERTs as needed. +INSERT INTO ip_pool (ip) +SELECT ('10.100.0.' || g)::INET +FROM generate_series(2, 254) AS g; diff --git a/lynx/dashboard/server/migrations/019_security_alerts.sql b/lynx/dashboard/server/migrations/019_security_alerts.sql new file mode 100644 index 0000000..b01ce85 --- /dev/null +++ b/lynx/dashboard/server/migrations/019_security_alerts.sql @@ -0,0 +1,13 @@ +-- Security alerts — written on detection, read by frontend via WS or polling. +-- All alerts are append-only; acknowledged_at marks admin has seen it. +CREATE TABLE security_alerts ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + kind TEXT NOT NULL, -- rate_limit_hit, intercepted, nftables_divergence, etc. + detail TEXT, + agent_id UUID REFERENCES agents(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + acknowledged_at TIMESTAMPTZ +); + +CREATE INDEX security_alerts_unacked ON security_alerts (created_at) + WHERE acknowledged_at IS NULL; diff --git a/lynx/dashboard/server/migrations/020_agents_is_local.sql b/lynx/dashboard/server/migrations/020_agents_is_local.sql new file mode 100644 index 0000000..efad8ec --- /dev/null +++ b/lynx/dashboard/server/migrations/020_agents_is_local.sql @@ -0,0 +1 @@ +ALTER TABLE agents ADD COLUMN is_local_agent BOOLEAN NOT NULL DEFAULT FALSE; diff --git a/lynx/dashboard/server/migrations/021_nftables_rules.sql b/lynx/dashboard/server/migrations/021_nftables_rules.sql new file mode 100644 index 0000000..9e2e14c --- /dev/null +++ b/lynx/dashboard/server/migrations/021_nftables_rules.sql @@ -0,0 +1,41 @@ +-- nftables rule management +-- scope: 'global' (all agents) or 'local' (specific agent) +-- kind: type of rule + +CREATE TABLE nftables_rules ( + id UUID PRIMARY KEY, + scope TEXT NOT NULL CHECK (scope IN ('global', 'local')), + agent_id UUID REFERENCES agents(id) ON DELETE CASCADE, + kind TEXT NOT NULL CHECK (kind IN ('allow_port', 'block_port', 'allow_ip', 'block_ip', 'rate_limit')), + port INTEGER, + protocol TEXT CHECK (protocol IN ('tcp', 'udp', 'both')), + ip_list TEXT[] NOT NULL DEFAULT '{}', + ip_version TEXT NOT NULL DEFAULT 'both' CHECK (ip_version IN ('ipv4', 'ipv6', 'both')), + rate_per_min INTEGER, + description TEXT, + priority INTEGER NOT NULL DEFAULT 0, + enabled BOOLEAN NOT NULL DEFAULT true, + created_by UUID REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT local_needs_agent CHECK (scope = 'global' OR agent_id IS NOT NULL), + CONSTRAINT port_rule_has_port CHECK ( + kind NOT IN ('allow_port', 'block_port', 'rate_limit') OR port IS NOT NULL + ), + CONSTRAINT rate_limit_has_rate CHECK ( + kind != 'rate_limit' OR rate_per_min IS NOT NULL + ) +); + +CREATE INDEX idx_nftables_rules_scope ON nftables_rules(scope); +CREATE INDEX idx_nftables_rules_agent_id ON nftables_rules(agent_id); +CREATE INDEX idx_nftables_rules_enabled ON nftables_rules(enabled); + +-- Track sync status of global rules per agent +CREATE TABLE global_rule_sync ( + rule_id UUID NOT NULL REFERENCES nftables_rules(id) ON DELETE CASCADE, + agent_id UUID NOT NULL REFERENCES agents(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + synced_at TIMESTAMPTZ, + PRIMARY KEY (rule_id, agent_id) +); diff --git a/lynx/dashboard/server/migrations/022_user_preferences.sql b/lynx/dashboard/server/migrations/022_user_preferences.sql new file mode 100644 index 0000000..172aeea --- /dev/null +++ b/lynx/dashboard/server/migrations/022_user_preferences.sql @@ -0,0 +1,7 @@ +-- Per-user UI preferences: theme and locale +CREATE TABLE user_preferences ( + user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + theme TEXT NOT NULL DEFAULT 'system', -- 'light' | 'dark' | 'system' + locale TEXT NOT NULL DEFAULT 'en', + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); diff --git a/lynx/dashboard/server/migrations/023_single_session.sql b/lynx/dashboard/server/migrations/023_single_session.sql new file mode 100644 index 0000000..ceaae13 --- /dev/null +++ b/lynx/dashboard/server/migrations/023_single_session.sql @@ -0,0 +1 @@ +ALTER TABLE users ADD COLUMN single_session BOOLEAN NOT NULL DEFAULT FALSE; diff --git a/lynx/dashboard/server/src/admin/handlers/alerts.rs b/lynx/dashboard/server/src/admin/handlers/alerts.rs new file mode 100644 index 0000000..e258722 --- /dev/null +++ b/lynx/dashboard/server/src/admin/handlers/alerts.rs @@ -0,0 +1,46 @@ +use crate::{error::AppError, state::AppState}; +use axum::{extract::State, response::IntoResponse, Json}; +use serde::Serialize; +use uuid::Uuid; + +#[derive(Serialize)] +pub struct AlertRow { + pub id: Uuid, + pub kind: String, + pub detail: Option, + pub agent_id: Option, + pub created_at: chrono::DateTime, +} + +pub async fn list_alerts(State(state): State) -> Result { + let rows = sqlx::query_as!( + AlertRow, + "SELECT id, kind, detail, agent_id, created_at + FROM security_alerts + WHERE acknowledged_at IS NULL + ORDER BY created_at DESC + LIMIT 100" + ) + .fetch_all(&state.db) + .await?; + + Ok(Json(rows)) +} + +pub async fn acknowledge_alert( + State(state): State, + axum::extract::Path(id): axum::extract::Path, +) -> Result { + let rows = sqlx::query!( + "UPDATE security_alerts SET acknowledged_at = NOW() WHERE id = $1", + id + ) + .execute(&state.db) + .await?; + + if rows.rows_affected() == 0 { + return Err(AppError::NotFound); + } + + Ok(axum::http::StatusCode::NO_CONTENT) +} diff --git a/lynx/dashboard/server/src/admin/handlers/logs.rs b/lynx/dashboard/server/src/admin/handlers/logs.rs new file mode 100644 index 0000000..6ea2d59 --- /dev/null +++ b/lynx/dashboard/server/src/admin/handlers/logs.rs @@ -0,0 +1,72 @@ +use crate::{auth::middleware::AuthUser, error::AppError, state::AppState}; +use axum::{ + extract::{Extension, State}, + response::IntoResponse, + Json, +}; + +pub async fn list_rotation_log( + State(state): State, + Extension(_user): Extension, +) -> Result { + let logs = sqlx::query!( + r#" + SELECT id, triggered_by, reason, scope, created_at + FROM rotation_log + ORDER BY created_at DESC + LIMIT 50 + "# + ) + .fetch_all(&state.db) + .await?; + + let result: Vec<_> = logs + .into_iter() + .map(|r| { + serde_json::json!({ + "id": r.id, + "triggered_by": r.triggered_by, + "reason": r.reason, + "scope": r.scope, + "created_at": r.created_at, + }) + }) + .collect(); + + Ok(Json(result)) +} + +pub async fn list_update_log( + State(state): State, + Extension(_user): Extension, +) -> Result { + let logs = sqlx::query!( + r#" + SELECT id, triggered_by, version, channel, scope, agent_id, status, error, created_at + FROM update_log + ORDER BY created_at DESC + LIMIT 50 + "# + ) + .fetch_all(&state.db) + .await?; + + let result: Vec<_> = logs + .into_iter() + .map(|r| { + serde_json::json!({ + "id": r.id, + "triggered_by": r.triggered_by, + "version": r.version, + "channel": r.channel, + "scope": r.scope, + "agent_id": r.agent_id, + "status": r.status, + "error": r.error, + "created_at": r.created_at, + }) + }) + .collect(); + + Ok(Json(result)) +} diff --git a/lynx/dashboard/server/src/admin/handlers/mod.rs b/lynx/dashboard/server/src/admin/handlers/mod.rs new file mode 100644 index 0000000..0ce3a54 --- /dev/null +++ b/lynx/dashboard/server/src/admin/handlers/mod.rs @@ -0,0 +1,21 @@ +mod alerts; +mod logs; +mod roles; +pub mod rotation; +mod sessions; +mod updates; +mod users; + +pub use alerts::{acknowledge_alert, list_alerts}; +pub use logs::{list_rotation_log, list_update_log}; +pub use roles::{ + add_role_permission, add_user_role, create_role, delete_role, delete_user, list_permissions, + list_roles, list_users, remove_role_permission, remove_user_role, +}; +pub use rotation::rotate_keys; +pub use sessions::{list_sessions, revoke_session}; +pub use updates::{trigger_update, update_check}; +pub use users::{ + admin_revoke_session, force_password_change, force_password_change_all, revoke_all_sessions, + revoke_user_sessions, +}; diff --git a/lynx/dashboard/server/src/admin/handlers/roles.rs b/lynx/dashboard/server/src/admin/handlers/roles.rs new file mode 100644 index 0000000..91a9f33 --- /dev/null +++ b/lynx/dashboard/server/src/admin/handlers/roles.rs @@ -0,0 +1,427 @@ +use crate::{auth::middleware::AuthUser, error::AppError, state::AppState}; +use axum::{ + extract::{Extension, Path, State}, + response::IntoResponse, + Json, +}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +// ── Response types ───────────────────────────────────────────────────────── + +#[derive(Serialize)] +pub struct UserRow { + pub id: Uuid, + pub username: String, + pub force_password_change: bool, + pub created_at: chrono::DateTime, + pub roles: Vec, +} + +#[derive(Serialize)] +pub struct RoleRef { + pub id: Uuid, + pub name: String, +} + +#[derive(Serialize)] +pub struct RoleRow { + pub id: Uuid, + pub name: String, + pub permissions: Vec, +} + +#[derive(Serialize)] +pub struct PermRef { + pub id: Uuid, + pub key: String, +} + +#[derive(Deserialize)] +pub struct CreateRoleBody { + pub name: String, +} + +// ── Handlers ─────────────────────────────────────────────────────────────── + +/// GET /admin/users +pub async fn list_users(State(state): State) -> Result { + let users = sqlx::query!( + r#"SELECT id, username, force_password_change, created_at FROM users ORDER BY created_at"# + ) + .fetch_all(&state.db) + .await?; + + let mut result: Vec = Vec::with_capacity(users.len()); + + for u in users { + let roles = sqlx::query!( + r#"SELECT r.id, r.name FROM user_roles ur JOIN roles r ON r.id = ur.role_id WHERE ur.user_id = $1 ORDER BY r.name"#, + u.id + ) + .fetch_all(&state.db) + .await?; + + result.push(UserRow { + id: u.id, + username: u.username, + force_password_change: u.force_password_change, + created_at: u.created_at, + roles: roles + .into_iter() + .map(|r| RoleRef { + id: r.id, + name: r.name, + }) + .collect(), + }); + } + + Ok(Json(result)) +} + +/// DELETE /admin/users/:id +pub async fn delete_user( + State(state): State, + Extension(caller): Extension, + Path(user_id): Path, +) -> Result { + if user_id == caller.user_id { + return Err(AppError::BadRequest("cannot delete your own account")); + } + + // Guard: don't delete the last admin + let is_admin: bool = sqlx::query_scalar!( + r#"SELECT EXISTS( + SELECT 1 FROM user_roles ur + JOIN role_permissions rp ON rp.role_id = ur.role_id + JOIN permissions p ON p.id = rp.permission_id + WHERE ur.user_id = $1 AND p.key = '*:*' + ) AS "exists!""#, + user_id + ) + .fetch_one(&state.db) + .await?; + + if is_admin { + let admin_count: i64 = sqlx::query_scalar!( + r#"SELECT COUNT(DISTINCT ur.user_id) FROM user_roles ur + JOIN role_permissions rp ON rp.role_id = ur.role_id + JOIN permissions p ON p.id = rp.permission_id + WHERE p.key = '*:*'"# + ) + .fetch_one(&state.db) + .await? + .unwrap_or(0); + + if admin_count <= 1 { + return Err(AppError::BadRequest("cannot delete the last admin account")); + } + } + + let rows = sqlx::query!("DELETE FROM users WHERE id = $1", user_id) + .execute(&state.db) + .await?; + + if rows.rows_affected() == 0 { + return Err(AppError::NotFound); + } + + Ok(axum::http::StatusCode::NO_CONTENT) +} + +/// GET /admin/permissions +pub async fn list_permissions( + State(state): State, +) -> Result { + let perms = sqlx::query!("SELECT id, key FROM permissions ORDER BY key") + .fetch_all(&state.db) + .await?; + + let result: Vec = perms + .into_iter() + .map(|p| PermRef { + id: p.id, + key: p.key, + }) + .collect(); + Ok(Json(result)) +} + +/// GET /admin/roles +pub async fn list_roles(State(state): State) -> Result { + let roles = sqlx::query!("SELECT id, name FROM roles ORDER BY name") + .fetch_all(&state.db) + .await?; + + let mut result: Vec = Vec::with_capacity(roles.len()); + + for r in roles { + let perms = sqlx::query!( + r#"SELECT p.id, p.key FROM role_permissions rp JOIN permissions p ON p.id = rp.permission_id WHERE rp.role_id = $1 ORDER BY p.key"#, + r.id + ) + .fetch_all(&state.db) + .await?; + + result.push(RoleRow { + id: r.id, + name: r.name, + permissions: perms + .into_iter() + .map(|p| PermRef { + id: p.id, + key: p.key, + }) + .collect(), + }); + } + + Ok(Json(result)) +} + +/// POST /admin/roles +pub async fn create_role( + State(state): State, + Extension(caller): Extension, + Json(body): Json, +) -> Result { + let name = body.name.trim().to_string(); + if name.is_empty() || name.len() > 64 { + return Err(AppError::Validation( + "role name must be 1–64 characters".into(), + )); + } + + let id = Uuid::now_v7(); + sqlx::query!( + "INSERT INTO roles (id, name, created_by) VALUES ($1, $2, $3)", + id, + name, + caller.user_id, + ) + .execute(&state.db) + .await + .map_err(|e| { + if e.to_string().contains("unique") { + AppError::Conflict("role name already exists") + } else { + AppError::from(e) + } + })?; + + Ok(( + axum::http::StatusCode::CREATED, + Json(serde_json::json!({ "id": id, "name": name })), + )) +} + +/// DELETE /admin/roles/:id +pub async fn delete_role( + State(state): State, + Path(role_id): Path, +) -> Result { + // Guard: don't delete role if it's the only source of *:* for any user + let admin_users_via_this_role: i64 = sqlx::query_scalar!( + r#"SELECT COUNT(DISTINCT ur.user_id) FROM user_roles ur + JOIN role_permissions rp ON rp.role_id = ur.role_id + JOIN permissions p ON p.id = rp.permission_id + WHERE ur.role_id = $1 AND p.key = '*:*'"#, + role_id + ) + .fetch_one(&state.db) + .await? + .unwrap_or(0); + + if admin_users_via_this_role > 0 { + // Check if those users have another role granting *:* + let users_without_other_admin: i64 = sqlx::query_scalar!( + r#"SELECT COUNT(DISTINCT ur.user_id) FROM user_roles ur + JOIN role_permissions rp ON rp.role_id = ur.role_id + JOIN permissions p ON p.id = rp.permission_id + WHERE ur.role_id = $1 AND p.key = '*:*' + AND NOT EXISTS ( + SELECT 1 FROM user_roles ur2 + JOIN role_permissions rp2 ON rp2.role_id = ur2.role_id + JOIN permissions p2 ON p2.id = rp2.permission_id + WHERE ur2.user_id = ur.user_id AND ur2.role_id != $1 AND p2.key = '*:*' + )"#, + role_id + ) + .fetch_one(&state.db) + .await? + .unwrap_or(0); + + if users_without_other_admin > 0 { + return Err(AppError::BadRequest( + "deleting this role would leave users without admin access", + )); + } + } + + let rows = sqlx::query!("DELETE FROM roles WHERE id = $1", role_id) + .execute(&state.db) + .await?; + + if rows.rows_affected() == 0 { + return Err(AppError::NotFound); + } + + Ok(axum::http::StatusCode::NO_CONTENT) +} + +/// POST /admin/roles/:id/permissions/:perm_id +pub async fn add_role_permission( + State(state): State, + Extension(caller): Extension, + Path((role_id, perm_id)): Path<(Uuid, Uuid)>, +) -> Result { + let id = Uuid::now_v7(); + sqlx::query!( + "INSERT INTO role_permissions (id, role_id, permission_id, created_by) VALUES ($1, $2, $3, $4) + ON CONFLICT (role_id, permission_id) DO NOTHING", + id, + role_id, + perm_id, + caller.user_id, + ) + .execute(&state.db) + .await?; + + Ok(axum::http::StatusCode::NO_CONTENT) +} + +/// DELETE /admin/roles/:id/permissions/:perm_id +pub async fn remove_role_permission( + State(state): State, + Path((role_id, perm_id)): Path<(Uuid, Uuid)>, +) -> Result { + // Guard: don't remove *:* if it would leave someone without an admin role + let perm_key: Option = + sqlx::query_scalar!("SELECT key FROM permissions WHERE id = $1", perm_id) + .fetch_optional(&state.db) + .await?; + + if perm_key.as_deref() == Some("*:*") { + let users_losing_admin: i64 = sqlx::query_scalar!( + r#"SELECT COUNT(DISTINCT ur.user_id) FROM user_roles ur WHERE ur.role_id = $1 + AND NOT EXISTS ( + SELECT 1 FROM user_roles ur2 + JOIN role_permissions rp2 ON rp2.role_id = ur2.role_id + JOIN permissions p2 ON p2.id = rp2.permission_id + WHERE ur2.user_id = ur.user_id AND ur2.role_id != $1 AND p2.key = '*:*' + )"#, + role_id + ) + .fetch_one(&state.db) + .await? + .unwrap_or(0); + + if users_losing_admin > 0 { + return Err(AppError::BadRequest( + "removing this permission would leave users without admin access", + )); + } + } + + sqlx::query!( + "DELETE FROM role_permissions WHERE role_id = $1 AND permission_id = $2", + role_id, + perm_id, + ) + .execute(&state.db) + .await?; + + Ok(axum::http::StatusCode::NO_CONTENT) +} + +/// POST /admin/users/:id/roles/:role_id +pub async fn add_user_role( + State(state): State, + Extension(caller): Extension, + Path((user_id, role_id)): Path<(Uuid, Uuid)>, +) -> Result { + // Verify user and role exist + let user_exists: bool = sqlx::query_scalar!( + r#"SELECT EXISTS(SELECT 1 FROM users WHERE id = $1) AS "exists!""#, + user_id + ) + .fetch_one(&state.db) + .await?; + + if !user_exists { + return Err(AppError::NotFound); + } + + let id = Uuid::now_v7(); + sqlx::query!( + "INSERT INTO user_roles (id, user_id, role_id, created_by) VALUES ($1, $2, $3, $4) + ON CONFLICT (user_id, role_id) DO NOTHING", + id, + user_id, + role_id, + caller.user_id, + ) + .execute(&state.db) + .await + .map_err(|e| { + if e.to_string().contains("foreign key") { + AppError::NotFound + } else { + AppError::from(e) + } + })?; + + Ok(axum::http::StatusCode::NO_CONTENT) +} + +/// DELETE /admin/users/:id/roles/:role_id +pub async fn remove_user_role( + State(state): State, + Extension(caller): Extension, + Path((user_id, role_id)): Path<(Uuid, Uuid)>, +) -> Result { + // Guard: can't remove own last admin role + if user_id == caller.user_id { + let is_last_admin_role: bool = sqlx::query_scalar!( + r#"SELECT EXISTS( + SELECT 1 FROM role_permissions rp + JOIN permissions p ON p.id = rp.permission_id + WHERE rp.role_id = $1 AND p.key = '*:*' + ) AS "exists!""#, + role_id + ) + .fetch_one(&state.db) + .await?; + + if is_last_admin_role { + let other_admin_roles: i64 = sqlx::query_scalar!( + r#"SELECT COUNT(*) FROM user_roles ur + JOIN role_permissions rp ON rp.role_id = ur.role_id + JOIN permissions p ON p.id = rp.permission_id + WHERE ur.user_id = $1 AND ur.role_id != $2 AND p.key = '*:*'"#, + user_id, + role_id + ) + .fetch_one(&state.db) + .await? + .unwrap_or(0); + + if other_admin_roles == 0 { + return Err(AppError::BadRequest( + "cannot remove your own last admin role", + )); + } + } + } + + sqlx::query!( + "DELETE FROM user_roles WHERE user_id = $1 AND role_id = $2", + user_id, + role_id, + ) + .execute(&state.db) + .await?; + + Ok(axum::http::StatusCode::NO_CONTENT) +} diff --git a/lynx/dashboard/server/src/admin/handlers/rotation.rs b/lynx/dashboard/server/src/admin/handlers/rotation.rs new file mode 100644 index 0000000..0725a0e --- /dev/null +++ b/lynx/dashboard/server/src/admin/handlers/rotation.rs @@ -0,0 +1,452 @@ +use crate::{auth::middleware::AuthUser, crypto::cmd, error::AppError, state::AppState}; +use axum::{ + extract::{Extension, State}, + response::IntoResponse, + Json, +}; +use serde::Deserialize; +use uuid::Uuid; + +#[derive(Debug, Deserialize)] +pub struct RotateRequest { + pub scope: String, + pub reason: Option, +} + +pub async fn rotate_keys( + State(state): State, + Extension(user): Extension, + Json(req): Json, +) -> Result { + let valid_scopes = ["jwt_keys", "wireguard_psks", "all", "certificates"]; + if !valid_scopes.contains(&req.scope.as_str()) { + return Err(AppError::BadRequest("invalid scope")); + } + + let reason = req.reason.as_deref().unwrap_or("manual"); + let valid_reasons = ["manual", "emergency", "scheduled", "update"]; + if !valid_reasons.contains(&reason) { + return Err(AppError::BadRequest("invalid reason")); + } + + match req.scope.as_str() { + "jwt_keys" | "all" => { + rotate_jwt_sessions(&state).await?; + } + _ => {} + } + + if matches!(req.scope.as_str(), "wireguard_psks" | "all") { + rotate_wireguard_psks(&state, user.user_id).await?; + } + + if matches!(req.scope.as_str(), "certificates" | "all") { + rotate_agent_certs(&state).await?; + } + + if req.scope == "all" { + if let Err(e) = rotate_pg_app_password(&state).await { + tracing::warn!("manual rotation: PostgreSQL password rotation failed: {e}"); + } + if let Err(e) = rotate_redis_password(&state).await { + tracing::warn!("manual rotation: Redis password rotation failed: {e}"); + } + } + + let log_id = Uuid::now_v7(); + sqlx::query!( + r#" + INSERT INTO rotation_log (id, triggered_by, reason, scope) + VALUES ($1, $2, $3, $4) + "#, + log_id, + user.user_id, + reason, + req.scope, + ) + .execute(&state.db) + .await?; + + tracing::info!( + user_id = %user.user_id, + scope = %req.scope, + reason, + "key rotation executed" + ); + + Ok(Json(serde_json::json!({ + "ok": true, + "scope": req.scope, + "rotation_id": log_id, + "sessions_invalidated": matches!(req.scope.as_str(), "jwt_keys" | "all"), + }))) +} + +pub async fn rotate_jwt_sessions(state: &AppState) -> Result<(), AppError> { + use redis::AsyncCommands; + let mut redis = state.redis.clone(); + + let keys: Vec = redis + .keys("access:*") + .await + .map_err(|e| AppError::Internal(anyhow::Error::from(e)))?; + + if !keys.is_empty() { + redis::cmd("DEL") + .arg(&keys) + .query_async::<()>(&mut redis) + .await + .map_err(|e| AppError::Internal(anyhow::Error::from(e)))?; + + tracing::info!(count = keys.len(), "flushed JWT tokens from Redis"); + } + + // UUID v7 required — generate in Rust, not via gen_random_uuid() (UUID v4) + let session_ids = sqlx::query_scalar!("SELECT id FROM sessions WHERE expires_at > NOW()") + .fetch_all(&state.db) + .await?; + + for session_id in &session_ids { + let log_id = Uuid::now_v7(); + sqlx::query!( + "INSERT INTO session_logs (id, session_id, reason) VALUES ($1, $2, 'jwt_rotation')", + log_id, + session_id, + ) + .execute(&state.db) + .await?; + } + + sqlx::query!("DELETE FROM sessions WHERE expires_at > NOW()") + .execute(&state.db) + .await?; + + Ok(()) +} + +pub async fn rotate_wireguard_psks(state: &AppState, triggered_by: Uuid) -> Result<(), AppError> { + use crate::agents::wg; + use std::io::Write; + + let agents = sqlx::query!( + "SELECT id, wg_pubkey, wg_ip::text AS wg_ip, api_port FROM agents WHERE status = 'online'" + ) + .fetch_all(&state.db) + .await?; + + let client = crate::agents::client::build_agent_client(&state.config); + + for agent in &agents { + // Generate new PSK and persist to Podman secret (replaces old one). + let new_psk = match wg::create_psk(agent.id) { + Ok(p) => p, + Err(e) => { + tracing::warn!(agent_id = %agent.id, "PSK generation failed: {e} — skipping"); + continue; + } + }; + + // Update WireGuard interface on dashboard side. + let psk_update = std::process::Command::new("wg") + .args([ + "set", + "wg-lynx-dash", + "peer", + &agent.wg_pubkey, + "preshared-key", + "/dev/stdin", + ]) + .stdin(std::process::Stdio::piped()) + .spawn() + .and_then(|mut child| { + if let Some(stdin) = child.stdin.as_mut() { + let _ = stdin.write_all(new_psk.as_bytes()); + } + child.wait() + }); + + if let Err(e) = psk_update { + tracing::warn!(agent_id = %agent.id, "wg set preshared-key failed: {e}"); + } + + // Update in-memory PSK cache. + state + .wg_psks + .write() + .await + .insert(agent.id, new_psk.clone()); + + // Send new PSK to agent via signed command. + let command = serde_json::json!({ + "type": "wg.rotate_psk", + "new_psk": *new_psk, + }); + + let signed = cmd::sign_command(&state.config, agent.id, triggered_by, "write", &command) + .map_err(AppError::Internal)?; + + let url = format!("http://{}:{}/cmd", agent.wg_ip, agent.api_port); + + let _ = client + .post(&url) + .header( + "Authorization", + format!("Bearer {}", &*state.config.internal_token), + ) + .json(&signed) + .send() + .await; + + tracing::info!(agent_id = %agent.id, "WireGuard PSK rotated"); + } + + Ok(()) +} + +async fn rotate_agent_certs(state: &AppState) -> Result<(), AppError> { + use crate::crypto::pki; + + let triggered_by = Uuid::nil(); + + let agents = sqlx::query!("SELECT id, wg_ip, api_port, status FROM agents") + .fetch_all(&state.db) + .await?; + + let client = crate::agents::client::build_agent_client(&state.config); + + for agent in &agents { + let cert = + pki::issue_cert(&state.config.ca_private_seed, agent.id).map_err(AppError::Internal)?; + + sqlx::query!( + "UPDATE agents SET cert_payload = $1, cert_signature = $2, cert_expires_at = NOW() + INTERVAL '90 days' WHERE id = $3", + cert.payload, + cert.signature, + agent.id, + ) + .execute(&state.db) + .await?; + + tracing::debug!(agent_id = %agent.id, "cert re-issued in DB"); + + if agent.status == "online" { + let command = serde_json::json!({ + "type": "cert.update", + "payload": cert.payload, + "signature": cert.signature, + }); + + let signed = + cmd::sign_command(&state.config, agent.id, triggered_by, "write", &command) + .map_err(AppError::Internal)?; + + let url = format!("http://{}:{}/cmd", agent.wg_ip, agent.api_port); + let result = client + .post(&url) + .header( + "Authorization", + format!("Bearer {}", &*state.config.internal_token), + ) + .json(&signed) + .send() + .await; + + match result { + Ok(r) if r.status().is_success() => { + tracing::info!(agent_id = %agent.id, "cert pushed to online agent") + } + Ok(r) => { + tracing::warn!(agent_id = %agent.id, status = %r.status(), "cert push returned non-2xx") + } + Err(e) => tracing::warn!(agent_id = %agent.id, "cert push failed: {e}"), + } + } + } + + tracing::info!(count = agents.len(), "agent certs rotated"); + Ok(()) +} + +/// Rotate `lynx_dashboard_app` PostgreSQL password. +/// +/// Uses the current pool connection (still authenticated) to issue ALTER USER, +/// then replaces the Podman secret so the new password survives a backend restart. +/// Existing pool connections remain valid until they close; new connections will +/// use the updated secret on the next backend start. +pub async fn rotate_pg_app_password(state: &AppState) -> Result<(), AppError> { + use rand::RngCore; + + let mut buf = [0u8; 24]; + rand::rngs::OsRng.fill_bytes(&mut buf); + let new_pass: String = buf.iter().map(|b| format!("{b:02x}")).collect(); + + sqlx::query(&format!( + "ALTER USER lynx_dashboard_app PASSWORD '{}'", + new_pass.replace('\'', "''") + )) + .execute(&state.db) + .await + .map_err(|e| AppError::Internal(anyhow::Error::from(e)))?; + + let status = std::process::Command::new("podman") + .args([ + "secret", + "create", + "--replace", + "lynx-dashboard-pg-pass", + "-", + ]) + .stdin(std::process::Stdio::piped()) + .spawn() + .and_then(|mut child| { + use std::io::Write; + if let Some(stdin) = child.stdin.as_mut() { + let _ = stdin.write_all(new_pass.as_bytes()); + } + child.wait() + }); + + match status { + Ok(s) if s.success() => { + tracing::info!("PostgreSQL app password rotated and Podman secret updated"); + } + Ok(s) => { + tracing::warn!(code = ?s.code(), "podman secret create --replace failed for pg-pass"); + } + Err(e) => { + tracing::warn!("podman secret replace pg-pass spawn error: {e}"); + } + } + + Ok(()) +} + +/// Rotate Redis password via `CONFIG SET requirepass`. +/// +/// Takes effect immediately for new connections. Existing ConnectionManager +/// connections remain valid until they are recycled; the Podman secret is updated +/// so the new password is used after the next backend restart. +pub async fn rotate_redis_password(state: &AppState) -> Result<(), AppError> { + use rand::RngCore; + + let mut buf = [0u8; 24]; + rand::rngs::OsRng.fill_bytes(&mut buf); + let new_pass: String = buf.iter().map(|b| format!("{b:02x}")).collect(); + + let mut redis = state.redis.clone(); + redis::cmd("CONFIG") + .arg("SET") + .arg("requirepass") + .arg(&new_pass) + .query_async::<()>(&mut redis) + .await + .map_err(|e| AppError::Internal(anyhow::Error::from(e)))?; + + let status = std::process::Command::new("podman") + .args([ + "secret", + "create", + "--replace", + "lynx-dashboard-redis-pass", + "-", + ]) + .stdin(std::process::Stdio::piped()) + .spawn() + .and_then(|mut child| { + use std::io::Write; + if let Some(stdin) = child.stdin.as_mut() { + let _ = stdin.write_all(new_pass.as_bytes()); + } + child.wait() + }); + + match status { + Ok(s) if s.success() => { + tracing::info!("Redis password rotated and Podman secret updated"); + } + Ok(s) => { + tracing::warn!(code = ?s.code(), "podman secret create --replace failed for redis-pass"); + } + Err(e) => { + tracing::warn!("podman secret replace redis-pass spawn error: {e}"); + } + } + + Ok(()) +} + +pub async fn rotate_expiring_certs(state: &AppState, threshold_days: i64) -> Result<(), AppError> { + use crate::crypto::pki; + + let triggered_by = Uuid::nil(); + + let agents = sqlx::query!( + r#" + SELECT id, wg_ip, api_port + FROM agents + WHERE status = 'online' + AND (cert_expires_at IS NULL OR cert_expires_at < NOW() + ($1 || ' days')::INTERVAL) + "#, + threshold_days.to_string(), + ) + .fetch_all(&state.db) + .await?; + + if agents.is_empty() { + return Ok(()); + } + + tracing::info!( + count = agents.len(), + threshold_days, + "rotating expiring agent certs" + ); + + let client = crate::agents::client::build_agent_client(&state.config); + + for agent in &agents { + let cert = + pki::issue_cert(&state.config.ca_private_seed, agent.id).map_err(AppError::Internal)?; + + sqlx::query!( + "UPDATE agents SET cert_payload = $1, cert_signature = $2, cert_expires_at = NOW() + INTERVAL '90 days' WHERE id = $3", + cert.payload, + cert.signature, + agent.id, + ) + .execute(&state.db) + .await?; + + let command = serde_json::json!({ + "type": "cert.update", + "payload": cert.payload, + "signature": cert.signature, + }); + + let signed = cmd::sign_command(&state.config, agent.id, triggered_by, "write", &command) + .map_err(AppError::Internal)?; + + let url = format!("http://{}:{}/cmd", agent.wg_ip, agent.api_port); + let result = client + .post(&url) + .header( + "Authorization", + format!("Bearer {}", &*state.config.internal_token), + ) + .json(&signed) + .send() + .await; + + match result { + Ok(r) if r.status().is_success() => { + tracing::info!(agent_id = %agent.id, "expiring cert rotated and pushed") + } + Ok(r) => { + tracing::warn!(agent_id = %agent.id, status = %r.status(), "cert push returned non-2xx") + } + Err(e) => tracing::warn!(agent_id = %agent.id, "cert push failed: {e}"), + } + } + + Ok(()) +} diff --git a/lynx/dashboard/server/src/admin/handlers/sessions.rs b/lynx/dashboard/server/src/admin/handlers/sessions.rs new file mode 100644 index 0000000..2646cba --- /dev/null +++ b/lynx/dashboard/server/src/admin/handlers/sessions.rs @@ -0,0 +1,67 @@ +use crate::{auth::middleware::AuthUser, error::AppError, state::AppState}; +use axum::{ + extract::{Extension, State}, + response::IntoResponse, + Json, +}; +use uuid::Uuid; + +pub async fn list_sessions( + State(state): State, + Extension(user): Extension, +) -> Result { + let sessions = sqlx::query!( + r#" + SELECT id, ip, user_agent, created_at, last_used_at, expires_at + FROM sessions + WHERE user_id = $1 AND expires_at > NOW() + ORDER BY last_used_at DESC + "#, + user.user_id + ) + .fetch_all(&state.db) + .await?; + + let result: Vec<_> = sessions + .into_iter() + .map(|s| { + serde_json::json!({ + "id": s.id, + "ip": s.ip, + "user_agent": s.user_agent, + "created_at": s.created_at, + "last_used_at": s.last_used_at, + "expires_at": s.expires_at, + }) + }) + .collect(); + + Ok(Json(result)) +} + +pub async fn revoke_session( + State(state): State, + Extension(user): Extension, + axum::extract::Path(session_id): axum::extract::Path, +) -> Result { + let mut redis = state.redis.clone(); + + let row = sqlx::query!( + "DELETE FROM sessions WHERE id = $1 AND user_id = $2 RETURNING last_jti", + session_id, + user.user_id + ) + .fetch_optional(&state.db) + .await? + .ok_or(AppError::NotFound)?; + + if let Some(jti) = row.last_jti { + let _ = crate::auth::session::revoke_access_jti(&mut redis, jti).await; + } + + crate::auth::session::log_event(&state.db, session_id, "user_logout") + .await + .map_err(AppError::Internal)?; + + Ok(axum::http::StatusCode::NO_CONTENT) +} diff --git a/lynx/dashboard/server/src/admin/handlers/updates.rs b/lynx/dashboard/server/src/admin/handlers/updates.rs new file mode 100644 index 0000000..7072b50 --- /dev/null +++ b/lynx/dashboard/server/src/admin/handlers/updates.rs @@ -0,0 +1,206 @@ +use crate::{auth::middleware::AuthUser, crypto::cmd, error::AppError, state::AppState}; +use axum::{ + extract::{Extension, State}, + response::IntoResponse, + Json, +}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +const GITHUB_REPO: &str = "Jaro-c/Lynx"; + +#[derive(Debug, Serialize)] +pub struct UpdateCheckResponse { + pub current_version: String, + pub latest_version: String, + pub update_available: bool, + pub release_url: Option, +} + +#[derive(Debug, Deserialize)] +pub struct TriggerUpdateRequest { + pub version: String, + #[serde(default = "default_channel")] + pub channel: String, + pub agent_id: Option, +} + +fn default_channel() -> String { + "stable".to_string() +} + +pub async fn update_check( + State(_state): State, + Extension(_user): Extension, +) -> Result { + let current = env!("CARGO_PKG_VERSION").to_string(); + + let client = reqwest::Client::builder() + .user_agent(format!("lynx-dashboard/{current}")) + .timeout(std::time::Duration::from_secs(10)) + .build() + .map_err(|e| AppError::Internal(anyhow::Error::from(e)))?; + + let api_url = format!("https://api.github.com/repos/{GITHUB_REPO}/releases/latest"); + let res = client + .get(&api_url) + .send() + .await + .map_err(|e| AppError::Internal(anyhow::Error::from(e)))?; + + if !res.status().is_success() { + return Err(AppError::BadGateway); + } + + let body: serde_json::Value = res + .json() + .await + .map_err(|e| AppError::Internal(anyhow::Error::from(e)))?; + + let latest = body["tag_name"] + .as_str() + .unwrap_or(¤t) + .trim_start_matches('v') + .to_string(); + + let release_url = body["html_url"].as_str().map(|s| s.to_string()); + let update_available = latest != current && !latest.is_empty(); + + Ok(Json(UpdateCheckResponse { + current_version: current, + latest_version: latest, + update_available, + release_url, + })) +} + +pub async fn trigger_update( + State(state): State, + Extension(user): Extension, + Json(req): Json, +) -> Result { + let valid_channels = ["stable", "edge"]; + if !valid_channels.contains(&req.channel.as_str()) { + return Err(AppError::BadRequest("invalid channel")); + } + + struct AgentTarget { + id: Uuid, + wg_ip: String, + api_port: i32, + } + + let agents: Vec = if let Some(id) = req.agent_id { + sqlx::query!( + "SELECT id, wg_ip, api_port FROM agents WHERE id = $1 AND status = 'online'", + id + ) + .fetch_all(&state.db) + .await? + .into_iter() + .map(|r| AgentTarget { + id: r.id, + wg_ip: r.wg_ip, + api_port: r.api_port, + }) + .collect() + } else { + sqlx::query!("SELECT id, wg_ip, api_port FROM agents WHERE status = 'online'") + .fetch_all(&state.db) + .await? + .into_iter() + .map(|r| AgentTarget { + id: r.id, + wg_ip: r.wg_ip, + api_port: r.api_port, + }) + .collect() + }; + + let mut sent = 0usize; + let mut failed = 0usize; + + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .build() + .map_err(|e| AppError::Internal(anyhow::Error::from(e)))?; + + for agent in &agents { + let download_url = format!( + "https://github.com/{GITHUB_REPO}/releases/download/{version}/lynx-agent-linux-x86_64", + version = req.version + ); + let sig_url = format!( + "https://github.com/{GITHUB_REPO}/releases/download/{version}/lynx-agent-linux-x86_64.sig", + version = req.version + ); + + let command = serde_json::json!({ + "type": "update.self", + "version": req.version, + "download_url": download_url, + "sig_url": sig_url, + }); + + let signed = cmd::sign_command(&state.config, agent.id, user.user_id, "write", &command) + .map_err(AppError::Internal)?; + + let url = format!("http://{}:{}/cmd", agent.wg_ip, agent.api_port); + + let log_id = Uuid::now_v7(); + let send_result = client + .post(&url) + .header( + "Authorization", + format!("Bearer {}", &*state.config.internal_token), + ) + .json(&signed) + .send() + .await; + + let status = match send_result { + Ok(r) if r.status().is_success() => { + sent += 1; + "success" + } + _ => { + failed += 1; + "failed" + } + }; + + sqlx::query!( + r#" + INSERT INTO update_log (id, triggered_by, version, channel, scope, agent_id, status) + VALUES ($1, $2, $3, $4, 'agent', $5, $6) + "#, + log_id, + user.user_id, + req.version, + req.channel, + agent.id, + status, + ) + .execute(&state.db) + .await?; + } + + tracing::info!( + user_id = %user.user_id, + version = %req.version, + sent, + failed, + "update triggered" + ); + + if let Err(e) = super::rotation::rotate_expiring_certs(&state, 14).await { + tracing::warn!("cert expiry check during update failed: {e}"); + } + + Ok(Json(serde_json::json!({ + "ok": true, + "version": req.version, + "agents_sent": sent, + "agents_failed": failed, + }))) +} diff --git a/lynx/dashboard/server/src/admin/handlers/users.rs b/lynx/dashboard/server/src/admin/handlers/users.rs new file mode 100644 index 0000000..98a0cbc --- /dev/null +++ b/lynx/dashboard/server/src/admin/handlers/users.rs @@ -0,0 +1,119 @@ +use crate::{auth::session, error::AppError, state::AppState}; +use axum::{ + extract::{Path, State}, + response::IntoResponse, +}; +use uuid::Uuid; + +/// POST /admin/users/:id/force-password-change — flag one user +pub async fn force_password_change( + State(state): State, + Path(user_id): Path, +) -> Result { + let rows = sqlx::query!( + "UPDATE users SET force_password_change = TRUE WHERE id = $1", + user_id + ) + .execute(&state.db) + .await?; + + if rows.rows_affected() == 0 { + return Err(AppError::NotFound); + } + + Ok(axum::http::StatusCode::NO_CONTENT) +} + +/// POST /admin/users/force-password-change-all — flag every user +pub async fn force_password_change_all( + State(state): State, +) -> Result { + sqlx::query!("UPDATE users SET force_password_change = TRUE") + .execute(&state.db) + .await?; + + Ok(axum::http::StatusCode::NO_CONTENT) +} + +/// DELETE /admin/users/:id/sessions — close all sessions of a specific user (mass_logout per user) +pub async fn revoke_user_sessions( + State(state): State, + Path(user_id): Path, +) -> Result { + let mut redis = state.redis.clone(); + + session::revoke_all_user_sessions(&state.db, &mut redis, user_id, "mass_logout") + .await + .map_err(AppError::Internal)?; + + Ok(axum::http::StatusCode::NO_CONTENT) +} + +/// DELETE /admin/sessions — close ALL sessions of ALL users (mass_logout global) +pub async fn revoke_all_sessions( + State(state): State, +) -> Result { + use redis::AsyncCommands; + let mut redis = state.redis.clone(); + + let keys: Vec = redis + .keys("access:*") + .await + .map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?; + + if !keys.is_empty() { + redis::cmd("DEL") + .arg(&keys) + .query_async::<()>(&mut redis) + .await + .map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?; + } + + let session_ids = sqlx::query_scalar!("SELECT id FROM sessions WHERE expires_at > NOW()") + .fetch_all(&state.db) + .await?; + + for session_id in &session_ids { + let log_id = Uuid::now_v7(); + sqlx::query!( + "INSERT INTO session_logs (id, session_id, reason) VALUES ($1, $2, 'mass_logout')", + log_id, + session_id, + ) + .execute(&state.db) + .await?; + } + + sqlx::query!("DELETE FROM sessions WHERE expires_at > NOW()") + .execute(&state.db) + .await?; + + Ok(axum::http::StatusCode::NO_CONTENT) +} + +/// DELETE /admin/users/:user_id/sessions/:session_id — admin closes a specific session of any user +pub async fn admin_revoke_session( + State(state): State, + Path((user_id, session_id)): Path<(Uuid, Uuid)>, +) -> Result { + let mut redis = state.redis.clone(); + + let row = sqlx::query!( + "DELETE FROM sessions WHERE id = $1 AND user_id = $2 RETURNING last_jti", + session_id, + user_id, + ) + .fetch_optional(&state.db) + .await? + .ok_or(AppError::NotFound)?; + + if let Some(jti) = row.last_jti { + let _ = session::revoke_access_jti(&mut redis, jti).await; + } + + session::log_event(&state.db, session_id, "admin_logout") + .await + .map_err(AppError::Internal)?; + + Ok(axum::http::StatusCode::NO_CONTENT) +} diff --git a/lynx/dashboard/server/src/admin/mod.rs b/lynx/dashboard/server/src/admin/mod.rs new file mode 100644 index 0000000..0591d7d --- /dev/null +++ b/lynx/dashboard/server/src/admin/mod.rs @@ -0,0 +1,2 @@ +pub mod handlers; +pub mod router; diff --git a/lynx/dashboard/server/src/admin/router.rs b/lynx/dashboard/server/src/admin/router.rs new file mode 100644 index 0000000..5688bf6 --- /dev/null +++ b/lynx/dashboard/server/src/admin/router.rs @@ -0,0 +1,69 @@ +use super::handlers; +use crate::{auth::middleware::require_admin, state::AppState}; +use axum::{ + middleware, + routing::{delete, get, post, put}, + Router, +}; + +pub fn router(state: AppState) -> Router { + let admin_layer = middleware::from_fn_with_state(state, require_admin); + + // Admin-only routes — require *:* permission on top of require_auth + let admin_only = Router::new() + .route("/rotate", post(handlers::rotate_keys)) + .route("/rotation-log", get(handlers::list_rotation_log)) + .route("/sessions/all", delete(handlers::revoke_all_sessions)) + .route("/users", get(handlers::list_users)) + .route("/users/{id}", delete(handlers::delete_user)) + .route( + "/users/{user_id}/sessions", + delete(handlers::revoke_user_sessions), + ) + .route( + "/users/{user_id}/sessions/{session_id}", + delete(handlers::admin_revoke_session), + ) + .route( + "/users/{id}/force-password-change", + post(handlers::force_password_change), + ) + .route( + "/users/force-password-change-all", + post(handlers::force_password_change_all), + ) + .route("/users/{id}/roles/{role_id}", post(handlers::add_user_role)) + .route( + "/users/{id}/roles/{role_id}", + delete(handlers::remove_user_role), + ) + .route("/roles", get(handlers::list_roles)) + .route("/roles", post(handlers::create_role)) + .route("/roles/{id}", delete(handlers::delete_role)) + .route( + "/roles/{id}/permissions/{perm_id}", + post(handlers::add_role_permission), + ) + .route( + "/roles/{id}/permissions/{perm_id}", + delete(handlers::remove_role_permission), + ) + .route("/permissions", get(handlers::list_permissions)) + .route("/trigger-update", post(handlers::trigger_update)) + .route("/branding", put(crate::branding::handlers::update_branding)) + .route_layer(admin_layer); + + // Authenticated-user routes — any logged-in user (require_auth already applied by main.rs) + let auth_only = Router::new() + .route("/sessions", get(handlers::list_sessions)) + .route("/sessions/{id}", delete(handlers::revoke_session)) + .route("/update-check", get(handlers::update_check)) + .route("/update-log", get(handlers::list_update_log)) + .route("/alerts", get(handlers::list_alerts)) + .route( + "/alerts/{id}/acknowledge", + post(handlers::acknowledge_alert), + ); + + Router::new().merge(admin_only).merge(auth_only) +} diff --git a/lynx/dashboard/server/src/agents/client.rs b/lynx/dashboard/server/src/agents/client.rs new file mode 100644 index 0000000..7664855 --- /dev/null +++ b/lynx/dashboard/server/src/agents/client.rs @@ -0,0 +1,64 @@ +use crate::config::Config; + +/// Build a reqwest client for calling agent HTTP endpoints. +/// +/// If X.509 mTLS certs are configured, the client presents the dashboard +/// client cert and trusts only the internal CA. Falls back to plain HTTP +/// client if certs are not yet available (dev mode / pre-bootstrap). +pub fn build_agent_client(config: &Config) -> reqwest::Client { + let timeout = std::time::Duration::from_secs(15); + + // Attempt to build mTLS client. + if let Some(client) = try_build_mtls_client(config, timeout) { + return client; + } + + // Fall back to plain client (WireGuard still provides transport security). + reqwest::Client::builder() + .timeout(timeout) + .build() + .expect("build plain agent HTTP client") +} + +fn try_build_mtls_client(config: &Config, timeout: std::time::Duration) -> Option { + use rustls::pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer}; + + let cert_der = config.x509_client_cert_der.as_slice(); + let key_der = config.x509_client_key_der.as_slice(); + let ca_cert_der = config.x509_ca_cert_der.as_slice(); + + // Trust root store with the internal CA. + let mut root_store = rustls::RootCertStore::empty(); + root_store + .add(CertificateDer::from(ca_cert_der.to_vec())) + .ok()?; + + // Dashboard client cert + key. + let cert_chain = vec![CertificateDer::from(cert_der.to_vec())]; + let key = PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(key_der.to_vec())); + + let tls_config = rustls::ClientConfig::builder() + .with_root_certificates(root_store) + .with_client_auth_cert(cert_chain, key) + .ok()?; + + reqwest::Client::builder() + .timeout(timeout) + .use_preconfigured_tls(tls_config) + .build() + .ok() +} + +/// Build a reqwest client for calling agent endpoints with a custom timeout. +pub fn build_agent_client_with_timeout( + config: &Config, + timeout: std::time::Duration, +) -> reqwest::Client { + if let Some(client) = try_build_mtls_client(config, timeout) { + return client; + } + reqwest::Client::builder() + .timeout(timeout) + .build() + .expect("build plain agent HTTP client") +} diff --git a/lynx/dashboard/server/src/agents/handlers/audit.rs b/lynx/dashboard/server/src/agents/handlers/audit.rs new file mode 100644 index 0000000..cdd02b8 --- /dev/null +++ b/lynx/dashboard/server/src/agents/handlers/audit.rs @@ -0,0 +1,149 @@ +use super::super::AuditSyncEntry; +use crate::{ + auth::middleware::AuthUser, crypto::hash::sha256_hex, error::AppError, state::AppState, +}; +use axum::{ + extract::{Extension, Path, State}, + http::HeaderMap, + response::IntoResponse, + Json, +}; +use serde_json::json; +use uuid::Uuid; + +pub async fn receive_audit_sync( + State(state): State, + Path(id): Path, + headers: HeaderMap, + Json(entries): Json>, +) -> Result { + let token = headers + .get(axum::http::header::AUTHORIZATION) + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.strip_prefix("Bearer ")) + .unwrap_or(""); + + let stored_hash = sqlx::query_scalar!("SELECT sync_token_hash FROM agents WHERE id = $1", id) + .fetch_optional(&state.db) + .await? + .flatten() + .ok_or(AppError::NotFound)?; + + let provided_hash = sha256_hex(token.as_bytes()); + let ok: bool = + subtle::ConstantTimeEq::ct_eq(provided_hash.as_bytes(), stored_hash.as_bytes()).into(); + if !ok { + return Err(AppError::Unauthorized); + } + + if entries.is_empty() { + return Ok(axum::http::StatusCode::NO_CONTENT); + } + + let mut tx = state.db.begin().await?; + + for entry in &entries { + if entry.agent_id != id { + continue; + } + + sqlx::query!( + r#" + INSERT INTO audit_log ( + id, agent_id, organization_id, user_id, command_type, + result, error, previous_hash, entry_hash, created_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + ON CONFLICT (id) DO NOTHING + "#, + entry.id, + entry.agent_id, + entry.organization_id, + entry.user_id, + entry.command_type, + entry.result, + entry.error, + entry.previous_hash, + entry.entry_hash, + entry.created_at, + ) + .execute(&mut *tx) + .await?; + } + + tx.commit().await?; + + tracing::info!( + agent_id = %id, + count = entries.len(), + "audit log sync received" + ); + + Ok(axum::http::StatusCode::NO_CONTENT) +} + +pub async fn list_audit_log( + State(state): State, + Extension(_user): Extension, + Path(id): Path, + axum::extract::Query(params): axum::extract::Query>, +) -> Result { + let limit: i64 = params + .get("limit") + .and_then(|v| v.parse().ok()) + .unwrap_or(50) + .min(200); + + let offset: i64 = params + .get("offset") + .and_then(|v| v.parse().ok()) + .unwrap_or(0); + + let exists = sqlx::query_scalar!("SELECT 1 FROM agents WHERE id = $1", id) + .fetch_optional(&state.db) + .await?; + if exists.is_none() { + return Err(AppError::NotFound); + } + + let entries = sqlx::query!( + r#" + SELECT id, agent_id, organization_id, user_id, + command_type, result, error, entry_hash, created_at + FROM audit_log + WHERE agent_id = $1 + ORDER BY created_at DESC + LIMIT $2 OFFSET $3 + "#, + id, + limit, + offset, + ) + .fetch_all(&state.db) + .await?; + + let total: i64 = sqlx::query_scalar!("SELECT COUNT(*) FROM audit_log WHERE agent_id = $1", id) + .fetch_one(&state.db) + .await? + .unwrap_or(0); + + let result: Vec<_> = entries + .into_iter() + .map(|e| { + serde_json::json!({ + "id": e.id, + "agent_id": e.agent_id, + "organization_id": e.organization_id, + "user_id": e.user_id, + "command_type": e.command_type, + "result": e.result, + "error": e.error, + "entry_hash": &e.entry_hash[..16], + "created_at": e.created_at, + }) + }) + .collect(); + + Ok(Json( + json!({ "entries": result, "total": total, "limit": limit, "offset": offset }), + )) +} diff --git a/lynx/dashboard/server/src/agents/handlers/commands.rs b/lynx/dashboard/server/src/agents/handlers/commands.rs new file mode 100644 index 0000000..2e5acae --- /dev/null +++ b/lynx/dashboard/server/src/agents/handlers/commands.rs @@ -0,0 +1,196 @@ +use crate::{ + agents::ws_hub, auth::middleware::AuthUser, crypto::cmd::sign_command, error::AppError, + state::AppState, +}; +use axum::{ + extract::{Extension, Path, State}, + response::IntoResponse, + Json, +}; +use serde_json::{json, Value}; +use uuid::Uuid; + +pub async fn relay_heartbeat( + State(state): State, + Path(id): Path, +) -> Result { + let agent = sqlx::query!( + "SELECT wg_ip::text AS wg_ip, api_port FROM agents WHERE id = $1", + id + ) + .fetch_optional(&state.db) + .await? + .ok_or(AppError::NotFound)?; + + let url = format!("http://{}:{}/heartbeat", agent.wg_ip, agent.api_port); + + let token = &*state.config.internal_token; + let client = reqwest::Client::new(); + let resp = client + .post(&url) + .header("Authorization", format!("Bearer {token}")) + .timeout(std::time::Duration::from_secs(5)) + .send() + .await; + + match resp { + Ok(r) if r.status().is_success() => { + sqlx::query!( + "UPDATE agents SET status='online', last_heartbeat=NOW() WHERE id=$1", + id + ) + .execute(&state.db) + .await?; + Ok(axum::http::StatusCode::NO_CONTENT) + } + Ok(r) => { + let status_code = r.status().as_u16(); + let is_lockdown = status_code == 423; + let new_status = if is_lockdown { "lockdown" } else { "offline" }; + sqlx::query!( + "UPDATE agents SET status=$1, last_heartbeat=NOW() WHERE id=$2", + new_status, + id + ) + .execute(&state.db) + .await?; + Err(AppError::BadGateway) + } + Err(_) => { + sqlx::query!("UPDATE agents SET status='offline' WHERE id=$1", id) + .execute(&state.db) + .await?; + Err(AppError::BadGateway) + } + } +} + +pub async fn send_command( + State(state): State, + Path(id): Path, + Json(payload): Json, +) -> Result { + let agent = sqlx::query!( + "SELECT wg_ip, api_port, status FROM agents WHERE id = $1", + id + ) + .fetch_optional(&state.db) + .await? + .ok_or(AppError::NotFound)?; + + if agent.status == "lockdown" || agent.status == "offline" { + return Err(AppError::AgentUnavailable); + } + + let cmd_user_id = payload + .get("user_id") + .and_then(|v| v.as_str()) + .and_then(|s| uuid::Uuid::parse_str(s).ok()) + .ok_or(AppError::BadRequest("user_id required in command"))?; + + let permission = payload + .get("permission") + .and_then(|v| v.as_str()) + .unwrap_or("read") + .to_string(); + + let signed = sign_command(&state.config, id, cmd_user_id, &permission, &payload)?; + + // Try WS first if agent has active connection. + let signed_val = serde_json::to_value(&signed).unwrap_or(serde_json::json!({})); + if let Some(body) = ws_hub::push_command(&state, id, signed_val).await { + let ok = body.get("ok").and_then(|v| v.as_bool()).unwrap_or(false); + let code = if ok { + axum::http::StatusCode::OK + } else { + axum::http::StatusCode::BAD_GATEWAY + }; + return Ok((code, Json(body))); + } + + // Fallback: HTTP POST to agent's /cmd endpoint. + let url = format!("http://{}:{}/cmd", agent.wg_ip, agent.api_port); + + let token = &*state.config.internal_token; + let client = reqwest::Client::new(); + let resp = client + .post(&url) + .header("Authorization", format!("Bearer {token}")) + .json(&signed) + .timeout(std::time::Duration::from_secs(30)) + .send() + .await + .map_err(|_| AppError::BadGateway)?; + + let status = resp.status(); + let body: Value = resp.json().await.unwrap_or(json!({})); + + Ok(( + axum::http::StatusCode::from_u16(status.as_u16()) + .unwrap_or(axum::http::StatusCode::BAD_GATEWAY), + Json(body), + )) +} + +pub async fn reboot_agent( + State(state): State, + Extension(user): Extension, + Path(id): Path, +) -> Result { + let agent = sqlx::query!( + "SELECT wg_ip, api_port, status FROM agents WHERE id = $1", + id + ) + .fetch_optional(&state.db) + .await? + .ok_or(AppError::NotFound)?; + + if agent.status == "lockdown" || agent.status == "offline" { + return Err(AppError::AgentUnavailable); + } + + let command = json!({ "type": "vps.reboot" }); + let signed = sign_command(&state.config, id, user.user_id, "admin", &command)?; + + sqlx::query!( + "INSERT INTO agent_events (id, agent_id, event, detail) VALUES ($1, $2, $3, $4)", + Uuid::now_v7(), + id, + "rebooting", + format!("requested_by={}", user.user_id), + ) + .execute(&state.db) + .await?; + + // Try WS first. + let signed_val = serde_json::to_value(&signed).unwrap_or_default(); + if let Some(body) = ws_hub::push_command(&state, id, signed_val).await { + let ok = body.get("ok").and_then(|v| v.as_bool()).unwrap_or(false); + let code = if ok { + axum::http::StatusCode::OK + } else { + axum::http::StatusCode::BAD_GATEWAY + }; + return Ok((code, Json(body))); + } + + // Fallback HTTP. + let url = format!("http://{}:{}/cmd", agent.wg_ip, agent.api_port); + let tok = &*state.config.internal_token; + let resp = reqwest::Client::new() + .post(&url) + .header("Authorization", format!("Bearer {tok}")) + .json(&signed) + .timeout(std::time::Duration::from_secs(10)) + .send() + .await + .map_err(|_| AppError::BadGateway)?; + + let status = resp.status(); + let body: Value = resp.json().await.unwrap_or(json!({ "ok": true })); + Ok(( + axum::http::StatusCode::from_u16(status.as_u16()) + .unwrap_or(axum::http::StatusCode::BAD_GATEWAY), + Json(body), + )) +} diff --git a/lynx/dashboard/server/src/agents/handlers/crud.rs b/lynx/dashboard/server/src/agents/handlers/crud.rs new file mode 100644 index 0000000..9a1fc2a --- /dev/null +++ b/lynx/dashboard/server/src/agents/handlers/crud.rs @@ -0,0 +1,171 @@ +use super::super::{wg, Agent, AgentSummary, RegisterAgentRequest, RegisterAgentResponse}; +use crate::{ + crypto::{hash::sha256_hex, pki}, + error::AppError, + state::AppState, +}; +use axum::{ + extract::{Path, State}, + response::IntoResponse, + Json, +}; +use uuid::Uuid; + +pub async fn list_agents(State(state): State) -> Result { + let agents = sqlx::query_as!( + AgentSummary, + "SELECT id, name, status, wg_ip, version, last_heartbeat FROM agents ORDER BY created_at ASC" + ) + .fetch_all(&state.db) + .await?; + + Ok(Json(agents)) +} + +pub async fn get_agent( + State(state): State, + Path(id): Path, +) -> Result { + let agent = sqlx::query_as!( + Agent, + "SELECT id, name, wg_pubkey, wg_ip, wg_endpoint, api_port, status, version, last_heartbeat, created_at FROM agents WHERE id = $1", + id + ) + .fetch_optional(&state.db) + .await? + .ok_or(AppError::NotFound)?; + + Ok(Json(agent)) +} + +pub async fn register_agent( + State(state): State, + Json(req): Json, +) -> Result { + let wg_ip = wg::allocate_ip(&state.db, req.agent_id).await?; + + let sync_token = format!("{}", uuid::Uuid::now_v7()).replace('-', "") + + &format!("{}", uuid::Uuid::now_v7()).replace('-', ""); + let sync_token_hash = sha256_hex(sync_token.as_bytes()); + + let is_local = req.is_local_agent.unwrap_or(false); + let agent = sqlx::query_as!( + Agent, + r#" + INSERT INTO agents (id, name, wg_pubkey, wg_ip, wg_endpoint, api_port, sync_token_hash, is_local_agent) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + RETURNING id, name, wg_pubkey, wg_ip, wg_endpoint, + api_port, status, version, last_heartbeat, created_at + "#, + req.agent_id, + req.name, + req.wg_pubkey, + wg_ip, + req.wg_endpoint, + req.api_port.unwrap_or(9090), + sync_token_hash, + is_local, + ) + .fetch_one(&state.db) + .await?; + + let psk = match wg::create_psk(req.agent_id) { + Ok(p) => p, + Err(e) => { + tracing::error!(agent_id = %req.agent_id, error = %e, "failed to create WG PSK"); + return Err(AppError::Internal(e)); + } + }; + + if let Err(e) = wg::add_peer( + &req.wg_pubkey, + wg_ip + .parse() + .unwrap_or(std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED)), + &psk, + ) { + tracing::error!(agent_id = %req.agent_id, error = %e, "failed to add WG peer — add manually"); + } + + state.wg_psks.write().await.insert(req.agent_id, psk); + + let cert = pki::issue_cert(&state.config.ca_private_seed, agent.id)?; + + sqlx::query!( + "UPDATE agents SET cert_payload = $1, cert_signature = $2, cert_expires_at = NOW() + INTERVAL '90 days' WHERE id = $3", + cert.payload, + cert.signature, + agent.id, + ) + .execute(&state.db) + .await?; + + // Issue X.509 mTLS server cert for the agent. + let (tls_cert_der, tls_key_der) = pki::issue_x509_agent_cert( + &state.config.x509_ca_cert_der, + &state.config.x509_ca_key_der, + agent.id, + &wg_ip, + )?; + + use base64ct::Encoding as _; + let ca_public_key = base64ct::Base64UrlUnpadded::encode_string(&state.config.ca_public_bytes); + let tls_cert_b64 = base64ct::Base64::encode_string(&tls_cert_der); + let tls_key_b64 = base64ct::Base64::encode_string(&tls_key_der); + let tls_ca_cert_b64 = base64ct::Base64::encode_string(&state.config.x509_ca_cert_der); + + let event_id = uuid::Uuid::now_v7(); + sqlx::query!( + "INSERT INTO agent_events (id, agent_id, event, detail) VALUES ($1, $2, $3, $4)", + event_id, + agent.id, + "bootstrap_completed", + Some(format!("wg_ip={wg_ip}")) + ) + .execute(&state.db) + .await?; + + let _ = &wg_ip; + + Ok(( + axum::http::StatusCode::CREATED, + Json(RegisterAgentResponse { + agent, + sync_token, + cert, + ca_public_key, + tls_cert_der: tls_cert_b64, + tls_key_der: tls_key_b64, + tls_ca_cert_der: tls_ca_cert_b64, + }), + )) +} + +pub async fn remove_agent( + State(state): State, + Path(id): Path, +) -> Result { + let agent = sqlx::query!("SELECT wg_pubkey FROM agents WHERE id = $1", id) + .fetch_optional(&state.db) + .await? + .ok_or(AppError::NotFound)?; + + if let Err(e) = wg::remove_peer(&agent.wg_pubkey) { + tracing::error!(agent_id = %id, error = %e, "failed to remove WG peer"); + } + + // Delete PSK from memory and Podman secrets. + state.wg_psks.write().await.remove(&id); + wg::delete_psk(id); + + // Release IP back to pool before deleting the agent row (FK constraint). + if let Err(e) = wg::release_ip(&state.db, id).await { + tracing::warn!(agent_id = %id, error = %e, "failed to release WG IP to pool"); + } + + sqlx::query!("DELETE FROM agents WHERE id = $1", id) + .execute(&state.db) + .await?; + + Ok(axum::http::StatusCode::NO_CONTENT) +} diff --git a/lynx/dashboard/server/src/agents/handlers/events.rs b/lynx/dashboard/server/src/agents/handlers/events.rs new file mode 100644 index 0000000..0e9e619 --- /dev/null +++ b/lynx/dashboard/server/src/agents/handlers/events.rs @@ -0,0 +1,128 @@ +use crate::{ + auth::middleware::AuthUser, crypto::hash::sha256_hex, error::AppError, state::AppState, +}; +use axum::{ + extract::{Extension, Path, State}, + http::HeaderMap, + response::IntoResponse, + Json, +}; +use std::sync::Arc; +use uuid::Uuid; + +/// Serialize an event frame and broadcast to all subscribed browser WS sessions. +pub fn broadcast_event(state: &AppState, agent_id: Uuid, event: &str, detail: Option<&str>) { + let frame = serde_json::json!({ + "type": "agent_event", + "agent_id": agent_id, + "event": event, + "detail": detail, + }); + let text = Arc::new(frame.to_string()); + let _ = state.events_tx.send(text); +} + +pub async fn receive_event( + State(state): State, + Path(id): Path, + headers: HeaderMap, + Json(body): Json, +) -> Result { + let token = headers + .get(axum::http::header::AUTHORIZATION) + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.strip_prefix("Bearer ")) + .unwrap_or(""); + + let stored_hash = sqlx::query_scalar!("SELECT sync_token_hash FROM agents WHERE id = $1", id) + .fetch_optional(&state.db) + .await? + .flatten() + .ok_or(AppError::NotFound)?; + + let provided_hash = sha256_hex(token.as_bytes()); + let ok: bool = + subtle::ConstantTimeEq::ct_eq(provided_hash.as_bytes(), stored_hash.as_bytes()).into(); + if !ok { + return Err(AppError::Unauthorized); + } + + let event = body + .get("event") + .and_then(|v| v.as_str()) + .ok_or(AppError::BadRequest("event field required"))?; + let detail = body + .get("detail") + .and_then(|v| v.as_str()) + .map(String::from); + + let allowed_events = [ + "connected", + "disconnected", + "lockdown", + "heartbeat_lost", + "update_applied", + "nftables_divergence", + "bootstrap_completed", + ]; + if !allowed_events.contains(&event) { + return Err(AppError::BadRequest("unknown event type")); + } + + let event_id = Uuid::now_v7(); + sqlx::query!( + "INSERT INTO agent_events (id, agent_id, event, detail) VALUES ($1, $2, $3, $4)", + event_id, + id, + event, + detail, + ) + .execute(&state.db) + .await?; + + // Broadcast to all subscribed browser WS sessions. + broadcast_event(&state, id, event, detail.as_deref()); + + tracing::info!(agent_id = %id, event, "agent event received"); + + Ok(axum::http::StatusCode::NO_CONTENT) +} + +pub async fn list_agent_events( + State(state): State, + Extension(_user): Extension, + axum::extract::Query(params): axum::extract::Query>, +) -> Result { + let limit: i64 = params + .get("limit") + .and_then(|v| v.parse().ok()) + .unwrap_or(20) + .min(100); + + let events = sqlx::query!( + r#" + SELECT id, agent_id, event, detail, created_at + FROM agent_events + ORDER BY created_at DESC + LIMIT $1 + "#, + limit + ) + .fetch_all(&state.db) + .await?; + + let result: Vec<_> = events + .into_iter() + .map(|e| { + serde_json::json!({ + "id": e.id, + "agent_id": e.agent_id, + "event": e.event, + "detail": e.detail, + "created_at": e.created_at, + }) + }) + .collect(); + + Ok(Json(result)) +} diff --git a/lynx/dashboard/server/src/agents/handlers/events_ws.rs b/lynx/dashboard/server/src/agents/handlers/events_ws.rs new file mode 100644 index 0000000..7226031 --- /dev/null +++ b/lynx/dashboard/server/src/agents/handlers/events_ws.rs @@ -0,0 +1,88 @@ +use crate::{auth::middleware::AuthUser, error::AppError, state::AppState}; +use axum::extract::ws::{Message, WebSocket}; +use axum::{ + extract::{State, WebSocketUpgrade}, + http::HeaderMap, + response::IntoResponse, + Extension, +}; +use std::sync::Arc; +use tokio::sync::broadcast; + +/// WebSocket endpoint that streams agent events to browser sessions. +/// Auth: JWT via cookie (same as other authenticated routes). +/// Each connected admin browser receives a copy of every agent event. +pub async fn frontend_events_ws( + State(state): State, + Extension(_user): Extension, + headers: HeaderMap, + ws: WebSocketUpgrade, +) -> Result { + validate_ws_origin(&state, &headers).await?; + let rx = state.events_tx.subscribe(); + Ok(ws.on_upgrade(move |socket| handle_events_socket(socket, rx))) +} + +async fn handle_events_socket(mut socket: WebSocket, mut rx: broadcast::Receiver>) { + loop { + tokio::select! { + result = rx.recv() => { + match result { + Ok(msg) => { + if socket.send(Message::Text(msg.as_str().to_owned().into())).await.is_err() { + break; + } + } + Err(broadcast::error::RecvError::Lagged(n)) => { + tracing::debug!(skipped = n, "frontend events WS lagged"); + } + Err(broadcast::error::RecvError::Closed) => break, + } + } + msg = socket.recv() => { + match msg { + Some(Ok(Message::Close(_))) | None => break, + _ => {} + } + } + } + } +} + +/// Validate the WebSocket `Origin` header against the configured dashboard domain. +/// +/// Absent Origin (non-browser clients, integration tests) → allow. +/// Present Origin → must match the configured domain (https only) or, when no domain is +/// configured, must start with `https://` (browser accessing the dashboard via IP:19443). +/// +/// Prevents cross-site WebSocket hijacking (CSWSH). +pub(crate) async fn validate_ws_origin( + state: &AppState, + headers: &HeaderMap, +) -> Result<(), AppError> { + let origin = match headers.get("origin").and_then(|v| v.to_str().ok()) { + Some(o) => o.to_string(), + None => return Ok(()), + }; + + let configured_domain: Option = + sqlx::query_scalar!("SELECT domain FROM domain_config WHERE id = 1") + .fetch_optional(&state.db) + .await + .unwrap_or(None) + .flatten(); + + let allowed = if let Some(ref domain) = configured_domain { + origin == format!("https://{domain}") + } else { + // No domain configured — browser reaches dashboard via https://IP:19443 + origin.starts_with("https://") + }; + + if !allowed { + tracing::warn!(origin = %origin, "WebSocket upgrade rejected: origin mismatch"); + return Err(AppError::Forbidden); + } + + Ok(()) +} diff --git a/lynx/dashboard/server/src/agents/handlers/metrics_ws.rs b/lynx/dashboard/server/src/agents/handlers/metrics_ws.rs new file mode 100644 index 0000000..77f3dee --- /dev/null +++ b/lynx/dashboard/server/src/agents/handlers/metrics_ws.rs @@ -0,0 +1,79 @@ +use crate::{auth::middleware::AuthUser, error::AppError, state::AppState}; +use axum::{ + extract::{ + ws::{Message, WebSocket}, + Extension, Path, State, WebSocketUpgrade, + }, + http::HeaderMap, + response::IntoResponse, +}; +use uuid::Uuid; + +/// Frontend WebSocket endpoint for real-time metric streaming. +/// Auth: standard JWT via the auth middleware (Extension). +/// Browser subscribes and receives metric frames pushed by the agent. +pub async fn frontend_metrics_ws( + State(state): State, + Extension(user): Extension, + Path(agent_id): Path, + headers: HeaderMap, + ws: WebSocketUpgrade, +) -> Result { + super::events_ws::validate_ws_origin(&state, &headers).await?; + + let exists = sqlx::query_scalar!("SELECT id FROM agents WHERE id = $1", agent_id) + .fetch_optional(&state.db) + .await? + .is_some(); + + if !exists { + return Err(AppError::NotFound); + } + + let _ = user; + + Ok(ws.on_upgrade(move |socket| handle_frontend_socket(state, agent_id, socket))) +} + +async fn handle_frontend_socket(state: AppState, agent_id: Uuid, mut socket: WebSocket) { + let rx = { + let map = state.agent_metric_tx.read().await; + map.get(&agent_id).map(|tx| tx.subscribe()) + }; + + let Some(mut rx) = rx else { + let frame = + serde_json::json!({ "type": "agent_offline", "agent_id": agent_id }).to_string(); + let _ = socket.send(Message::Text(frame.into())).await; + return; + }; + + loop { + tokio::select! { + result = rx.recv() => { + match result { + Ok(frame) => { + if socket.send(Message::Text(frame.as_str().to_string().into())).await.is_err() { + break; + } + } + Err(tokio::sync::broadcast::error::RecvError::Closed) => { + let frame = serde_json::json!({ "type": "agent_offline", "agent_id": agent_id }).to_string(); + let _ = socket.send(Message::Text(frame.into())).await; + break; + } + Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => { + tracing::warn!(agent_id = %agent_id, skipped = n, "frontend WS lagged behind agent metrics"); + } + } + } + msg = socket.recv() => { + match msg { + Some(Ok(Message::Close(_))) | None => break, + Some(Err(_)) => break, + _ => {} + } + } + } + } +} diff --git a/lynx/dashboard/server/src/agents/handlers/mod.rs b/lynx/dashboard/server/src/agents/handlers/mod.rs new file mode 100644 index 0000000..5bedbd5 --- /dev/null +++ b/lynx/dashboard/server/src/agents/handlers/mod.rs @@ -0,0 +1,15 @@ +mod audit; +mod commands; +mod crud; +mod events; +mod events_ws; +mod metrics_ws; +mod nftables; + +pub use audit::{list_audit_log, receive_audit_sync}; +pub use commands::{reboot_agent, relay_heartbeat, send_command}; +pub use crud::{get_agent, list_agents, register_agent, remove_agent}; +pub use events::{broadcast_event, list_agent_events, receive_event}; +pub use events_ws::frontend_events_ws; +pub use metrics_ws::frontend_metrics_ws; +pub use nftables::{nftables_resolve, nftables_status}; diff --git a/lynx/dashboard/server/src/agents/handlers/nftables.rs b/lynx/dashboard/server/src/agents/handlers/nftables.rs new file mode 100644 index 0000000..ac4cac1 --- /dev/null +++ b/lynx/dashboard/server/src/agents/handlers/nftables.rs @@ -0,0 +1,119 @@ +use crate::{ + auth::middleware::AuthUser, crypto::cmd::sign_command, error::AppError, state::AppState, +}; +use axum::{ + extract::{Extension, Path, State}, + response::IntoResponse, + Json, +}; +use serde::Deserialize; +use serde_json::{json, Value}; +use uuid::Uuid; + +pub async fn nftables_status( + State(state): State, + Extension(_user): Extension, + Path(id): Path, +) -> Result { + let event = sqlx::query!( + r#" + SELECT id, detail, created_at + FROM agent_events + WHERE agent_id = $1 + AND event = 'nftables_divergence' + AND created_at > COALESCE( + (SELECT created_at FROM agent_events + WHERE agent_id = $1 AND event IN ('nftables_restored', 'nftables_accepted') + ORDER BY created_at DESC LIMIT 1), + '1970-01-01'::timestamptz + ) + ORDER BY created_at DESC + LIMIT 1 + "#, + id + ) + .fetch_optional(&state.db) + .await?; + + match event { + None => Ok(Json(json!({ "diverged": false }))), + Some(e) => Ok(Json(json!({ + "diverged": true, + "event_id": e.id, + "detail": e.detail, + "detected_at": e.created_at, + }))), + } +} + +#[derive(Debug, Deserialize)] +pub struct NftablesResolveRequest { + pub action: String, +} + +pub async fn nftables_resolve( + State(state): State, + Extension(user): Extension, + Path(id): Path, + Json(req): Json, +) -> Result { + if req.action != "restore" && req.action != "accept" { + return Err(AppError::Validation( + "action must be restore or accept".into(), + )); + } + + let agent = sqlx::query!( + "SELECT wg_ip, api_port, status FROM agents WHERE id = $1", + id + ) + .fetch_optional(&state.db) + .await? + .ok_or(AppError::NotFound)?; + + if agent.status == "lockdown" || agent.status == "offline" { + return Err(AppError::AgentUnavailable); + } + + let cmd_type = format!("nftables.{}", req.action); + let command = json!({ "type": cmd_type }); + + let signed = sign_command(&state.config, id, user.user_id, "write", &command)?; + + let url = format!("http://{}:{}/cmd", agent.wg_ip, agent.api_port); + let tok = &*state.config.internal_token; + let client = reqwest::Client::new(); + let resp = client + .post(&url) + .header("Authorization", format!("Bearer {tok}")) + .json(&signed) + .timeout(std::time::Duration::from_secs(30)) + .send() + .await + .map_err(|_| AppError::BadGateway)?; + + let resolution_event = if req.action == "restore" { + "nftables_restored" + } else { + "nftables_accepted" + }; + + sqlx::query!( + "INSERT INTO agent_events (id, agent_id, event, detail, created_at) VALUES ($1, $2, $3, $4, NOW())", + Uuid::now_v7(), + id, + resolution_event, + format!("action={} by user={}", req.action, user.user_id), + ) + .execute(&state.db) + .await?; + + let status = resp.status(); + let body: Value = resp.json().await.unwrap_or(json!({})); + + Ok(( + axum::http::StatusCode::from_u16(status.as_u16()) + .unwrap_or(axum::http::StatusCode::BAD_GATEWAY), + Json(body), + )) +} diff --git a/lynx/dashboard/server/src/agents/heartbeat.rs b/lynx/dashboard/server/src/agents/heartbeat.rs new file mode 100644 index 0000000..9e45e1b --- /dev/null +++ b/lynx/dashboard/server/src/agents/heartbeat.rs @@ -0,0 +1,221 @@ +use crate::{ + agents::{handlers::broadcast_event, ws_hub}, + alerts, + crypto::cmd, + state::AppState, +}; +use std::time::Duration; +use uuid::Uuid; + +const HEARTBEAT_INTERVAL_SECS: u64 = 30; + +pub async fn run_scheduler(state: AppState) { + let mut interval = tokio::time::interval(Duration::from_secs(HEARTBEAT_INTERVAL_SECS)); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + + loop { + interval.tick().await; + poll_agents(&state).await; + } +} + +async fn poll_agents(state: &AppState) { + let agents = match sqlx::query!( + "SELECT id, wg_ip::text AS wg_ip, api_port, status FROM agents WHERE status != 'lockdown'" + ) + .fetch_all(&state.db) + .await + { + Ok(rows) => rows, + Err(e) => { + tracing::warn!(err = %e, "heartbeat scheduler: failed to fetch agents"); + return; + } + }; + + let token = &*state.config.internal_token; + let client = + super::client::build_agent_client_with_timeout(&state.config, Duration::from_secs(5)); + + let latest = state.latest_agent_version.read().await.clone(); + + for agent in agents { + let id = agent.id; + + // Skip HTTP poll if agent has an active WS connection — it sends heartbeats proactively. + if ws_hub::is_connected(state, id).await { + // Check for pending updates even for WS-connected agents. + if let Some(ref target) = latest { + let current_ver: Option = + sqlx::query_scalar!("SELECT version FROM agents WHERE id = $1", id) + .fetch_optional(&state.db) + .await + .ok() + .flatten() + .flatten(); + + if let Some(ref current) = current_ver { + if current != target { + dispatch_update_ws(state, id, target).await; + } + } + } + continue; + } + + let url = format!("http://{}:{}/heartbeat", agent.wg_ip, agent.api_port); + let resp = client + .post(&url) + .header("Authorization", format!("Bearer {token}")) + .send() + .await; + + let (new_status, reported_version) = match resp { + Ok(r) if r.status().is_success() => { + let ver = r + .json::() + .await + .ok() + .and_then(|v| v["version"].as_str().map(|s| s.to_string())); + ("online", ver) + } + Ok(r) if r.status().as_u16() == 423 => ("lockdown", None), + _ => ("offline", None), + }; + + // Build update query: always update status/heartbeat, conditionally update version. + if let Some(ref ver) = reported_version { + let _ = sqlx::query!( + "UPDATE agents SET status=$1, last_heartbeat=NOW(), version=$2 WHERE id=$3", + new_status, + ver, + id + ) + .execute(&state.db) + .await; + } else { + let _ = sqlx::query!( + "UPDATE agents SET status=$1, last_heartbeat=NOW() WHERE id=$2", + new_status, + id + ) + .execute(&state.db) + .await; + } + + tracing::debug!(agent_id = %id, status = new_status, version = ?reported_version, "heartbeat polled"); + + // Fire heartbeat_lost event when a previously-online agent becomes unreachable. + if new_status == "offline" && agent.status == "online" { + let event_id = uuid::Uuid::now_v7(); + let _ = sqlx::query!( + "INSERT INTO agent_events (id, agent_id, event, detail) VALUES ($1, $2, 'heartbeat_lost', NULL)", + event_id, id + ) + .execute(&state.db) + .await; + broadcast_event(state, id, "heartbeat_lost", None); + alerts::fire(state, "heartbeat_lost", None, id).await; + tracing::warn!(agent_id = %id, "heartbeat lost — agent went offline"); + } + + // Trigger update.self if agent is online, version known, and outdated. + if new_status == "online" { + if let Some(ref current) = reported_version { + if let Some(ref target) = latest { + if current != target { + dispatch_update(state, id, &agent.wg_ip, agent.api_port, target).await; + } + } + } + } + } +} + +async fn dispatch_update_ws(state: &AppState, agent_id: Uuid, version: &str) { + let github_repo = "Jaro-c/Lynx"; + let download_url = format!( + "https://github.com/{github_repo}/releases/download/agent@{version}/lynx-agent-linux-x86_64" + ); + let sig_url = format!( + "https://github.com/{github_repo}/releases/download/agent@{version}/lynx-agent-linux-x86_64.sig" + ); + let command = serde_json::json!({ + "type": "update.self", + "version": version, + "download_url": download_url, + "sig_url": sig_url, + }); + + let signed = match cmd::sign_command(&state.config, agent_id, Uuid::nil(), "write", &command) { + Ok(s) => s, + Err(e) => { + tracing::warn!(agent_id = %agent_id, "WS update sign_command failed: {e}"); + return; + } + }; + + let signed_val = serde_json::to_value(&signed).unwrap_or_default(); + match ws_hub::push_command(state, agent_id, signed_val).await { + Some(_) => tracing::info!(agent_id = %agent_id, version, "WS update.self dispatched"), + None => { + tracing::warn!(agent_id = %agent_id, "WS update.self: no response (agent may have disconnected)") + } + } +} + +async fn dispatch_update( + state: &AppState, + agent_id: Uuid, + wg_ip: &str, + api_port: i32, + version: &str, +) { + let github_repo = "Jaro-c/Lynx"; + let download_url = format!( + "https://github.com/{github_repo}/releases/download/agent@{version}/lynx-agent-linux-x86_64" + ); + let sig_url = format!( + "https://github.com/{github_repo}/releases/download/agent@{version}/lynx-agent-linux-x86_64.sig" + ); + let command = serde_json::json!({ + "type": "update.self", + "version": version, + "download_url": download_url, + "sig_url": sig_url, + }); + + let signed = match cmd::sign_command(&state.config, agent_id, Uuid::nil(), "write", &command) { + Ok(s) => s, + Err(e) => { + tracing::warn!(agent_id = %agent_id, "heartbeat: sign_command failed: {e}"); + return; + } + }; + + let client = + super::client::build_agent_client_with_timeout(&state.config, Duration::from_secs(10)); + + let url = format!("http://{wg_ip}:{api_port}/cmd"); + let result = client + .post(&url) + .header( + "Authorization", + format!("Bearer {}", &*state.config.internal_token), + ) + .json(&signed) + .send() + .await; + + match result { + Ok(r) if r.status().is_success() => { + tracing::info!(agent_id = %agent_id, version, "heartbeat: update.self dispatched"); + } + Ok(r) => { + tracing::warn!(agent_id = %agent_id, status = %r.status(), "heartbeat: update.self rejected"); + } + Err(e) => { + tracing::warn!(agent_id = %agent_id, "heartbeat: update.self delivery failed: {e}"); + } + } +} diff --git a/lynx/dashboard/server/src/agents/mod.rs b/lynx/dashboard/server/src/agents/mod.rs new file mode 100644 index 0000000..92f4545 --- /dev/null +++ b/lynx/dashboard/server/src/agents/mod.rs @@ -0,0 +1,81 @@ +pub mod client; +pub mod handlers; +pub mod heartbeat; +pub mod router; +pub mod wg; +pub mod ws_hub; + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Serialize, sqlx::FromRow)] +pub struct Agent { + pub id: Uuid, + pub name: String, + pub wg_pubkey: String, + pub wg_ip: String, + pub wg_endpoint: Option, + pub api_port: i32, + pub status: String, + pub version: Option, + pub last_heartbeat: Option>, + pub created_at: DateTime, +} + +#[derive(Debug, Deserialize)] +pub struct RegisterAgentRequest { + /// UUID v7 generated by the agent install script + pub agent_id: Uuid, + pub name: String, + /// Agent's WireGuard public key (base64) + pub wg_pubkey: String, + /// Agent VPS public IP (for WG endpoint display) + pub wg_endpoint: Option, + pub api_port: Option, + /// Set to true when registering the agent running on the same VPS as the dashboard. + pub is_local_agent: Option, +} + +#[derive(Debug, Serialize)] +pub struct AgentSummary { + pub id: Uuid, + pub name: String, + pub status: String, + pub wg_ip: String, + pub version: Option, + pub last_heartbeat: Option>, +} + +/// Returned after successful agent registration. +/// `sync_token`, `cert`, and X.509 material are shown once. +#[derive(Debug, Serialize)] +pub struct RegisterAgentResponse { + #[serde(flatten)] + pub agent: Agent, + pub sync_token: String, + /// JSON-signed certificate for this agent — agent uses to verify command authority + pub cert: crate::crypto::pki::SignedCert, + /// CA Ed25519 public key (base64url) — for JSON cert verification + pub ca_public_key: String, + /// X.509 agent server certificate DER (base64) — for mTLS listener + pub tls_cert_der: String, + /// X.509 agent private key DER PKCS#8 (base64) — for mTLS listener + pub tls_key_der: String, + /// X.509 CA certificate DER (base64) — agent verifies dashboard client certs against this + pub tls_ca_cert_der: String, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct AuditSyncEntry { + pub id: Uuid, + pub agent_id: 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: DateTime, +} diff --git a/lynx/dashboard/server/src/agents/router.rs b/lynx/dashboard/server/src/agents/router.rs new file mode 100644 index 0000000..078a3b8 --- /dev/null +++ b/lynx/dashboard/server/src/agents/router.rs @@ -0,0 +1,36 @@ +use super::{handlers, ws_hub}; +use crate::state::AppState; +use axum::{ + routing::{get, post}, + Router, +}; + +/// Routes that require user JWT auth (applied via route_layer in main.rs) +pub fn router() -> Router { + Router::new() + .route( + "/", + get(handlers::list_agents).post(handlers::register_agent), + ) + .route("/events", get(handlers::list_agent_events)) + .route( + "/{id}", + get(handlers::get_agent).delete(handlers::remove_agent), + ) + .route("/{id}/heartbeat", post(handlers::relay_heartbeat)) + .route("/{id}/cmd", post(handlers::send_command)) + .route("/{id}/reboot", post(handlers::reboot_agent)) + .route("/{id}/nftables-status", get(handlers::nftables_status)) + .route("/{id}/nftables-resolve", post(handlers::nftables_resolve)) + .route("/{id}/audit-log", get(handlers::list_audit_log)) + .route("/{id}/metrics/ws", get(handlers::frontend_metrics_ws)) + .route("/events/ws", get(handlers::frontend_events_ws)) +} + +/// Routes that agents call directly (own sync token, not user JWT) +pub fn agent_router() -> Router { + Router::new() + .route("/{id}/audit-sync", post(handlers::receive_audit_sync)) + .route("/{id}/events", post(handlers::receive_event)) + .route("/{id}/ws", get(ws_hub::agent_ws_handler)) +} diff --git a/lynx/dashboard/server/src/agents/wg.rs b/lynx/dashboard/server/src/agents/wg.rs new file mode 100644 index 0000000..46be9e6 --- /dev/null +++ b/lynx/dashboard/server/src/agents/wg.rs @@ -0,0 +1,228 @@ +use anyhow::{Context, Result}; +use std::{io::Write, net::IpAddr}; +use uuid::Uuid; +use zeroize::Zeroizing; + +const WG_IFACE: &str = "wg-lynx-dash"; +const SECRET_PREFIX: &str = "lynx-dashboard-wg-psk-"; +const SECRET_DIR: &str = "/run/secrets"; + +fn psk_secret_name(agent_id: Uuid) -> String { + format!("{SECRET_PREFIX}{agent_id}") +} + +fn psk_secret_path(agent_id: Uuid) -> String { + format!("{SECRET_DIR}/{}", psk_secret_name(agent_id)) +} + +/// Generate a WireGuard PSK (32 random bytes, base64-encoded) and store it +/// as a Podman secret `lynx-dashboard-wg-psk-{agent_id}`. +/// Returns the PSK value (caller must zeroize when done). +pub fn create_psk(agent_id: Uuid) -> Result> { + use base64ct::Encoding as _; + use rand::RngCore; + let mut raw = [0u8; 32]; + rand::thread_rng().fill_bytes(&mut raw); + let psk = Zeroizing::new(base64ct::Base64::encode_string(&raw)); + raw.fill(0); + + let secret_name = psk_secret_name(agent_id); + let status = std::process::Command::new("podman") + .args(["secret", "create", "--replace", &secret_name, "-"]) + .stdin(std::process::Stdio::piped()) + .spawn() + .and_then(|mut child| { + child.stdin.as_mut().unwrap().write_all(psk.as_bytes())?; + child.wait() + }) + .context("podman secret create")?; + + if !status.success() { + anyhow::bail!("podman secret create failed for {secret_name}"); + } + + Ok(psk) +} + +/// Delete the Podman secret for the given agent's PSK. +pub fn delete_psk(agent_id: Uuid) { + let name = psk_secret_name(agent_id); + let _ = std::process::Command::new("podman") + .args(["secret", "rm", &name]) + .status(); +} + +/// Load PSK from mounted secret file. Returns None if the file doesn't exist yet +/// (container hasn't been restarted since secret was created). +pub fn read_psk_file(agent_id: Uuid) -> Option> { + std::fs::read_to_string(psk_secret_path(agent_id)) + .ok() + .map(|s| Zeroizing::new(s.trim().to_string())) +} + +/// Load all PSKs from mounted secret files at startup. +/// Scans SECRET_DIR for files matching the `lynx-dashboard-wg-psk-*` pattern. +pub fn load_all_psks() -> std::collections::HashMap> { + let mut map = std::collections::HashMap::new(); + let Ok(dir) = std::fs::read_dir(SECRET_DIR) else { + return map; + }; + for entry in dir.flatten() { + let name = entry.file_name(); + let name = name.to_string_lossy(); + if let Some(id_str) = name.strip_prefix(SECRET_PREFIX) { + if let Ok(id) = id_str.parse::() { + if let Some(psk) = read_psk_file(id) { + map.insert(id, psk); + } + } + } + } + map +} + +/// Add an agent as a WireGuard peer using the provided PSK. +pub fn add_peer(pubkey: &str, allowed_ip: IpAddr, psk: &str) -> Result<()> { + let allowed = format!("{allowed_ip}/32"); + + let status = std::process::Command::new("wg") + .args([ + "set", + WG_IFACE, + "peer", + pubkey, + "preshared-key", + "/dev/stdin", + "allowed-ips", + &allowed, + ]) + .stdin(std::process::Stdio::piped()) + .spawn() + .and_then(|mut child| { + child.stdin.as_mut().unwrap().write_all(psk.as_bytes())?; + child.wait() + }) + .context("wg set peer")?; + + if !status.success() { + anyhow::bail!("wg set peer failed with status {status}"); + } + + Ok(()) +} + +/// Remove an agent's WireGuard peer. +pub fn remove_peer(pubkey: &str) -> Result<()> { + let status = std::process::Command::new("wg") + .args(["set", WG_IFACE, "peer", pubkey, "remove"]) + .status() + .context("wg set peer remove")?; + + if !status.success() { + anyhow::bail!("wg peer remove failed with status {status}"); + } + + Ok(()) +} + +/// Reconcile WireGuard kernel peers against the DB at startup. +/// Any peer in the kernel that has no corresponding agent row in DB is removed. +pub async fn reconcile_peers(db: &sqlx::PgPool) { + let kernel_peers = match list_kernel_peers() { + Ok(p) => p, + Err(e) => { + tracing::warn!("wg reconcile: failed to list kernel peers: {e}"); + return; + } + }; + + if kernel_peers.is_empty() { + return; + } + + let db_pubkeys: Vec = match sqlx::query_scalar!("SELECT wg_pubkey FROM agents") + .fetch_all(db) + .await + { + Ok(rows) => rows, + Err(e) => { + tracing::warn!("wg reconcile: failed to query agents: {e}"); + return; + } + }; + + for pubkey in &kernel_peers { + if !db_pubkeys.contains(pubkey) { + tracing::warn!(?pubkey, "wg reconcile: removing orphan peer"); + if let Err(e) = remove_peer(pubkey) { + tracing::warn!(?pubkey, "wg reconcile: remove_peer failed: {e}"); + } + } + } + + tracing::info!( + total = kernel_peers.len(), + db_known = db_pubkeys.len(), + "wg reconcile: complete" + ); +} + +fn list_kernel_peers() -> Result> { + let out = std::process::Command::new("wg") + .args(["show", WG_IFACE, "peers"]) + .output() + .context("wg show peers")?; + + if !out.status.success() { + // Interface may not exist yet — treat as empty. + return Ok(vec![]); + } + + let peers = String::from_utf8_lossy(&out.stdout) + .lines() + .map(|l| l.trim().to_string()) + .filter(|l| !l.is_empty()) + .collect(); + + Ok(peers) +} + +/// Allocate the next free IP from the ip_pool table (SELECT FOR UPDATE, race-safe). +/// Returns the allocated IP string (e.g. "10.100.0.2") without the prefix length. +pub async fn allocate_ip(db: &sqlx::PgPool, agent_id: uuid::Uuid) -> Result { + let mut tx = db.begin().await.context("begin ip_pool transaction")?; + + let row = sqlx::query!( + "SELECT ip::text AS ip FROM ip_pool WHERE agent_id IS NULL ORDER BY ip LIMIT 1 FOR UPDATE SKIP LOCKED" + ) + .fetch_optional(&mut *tx) + .await + .context("fetch free ip")? + .ok_or_else(|| anyhow::anyhow!("WireGuard IP pool exhausted"))?; + + let ip = row.ip.unwrap_or_default(); + + sqlx::query!( + "UPDATE ip_pool SET agent_id = $1, updated_at = NOW() WHERE ip::text = $2", + agent_id, + ip, + ) + .execute(&mut *tx) + .await + .context("claim ip in pool")?; + + tx.commit().await.context("commit ip_pool transaction")?; + Ok(ip) +} + +/// Release an agent's IP back to the pool. +pub async fn release_ip(db: &sqlx::PgPool, agent_id: uuid::Uuid) -> Result<()> { + sqlx::query!( + "UPDATE ip_pool SET agent_id = NULL, updated_at = NOW() WHERE agent_id = $1", + agent_id, + ) + .execute(db) + .await + .context("release ip to pool")?; + Ok(()) +} diff --git a/lynx/dashboard/server/src/agents/ws_hub.rs b/lynx/dashboard/server/src/agents/ws_hub.rs new file mode 100644 index 0000000..e173e1a --- /dev/null +++ b/lynx/dashboard/server/src/agents/ws_hub.rs @@ -0,0 +1,445 @@ +use super::{handlers::broadcast_event, AuditSyncEntry}; +use crate::{ + crypto::hash::sha256_hex, + error::AppError, + state::{AgentWsConn, AppState}, +}; +use axum::{ + extract::{ + ws::{Message, WebSocket}, + Path, Query, State, WebSocketUpgrade, + }, + response::IntoResponse, +}; +use serde::Deserialize; +use serde_json::{json, Value}; +use std::{collections::HashMap, sync::Arc, time::Duration}; +use tokio::sync::{broadcast, oneshot, Mutex}; +use uuid::Uuid; + +/// Capacity of each per-agent broadcast channel (number of metric frames buffered). +const METRIC_BROADCAST_CAP: usize = 32; + +#[derive(Deserialize)] +pub struct WsQuery { + token: String, +} + +/// WebSocket upgrade endpoint: agents connect here to establish a persistent channel. +/// Auth: `?token=` verified against `sync_token_hash` in DB. +pub async fn agent_ws_handler( + State(state): State, + Path(id): Path, + Query(q): Query, + ws: WebSocketUpgrade, +) -> Result { + let stored_hash = sqlx::query_scalar!("SELECT sync_token_hash FROM agents WHERE id = $1", id) + .fetch_optional(&state.db) + .await? + .flatten() + .ok_or(AppError::NotFound)?; + + let provided_hash = sha256_hex(q.token.as_bytes()); + let ok: bool = + subtle::ConstantTimeEq::ct_eq(provided_hash.as_bytes(), stored_hash.as_bytes()).into(); + if !ok { + return Err(AppError::Unauthorized); + } + + Ok(ws.on_upgrade(move |socket| handle_socket(state, id, socket))) +} + +async fn handle_socket(state: AppState, agent_id: Uuid, mut socket: WebSocket) { + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::(); + let pending: Arc>>> = + Arc::new(Mutex::new(HashMap::new())); + + let conn = Arc::new(AgentWsConn { + sender: tx, + pending: pending.clone(), + }); + + // Create broadcast channel for metric fan-out to frontend WS clients. + let (metric_tx, _) = broadcast::channel::>(METRIC_BROADCAST_CAP); + { + let mut map = state.agent_ws_conns.write().await; + map.insert(agent_id, conn); + } + { + let mut map = state.agent_metric_tx.write().await; + map.insert(agent_id, metric_tx); + } + + let _ = sqlx::query!( + "UPDATE agents SET status='online', last_heartbeat=NOW() WHERE id=$1", + agent_id + ) + .execute(&state.db) + .await; + + tracing::info!(agent_id = %agent_id, "agent WS connected"); + + // Record connect event + push to browser WS sessions. + let event_id = Uuid::now_v7(); + let _ = sqlx::query!( + "INSERT INTO agent_events (id, agent_id, event, detail) VALUES ($1, $2, 'connected', NULL)", + event_id, + agent_id + ) + .execute(&state.db) + .await; + broadcast_event(&state, agent_id, "connected", None); + + // Push pending global rule syncs if the agent missed any while offline. + push_pending_global_sync(&state, agent_id).await; + + loop { + tokio::select! { + Some(msg) = rx.recv() => { + if socket.send(msg).await.is_err() { + break; + } + } + msg = socket.recv() => { + match msg { + Some(Ok(Message::Text(text))) => { + if let Err(e) = handle_agent_message(&state, agent_id, &pending, text.as_str()).await { + tracing::warn!(agent_id = %agent_id, error = %e, "WS message error"); + } + } + Some(Ok(Message::Ping(data))) => { + let _ = socket.send(Message::Pong(data)).await; + } + Some(Ok(Message::Close(_))) | None => break, + _ => {} + } + } + } + } + + { + let mut map = state.agent_ws_conns.write().await; + map.remove(&agent_id); + } + { + let mut map = state.agent_metric_tx.write().await; + map.remove(&agent_id); + } + + // Cancel pending requests + { + let mut pending_map = pending.lock().await; + for (_, tx) in pending_map.drain() { + let _ = tx.send(json!({"ok": false, "error": "agent disconnected"})); + } + } + + let _ = sqlx::query!("UPDATE agents SET status='offline' WHERE id=$1", agent_id) + .execute(&state.db) + .await; + + // Record disconnect event + push to browser WS sessions. + let event_id = Uuid::now_v7(); + let _ = sqlx::query!( + "INSERT INTO agent_events (id, agent_id, event, detail) VALUES ($1, $2, 'disconnected', NULL)", + event_id, agent_id + ) + .execute(&state.db) + .await; + broadcast_event(&state, agent_id, "disconnected", None); + + tracing::info!(agent_id = %agent_id, "agent WS disconnected"); +} + +#[derive(Deserialize)] +struct AgentMsg { + #[serde(rename = "type")] + msg_type: String, + #[serde(default)] + id: Option, + #[serde(flatten)] + data: Value, +} + +async fn handle_agent_message( + state: &AppState, + agent_id: Uuid, + pending: &Arc>>>, + text: &str, +) -> anyhow::Result<()> { + let msg: AgentMsg = serde_json::from_str(text)?; + + match msg.msg_type.as_str() { + "heartbeat" => { + let status = msg + .data + .get("status") + .and_then(|v| v.as_str()) + .unwrap_or("online"); + let version = msg + .data + .get("version") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + sqlx::query!( + "UPDATE agents SET status=$1, last_heartbeat=NOW(), version=$2 WHERE id=$3", + status, + version, + agent_id + ) + .execute(&state.db) + .await?; + } + "command_response" => { + if let Some(id_str) = msg.id.as_deref() { + if let Ok(req_id) = Uuid::parse_str(id_str) { + let mut map = pending.lock().await; + if let Some(tx) = map.remove(&req_id) { + let body = msg.data.get("body").cloned().unwrap_or(json!({})); + let _ = tx.send(body); + } + } + } + } + "metrics" => { + let shared = Arc::new(text.to_string()); + let map = state.agent_metric_tx.read().await; + if let Some(tx) = map.get(&agent_id) { + // Ignore send errors — no subscribers is normal. + let _ = tx.send(shared); + } + } + "audit_sync" => { + if let Some(entries_val) = msg.data.get("entries") { + let entries: Vec = serde_json::from_value(entries_val.clone())?; + store_audit_entries(state, agent_id, entries).await?; + } + } + other => { + tracing::debug!(agent_id = %agent_id, msg_type = other, "unhandled WS message type"); + } + } + + Ok(()) +} + +async fn store_audit_entries( + state: &AppState, + agent_id: Uuid, + entries: Vec, +) -> anyhow::Result<()> { + if entries.is_empty() { + return Ok(()); + } + + // Verify hash chain integrity before persisting: each entry's previous_hash must + // match the entry_hash of the entry immediately before it in the chain. + // Convention: first entry ever has previous_hash = "" (empty string). + let mut expected_prev: String = sqlx::query_scalar!( + "SELECT entry_hash FROM audit_log WHERE agent_id = $1 ORDER BY created_at DESC LIMIT 1", + agent_id + ) + .fetch_optional(&state.db) + .await? + .unwrap_or_default(); + + // Sort entries by created_at to process in chronological order. + let mut ordered = entries.clone(); + ordered.sort_by_key(|e| e.created_at); + + for entry in &ordered { + if entry.agent_id != agent_id { + continue; + } + + let chain_ok = entry.previous_hash == expected_prev; + + if !chain_ok { + tracing::error!( + agent_id = %agent_id, + entry_id = %entry.id, + "audit_log hash chain mismatch — rejecting batch" + ); + crate::alerts::fire( + state, + "audit_integrity_failure", + Some(format!( + "agent={agent_id} entry={} hash chain mismatch — entries rejected", + entry.id + )), + None::, + ) + .await; + // Mark agent with the failure event. + let event_id = Uuid::now_v7(); + let _ = sqlx::query!( + "INSERT INTO agent_events (id, agent_id, event, detail) VALUES ($1, $2, 'audit_integrity_failure', $3)", + event_id, + agent_id, + Some(format!("hash chain broken at entry {}", entry.id)) + ) + .execute(&state.db) + .await; + super::handlers::broadcast_event(state, agent_id, "audit_integrity_failure", None); + return Err(anyhow::anyhow!( + "audit hash chain mismatch for agent {agent_id}" + )); + } + + expected_prev = entry.entry_hash.clone(); + } + + let mut tx = state.db.begin().await?; + for entry in &ordered { + if entry.agent_id != agent_id { + continue; + } + sqlx::query!( + r#" + INSERT INTO audit_log ( + id, agent_id, organization_id, user_id, command_type, + result, error, previous_hash, entry_hash, created_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + ON CONFLICT (id) DO NOTHING + "#, + entry.id, + entry.agent_id, + entry.organization_id, + entry.user_id, + entry.command_type, + entry.result, + entry.error, + entry.previous_hash, + entry.entry_hash, + entry.created_at, + ) + .execute(&mut *tx) + .await?; + } + tx.commit().await?; + tracing::info!(agent_id = %agent_id, count = ordered.len(), "audit entries received via WS"); + Ok(()) +} + +/// Push a signed command to a connected agent via WS. +/// Returns `Some(response_body)` on success, `None` if no WS connection or timeout. +pub async fn push_command(state: &AppState, agent_id: Uuid, signed_cmd: Value) -> Option { + let req_id = Uuid::now_v7(); + + let conn = { + let map = state.agent_ws_conns.read().await; + map.get(&agent_id).cloned() + }?; + + let (tx, rx) = oneshot::channel(); + { + let mut pending = conn.pending.lock().await; + pending.insert(req_id, tx); + } + + let envelope = json!({ + "type": "command", + "id": req_id, + "payload": signed_cmd, + }); + + let text = serde_json::to_string(&envelope).ok()?; + if conn.sender.send(Message::Text(text.into())).is_err() { + let mut pending = conn.pending.lock().await; + pending.remove(&req_id); + return None; + } + + match tokio::time::timeout(Duration::from_secs(30), rx).await { + Ok(Ok(body)) => Some(body), + _ => { + let mut pending = conn.pending.lock().await; + pending.remove(&req_id); + None + } + } +} + +/// Returns true if an agent currently has an active WS connection. +pub async fn is_connected(state: &AppState, agent_id: Uuid) -> bool { + let map = state.agent_ws_conns.read().await; + map.contains_key(&agent_id) +} + +/// Push current global rules to an agent that has pending unsynced entries. +/// Called on WS connect — catches up agents that were offline during a global push. +async fn push_pending_global_sync(state: &AppState, agent_id: Uuid) { + let has_pending = sqlx::query_scalar!( + "SELECT 1 FROM global_rule_sync WHERE agent_id = $1 AND synced_at IS NULL LIMIT 1", + agent_id + ) + .fetch_optional(&state.db) + .await + .ok() + .flatten() + .is_some(); + + if !has_pending { + return; + } + + // Generate current global chain body from all enabled global rules. + let rules = match sqlx::query!( + r#"SELECT kind, port, protocol, ip_list, rate_per_min, priority + FROM nftables_rules + WHERE scope = 'global' AND enabled = true + ORDER BY priority ASC, created_at ASC"# + ) + .fetch_all(&state.db) + .await + { + Ok(r) => r, + Err(e) => { + tracing::warn!(agent_id = %agent_id, error = %e, "pending_sync: failed to fetch global rules"); + return; + } + }; + + // Convert DB rows to nft chain body text. + let body = rules + .iter() + .map(|r| { + crate::nftables::rule_line( + &r.kind, + r.port.map(|p| p as u16), + r.protocol.as_deref(), + &r.ip_list, + r.rate_per_min.map(|r| r as u32), + ) + }) + .collect::>() + .join("\n"); + + let signed = match crate::crypto::cmd::sign_command_system( + &state.config, + agent_id, + "write", + &serde_json::json!({ + "type": "nftables.apply", + "chain": "lynx-global", + "rules": body, + }), + ) { + Ok(s) => s, + Err(e) => { + tracing::warn!(agent_id = %agent_id, error = %e, "pending_sync: sign failed"); + return; + } + }; + + let signed_val = serde_json::to_value(&signed).unwrap_or_default(); + if push_command(state, agent_id, signed_val).await.is_some() { + let _ = sqlx::query!( + "UPDATE global_rule_sync SET synced_at = NOW() WHERE agent_id = $1 AND synced_at IS NULL", + agent_id + ) + .execute(&state.db) + .await; + tracing::info!(agent_id = %agent_id, "pending global rules synced on reconnect"); + } +} diff --git a/lynx/dashboard/server/src/alerts.rs b/lynx/dashboard/server/src/alerts.rs new file mode 100644 index 0000000..dfc26fb --- /dev/null +++ b/lynx/dashboard/server/src/alerts.rs @@ -0,0 +1,35 @@ +use crate::state::AppState; +use std::sync::Arc; +use uuid::Uuid; + +pub async fn fire( + state: &AppState, + kind: &str, + detail: impl Into>, + agent_id: impl Into>, +) { + let id = Uuid::now_v7(); + let detail = detail.into(); + let agent_id = agent_id.into(); + + let _ = sqlx::query!( + "INSERT INTO security_alerts (id, kind, detail, agent_id) VALUES ($1, $2, $3, $4)", + id, + kind, + detail, + agent_id, + ) + .execute(&state.db) + .await; + + let frame = serde_json::json!({ + "type": "security_alert", + "kind": kind, + "detail": detail, + "agent_id": agent_id, + }); + let text = serde_json::to_string(&frame).unwrap_or_default(); + let _ = state.events_tx.send(Arc::new(text)); + + tracing::warn!(kind, ?detail, ?agent_id, "security alert fired"); +} diff --git a/lynx/dashboard/server/src/auth/handlers/login.rs b/lynx/dashboard/server/src/auth/handlers/login.rs new file mode 100644 index 0000000..880fd67 --- /dev/null +++ b/lynx/dashboard/server/src/auth/handlers/login.rs @@ -0,0 +1,130 @@ +use super::{build_jwt_keys, extract_ip, extract_ua}; +use crate::{ + auth::{models::LoginRequest, rate_limit, session}, + crypto::{hash, jwt, password}, + error::{AppError, Result}, + state::AppState, +}; +use axum::{extract::State, http::HeaderMap, response::IntoResponse, Json}; +use base64ct::{Base64UrlUnpadded, Encoding}; +use chrono::{Duration, Utc}; +use uuid::Uuid; + +pub async fn login( + State(state): State, + headers: HeaderMap, + Json(body): Json, +) -> Result { + let ip = extract_ip(&headers); + let ua = extract_ua(&headers); + let mut redis = state.redis.clone(); + + if let Err(e) = rate_limit::check_login(&mut redis, &ip).await { + if matches!(e, AppError::RateLimited { .. }) { + crate::alerts::fire( + &state, + "rate_limit_hit", + Some(format!("login rate limit exceeded from ip={ip}")), + None::, + ) + .await; + } + return Err(e); + } + + let username = body.username.to_lowercase(); + + struct UserRow { + id: Uuid, + password_hash: String, + force_password_change: bool, + single_session: bool, + } + + let user = sqlx::query_as!( + UserRow, + "SELECT id, password_hash, force_password_change, single_session FROM users WHERE username = $1", + username + ) + .fetch_optional(&state.db) + .await + .map_err(anyhow::Error::from)?; + + let u = match user { + None => { + password::verify_dummy(&body.password); + return Err(AppError::InvalidCredentials); + } + Some(u) => u, + }; + + let ok = password::verify(&body.password, &u.password_hash)?; + if !ok { + return Err(AppError::InvalidCredentials); + } + + if u.single_session { + session::revoke_all_user_sessions(&state.db, &mut redis, u.id, "mass_logout").await?; + } + + let session_id = Uuid::now_v7(); + let jti = Uuid::now_v7(); + let refresh_raw = session::gen_refresh_token(); + let refresh_hash = hash::token_hash(&refresh_raw, &state.config.pepper); + + let keys = build_jwt_keys(&state); + let access_token = jwt::issue_access_token( + &keys, + u.id, + jti, + session_id, + &hash::ip_hash(&ip), + &hash::ua_hash(&ua), + )?; + + let expires_at = Utc::now() + Duration::days(1); + + session::create( + &state.db, + &session::NewSession { + id: session_id, + user_id: u.id, + ip, + user_agent: if ua.is_empty() { None } else { Some(ua) }, + refresh_token_raw: refresh_raw.clone(), + refresh_token_hash: refresh_hash, + expires_at, + last_jti: jti, + }, + ) + .await?; + + session::store_access_jti(&mut redis, jti, session_id).await?; + + let theme = sqlx::query_scalar!( + "SELECT theme FROM user_preferences WHERE user_id = $1", + u.id + ) + .fetch_optional(&state.db) + .await + .map_err(anyhow::Error::from)? + .unwrap_or_else(|| "system".to_string()); + + let redirect = body.redirect_to.as_deref().and_then(|r| { + // Only allow relative paths (/...) — external URLs silently discarded. + if r.starts_with('/') && !r.starts_with("//") { + Some(r.to_string()) + } else { + None + } + }); + + Ok(Json(serde_json::json!({ + "access_token": access_token, + "refresh_token": Base64UrlUnpadded::encode_string(&refresh_raw), + "expires_in": 900_u64, + "force_password_change": u.force_password_change, + "theme": theme, + "redirect_to": redirect, + }))) +} diff --git a/lynx/dashboard/server/src/auth/handlers/me.rs b/lynx/dashboard/server/src/auth/handlers/me.rs new file mode 100644 index 0000000..1f845f5 --- /dev/null +++ b/lynx/dashboard/server/src/auth/handlers/me.rs @@ -0,0 +1,51 @@ +use super::build_jwt_keys; +use crate::{ + auth::session, + crypto::jwt, + error::{AppError, Result}, + state::AppState, +}; +use axum::{extract::State, http::HeaderMap, response::IntoResponse, Json}; + +pub async fn me(State(state): State, headers: HeaderMap) -> Result { + let token = headers + .get(axum::http::header::AUTHORIZATION) + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.strip_prefix("Bearer ")) + .ok_or(AppError::Unauthorized)?; + + let keys = build_jwt_keys(&state); + let claims = jwt::verify_access_token(&keys, token).map_err(|_| AppError::Unauthorized)?; + + let mut redis = state.redis.clone(); + if !session::check_jti_valid(&mut redis, claims.jti).await? { + return Err(AppError::Unauthorized); + } + + let user = sqlx::query!( + "SELECT username, single_session FROM users WHERE id = $1", + claims.sub + ) + .fetch_optional(&state.db) + .await? + .ok_or(AppError::Unauthorized)?; + + let is_admin: bool = sqlx::query_scalar!( + r#"SELECT EXISTS( + SELECT 1 FROM user_roles ur + JOIN role_permissions rp ON rp.role_id = ur.role_id + JOIN permissions p ON p.id = rp.permission_id + WHERE ur.user_id = $1 AND p.key = '*:*' + ) AS "exists!""#, + claims.sub + ) + .fetch_one(&state.db) + .await?; + + Ok(Json(serde_json::json!({ + "id": claims.sub, + "username": user.username, + "is_admin": is_admin, + "single_session": user.single_session, + }))) +} diff --git a/lynx/dashboard/server/src/auth/handlers/mod.rs b/lynx/dashboard/server/src/auth/handlers/mod.rs new file mode 100644 index 0000000..2f49951 --- /dev/null +++ b/lynx/dashboard/server/src/auth/handlers/mod.rs @@ -0,0 +1,55 @@ +mod login; +mod me; +mod password; +mod preferences; +mod register; +mod session; + +pub use login::login; +pub use me::me; +pub use password::change_password; +pub use preferences::{get_preferences, update_preferences, update_single_session}; +pub use register::register; +pub use session::{logout, refresh}; + +use crate::{crypto::jwt, state::AppState}; +use axum::http::HeaderMap; + +pub(super) fn build_jwt_keys(state: &AppState) -> jwt::JwtKeys { + jwt::JwtKeys { + sign_private_seed: *state.config.jwt_sign_private_seed, + sign_public_bytes: state.config.jwt_sign_public_bytes, + enc_private_bytes: *state.config.jwt_enc_private_bytes, + enc_public_bytes: state.config.jwt_enc_public_bytes, + } +} + +pub(super) fn extract_ip(headers: &HeaderMap) -> String { + let raw = headers + .get("x-real-ip") + .or_else(|| headers.get("x-forwarded-for")) + .and_then(|v| v.to_str().ok()) + .unwrap_or_default(); + + // x-forwarded-for may contain a comma-separated list; prefer IPv4 over IPv6. + let mut ipv4: Option<&str> = None; + let mut first: Option<&str> = None; + for candidate in raw.split(',') { + let s = candidate.trim(); + if first.is_none() { + first = Some(s); + } + if s.contains('.') && !s.contains(':') && ipv4.is_none() { + ipv4 = Some(s); + } + } + ipv4.or(first).unwrap_or_default().to_string() +} + +pub(super) fn extract_ua(headers: &HeaderMap) -> String { + headers + .get(axum::http::header::USER_AGENT) + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_string() +} diff --git a/lynx/dashboard/server/src/auth/handlers/password.rs b/lynx/dashboard/server/src/auth/handlers/password.rs new file mode 100644 index 0000000..2fc90d8 --- /dev/null +++ b/lynx/dashboard/server/src/auth/handlers/password.rs @@ -0,0 +1,68 @@ +use super::build_jwt_keys; +use crate::{ + auth::session, + crypto::{jwt, password}, + error::{AppError, Result}, + state::AppState, +}; +use axum::{ + extract::State, + http::{HeaderMap, StatusCode}, + Json, +}; +use serde::Deserialize; + +#[derive(Deserialize)] +pub struct ChangePasswordRequest { + pub current_password: String, + pub new_password: String, +} + +pub async fn change_password( + State(state): State, + headers: HeaderMap, + Json(body): Json, +) -> Result { + let token = headers + .get(axum::http::header::AUTHORIZATION) + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.strip_prefix("Bearer ")) + .ok_or(AppError::Unauthorized)?; + + let keys = build_jwt_keys(&state); + let claims = jwt::verify_access_token(&keys, token).map_err(|_| AppError::Unauthorized)?; + + let mut redis = state.redis.clone(); + if !session::check_jti_valid(&mut redis, claims.jti).await? { + return Err(AppError::Unauthorized); + } + + crate::auth::validate::password(&body.new_password)?; + + let user = sqlx::query!( + "SELECT id, password_hash FROM users WHERE id = $1", + claims.sub + ) + .fetch_optional(&state.db) + .await? + .ok_or(AppError::Unauthorized)?; + + let ok = password::verify(&body.current_password, &user.password_hash)?; + if !ok { + return Err(AppError::InvalidCredentials); + } + + let new_hash = password::hash(&body.new_password)?; + + sqlx::query!( + "UPDATE users SET password_hash = $1, force_password_change = FALSE WHERE id = $2", + new_hash, + user.id + ) + .execute(&state.db) + .await?; + + session::revoke_all_user_sessions(&state.db, &mut redis, user.id, "password_changed").await?; + + Ok(StatusCode::NO_CONTENT) +} diff --git a/lynx/dashboard/server/src/auth/handlers/preferences.rs b/lynx/dashboard/server/src/auth/handlers/preferences.rs new file mode 100644 index 0000000..ed7c8a9 --- /dev/null +++ b/lynx/dashboard/server/src/auth/handlers/preferences.rs @@ -0,0 +1,98 @@ +use crate::{ + auth::middleware::AuthUser, + error::{AppError, Result}, + state::AppState, +}; +use axum::{ + extract::{Extension, State}, + http::StatusCode, + response::IntoResponse, + Json, +}; +use serde::Deserialize; + +pub async fn get_preferences( + State(state): State, + Extension(user): Extension, +) -> Result { + let prefs = sqlx::query!( + "SELECT theme, locale FROM user_preferences WHERE user_id = $1", + user.user_id + ) + .fetch_optional(&state.db) + .await?; + + let (theme, locale) = match prefs { + Some(p) => (p.theme, p.locale), + None => ("system".to_string(), "en".to_string()), + }; + + Ok(Json( + serde_json::json!({ "theme": theme, "locale": locale }), + )) +} + +#[derive(Deserialize)] +pub struct UpdatePreferencesRequest { + pub theme: Option, + pub locale: Option, +} + +#[derive(Deserialize)] +pub struct SingleSessionRequest { + pub enabled: bool, +} + +pub async fn update_single_session( + State(state): State, + Extension(user): Extension, + Json(body): Json, +) -> Result { + sqlx::query!( + "UPDATE users SET single_session = $1 WHERE id = $2", + body.enabled, + user.user_id + ) + .execute(&state.db) + .await?; + + Ok(StatusCode::NO_CONTENT) +} + +pub async fn update_preferences( + State(state): State, + Extension(user): Extension, + Json(body): Json, +) -> Result { + let valid_themes = ["light", "dark", "system"]; + let valid_locales = ["en", "es"]; + + if let Some(ref t) = body.theme { + if !valid_themes.contains(&t.as_str()) { + return Err(AppError::Validation( + "theme must be light, dark, or system".into(), + )); + } + } + if let Some(ref l) = body.locale { + if !valid_locales.contains(&l.as_str()) { + return Err(AppError::Validation("unsupported locale".into())); + } + } + + sqlx::query!( + r#"INSERT INTO user_preferences (user_id, theme, locale) + VALUES ($1, COALESCE($2, 'system'), COALESCE($3, 'en')) + ON CONFLICT (user_id) DO UPDATE SET + theme = COALESCE($2, user_preferences.theme), + locale = COALESCE($3, user_preferences.locale), + updated_at = NOW()"#, + user.user_id, + body.theme, + body.locale, + ) + .execute(&state.db) + .await?; + + Ok(StatusCode::NO_CONTENT) +} diff --git a/lynx/dashboard/server/src/auth/handlers/register.rs b/lynx/dashboard/server/src/auth/handlers/register.rs new file mode 100644 index 0000000..97481e6 --- /dev/null +++ b/lynx/dashboard/server/src/auth/handlers/register.rs @@ -0,0 +1,189 @@ +use super::extract_ip; +use crate::{ + auth::{models::RegisterRequest, rate_limit, validate}, + crypto::{hash, kek, password}, + error::{AppError, Result}, + state::AppState, +}; +use axum::{ + extract::State, + http::{HeaderMap, StatusCode}, + response::IntoResponse, + Json, +}; +use chrono::{Duration, Utc}; +use subtle::ConstantTimeEq; +use uuid::Uuid; + +pub async fn register( + State(state): State, + headers: HeaderMap, + Json(mut body): Json, +) -> Result { + let ip = extract_ip(&headers); + let mut redis = state.redis.clone(); + + rate_limit::check_register(&mut redis, &ip).await?; + + validate::username(&body.username)?; + validate::email(&body.email)?; + validate::password(&body.password)?; + + let username = body.username.to_lowercase(); + let email_lower = body.email.to_lowercase(); + + let admin_exists: bool = sqlx::query_scalar!( + r#" + SELECT EXISTS( + SELECT 1 FROM user_roles ur + JOIN role_permissions rp ON rp.role_id = ur.role_id + JOIN permissions p ON p.id = rp.permission_id + WHERE p.key = '*:*' + ) + "# + ) + .fetch_one(&state.db) + .await + .map_err(anyhow::Error::from)? + .unwrap_or(false); + + let is_bootstrap = !admin_exists; + + if is_bootstrap { + let provided = body.setup_token.as_deref().unwrap_or("").as_bytes(); + let expected = state + .config + .setup_token + .as_deref() + .map(|s| s.as_bytes()) + .unwrap_or(b""); + + let token_ok: bool = if provided.len() == expected.len() && !expected.is_empty() { + provided.ct_eq(expected).into() + } else { + false + }; + + if !token_ok { + password::zeroize_str(&mut body.password); + return Err(AppError::Unauthorized); + } + + let issued_at: Option = sqlx::query_scalar!( + "SELECT value FROM system_config WHERE key = 'setup_token_issued_at'" + ) + .fetch_optional(&state.db) + .await + .map_err(anyhow::Error::from)?; + + if let Some(ts) = issued_at { + if let Ok(issued) = ts.parse::>() { + if Utc::now() - issued > Duration::hours(24) { + password::zeroize_str(&mut body.password); + return Err(AppError::Unauthorized); + } + } + } + } + + let taken: bool = sqlx::query_scalar!( + "SELECT EXISTS(SELECT 1 FROM users WHERE username = $1)", + username + ) + .fetch_one(&state.db) + .await + .map_err(anyhow::Error::from)? + .unwrap_or(false); + + if taken { + password::zeroize_str(&mut body.password); + return Err(AppError::Conflict("username already taken")); + } + + let email_h = hash::email_hash(&email_lower, &state.config.pepper); + + let email_taken: bool = sqlx::query_scalar!( + "SELECT EXISTS(SELECT 1 FROM users WHERE email_hash = $1)", + email_h + ) + .fetch_one(&state.db) + .await + .map_err(anyhow::Error::from)? + .unwrap_or(false); + + if email_taken { + password::zeroize_str(&mut body.password); + return Err(AppError::Conflict("email already registered")); + } + + let pwd_hash = password::hash(&body.password)?; + password::zeroize_str(&mut body.password); + + let dek = kek::gen_dek(); + let dek_encrypted = kek::encrypt_dek(&dek, &state.config.kek)?; + let email_encrypted = kek::encrypt_with_dek(email_lower.as_bytes(), &dek)?; + + let user_id = Uuid::now_v7(); + + sqlx::query!( + r#" + INSERT INTO users (id, username, email_hash, email_encrypted, password_hash, dek_encrypted) + VALUES ($1, $2, $3, $4, $5, $6) + "#, + user_id, + username, + email_h, + email_encrypted, + pwd_hash, + dek_encrypted, + ) + .execute(&state.db) + .await + .map_err(anyhow::Error::from)?; + + if is_bootstrap { + bootstrap_admin(&state.db, user_id).await?; + } + + Ok(StatusCode::CREATED) +} + +async fn bootstrap_admin(db: &sqlx::PgPool, user_id: Uuid) -> anyhow::Result<()> { + let star_perm_id: Uuid = sqlx::query_scalar!("SELECT id FROM permissions WHERE key = '*:*'") + .fetch_one(db) + .await?; + + let role_id = Uuid::now_v7(); + sqlx::query!( + "INSERT INTO roles (id, name, created_by) VALUES ($1, 'Admin', $2)", + role_id, + user_id, + ) + .execute(db) + .await?; + + let rp_id = Uuid::now_v7(); + sqlx::query!( + "INSERT INTO role_permissions (id, role_id, permission_id, created_by) VALUES ($1, $2, $3, $4)", + rp_id, + role_id, + star_perm_id, + user_id, + ) + .execute(db) + .await?; + + let ur_id = Uuid::now_v7(); + sqlx::query!( + "INSERT INTO user_roles (id, user_id, role_id, created_by) VALUES ($1, $2, $3, $4)", + ur_id, + user_id, + role_id, + user_id, + ) + .execute(db) + .await?; + + tracing::info!(user_id = %user_id, "bootstrap admin created"); + Ok(()) +} diff --git a/lynx/dashboard/server/src/auth/handlers/session.rs b/lynx/dashboard/server/src/auth/handlers/session.rs new file mode 100644 index 0000000..d536d23 --- /dev/null +++ b/lynx/dashboard/server/src/auth/handlers/session.rs @@ -0,0 +1,91 @@ +use super::{build_jwt_keys, extract_ip, extract_ua}; +use crate::{ + auth::{models::RefreshRequest, rate_limit, session}, + crypto::{hash, jwt}, + error::{AppError, Result}, + state::AppState, +}; +use axum::{ + extract::State, + http::{HeaderMap, StatusCode}, + Json, +}; +use base64ct::{Base64UrlUnpadded, Encoding}; +use uuid::Uuid; + +use crate::auth::models::TokenResponse; + +pub async fn logout(State(state): State, headers: HeaderMap) -> Result { + let token = headers + .get(axum::http::header::AUTHORIZATION) + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.strip_prefix("Bearer ")) + .ok_or(AppError::Unauthorized)?; + + let keys = build_jwt_keys(&state); + let claims = jwt::verify_access_token(&keys, token).map_err(|_| AppError::Unauthorized)?; + + let mut redis = state.redis.clone(); + + if !session::check_jti_valid(&mut redis, claims.jti).await? { + return Err(AppError::Unauthorized); + } + + session::revoke_access_jti(&mut redis, claims.jti).await?; + + session::delete_by_session_id(&state.db, claims.session_id).await?; + + session::log_event(&state.db, claims.session_id, "user_logout").await?; + + Ok(StatusCode::NO_CONTENT) +} + +pub async fn refresh( + State(state): State, + headers: HeaderMap, + Json(body): Json, +) -> Result> { + let ip = extract_ip(&headers); + let ua = extract_ua(&headers); + let mut redis = state.redis.clone(); + rate_limit::check_refresh(&mut redis, &ip).await?; + + let token_bytes = + Base64UrlUnpadded::decode_vec(&body.refresh_token).map_err(|_| AppError::Unauthorized)?; + let token_hash = hash::token_hash(&token_bytes, &state.config.pepper); + + let record = session::find_by_refresh_hash(&state.db, &token_hash) + .await? + .ok_or(AppError::Unauthorized)?; + + let new_refresh_raw = session::gen_refresh_token(); + let new_refresh_hash = hash::token_hash(&new_refresh_raw, &state.config.pepper); + + let jti = Uuid::now_v7(); + + let rotated = + session::rotate_refresh(&state.db, record.id, &token_hash, &new_refresh_hash, jti).await?; + + if !rotated { + return Err(AppError::Unauthorized); + } + + let keys = build_jwt_keys(&state); + let access_token = jwt::issue_access_token( + &keys, + record.user_id, + jti, + record.id, + &hash::ip_hash(&ip), + &hash::ua_hash(&ua), + )?; + + session::store_access_jti(&mut redis, jti, record.id).await?; + + Ok(Json(TokenResponse { + access_token, + refresh_token: Base64UrlUnpadded::encode_string(&new_refresh_raw), + expires_in: 900, + force_password_change: false, + })) +} diff --git a/lynx/dashboard/server/src/auth/middleware.rs b/lynx/dashboard/server/src/auth/middleware.rs new file mode 100644 index 0000000..3a5fe77 --- /dev/null +++ b/lynx/dashboard/server/src/auth/middleware.rs @@ -0,0 +1,156 @@ +use crate::{crypto, error::AppError, state::AppState}; +use axum::{ + extract::{Extension, Request, State}, + middleware::Next, + response::Response, +}; +use uuid::Uuid; + +#[derive(Clone)] +pub struct AuthUser { + pub user_id: Uuid, + pub session_id: Uuid, +} + +pub async fn require_auth( + State(state): State, + mut req: Request, + next: Next, +) -> Result { + let token = extract_bearer(&req).ok_or(AppError::Unauthorized)?; + let token = token.as_str(); + + let keys = crypto::jwt::JwtKeys { + sign_private_seed: *state.config.jwt_sign_private_seed, + sign_public_bytes: state.config.jwt_sign_public_bytes, + enc_private_bytes: *state.config.jwt_enc_private_bytes, + enc_public_bytes: state.config.jwt_enc_public_bytes, + }; + + let claims = + crypto::jwt::verify_access_token(&keys, token).map_err(|_| AppError::Unauthorized)?; + + // Verify jti in Redis (not revoked) + let mut redis = state.redis.clone(); + let valid = crate::auth::session::check_jti_valid(&mut redis, claims.jti) + .await + .map_err(AppError::Internal)?; + if !valid { + return Err(AppError::Unauthorized); + } + + // Verify IP + UA match + let client_ip = client_ip(&req); + let client_ua = client_ua(&req); + let expected_ip = crypto::hash::ip_hash(&client_ip); + let expected_ua = crypto::hash::ua_hash(&client_ua); + + if claims.ip_hash != expected_ip || claims.ua_hash != expected_ua { + let _ = crate::auth::session::revoke_access_jti(&mut redis, claims.jti).await; + let _ = crate::auth::session::log_event(&state.db, claims.session_id, "intercepted").await; + let _ = crate::auth::session::delete_by_session_id(&state.db, claims.session_id).await; + crate::alerts::fire( + &state, + "intercepted", + Some(format!( + "session={} ip_mismatch={}", + claims.session_id, + claims.ip_hash != expected_ip + )), + None, + ) + .await; + return Err(AppError::Unauthorized); + } + + // Enforce force_password_change — block all authenticated routes + let force_pw: bool = sqlx::query_scalar!( + "SELECT force_password_change FROM users WHERE id = $1", + claims.sub + ) + .fetch_optional(&state.db) + .await + .map_err(|e| AppError::Internal(anyhow::Error::from(e)))? + .unwrap_or(false); + + if force_pw { + return Err(AppError::ForcePasswordChange); + } + + req.extensions_mut().insert(AuthUser { + user_id: claims.sub, + session_id: claims.session_id, + }); + + Ok(next.run(req).await) +} + +/// Middleware: requires the authenticated user to have the `*:*` permission (admin). +/// Must run after `require_auth` (needs `Extension`). +pub async fn require_admin( + State(state): State, + Extension(user): Extension, + req: Request, + next: Next, +) -> Result { + let is_admin: bool = sqlx::query_scalar!( + r#"SELECT EXISTS( + SELECT 1 FROM user_roles ur + JOIN role_permissions rp ON rp.role_id = ur.role_id + JOIN permissions p ON p.id = rp.permission_id + WHERE ur.user_id = $1 AND p.key = '*:*' + ) AS "exists!""#, + user.user_id + ) + .fetch_one(&state.db) + .await + .map_err(|e| AppError::Internal(anyhow::Error::from(e)))?; + + if !is_admin { + return Err(AppError::Forbidden); + } + + Ok(next.run(req).await) +} + +fn extract_bearer(req: &Request) -> Option { + // Primary: Authorization: Bearer + if let Some(bearer) = req + .headers() + .get(axum::http::header::AUTHORIZATION) + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.strip_prefix("Bearer ")) + { + return Some(bearer.to_string()); + } + + // Fallback: access_token cookie (used by browser WebSocket clients — browsers + // cannot set custom headers on WS connections, but do send cookies automatically). + req.headers() + .get(axum::http::header::COOKIE) + .and_then(|v| v.to_str().ok()) + .and_then(|cookie_hdr| { + cookie_hdr.split(';').find_map(|pair| { + let pair = pair.trim(); + pair.strip_prefix("access_token=") + .map(|t| t.trim().to_string()) + }) + }) +} + +fn client_ip(req: &Request) -> String { + req.headers() + .get("x-real-ip") + .or_else(|| req.headers().get("x-forwarded-for")) + .and_then(|v| v.to_str().ok()) + .map(|s| s.split(',').next().unwrap_or(s).trim().to_string()) + .unwrap_or_default() +} + +fn client_ua(req: &Request) -> String { + req.headers() + .get(axum::http::header::USER_AGENT) + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_string() +} diff --git a/lynx/dashboard/server/src/auth/mod.rs b/lynx/dashboard/server/src/auth/mod.rs new file mode 100644 index 0000000..2820d40 --- /dev/null +++ b/lynx/dashboard/server/src/auth/mod.rs @@ -0,0 +1,7 @@ +pub mod handlers; +pub mod middleware; +pub mod models; +pub mod rate_limit; +pub mod router; +pub mod session; +pub mod validate; diff --git a/lynx/dashboard/server/src/auth/models.rs b/lynx/dashboard/server/src/auth/models.rs new file mode 100644 index 0000000..696158e --- /dev/null +++ b/lynx/dashboard/server/src/auth/models.rs @@ -0,0 +1,31 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Deserialize)] +pub struct RegisterRequest { + pub username: String, + pub email: String, + pub password: String, + /// Required only during bootstrap (no admin exists yet) + pub setup_token: Option, +} + +#[derive(Deserialize)] +pub struct LoginRequest { + pub username: String, + pub password: String, + /// Optional post-login destination; validated server-side (relative paths only). + pub redirect_to: Option, +} + +#[derive(Deserialize)] +pub struct RefreshRequest { + pub refresh_token: String, +} + +#[derive(Serialize)] +pub struct TokenResponse { + pub access_token: String, + pub refresh_token: String, + pub expires_in: u64, + pub force_password_change: bool, +} diff --git a/lynx/dashboard/server/src/auth/rate_limit.rs b/lynx/dashboard/server/src/auth/rate_limit.rs new file mode 100644 index 0000000..0a40269 --- /dev/null +++ b/lynx/dashboard/server/src/auth/rate_limit.rs @@ -0,0 +1,52 @@ +use crate::error::{AppError, Result}; +use redis::{aio::ConnectionManager, AsyncCommands}; + +const LOGIN_LIMIT: i64 = 5; +const LOGIN_WINDOW: i64 = 900; +const REGISTER_LIMIT: i64 = 3; +const REGISTER_WINDOW: i64 = 3600; +const REFRESH_LIMIT: i64 = 10; +const REFRESH_WINDOW: i64 = 300; + +pub async fn check_login(redis: &mut ConnectionManager, ip: &str) -> Result<()> { + check(redis, &format!("rl:login:{ip}"), LOGIN_LIMIT, LOGIN_WINDOW).await +} + +pub async fn check_register(redis: &mut ConnectionManager, ip: &str) -> Result<()> { + check( + redis, + &format!("rl:register:{ip}"), + REGISTER_LIMIT, + REGISTER_WINDOW, + ) + .await +} + +pub async fn check_refresh(redis: &mut ConnectionManager, ip: &str) -> Result<()> { + check( + redis, + &format!("rl:refresh:{ip}"), + REFRESH_LIMIT, + REFRESH_WINDOW, + ) + .await +} + +async fn check(redis: &mut ConnectionManager, key: &str, limit: i64, window: i64) -> Result<()> { + let count: i64 = redis + .incr(key, 1i64) + .await + .map_err(|_| AppError::ServiceUnavailable)?; + + if count == 1 { + let _: std::result::Result<(), _> = redis.expire(key, window).await; + } + + if count > limit { + let ttl: i64 = redis.ttl(key).await.unwrap_or(window); + let retry = if ttl > 0 { ttl as u64 } else { window as u64 }; + return Err(AppError::RateLimited { retry_after: retry }); + } + + Ok(()) +} diff --git a/lynx/dashboard/server/src/auth/router.rs b/lynx/dashboard/server/src/auth/router.rs new file mode 100644 index 0000000..e282fac --- /dev/null +++ b/lynx/dashboard/server/src/auth/router.rs @@ -0,0 +1,25 @@ +use super::handlers; +use crate::state::AppState; +use axum::{ + routing::{get, post}, + Router, +}; + +/// Public auth routes — no require_auth middleware. +pub fn router() -> Router { + Router::new() + .route("/register", post(handlers::register)) + .route("/login", post(handlers::login)) + .route("/logout", post(handlers::logout)) + .route("/refresh", post(handlers::refresh)) + .route("/me", get(handlers::me)) + .route("/change-password", post(handlers::change_password)) +} + +/// Protected auth routes — require_auth applied by main.rs. +pub fn protected_router() -> Router { + Router::new() + .route("/me/preferences", get(handlers::get_preferences)) + .route("/me/preferences", post(handlers::update_preferences)) + .route("/me/single-session", post(handlers::update_single_session)) +} diff --git a/lynx/dashboard/server/src/auth/session.rs b/lynx/dashboard/server/src/auth/session.rs new file mode 100644 index 0000000..2bc0bbe --- /dev/null +++ b/lynx/dashboard/server/src/auth/session.rs @@ -0,0 +1,181 @@ +use anyhow::{Context, Result}; +use chrono::{DateTime, Utc}; +use rand::rngs::OsRng; +use redis::{aio::ConnectionManager, AsyncCommands}; +use sqlx::PgPool; +use uuid::Uuid; + +pub struct NewSession { + pub id: Uuid, + pub user_id: Uuid, + pub ip: String, + pub user_agent: Option, + pub refresh_token_raw: Vec, + pub refresh_token_hash: String, + pub expires_at: DateTime, + pub last_jti: Uuid, +} + +pub struct SessionRecord { + pub id: Uuid, + pub user_id: Uuid, + pub refresh_token_hash: String, + pub expires_at: DateTime, +} + +pub fn gen_refresh_token() -> Vec { + let mut buf = vec![0u8; 32]; + rand::RngCore::fill_bytes(&mut OsRng, &mut buf); + buf +} + +pub async fn create(db: &PgPool, s: &NewSession) -> Result<()> { + sqlx::query!( + r#" + INSERT INTO sessions (id, user_id, ip, user_agent, refresh_token_hash, expires_at, last_jti) + VALUES ($1, $2, $3, $4, $5, $6, $7) + "#, + s.id, + s.user_id, + s.ip, + s.user_agent, + s.refresh_token_hash, + s.expires_at, + s.last_jti, + ) + .execute(db) + .await + .context("insert session")?; + Ok(()) +} + +pub async fn find_by_refresh_hash(db: &PgPool, hash: &str) -> Result> { + sqlx::query_as!( + SessionRecord, + r#" + SELECT id, user_id, refresh_token_hash, expires_at + FROM sessions + WHERE refresh_token_hash = $1 + AND expires_at > NOW() + "#, + hash + ) + .fetch_optional(db) + .await + .context("find session by refresh hash") +} + +/// Atomically swap `old_hash` → `new_hash` on the session row. +/// Returns `Ok(true)` if the swap succeeded, `Ok(false)` if the token was +/// already consumed by a concurrent request (0 rows affected). +pub async fn rotate_refresh( + db: &PgPool, + session_id: Uuid, + old_hash: &str, + new_hash: &str, + new_jti: Uuid, +) -> Result { + let result = sqlx::query!( + r#" + UPDATE sessions + SET refresh_token_hash = $1, last_used_at = NOW(), last_jti = $4 + WHERE id = $2 AND refresh_token_hash = $3 + "#, + new_hash, + session_id, + old_hash, + new_jti, + ) + .execute(db) + .await + .context("rotate refresh token")?; + + Ok(result.rows_affected() > 0) +} + +pub async fn delete_by_session_id(db: &PgPool, session_id: Uuid) -> Result<()> { + sqlx::query!("DELETE FROM sessions WHERE id = $1", session_id) + .execute(db) + .await + .context("delete session")?; + Ok(()) +} + +pub async fn log_event(db: &PgPool, session_id: Uuid, reason: &str) -> Result<()> { + let id = Uuid::now_v7(); + sqlx::query!( + "INSERT INTO session_logs (id, session_id, reason) VALUES ($1, $2, $3)", + id, + session_id, + reason, + ) + .execute(db) + .await + .context("insert session log")?; + Ok(()) +} + +pub async fn store_access_jti( + redis: &mut ConnectionManager, + jti: Uuid, + session_id: Uuid, +) -> Result<()> { + let key = format!("access:{jti}"); + let _: () = redis + .set_ex(key, session_id.to_string(), 900u64) + .await + .context("store access jti")?; + Ok(()) +} + +pub async fn revoke_access_jti(redis: &mut ConnectionManager, jti: Uuid) -> Result<()> { + let _: () = redis + .del(format!("access:{jti}")) + .await + .context("revoke access jti")?; + Ok(()) +} + +pub async fn check_jti_valid(redis: &mut ConnectionManager, jti: Uuid) -> Result { + let exists: bool = redis + .exists(format!("access:{jti}")) + .await + .context("check jti")?; + Ok(exists) +} + +/// Revoke all sessions for a user: flush Redis JTIs, delete DB rows, log reason. +pub async fn revoke_all_user_sessions( + db: &PgPool, + redis: &mut ConnectionManager, + user_id: Uuid, + reason: &str, +) -> Result<()> { + struct Row { + id: Uuid, + last_jti: Option, + } + + let rows = sqlx::query_as!( + Row, + "SELECT id, last_jti FROM sessions WHERE user_id = $1", + user_id + ) + .fetch_all(db) + .await + .context("fetch user sessions")?; + + for row in &rows { + if let Some(jti) = row.last_jti { + let _: Result<(), _> = redis.del(format!("access:{jti}")).await; + } + let _ = log_event(db, row.id, reason).await; + } + + sqlx::query!("DELETE FROM sessions WHERE user_id = $1", user_id) + .execute(db) + .await + .context("delete user sessions")?; + + Ok(()) +} diff --git a/lynx/dashboard/server/src/auth/validate.rs b/lynx/dashboard/server/src/auth/validate.rs new file mode 100644 index 0000000..4365a29 --- /dev/null +++ b/lynx/dashboard/server/src/auth/validate.rs @@ -0,0 +1,203 @@ +use crate::error::{AppError, Result}; + +const RESERVED_USERNAMES: &[&str] = &[ + "admin", + "root", + "system", + "lynx", + "support", + "api", + "null", + "undefined", +]; + +pub fn username(s: &str) -> Result<()> { + if s.len() < 3 || s.len() > 32 { + return Err(AppError::Validation("username: 3–32 characters".into())); + } + if !s + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_') + { + return Err(AppError::Validation( + "username: only lowercase letters, digits, - and _".into(), + )); + } + if s.starts_with(['-', '_']) || s.ends_with(['-', '_']) { + return Err(AppError::Validation( + "username: cannot start or end with - or _".into(), + )); + } + if RESERVED_USERNAMES.contains(&s) { + return Err(AppError::Validation("username: reserved".into())); + } + Ok(()) +} + +pub fn password(s: &str) -> Result<()> { + if s.len() < 12 || s.len() > 30 { + return Err(AppError::Validation("password: 12–30 characters".into())); + } + let has_upper = s.chars().any(|c| c.is_ascii_uppercase()); + let has_lower = s.chars().any(|c| c.is_ascii_lowercase()); + let has_digit = s.chars().any(|c| c.is_ascii_digit()); + let has_special = s.chars().any(|c| !c.is_alphanumeric()); + if !has_upper || !has_lower || !has_digit || !has_special { + return Err(AppError::Validation( + "password: requires uppercase, lowercase, digit, and special character".into(), + )); + } + Ok(()) +} + +pub fn email(s: &str) -> Result<()> { + let s = s.trim(); + if s.len() > 254 { + return Err(AppError::Validation("email: too long".into())); + } + let parts: Vec<&str> = s.splitn(2, '@').collect(); + if parts.len() != 2 || parts[0].is_empty() || parts[1].is_empty() { + return Err(AppError::Validation("email: invalid format".into())); + } + if !parts[1].contains('.') { + return Err(AppError::Validation("email: invalid domain".into())); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + // --- username --- + + #[test] + fn username_valid() { + assert!(username("alice").is_ok()); + assert!(username("alice-bob").is_ok()); + assert!(username("alice_bob").is_ok()); + assert!(username("alice123").is_ok()); + assert!(username("abc").is_ok()); + assert!(username(&"a".repeat(32)).is_ok()); + } + + #[test] + fn username_too_short() { + assert!(username("ab").is_err()); + assert!(username("").is_err()); + } + + #[test] + fn username_too_long() { + assert!(username(&"a".repeat(33)).is_err()); + } + + #[test] + fn username_invalid_chars() { + assert!(username("Alice").is_err()); // uppercase + assert!(username("ali ce").is_err()); // space + assert!(username("ali@ce").is_err()); // @ + assert!(username("ali.ce").is_err()); // dot + } + + #[test] + fn username_bad_edge_chars() { + assert!(username("-alice").is_err()); + assert!(username("alice-").is_err()); + assert!(username("_alice").is_err()); + assert!(username("alice_").is_err()); + } + + #[test] + fn username_reserved() { + for r in [ + "admin", + "root", + "system", + "lynx", + "support", + "api", + "null", + "undefined", + ] { + assert!(username(r).is_err(), "{r} should be reserved"); + } + } + + // --- password --- + + #[test] + fn password_valid() { + assert!(password("Abcdef1234!@").is_ok()); + assert!(password("Hunter2#Correct").is_ok()); + } + + #[test] + fn password_too_short() { + assert!(password("Ab1!").is_err()); + assert!(password("Ab1!defghijk").is_ok()); // exactly 12 → ok + } + + #[test] + fn password_too_long() { + let p31 = "Aa1!".repeat(7) + "Aa1!Aa1"; // 31 chars + assert!(password(&p31).is_err()); + } + + #[test] + fn password_boundary_30() { + let p = "Aa1!".repeat(7) + "Aa"; // 30 chars + assert!(password(&p).is_ok()); + } + + #[test] + fn password_missing_uppercase() { + assert!(password("abcdef1234!@").is_err()); + } + + #[test] + fn password_missing_lowercase() { + assert!(password("ABCDEF1234!@").is_err()); + } + + #[test] + fn password_missing_digit() { + assert!(password("AbcdefGhij!@").is_err()); + } + + #[test] + fn password_missing_special() { + assert!(password("Abcdef123456").is_err()); + } + + // --- email --- + + #[test] + fn email_valid() { + assert!(email("user@example.com").is_ok()); + assert!(email("USER@EXAMPLE.COM").is_ok()); + assert!(email(" user@example.com ").is_ok()); // trimmed + assert!(email("a@b.c").is_ok()); + } + + #[test] + fn email_no_at() { + assert!(email("userexample.com").is_err()); + } + + #[test] + fn email_no_domain_dot() { + assert!(email("user@localhost").is_err()); + } + + #[test] + fn email_empty_local() { + assert!(email("@example.com").is_err()); + } + + #[test] + fn email_too_long() { + let long = "a".repeat(250) + "@x.co"; + assert!(email(&long).is_err()); + } +} diff --git a/lynx/dashboard/server/src/branding/handlers.rs b/lynx/dashboard/server/src/branding/handlers.rs new file mode 100644 index 0000000..92cce42 --- /dev/null +++ b/lynx/dashboard/server/src/branding/handlers.rs @@ -0,0 +1,80 @@ +use super::{BrandingRow, UpdateBrandingRequest}; +use crate::{error::AppError, state::AppState}; +use axum::{extract::State, http::StatusCode, response::IntoResponse, Json}; + +// -------------------------------------------------------------------------- +// GET /branding — public, no auth required +// -------------------------------------------------------------------------- + +pub async fn get_branding(State(state): State) -> Result { + let row = sqlx::query_as!( + BrandingRow, + "SELECT company_name, logo_url, primary_color, secondary_color, accent_color, updated_at FROM white_label WHERE id = 1" + ) + .fetch_optional(&state.db) + .await? + .unwrap_or_else(|| BrandingRow { + company_name: "Lynx".into(), + logo_url: None, + primary_color: "#0f172a".into(), + secondary_color: "#38bdf8".into(), + accent_color: "#6366f1".into(), + updated_at: chrono::Utc::now(), + }); + + Ok(Json(row)) +} + +// -------------------------------------------------------------------------- +// PUT /branding — requires auth (admin only in practice, enforced by route_layer) +// -------------------------------------------------------------------------- + +pub async fn update_branding( + State(state): State, + Json(req): Json, +) -> Result { + // Validate hex colors if provided + for (field, val) in [ + ("primary_color", &req.primary_color), + ("secondary_color", &req.secondary_color), + ("accent_color", &req.accent_color), + ] { + if let Some(v) = val { + if !v.starts_with('#') || v.len() != 7 { + return Err(AppError::Validation(format!( + "{field}: must be a 7-char hex color like #0f172a" + ))); + } + } + } + + sqlx::query!( + r#" + INSERT INTO white_label (id, company_name, logo_url, primary_color, secondary_color, accent_color, updated_at) + VALUES (1, + COALESCE($1, 'Lynx'), + $2, + COALESCE($3, '#0f172a'), + COALESCE($4, '#38bdf8'), + COALESCE($5, '#6366f1'), + NOW() + ) + ON CONFLICT (id) DO UPDATE SET + company_name = COALESCE($1, white_label.company_name), + logo_url = COALESCE($2, white_label.logo_url), + primary_color = COALESCE($3, white_label.primary_color), + secondary_color = COALESCE($4, white_label.secondary_color), + accent_color = COALESCE($5, white_label.accent_color), + updated_at = NOW() + "#, + req.company_name, + req.logo_url, + req.primary_color, + req.secondary_color, + req.accent_color, + ) + .execute(&state.db) + .await?; + + Ok(StatusCode::NO_CONTENT) +} diff --git a/lynx/dashboard/server/src/branding/mod.rs b/lynx/dashboard/server/src/branding/mod.rs new file mode 100644 index 0000000..d2dd8ca --- /dev/null +++ b/lynx/dashboard/server/src/branding/mod.rs @@ -0,0 +1,24 @@ +pub mod handlers; +pub mod router; + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, sqlx::FromRow)] +pub struct BrandingRow { + pub company_name: String, + pub logo_url: Option, + pub primary_color: String, + pub secondary_color: String, + pub accent_color: String, + pub updated_at: DateTime, +} + +#[derive(Debug, Deserialize)] +pub struct UpdateBrandingRequest { + pub company_name: Option, + pub logo_url: Option, + pub primary_color: Option, + pub secondary_color: Option, + pub accent_color: Option, +} diff --git a/lynx/dashboard/server/src/branding/router.rs b/lynx/dashboard/server/src/branding/router.rs new file mode 100644 index 0000000..a029cae --- /dev/null +++ b/lynx/dashboard/server/src/branding/router.rs @@ -0,0 +1,10 @@ +use super::handlers; +use crate::state::AppState; +use axum::{routing::get, Router}; + +pub fn router() -> Router { + Router::new().route( + "/", + get(handlers::get_branding).put(handlers::update_branding), + ) +} diff --git a/lynx/dashboard/server/src/config.rs b/lynx/dashboard/server/src/config.rs new file mode 100644 index 0000000..d7c0904 --- /dev/null +++ b/lynx/dashboard/server/src/config.rs @@ -0,0 +1,196 @@ +use anyhow::{Context, Result}; +use base64ct::{Base64, Encoding}; +use zeroize::Zeroizing; + +use crate::crypto::pki; + +pub struct Config { + pub database_url: String, + pub redis_url: String, + pub internal_token: Zeroizing, + pub kek: Zeroizing<[u8; 32]>, + pub pepper: Zeroizing, + /// One-time bootstrap token for creating the first admin. None after bootstrap completes. + pub setup_token: Option>, + /// Ed25519 seed (32 bytes) — private signing key + pub jwt_sign_private_seed: Zeroizing<[u8; 32]>, + /// Ed25519 public key (32 bytes) + pub jwt_sign_public_bytes: [u8; 32], + /// X25519 private key (32 bytes) + pub jwt_enc_private_bytes: Zeroizing<[u8; 32]>, + /// X25519 public key (32 bytes) + pub jwt_enc_public_bytes: [u8; 32], + /// CA Ed25519 seed (32 bytes) — signs agent JSON certificates + pub ca_private_seed: Zeroizing<[u8; 32]>, + /// CA Ed25519 public key (32 bytes) — distributed to agents for JSON cert verification + pub ca_public_bytes: [u8; 32], + /// X.509 CA certificate DER — stable trust anchor distributed to agents for mTLS + pub x509_ca_cert_der: Vec, + /// X.509 CA private key DER (PKCS#8) — used to sign agent/client X.509 certs + pub x509_ca_key_der: Zeroizing>, + /// X.509 dashboard client certificate DER — presented to agents during mTLS handshake + pub x509_client_cert_der: Vec, + /// X.509 dashboard client private key DER (PKCS#8) + pub x509_client_key_der: Zeroizing>, +} + +impl Config { + pub fn load() -> Result { + let internal_token = load_secret("INTERNAL_API_TOKEN", "INTERNAL_API_TOKEN_FILE")?; + let kek = load_key32("KEK", "KEK_FILE")?; + let pepper = load_secret("PEPPER", "PEPPER_FILE")?; + let setup_token = load_secret_opt("SETUP_TOKEN", "SETUP_TOKEN_FILE"); + let (jwt_sign_private_seed, jwt_sign_public_bytes) = load_or_gen_ed25519()?; + let (jwt_enc_private_bytes, jwt_enc_public_bytes) = load_or_gen_x25519()?; + let (ca_private_seed, ca_public_bytes) = load_or_gen_ca_ed25519()?; + let (x509_ca_cert_der, x509_ca_key_der) = load_or_gen_x509_ca()?; + let (x509_client_cert_der, x509_client_key_der) = + pki::issue_x509_dashboard_client_cert(&x509_ca_cert_der, &x509_ca_key_der) + .context("issue dashboard client cert")?; + + let database_url = load_secret("DATABASE_URL", "DATABASE_URL_FILE") + .map(|s| s.as_str().to_owned()) + .context("DATABASE_URL or DATABASE_URL_FILE required")?; + let redis_url = load_secret("REDIS_URL", "REDIS_URL_FILE") + .map(|s| s.as_str().to_owned()) + .context("REDIS_URL or REDIS_URL_FILE required")?; + + Ok(Config { + database_url, + redis_url, + internal_token, + kek, + pepper, + setup_token, + jwt_sign_private_seed, + jwt_sign_public_bytes, + jwt_enc_private_bytes, + jwt_enc_public_bytes, + ca_private_seed, + ca_public_bytes, + x509_ca_cert_der, + x509_ca_key_der, + x509_client_cert_der, + x509_client_key_der, + }) + } +} + +fn load_secret(env: &str, file_env: &str) -> Result> { + 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} or {file_env} required"))?; + Ok(Zeroizing::new(val)) +} + +fn load_secret_opt(env: &str, file_env: &str) -> Option> { + 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, file_env: &str) -> Result> { + let raw = load_secret(env, file_env)?; + let bytes = Base64::decode_vec(raw.trim()).context("key must be base64-encoded 32 bytes")?; + let arr: [u8; 32] = bytes + .try_into() + .map_err(|_| anyhow::anyhow!("key must be exactly 32 bytes"))?; + Ok(Zeroizing::new(arr)) +} + +fn load_or_gen_ed25519() -> Result<(Zeroizing<[u8; 32]>, [u8; 32])> { + if let (Ok(p), Ok(q)) = ( + load_secret("JWT_SIGN_PRIVATE_KEY", "JWT_SIGN_PRIVATE_KEY_FILE"), + load_secret("JWT_SIGN_PUBLIC_KEY", "JWT_SIGN_PUBLIC_KEY_FILE"), + ) { + let seed: [u8; 32] = Base64::decode_vec(p.trim()) + .context("JWT_SIGN_PRIVATE_KEY base64")? + .try_into() + .map_err(|_| anyhow::anyhow!("JWT_SIGN_PRIVATE_KEY must be 32 bytes"))?; + let pub_bytes: [u8; 32] = Base64::decode_vec(q.trim()) + .context("JWT_SIGN_PUBLIC_KEY base64")? + .try_into() + .map_err(|_| anyhow::anyhow!("JWT_SIGN_PUBLIC_KEY must be 32 bytes"))?; + return Ok((Zeroizing::new(seed), pub_bytes)); + } + + tracing::warn!("JWT signing keys not configured — using ephemeral keys (dev only)"); + + let mut seed = [0u8; 32]; + rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut seed); + let signing_key = ed25519_dalek::SigningKey::from_bytes(&seed); + let pub_bytes = signing_key.verifying_key().to_bytes(); + + Ok((Zeroizing::new(seed), pub_bytes)) +} + +fn load_or_gen_x25519() -> Result<(Zeroizing<[u8; 32]>, [u8; 32])> { + if let (Ok(p), Ok(q)) = ( + load_secret("JWT_ENC_PRIVATE_KEY", "JWT_ENC_PRIVATE_KEY_FILE"), + load_secret("JWT_ENC_PUBLIC_KEY", "JWT_ENC_PUBLIC_KEY_FILE"), + ) { + let priv_bytes: [u8; 32] = Base64::decode_vec(p.trim()) + .context("JWT_ENC_PRIVATE_KEY base64")? + .try_into() + .map_err(|_| anyhow::anyhow!("JWT_ENC_PRIVATE_KEY must be 32 bytes"))?; + let pub_bytes: [u8; 32] = Base64::decode_vec(q.trim()) + .context("JWT_ENC_PUBLIC_KEY base64")? + .try_into() + .map_err(|_| anyhow::anyhow!("JWT_ENC_PUBLIC_KEY must be 32 bytes"))?; + return Ok((Zeroizing::new(priv_bytes), pub_bytes)); + } + + tracing::warn!("JWT encryption keys not configured — using ephemeral keys (dev only)"); + + let mut priv_bytes = [0u8; 32]; + rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut priv_bytes); + let secret = x25519_dalek::StaticSecret::from(priv_bytes); + let public = x25519_dalek::PublicKey::from(&secret); + + Ok((Zeroizing::new(priv_bytes), public.to_bytes())) +} + +fn load_or_gen_x509_ca() -> Result<(Vec, Zeroizing>)> { + let cert_raw = load_secret_opt("X509_CA_CERT", "X509_CA_CERT_FILE"); + let key_raw = load_secret_opt("X509_CA_KEY", "X509_CA_KEY_FILE"); + + if let (Some(cert_b64), Some(key_b64)) = (cert_raw, key_raw) { + let cert_der = Base64::decode_vec(cert_b64.trim()).context("X509_CA_CERT base64 decode")?; + let key_der = Base64::decode_vec(key_b64.trim()).context("X509_CA_KEY base64 decode")?; + return Ok((cert_der, Zeroizing::new(key_der))); + } + + tracing::warn!("X509_CA_CERT/KEY not configured — generating ephemeral X.509 CA (dev only; agents will reject certs after restart)"); + pki::generate_x509_ca().context("generate ephemeral X.509 CA") +} + +fn load_or_gen_ca_ed25519() -> Result<(Zeroizing<[u8; 32]>, [u8; 32])> { + if let (Ok(p), Ok(q)) = ( + load_secret("CA_PRIVATE_KEY", "CA_PRIVATE_KEY_FILE"), + load_secret("CA_PUBLIC_KEY", "CA_PUBLIC_KEY_FILE"), + ) { + let seed: [u8; 32] = Base64::decode_vec(p.trim()) + .context("CA_PRIVATE_KEY base64")? + .try_into() + .map_err(|_| anyhow::anyhow!("CA_PRIVATE_KEY must be 32 bytes"))?; + let pub_bytes: [u8; 32] = Base64::decode_vec(q.trim()) + .context("CA_PUBLIC_KEY base64")? + .try_into() + .map_err(|_| anyhow::anyhow!("CA_PUBLIC_KEY must be 32 bytes"))?; + return Ok((Zeroizing::new(seed), pub_bytes)); + } + + tracing::warn!("CA keypair not configured — using ephemeral CA (dev only, agents will reject certs on restart)"); + + let mut seed = [0u8; 32]; + rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut seed); + let signing_key = ed25519_dalek::SigningKey::from_bytes(&seed); + let pub_bytes = signing_key.verifying_key().to_bytes(); + Ok((Zeroizing::new(seed), pub_bytes)) +} diff --git a/lynx/dashboard/server/src/crypto/cmd.rs b/lynx/dashboard/server/src/crypto/cmd.rs new file mode 100644 index 0000000..bbab6f2 --- /dev/null +++ b/lynx/dashboard/server/src/crypto/cmd.rs @@ -0,0 +1,58 @@ +use crate::config::Config; +use anyhow::Result; +use base64ct::{Base64UrlUnpadded, Encoding}; +use ed25519_dalek::{Signer, SigningKey}; +use serde_json::{json, Value}; +use uuid::Uuid; + +/// Signed command payload sent to the agent's /cmd endpoint. +#[derive(serde::Serialize, Clone)] +pub struct SignedCommand { + /// base64url(JSON payload) + pub payload: String, + /// base64url(Ed25519 signature over payload bytes) + pub signature: String, +} + +/// Sign a command on behalf of the system (no human user). +/// Uses nil UUID as user_id — for internal pushes like pending_sync on reconnect. +pub fn sign_command_system( + config: &Config, + agent_id: Uuid, + permission: &str, + command: &Value, +) -> Result { + sign_command(config, agent_id, Uuid::nil(), permission, command) +} + +pub fn sign_command( + config: &Config, + agent_id: Uuid, + user_id: Uuid, + permission: &str, + command: &Value, +) -> Result { + let nonce = Uuid::now_v7().to_string(); + let timestamp = chrono::Utc::now().timestamp(); + + let payload_json = json!({ + "agent_id": agent_id, + "user_id": user_id, + "permission": permission, + "nonce": nonce, + "timestamp": timestamp, + "command": command, + }); + + let payload_bytes = serde_json::to_vec(&payload_json)?; + let payload_b64 = Base64UrlUnpadded::encode_string(&payload_bytes); + + let signing_key = SigningKey::from_bytes(&config.jwt_sign_private_seed); + let signature = signing_key.sign(&payload_bytes); + let sig_b64 = Base64UrlUnpadded::encode_string(&signature.to_bytes()); + + Ok(SignedCommand { + payload: payload_b64, + signature: sig_b64, + }) +} diff --git a/lynx/dashboard/server/src/crypto/hash.rs b/lynx/dashboard/server/src/crypto/hash.rs new file mode 100644 index 0000000..e956807 --- /dev/null +++ b/lynx/dashboard/server/src/crypto/hash.rs @@ -0,0 +1,97 @@ +use sha2::{Digest, Sha256}; + +pub fn sha256_hex(data: &[u8]) -> String { + let mut h = Sha256::new(); + h.update(data); + hex_encode(&h.finalize()) +} + +pub fn email_hash(email: &str, pepper: &str) -> String { + let mut h = Sha256::new(); + h.update(email.to_lowercase().as_bytes()); + h.update(pepper.as_bytes()); + hex_encode(&h.finalize()) +} + +pub fn token_hash(token: &[u8], pepper: &str) -> String { + let mut h = Sha256::new(); + h.update(token); + h.update(pepper.as_bytes()); + hex_encode(&h.finalize()) +} + +pub fn ip_hash(ip: &str) -> String { + let digest = Sha256::digest(ip.as_bytes()); + hex_encode(&digest[..16]) +} + +pub fn ua_hash(ua: &str) -> String { + let digest = Sha256::digest(ua.as_bytes()); + hex_encode(&digest[..16]) +} + +fn hex_encode(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{b:02x}")).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn email_hash_deterministic_and_lowercase() { + let h1 = email_hash("User@Example.COM", "pepper"); + let h2 = email_hash("user@example.com", "pepper"); + assert_eq!(h1, h2, "email_hash must be case-insensitive"); + } + + #[test] + fn email_hash_pepper_matters() { + let h1 = email_hash("user@example.com", "pepper1"); + let h2 = email_hash("user@example.com", "pepper2"); + assert_ne!(h1, h2, "different pepper must produce different hash"); + } + + #[test] + fn email_hash_is_hex() { + let h = email_hash("user@example.com", "pepper"); + assert!(h.chars().all(|c| c.is_ascii_hexdigit()), "must be hex"); + assert_eq!(h.len(), 64, "sha256 → 32 bytes → 64 hex chars"); + } + + #[test] + fn token_hash_deterministic() { + let tok = b"random-token-bytes"; + let h1 = token_hash(tok, "pepper"); + let h2 = token_hash(tok, "pepper"); + assert_eq!(h1, h2); + } + + #[test] + fn token_hash_pepper_matters() { + let tok = b"token"; + assert_ne!(token_hash(tok, "p1"), token_hash(tok, "p2")); + } + + #[test] + fn ip_hash_length() { + let h = ip_hash("192.168.1.1"); + assert_eq!(h.len(), 32, "16 bytes → 32 hex chars"); + } + + #[test] + fn ua_hash_length() { + let h = ua_hash("Mozilla/5.0"); + assert_eq!(h.len(), 32); + } + + #[test] + fn sha256_hex_known_value() { + // sha256("") = e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 + let h = sha256_hex(b""); + assert_eq!( + h, + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + ); + } +} diff --git a/lynx/dashboard/server/src/crypto/jwt.rs b/lynx/dashboard/server/src/crypto/jwt.rs new file mode 100644 index 0000000..12cd17b --- /dev/null +++ b/lynx/dashboard/server/src/crypto/jwt.rs @@ -0,0 +1,290 @@ +use anyhow::{Context, Result}; +use base64ct::{Base64UrlUnpadded, Encoding}; +use josekit::{ + jwe::{self, JweHeader, ECDH_ES_A256KW}, + jwk::Jwk, + jws::{self, EdDSA, JwsHeader}, +}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use uuid::Uuid; +use zeroize::ZeroizeOnDrop; + +pub struct AccessClaims { + pub sub: Uuid, + pub jti: Uuid, + pub session_id: Uuid, + pub ip_hash: String, + pub ua_hash: String, +} + +#[derive(ZeroizeOnDrop)] +pub struct JwtKeys { + /// Ed25519 raw seed (32 bytes) — private signing key + pub sign_private_seed: [u8; 32], + /// Ed25519 raw public key (32 bytes) + #[zeroize(skip)] + pub sign_public_bytes: [u8; 32], + /// X25519 raw private key (32 bytes) + pub enc_private_bytes: [u8; 32], + /// X25519 raw public key (32 bytes) + #[zeroize(skip)] + pub enc_public_bytes: [u8; 32], +} + +pub fn issue_access_token( + keys: &JwtKeys, + user_id: Uuid, + jti: Uuid, + session_id: Uuid, + ip_hash: &str, + ua_hash: &str, +) -> Result { + let now = unix_now(); + let exp = now + 900; + + let payload = serde_json::json!({ + "iss": "lynx-dashboard", + "sub": user_id.to_string(), + "aud": "lynx-dashboard", + "exp": exp, + "nbf": now, + "iat": now, + "jti": jti.to_string(), + "session_id": session_id.to_string(), + "ip_hash": ip_hash, + "ua_hash": ua_hash, + }); + let payload_bytes = serde_json::to_vec(&payload).context("serialize JWT payload")?; + + // Sign (JWS — EdDSA/Ed25519) + let mut jws_header = JwsHeader::new(); + jws_header.set_token_type("JWT"); + let signer_jwk = ed25519_private_jwk(&keys.sign_private_seed, &keys.sign_public_bytes)?; + let signer = EdDSA + .signer_from_jwk(&signer_jwk) + .context("create Ed25519 signer")?; + let inner_jws = + jws::serialize_compact(&payload_bytes, &jws_header, &signer).context("JWS sign")?; + + // Encrypt (JWE — ECDH-ES+A256KW / X25519 / A256GCM) + let mut jwe_header = JweHeader::new(); + jwe_header.set_content_encryption("A256GCM"); + jwe_header.set_content_type("JWT"); + let public_jwk = x25519_public_jwk(&keys.enc_public_bytes)?; + let encrypter = ECDH_ES_A256KW + .encrypter_from_jwk(&public_jwk) + .context("create X25519 encrypter")?; + let outer_jwe = jwe::serialize_compact(inner_jws.as_bytes(), &jwe_header, &encrypter) + .context("JWE encrypt")?; + + Ok(outer_jwe) +} + +pub fn verify_access_token(keys: &JwtKeys, token: &str) -> Result { + // Decrypt (JWE) + let private_jwk = x25519_private_jwk(&keys.enc_private_bytes, &keys.enc_public_bytes)?; + let decrypter = ECDH_ES_A256KW + .decrypter_from_jwk(&private_jwk) + .context("create X25519 decrypter")?; + let (inner_bytes, _) = jwe::deserialize_compact(token, &decrypter).context("JWE decrypt")?; + + // Verify (JWS) + let verifier_jwk = ed25519_public_jwk(&keys.sign_public_bytes)?; + let verifier = EdDSA + .verifier_from_jwk(&verifier_jwk) + .context("create Ed25519 verifier")?; + let (payload_bytes, _) = + jws::deserialize_compact(&inner_bytes, &verifier).context("JWS verify")?; + + let claims: serde_json::Value = + serde_json::from_slice(&payload_bytes).context("parse JWT claims")?; + + validate_claims(&claims)?; + + let sub = parse_uuid(&claims, "sub")?; + let jti = parse_uuid(&claims, "jti")?; + let session_id = parse_uuid(&claims, "session_id")?; + let ip_hash = parse_str(&claims, "ip_hash")?.to_string(); + let ua_hash = parse_str(&claims, "ua_hash")?.to_string(); + + Ok(AccessClaims { + sub, + jti, + session_id, + ip_hash, + ua_hash, + }) +} + +fn validate_claims(c: &serde_json::Value) -> Result<()> { + if c["iss"].as_str() != Some("lynx-dashboard") { + anyhow::bail!("invalid issuer"); + } + if c["aud"].as_str() != Some("lynx-dashboard") { + anyhow::bail!("invalid audience"); + } + let now = unix_now(); + let exp = c["exp"].as_u64().context("missing exp")?; + if exp <= now { + anyhow::bail!("token expired"); + } + if let Some(nbf) = c["nbf"].as_u64() { + if nbf > now { + anyhow::bail!("token not yet valid"); + } + } + Ok(()) +} + +fn parse_uuid(c: &serde_json::Value, key: &str) -> Result { + let s = c[key] + .as_str() + .with_context(|| format!("missing claim: {key}"))?; + Uuid::parse_str(s).with_context(|| format!("{key} not a UUID")) +} + +fn parse_str<'a>(c: &'a serde_json::Value, key: &str) -> Result<&'a str> { + c[key] + .as_str() + .with_context(|| format!("missing claim: {key}")) +} + +fn unix_now() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or(Duration::ZERO) + .as_secs() +} + +fn ed25519_private_jwk(seed: &[u8; 32], pub_bytes: &[u8; 32]) -> Result { + serde_json::from_value(serde_json::json!({ + "kty": "OKP", + "crv": "Ed25519", + "x": Base64UrlUnpadded::encode_string(pub_bytes), + "d": Base64UrlUnpadded::encode_string(seed), + })) + .context("build Ed25519 private JWK") +} + +fn ed25519_public_jwk(pub_bytes: &[u8; 32]) -> Result { + serde_json::from_value(serde_json::json!({ + "kty": "OKP", + "crv": "Ed25519", + "x": Base64UrlUnpadded::encode_string(pub_bytes), + })) + .context("build Ed25519 public JWK") +} + +fn x25519_private_jwk(priv_bytes: &[u8; 32], pub_bytes: &[u8; 32]) -> Result { + serde_json::from_value(serde_json::json!({ + "kty": "OKP", + "crv": "X25519", + "x": Base64UrlUnpadded::encode_string(pub_bytes), + "d": Base64UrlUnpadded::encode_string(priv_bytes), + })) + .context("build X25519 private JWK") +} + +fn x25519_public_jwk(pub_bytes: &[u8; 32]) -> Result { + serde_json::from_value(serde_json::json!({ + "kty": "OKP", + "crv": "X25519", + "x": Base64UrlUnpadded::encode_string(pub_bytes), + })) + .context("build X25519 public JWK") +} + +#[cfg(test)] +mod tests { + use super::*; + + fn test_keys() -> JwtKeys { + use ed25519_dalek::SigningKey; + use x25519_dalek::{PublicKey, StaticSecret}; + + let sign_seed: [u8; 32] = [0x42u8; 32]; + let signing = SigningKey::from_bytes(&sign_seed); + let sign_pub: [u8; 32] = signing.verifying_key().to_bytes(); + + let enc_priv: [u8; 32] = [0x77u8; 32]; + let enc_pub: [u8; 32] = *PublicKey::from(&StaticSecret::from(enc_priv)).as_bytes(); + + JwtKeys { + sign_private_seed: sign_seed, + sign_public_bytes: sign_pub, + enc_private_bytes: enc_priv, + enc_public_bytes: enc_pub, + } + } + + fn dummy_uuid() -> Uuid { + Uuid::now_v7() + } + + #[test] + fn roundtrip_valid_token() { + let keys = test_keys(); + let user_id = dummy_uuid(); + let jti = dummy_uuid(); + let session_id = dummy_uuid(); + + let token = issue_access_token( + &keys, + user_id, + jti, + session_id, + "ip_hash_val", + "ua_hash_val", + ) + .expect("issue token"); + + let claims = verify_access_token(&keys, &token).expect("verify token"); + assert_eq!(claims.sub, user_id); + assert_eq!(claims.jti, jti); + assert_eq!(claims.session_id, session_id); + assert_eq!(claims.ip_hash, "ip_hash_val"); + assert_eq!(claims.ua_hash, "ua_hash_val"); + } + + #[test] + fn wrong_sign_key_rejected() { + let keys = test_keys(); + let token = issue_access_token(&keys, dummy_uuid(), dummy_uuid(), dummy_uuid(), "ip", "ua") + .expect("issue"); + + let mut bad_keys = test_keys(); + bad_keys.sign_public_bytes = [0u8; 32]; // invalid pub key → verify fails + assert!(verify_access_token(&bad_keys, &token).is_err()); + } + + #[test] + fn wrong_enc_key_rejected() { + let keys = test_keys(); + let token = issue_access_token(&keys, dummy_uuid(), dummy_uuid(), dummy_uuid(), "ip", "ua") + .expect("issue"); + + let mut bad_keys = test_keys(); + bad_keys.enc_private_bytes = [0u8; 32]; // wrong private key → decrypt fails + let enc_pub: [u8; 32] = { + use x25519_dalek::{PublicKey, StaticSecret}; + let secret = StaticSecret::from([0u8; 32]); + *PublicKey::from(&secret).as_bytes() + }; + bad_keys.enc_public_bytes = enc_pub; + assert!(verify_access_token(&bad_keys, &token).is_err()); + } + + #[test] + fn tampered_token_rejected() { + let keys = test_keys(); + let token = issue_access_token(&keys, dummy_uuid(), dummy_uuid(), dummy_uuid(), "ip", "ua") + .expect("issue"); + + // Corrupt a character near the middle of the compact JWE string + let mut bytes = token.into_bytes(); + let mid = bytes.len() / 2; + bytes[mid] ^= 0x01; + let corrupted = String::from_utf8_lossy(&bytes).into_owned(); + assert!(verify_access_token(&keys, &corrupted).is_err()); + } +} diff --git a/lynx/dashboard/server/src/crypto/kek.rs b/lynx/dashboard/server/src/crypto/kek.rs new file mode 100644 index 0000000..ae23a3b --- /dev/null +++ b/lynx/dashboard/server/src/crypto/kek.rs @@ -0,0 +1,129 @@ +use aes_gcm::{ + aead::{Aead, AeadCore, KeyInit}, + Aes256Gcm, Key, Nonce, +}; +use anyhow::Result; +use rand::rngs::OsRng; +use zeroize::Zeroizing; + +pub fn gen_dek() -> Zeroizing<[u8; 32]> { + let mut dek = [0u8; 32]; + rand::RngCore::fill_bytes(&mut OsRng, &mut dek); + Zeroizing::new(dek) +} + +pub fn encrypt_dek(dek: &[u8; 32], kek: &[u8; 32]) -> Result> { + encrypt_aes_gcm(dek, kek) +} + +pub fn decrypt_dek(ciphertext: &[u8], kek: &[u8; 32]) -> Result> { + let plain = decrypt_aes_gcm(ciphertext, kek)?; + let arr: [u8; 32] = plain + .try_into() + .map_err(|_| anyhow::anyhow!("DEK wrong length after decrypt"))?; + Ok(Zeroizing::new(arr)) +} + +pub fn encrypt_with_dek(plaintext: &[u8], dek: &[u8; 32]) -> Result> { + encrypt_aes_gcm(plaintext, dek) +} + +pub fn decrypt_with_dek(ciphertext: &[u8], dek: &[u8; 32]) -> Result> { + decrypt_aes_gcm(ciphertext, dek) +} + +fn encrypt_aes_gcm(plaintext: &[u8], key_bytes: &[u8; 32]) -> Result> { + let key = Key::::from_slice(key_bytes); + let cipher = Aes256Gcm::new(key); + let nonce = Aes256Gcm::generate_nonce(&mut OsRng); + let ciphertext = cipher + .encrypt(&nonce, plaintext) + .map_err(|e| anyhow::anyhow!("AES-GCM encrypt: {e}"))?; + // nonce (12 bytes) || ciphertext + let mut out = nonce.to_vec(); + out.extend_from_slice(&ciphertext); + Ok(out) +} + +fn decrypt_aes_gcm(data: &[u8], key_bytes: &[u8; 32]) -> Result> { + if data.len() < 12 { + anyhow::bail!("ciphertext too short"); + } + let (nonce_bytes, ciphertext) = data.split_at(12); + let key = Key::::from_slice(key_bytes); + let cipher = Aes256Gcm::new(key); + let nonce = Nonce::from_slice(nonce_bytes); + cipher + .decrypt(nonce, ciphertext) + .map_err(|e| anyhow::anyhow!("AES-GCM decrypt: {e}")) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn test_kek() -> [u8; 32] { + [0x11u8; 32] + } + + #[test] + fn dek_encrypt_decrypt_roundtrip() { + let kek = test_kek(); + let dek = gen_dek(); + let ct = encrypt_dek(&dek, &kek).expect("encrypt"); + let recovered = decrypt_dek(&ct, &kek).expect("decrypt"); + assert_eq!(*dek, *recovered); + } + + #[test] + fn dek_wrong_key_fails() { + let kek = test_kek(); + let dek = gen_dek(); + let ct = encrypt_dek(&dek, &kek).expect("encrypt"); + let bad_kek = [0x22u8; 32]; + assert!(decrypt_dek(&ct, &bad_kek).is_err(), "wrong key must fail"); + } + + #[test] + fn data_encrypt_decrypt_roundtrip() { + let dek: [u8; 32] = [0x33u8; 32]; + let plaintext = b"hello lynx world"; + let ct = encrypt_with_dek(plaintext, &dek).expect("encrypt"); + let recovered = decrypt_with_dek(&ct, &dek).expect("decrypt"); + assert_eq!(recovered, plaintext); + } + + #[test] + fn nonce_prepended_makes_ciphertext_longer_than_plaintext() { + let dek: [u8; 32] = [0x44u8; 32]; + let plaintext = b"test"; + let ct = encrypt_with_dek(plaintext, &dek).expect("encrypt"); + // nonce (12) + GCM tag (16) + plaintext length + assert!(ct.len() > plaintext.len() + 12); + } + + #[test] + fn decrypt_too_short_fails() { + let dek: [u8; 32] = [0x44u8; 32]; + assert!(decrypt_with_dek(&[0u8; 5], &dek).is_err()); + } + + #[test] + fn tampered_ciphertext_fails() { + let dek: [u8; 32] = [0x55u8; 32]; + let plaintext = b"secret data"; + let mut ct = encrypt_with_dek(plaintext, &dek).expect("encrypt"); + ct[20] ^= 0xff; // flip a byte in the ciphertext + assert!(decrypt_with_dek(&ct, &dek).is_err(), "tamper must fail"); + } + + #[test] + fn each_encryption_uses_different_nonce() { + let dek: [u8; 32] = [0x66u8; 32]; + let plaintext = b"same input"; + let ct1 = encrypt_with_dek(plaintext, &dek).expect("ct1"); + let ct2 = encrypt_with_dek(plaintext, &dek).expect("ct2"); + // Different nonces → different ciphertexts + assert_ne!(ct1, ct2, "nonce must be random per encryption"); + } +} diff --git a/lynx/dashboard/server/src/crypto/mod.rs b/lynx/dashboard/server/src/crypto/mod.rs new file mode 100644 index 0000000..b615c89 --- /dev/null +++ b/lynx/dashboard/server/src/crypto/mod.rs @@ -0,0 +1,6 @@ +pub mod cmd; +pub mod hash; +pub mod jwt; +pub mod kek; +pub mod password; +pub mod pki; diff --git a/lynx/dashboard/server/src/crypto/password.rs b/lynx/dashboard/server/src/crypto/password.rs new file mode 100644 index 0000000..83dc037 --- /dev/null +++ b/lynx/dashboard/server/src/crypto/password.rs @@ -0,0 +1,76 @@ +use anyhow::Result; +use argon2::{ + password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString}, + Argon2, +}; +use zeroize::Zeroizing; + +pub fn hash(password: &str) -> Result { + let salt = SaltString::generate(&mut OsRng); + Argon2::default() + .hash_password(password.as_bytes(), &salt) + .map(|h| h.to_string()) + .map_err(|e| anyhow::anyhow!("argon2 hash: {e}")) +} + +pub fn verify(password: &str, hash: &str) -> Result { + let parsed = PasswordHash::new(hash).map_err(|e| anyhow::anyhow!("parse hash: {e}"))?; + Ok(Argon2::default() + .verify_password(password.as_bytes(), &parsed) + .is_ok()) +} + +static DUMMY_HASH: &str = + "$argon2id$v=19$m=19456,t=2,p=1$c29tZXNhbHRzb21lc2FsdA$VtJfVEBMQG4mOXlm5M5Fl8bAHYE5L7Hp/fSKl3y6z2E"; + +pub fn verify_dummy(password: &str) { + let _ = verify(password, DUMMY_HASH); +} + +pub fn zeroize_str(s: &mut String) { + let _ = Zeroizing::new(std::mem::take(s)); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn hash_and_verify_correct() { + let h = hash("MyP@ssword123").expect("hash"); + assert!( + verify("MyP@ssword123", &h).expect("verify"), + "correct password must verify" + ); + } + + #[test] + fn wrong_password_rejected() { + let h = hash("MyP@ssword123").expect("hash"); + assert!( + !verify("WrongP@ssword1", &h).expect("verify"), + "wrong password must not verify" + ); + } + + #[test] + fn hashes_differ_for_same_input() { + // Different salts → different hashes + let h1 = hash("MyP@ssword123").expect("h1"); + let h2 = hash("MyP@ssword123").expect("h2"); + assert_ne!(h1, h2, "each hash must use a fresh salt"); + } + + #[test] + fn dummy_verify_does_not_panic() { + // verify_dummy must never panic regardless of input + verify_dummy("anything"); + verify_dummy(""); + verify_dummy(&"a".repeat(1000)); + } + + #[test] + fn invalid_hash_string_returns_err() { + assert!(verify("password", "not-a-valid-hash").is_err()); + } +} diff --git a/lynx/dashboard/server/src/crypto/pki.rs b/lynx/dashboard/server/src/crypto/pki.rs new file mode 100644 index 0000000..e0a2ad8 --- /dev/null +++ b/lynx/dashboard/server/src/crypto/pki.rs @@ -0,0 +1,201 @@ +//! Internal PKI — dashboard CA issues certificates to agents. +//! +//! Two certificate systems in parallel: +//! - JSON-signed certs (Ed25519): lightweight, used to verify dashboard command authority +//! - X.509 certs (rcgen/Ed25519): used for mTLS between dashboard and agent HTTP endpoints + +use anyhow::{Context, Result}; +use base64ct::{Base64UrlUnpadded, Encoding}; +use chrono::{Duration, Utc}; +use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey}; +use rcgen::{ + BasicConstraints, CertificateParams, DnType, ExtendedKeyUsagePurpose, IsCa, KeyPair, SanType, + PKCS_ED25519, +}; +use rustls::pki_types::CertificateDer as RustlsCertDer; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; +use zeroize::Zeroizing; + +pub const CERT_VALIDITY_DAYS: i64 = 90; + +/// Certificate payload — signed by the CA key. +#[derive(Debug, Serialize, Deserialize)] +pub struct AgentCert { + pub agent_id: Uuid, + pub issued_at: i64, // Unix timestamp + pub expires_at: i64, // Unix timestamp +} + +/// Serialized, signed certificate returned to agent at registration. +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct SignedCert { + /// Base64url-encoded JSON payload + pub payload: String, + /// Base64url-encoded Ed25519 signature + pub signature: String, +} + +/// Issue a new certificate for an agent, signed by the CA private key. +pub fn issue_cert(ca_private_seed: &[u8; 32], agent_id: Uuid) -> Result { + let now = Utc::now(); + let cert = AgentCert { + agent_id, + issued_at: now.timestamp(), + expires_at: (now + Duration::days(CERT_VALIDITY_DAYS)).timestamp(), + }; + + let payload_bytes = serde_json::to_vec(&cert).context("serialize cert payload")?; + let payload = Base64UrlUnpadded::encode_string(&payload_bytes); + + let signing_key = SigningKey::from_bytes(ca_private_seed); + let sig = signing_key.sign(&payload_bytes); + let signature = Base64UrlUnpadded::encode_string(&sig.to_bytes()); + + Ok(SignedCert { payload, signature }) +} + +/// Verify a certificate against the CA public key. +/// Returns the decoded payload if valid and not expired. +pub fn verify_cert( + ca_public_bytes: &[u8; 32], + cert: &SignedCert, + 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_bytes).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 = Utc::now().timestamp(); + if now > payload.expires_at { + anyhow::bail!("cert expired"); + } + + Ok(payload) +} + +/// Generate a new CA Ed25519 keypair (for startup when not configured). +pub fn gen_ca_keypair() -> (Zeroizing<[u8; 32]>, [u8; 32]) { + let mut seed = [0u8; 32]; + rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut seed); + let signing_key = SigningKey::from_bytes(&seed); + let pub_bytes = signing_key.verifying_key().to_bytes(); + (Zeroizing::new(seed), pub_bytes) +} + +// --------------------------------------------------------------------------- +// X.509 mTLS certificate functions +// --------------------------------------------------------------------------- + +/// Generate a self-signed X.509 CA certificate (Ed25519). +/// Returns `(cert_der, key_der_pkcs8)`. +pub fn generate_x509_ca() -> Result<(Vec, Zeroizing>)> { + let key = KeyPair::generate_for(&PKCS_ED25519).context("generate CA Ed25519 key")?; + + let mut params = CertificateParams::new(vec![]).context("create CA cert params")?; + params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained); + params + .distinguished_name + .push(DnType::CommonName, "Lynx Internal CA"); + params + .distinguished_name + .push(DnType::OrganizationName, "Lynx"); + + let cert = params.self_signed(&key).context("self-sign CA cert")?; + let cert_der = cert.der().to_vec(); + let key_der = Zeroizing::new(key.serialize_der()); + + Ok((cert_der, key_der)) +} + +/// Reconstruct an `rcgen::Certificate` from stored DER bytes. +/// Used as the issuer when signing leaf certs. +fn load_ca_rcgen(ca_cert_der: &[u8], ca_key_der: &[u8]) -> Result<(rcgen::Certificate, KeyPair)> { + let ca_key = KeyPair::try_from(ca_key_der).context("load CA key from DER")?; + let cert_der = RustlsCertDer::from(ca_cert_der.to_vec()); + let ca_params = + CertificateParams::from_ca_cert_der(&cert_der).context("parse CA cert params from DER")?; + let ca_cert = ca_params + .self_signed(&ca_key) + .context("reconstruct CA cert")?; + Ok((ca_cert, ca_key)) +} + +/// Issue an X.509 server certificate for an agent (Ed25519). +/// The cert includes the agent's WireGuard IP as a SAN. +/// Returns `(cert_der, key_der_pkcs8)`. +pub fn issue_x509_agent_cert( + ca_cert_der: &[u8], + ca_key_der: &[u8], + agent_id: Uuid, + wg_ip: &str, +) -> Result<(Vec, Zeroizing>)> { + let (ca_cert, ca_key) = load_ca_rcgen(ca_cert_der, ca_key_der)?; + let leaf_key = KeyPair::generate_for(&PKCS_ED25519).context("generate agent Ed25519 key")?; + + let mut params = CertificateParams::new(vec![]).context("create agent cert params")?; + params + .distinguished_name + .push(DnType::CommonName, format!("lynx-agent-{agent_id}")); + // Extended key usages: both server auth (listener) and client auth (future) + params.extended_key_usages = vec![ + ExtendedKeyUsagePurpose::ServerAuth, + ExtendedKeyUsagePurpose::ClientAuth, + ]; + if let Ok(ip) = wg_ip.parse::() { + params.subject_alt_names.push(SanType::IpAddress(ip)); + } + + let cert = params + .signed_by(&leaf_key, &ca_cert, &ca_key) + .context("sign agent cert")?; + let cert_der = cert.der().to_vec(); + let key_der = Zeroizing::new(leaf_key.serialize_der()); + + Ok((cert_der, key_der)) +} + +/// Issue an X.509 client certificate for the dashboard (Ed25519). +/// Used by the dashboard's reqwest client when connecting to agent TLS endpoints. +/// Returns `(cert_der, key_der_pkcs8)`. +pub fn issue_x509_dashboard_client_cert( + ca_cert_der: &[u8], + ca_key_der: &[u8], +) -> Result<(Vec, Zeroizing>)> { + let (ca_cert, ca_key) = load_ca_rcgen(ca_cert_der, ca_key_der)?; + let leaf_key = + KeyPair::generate_for(&PKCS_ED25519).context("generate dashboard client Ed25519 key")?; + + let mut params = + CertificateParams::new(vec![]).context("create dashboard client cert params")?; + params + .distinguished_name + .push(DnType::CommonName, "lynx-dashboard"); + params.extended_key_usages = vec![ExtendedKeyUsagePurpose::ClientAuth]; + + let cert = params + .signed_by(&leaf_key, &ca_cert, &ca_key) + .context("sign dashboard client cert")?; + let cert_der = cert.der().to_vec(); + let key_der = Zeroizing::new(leaf_key.serialize_der()); + + Ok((cert_der, key_der)) +} diff --git a/lynx/dashboard/server/src/domain/handlers/api.rs b/lynx/dashboard/server/src/domain/handlers/api.rs new file mode 100644 index 0000000..13bad43 --- /dev/null +++ b/lynx/dashboard/server/src/domain/handlers/api.rs @@ -0,0 +1,498 @@ +use super::super::{DomainConfig, SetDomainRequest, SetHstsRequest, UploadCertRequest}; +use super::nginx::{ + custom_cert_path, custom_key_path, nginx_conf, nginx_conf_with_cert, NGINX_IMAGE, +}; +use crate::{ + agents::client::build_agent_client, auth::middleware::AuthUser, crypto::cmd, error::AppError, + state::AppState, +}; +use axum::{ + extract::{Extension, State}, + http::StatusCode, + response::IntoResponse, + Json, +}; +use serde_json::json; +use std::net::ToSocketAddrs; +use uuid::Uuid; + +struct LocalAgent { + id: Uuid, + wg_ip: String, + api_port: i32, +} + +async fn get_local_agent(state: &AppState) -> Result { + let row = sqlx::query!( + "SELECT id, wg_ip::text AS wg_ip, api_port FROM agents WHERE is_local_agent = true LIMIT 1" + ) + .fetch_optional(&state.db) + .await? + .ok_or(AppError::BadRequest("no local agent registered"))?; + + Ok(LocalAgent { + id: row.id, + wg_ip: row.wg_ip, + api_port: row.api_port, + }) +} + +async fn send_cmd( + state: &AppState, + agent: &LocalAgent, + triggered_by: Uuid, + command: &serde_json::Value, +) -> Result { + let signed = cmd::sign_command(&state.config, agent.id, triggered_by, "write", command) + .map_err(AppError::Internal)?; + + let url = format!("http://{}:{}/cmd", agent.wg_ip, agent.api_port); + let client = build_agent_client(&state.config); + + client + .post(&url) + .header( + "Authorization", + format!("Bearer {}", &*state.config.internal_token), + ) + .json(&signed) + .send() + .await + .map_err(|e| AppError::Internal(anyhow::anyhow!("agent request failed: {e}"))) +} + +pub async fn get_domain( + State(state): State, + Extension(_user): Extension, +) -> Result { + let cfg = sqlx::query_as!( + DomainConfig, + "SELECT id, domain, cert_type, cert_expires_at, hsts_enabled, port_19443_open, status, error_message, updated_at FROM domain_config WHERE id = 1" + ) + .fetch_one(&state.db) + .await?; + + Ok(Json(cfg)) +} + +pub async fn set_domain( + State(state): State, + Extension(user): Extension, + Json(req): Json, +) -> Result { + let domain = req.domain.trim().to_lowercase(); + + if domain.is_empty() || domain.contains(' ') { + return Err(AppError::Validation("invalid domain".into())); + } + + if !req.email.contains('@') || req.email.contains(' ') { + return Err(AppError::Validation( + "invalid email for Let's Encrypt".into(), + )); + } + + sqlx::query!( + "UPDATE domain_config SET domain=$1, status='pending', error_message=NULL, updated_at=NOW() WHERE id=1", + domain + ) + .execute(&state.db) + .await?; + + let state_clone = state.clone(); + let domain_clone = domain.clone(); + let email = req.email.clone(); + let user_id = user.user_id; + + tokio::spawn(async move { + let result = configure_domain_via_agent(&state_clone, &domain_clone, &email, user_id).await; + match result { + Ok(()) => { + let _ = sqlx::query!( + "UPDATE domain_config SET status='active', cert_type='lets_encrypt', cert_expires_at=NOW() + INTERVAL '90 days', updated_at=NOW() WHERE id=1" + ) + .execute(&state_clone.db) + .await; + } + Err(e) => { + let msg = e.to_string(); + let _ = sqlx::query!( + "UPDATE domain_config SET status='error', error_message=$1, updated_at=NOW() WHERE id=1", + msg + ) + .execute(&state_clone.db) + .await; + } + } + }); + + Ok(( + StatusCode::ACCEPTED, + Json(json!({ "status": "pending", "domain": domain })), + )) +} + +async fn configure_domain_via_agent( + state: &AppState, + domain: &str, + email: &str, + user_id: Uuid, +) -> Result<(), AppError> { + let agent = get_local_agent(state).await?; + + // 1. Deploy nginx with HTTP-only config for ACME challenge. + let initial_config = nginx_conf(domain, false, false); + let deploy_cmd = json!({ + "type": "nginx.deploy", + "image": NGINX_IMAGE, + "config": initial_config, + }); + let resp = send_cmd(state, &agent, user_id, &deploy_cmd).await?; + if !resp.status().is_success() { + return Err(AppError::Internal(anyhow::anyhow!( + "nginx.deploy failed: {}", + resp.status() + ))); + } + + // 2. Obtain Let's Encrypt cert via certbot (webroot challenge). + let certbot_cmd = json!({ + "type": "certbot.obtain", + "domain": domain, + "email": email, + }); + let resp = send_cmd(state, &agent, user_id, &certbot_cmd).await?; + if !resp.status().is_success() { + return Err(AppError::Internal(anyhow::anyhow!( + "certbot.obtain failed: {}", + resp.status() + ))); + } + + // 3. Reload nginx with TLS config. + let tls_config = nginx_conf(domain, true, false); + let update_cmd = json!({ + "type": "nginx.update_config", + "config": tls_config, + }); + let resp = send_cmd(state, &agent, user_id, &update_cmd).await?; + if !resp.status().is_success() { + return Err(AppError::Internal(anyhow::anyhow!( + "nginx.update_config failed: {}", + resp.status() + ))); + } + + Ok(()) +} + +pub async fn verify_domain( + State(state): State, + Extension(_user): Extension, +) -> Result { + let cfg = sqlx::query!("SELECT domain FROM domain_config WHERE id = 1") + .fetch_one(&state.db) + .await?; + + let domain = cfg + .domain + .ok_or(AppError::Validation("no domain configured".into()))?; + + let dns_ok = check_dns(&domain).await; + + Ok(Json(json!({ + "domain": domain, + "dns_ok": dns_ok, + }))) +} + +pub async fn set_hsts( + State(state): State, + Extension(user): Extension, + Json(req): Json, +) -> Result { + let cfg = sqlx::query!("SELECT status, domain, cert_type FROM domain_config WHERE id = 1") + .fetch_one(&state.db) + .await?; + + if cfg.status != "active" { + return Err(AppError::Validation( + "HSTS can only be enabled after domain is fully configured".into(), + )); + } + + sqlx::query!( + "UPDATE domain_config SET hsts_enabled=$1, updated_at=NOW() WHERE id=1", + req.enabled + ) + .execute(&state.db) + .await?; + + if let Some(domain) = cfg.domain { + let agent = match get_local_agent(&state).await { + Ok(a) => a, + Err(e) => { + tracing::warn!("HSTS change persisted but nginx reload failed: {e}"); + return Ok(Json(json!({ "hsts_enabled": req.enabled }))); + } + }; + + // Use cert paths appropriate for cert_type. + let nginx_config = match cfg.cert_type.as_str() { + "cloudflare" | "custom" => { + let cert_path = custom_cert_path(&domain); + let key_path = custom_key_path(&domain); + nginx_conf_with_cert(&domain, &cert_path, &key_path, req.enabled) + } + _ => nginx_conf(&domain, true, req.enabled), + }; + + let update_cmd = json!({ + "type": "nginx.update_config", + "config": nginx_config, + }); + if let Err(e) = send_cmd(&state, &agent, user.user_id, &update_cmd).await { + tracing::warn!("nginx reload after HSTS change failed: {e}"); + } + } + + Ok(Json(json!({ "hsts_enabled": req.enabled }))) +} + +pub async fn close_port( + State(state): State, + Extension(user): Extension, +) -> Result { + let cfg = sqlx::query!("SELECT status FROM domain_config WHERE id = 1") + .fetch_one(&state.db) + .await?; + + if cfg.status != "active" { + return Err(AppError::Validation( + "can only close port 19443 after domain is fully active".into(), + )); + } + + let agent = get_local_agent(&state).await?; + let close_cmd = json!({ "type": "nftables.close_setup_port" }); + let resp = send_cmd(&state, &agent, user.user_id, &close_cmd).await?; + + if !resp.status().is_success() { + return Err(AppError::Internal(anyhow::anyhow!( + "nftables.close_setup_port failed: {}", + resp.status() + ))); + } + + sqlx::query!("UPDATE domain_config SET port_19443_open=false, updated_at=NOW() WHERE id=1") + .execute(&state.db) + .await?; + + Ok(Json(json!({ "port_19443_open": false }))) +} + +/// Upload a Cloudflare Origin Certificate or a custom cert+key pair. +/// Validates the certificate before sending it to the local agent. +pub async fn upload_cert( + State(state): State, + Extension(user): Extension, + Json(req): Json, +) -> Result { + const MAX_CERT_BYTES: usize = 64 * 1024; // 64 KB + + if !["cloudflare", "custom"].contains(&req.cert_type.as_str()) { + return Err(AppError::Validation( + "cert_type must be 'cloudflare' or 'custom'".into(), + )); + } + + if req.cert_pem.len() > MAX_CERT_BYTES { + return Err(AppError::Validation("cert_pem exceeds 64 KB limit".into())); + } + + if let Some(ref key) = req.key_pem { + if key.len() > MAX_CERT_BYTES { + return Err(AppError::Validation("key_pem exceeds 64 KB limit".into())); + } + } + + let cfg = sqlx::query!("SELECT domain, status FROM domain_config WHERE id = 1") + .fetch_one(&state.db) + .await?; + + let domain = cfg + .domain + .ok_or(AppError::Validation("no domain configured".into()))?; + + validate_cert(&req.cert_pem, &domain, req.key_pem.as_deref())?; + + let agent = get_local_agent(&state).await?; + + // Install cert on agent. + let mut install_cmd = json!({ + "type": "nginx.install_cert", + "domain": domain, + "cert_pem": req.cert_pem, + }); + if let Some(ref key) = req.key_pem { + install_cmd["key_pem"] = json!(key); + } + let resp = send_cmd(&state, &agent, user.user_id, &install_cmd).await?; + if !resp.status().is_success() { + return Err(AppError::Internal(anyhow::anyhow!( + "nginx.install_cert failed: {}", + resp.status() + ))); + } + + // Update nginx config to use custom cert paths. + let cert_path = custom_cert_path(&domain); + let key_path = custom_key_path(&domain); + let hsts = cfg.status == "active"; // keep existing HSTS if already active + let nginx_cfg = nginx_conf_with_cert(&domain, &cert_path, &key_path, false); + let update_cmd = json!({ + "type": "nginx.update_config", + "config": nginx_cfg, + }); + let _ = send_cmd(&state, &agent, user.user_id, &update_cmd).await; + + let _ = hsts; // suppress unused warning + + // Detect expiry from cert for DB record. + let expires_at = cert_expires_at(&req.cert_pem); + + sqlx::query!( + r#"UPDATE domain_config + SET cert_type=$1, cert_expires_at=$2, status='active', error_message=NULL, updated_at=NOW() + WHERE id=1"#, + req.cert_type, + expires_at, + ) + .execute(&state.db) + .await?; + + Ok(Json(json!({ + "ok": true, + "cert_type": req.cert_type, + "expires_at": expires_at, + }))) +} + +/// Validate a PEM cert (and optional key) before sending to the agent. +fn validate_cert(cert_pem: &str, domain: &str, key_pem: Option<&str>) -> Result<(), AppError> { + use x509_parser::extensions::GeneralName; + use x509_parser::pem::parse_x509_pem; + + // Parse PEM → X.509 cert in one step. + let (_, pem) = parse_x509_pem(cert_pem.as_bytes()) + .map_err(|_| AppError::Validation("cert_pem is not valid PEM".into()))?; + let cert = pem + .parse_x509() + .map_err(|_| AppError::Validation("cert_pem is not a valid X.509 certificate".into()))?; + + // Check expiry. + let now = chrono::Utc::now(); + let not_before = cert.validity().not_before.to_datetime(); + let not_after = cert.validity().not_after.to_datetime(); + + // x509-parser returns OffsetDateTime; convert to chrono + let not_before_ts = chrono::DateTime::from_timestamp(not_before.unix_timestamp(), 0) + .unwrap_or(chrono::DateTime::UNIX_EPOCH); + let not_after_ts = chrono::DateTime::from_timestamp(not_after.unix_timestamp(), 0) + .unwrap_or(chrono::DateTime::UNIX_EPOCH); + + if now < not_before_ts { + return Err(AppError::Validation( + "certificate is not yet valid (not_before in future)".into(), + )); + } + if now > not_after_ts { + return Err(AppError::Validation("certificate has expired".into())); + } + + // Check SAN or CN matches domain. + let san_ok = cert + .subject_alternative_name() + .ok() + .flatten() + .map(|san_ext| { + san_ext.value.general_names.iter().any(|name| match name { + GeneralName::DNSName(dns) => { + *dns == domain + || dns.strip_prefix("*.").is_some_and(|suffix| { + domain.ends_with(suffix) + && !domain + .trim_end_matches(suffix) + .trim_end_matches('.') + .contains('.') + }) + } + _ => false, + }) + }) + .unwrap_or(false); + + let cn_ok = cert + .subject() + .iter_common_name() + .any(|cn| cn.as_str() == Ok(domain)); + + if !san_ok && !cn_ok { + return Err(AppError::Validation(format!( + "certificate SAN/CN does not match domain '{domain}'" + ))); + } + + // For custom certs, verify key pair. + if let Some(key_pem_str) = key_pem { + verify_key_pair(cert_pem, key_pem_str) + .map_err(|e| AppError::Validation(format!("cert/key pair mismatch: {e}")))?; + } + + Ok(()) +} + +/// Extract expiry timestamp from PEM cert. +fn cert_expires_at(cert_pem: &str) -> Option> { + use x509_parser::pem::parse_x509_pem; + let (_, pem) = parse_x509_pem(cert_pem.as_bytes()).ok()?; + let cert = pem.parse_x509().ok()?; + let ts = cert.validity().not_after.to_datetime().unix_timestamp(); + chrono::DateTime::from_timestamp(ts, 0) +} + +/// Verify that cert and key are a matching pair using rcgen round-trip. +fn verify_key_pair(cert_pem: &str, key_pem: &str) -> Result<(), anyhow::Error> { + use rcgen::KeyPair; + use x509_parser::pem::parse_x509_pem; + + let key = + KeyPair::from_pem(key_pem).map_err(|e| anyhow::anyhow!("invalid private key PEM: {e}"))?; + + let (_, pem) = parse_x509_pem(cert_pem.as_bytes()) + .map_err(|e| anyhow::anyhow!("invalid cert PEM: {e}"))?; + let cert = pem + .parse_x509() + .map_err(|e| anyhow::anyhow!("invalid cert DER: {e}"))?; + + let cert_pubkey = cert.public_key().raw; + let key_pubkey = key.public_key_raw(); + + if cert_pubkey != key_pubkey { + anyhow::bail!("public key in cert does not match private key"); + } + + Ok(()) +} + +async fn check_dns(domain: &str) -> bool { + let domain = domain.to_string(); + tokio::task::spawn_blocking(move || { + format!("{domain}:443") + .to_socket_addrs() + .map(|mut addrs| addrs.next().is_some()) + .unwrap_or(false) + }) + .await + .unwrap_or(false) +} diff --git a/lynx/dashboard/server/src/domain/handlers/mod.rs b/lynx/dashboard/server/src/domain/handlers/mod.rs new file mode 100644 index 0000000..474dba8 --- /dev/null +++ b/lynx/dashboard/server/src/domain/handlers/mod.rs @@ -0,0 +1,4 @@ +mod api; +mod nginx; + +pub use api::{close_port, get_domain, set_domain, set_hsts, upload_cert, verify_domain}; diff --git a/lynx/dashboard/server/src/domain/handlers/nginx.rs b/lynx/dashboard/server/src/domain/handlers/nginx.rs new file mode 100644 index 0000000..088caab --- /dev/null +++ b/lynx/dashboard/server/src/domain/handlers/nginx.rs @@ -0,0 +1,120 @@ +//! nginx config generation — pure functions, no OS calls. +//! All deployment goes through the local agent via signed commands. + +pub const NGINX_IMAGE: &str = + "docker.io/library/nginx@sha256:ceba1c7f1e2c42e5f43c9fa55e74ef90a1d08e7fde12f25e2a6706f4c80e0428"; + +/// Path where the agent stores externally-uploaded certs. +pub fn custom_cert_path(domain: &str) -> String { + format!("/etc/lynx/nginx/certs/{domain}/fullchain.pem") +} + +pub fn custom_key_path(domain: &str) -> String { + format!("/etc/lynx/nginx/certs/{domain}/privkey.pem") +} + +/// Generate nginx config. When `cert_path` is Some, uses a custom cert path +/// instead of the Let's Encrypt path. +pub fn nginx_conf_with_cert(domain: &str, cert_path: &str, key_path: &str, hsts: bool) -> String { + let hsts_header = if hsts { + " add_header Strict-Transport-Security \"max-age=63072000; includeSubDomains\" always;\n" + } else { + "" + }; + + format!( + r#"server {{ + listen 80; + server_name {domain}; + location /.well-known/acme-challenge/ {{ + root /var/www/html; + }} + location / {{ + return 301 https://$host$request_uri; + }} +}} + +server {{ + listen 443 ssl; + http2 on; + server_name {domain}; + ssl_certificate {cert_path}; + ssl_certificate_key {key_path}; + ssl_session_timeout 1d; + ssl_session_cache shared:MozSSL:10m; + ssl_protocols TLSv1.3; + ssl_prefer_server_ciphers off; +{hsts_header} + location / {{ + proxy_pass http://lynx-dashboard-frontend:3000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + }} +}} +"# + ) +} + +pub fn nginx_conf(domain: &str, has_cert: bool, hsts: bool) -> String { + let hsts_header = if hsts && has_cert { + " add_header Strict-Transport-Security \"max-age=63072000; includeSubDomains\" always;\n" + } else { + "" + }; + + if has_cert { + format!( + r#"server {{ + listen 80; + server_name {domain}; + location /.well-known/acme-challenge/ {{ + root /var/www/html; + }} + location / {{ + return 301 https://$host$request_uri; + }} +}} + +server {{ + listen 443 ssl; + http2 on; + server_name {domain}; + ssl_certificate /etc/letsencrypt/live/{domain}/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/{domain}/privkey.pem; + ssl_session_timeout 1d; + ssl_session_cache shared:MozSSL:10m; + ssl_protocols TLSv1.3; + ssl_prefer_server_ciphers off; +{hsts_header} + location / {{ + proxy_pass http://lynx-dashboard-frontend:3000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + }} +}} +"# + ) + } else { + format!( + r#"server {{ + listen 80; + server_name {domain}; + location /.well-known/acme-challenge/ {{ + root /var/www/html; + }} + location / {{ + proxy_pass http://lynx-dashboard-frontend:3000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + }} +}} +"# + ) + } +} diff --git a/lynx/dashboard/server/src/domain/mod.rs b/lynx/dashboard/server/src/domain/mod.rs new file mode 100644 index 0000000..832d280 --- /dev/null +++ b/lynx/dashboard/server/src/domain/mod.rs @@ -0,0 +1,41 @@ +pub mod handlers; +pub mod router; + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, sqlx::FromRow)] +pub struct DomainConfig { + pub id: i32, + pub domain: Option, + pub cert_type: String, + pub cert_expires_at: Option>, + pub hsts_enabled: bool, + pub port_19443_open: bool, + pub status: String, + pub error_message: Option, + pub updated_at: DateTime, +} + +#[derive(Debug, Deserialize)] +pub struct SetDomainRequest { + pub domain: String, + pub email: String, +} + +#[derive(Debug, Deserialize)] +pub struct SetHstsRequest { + pub enabled: bool, +} + +/// Upload a Cloudflare Origin Certificate (cert only, no key required by nginx +/// since Cloudflare terminates TLS) or a custom cert+key pair. +#[derive(Debug, Deserialize)] +pub struct UploadCertRequest { + /// PEM-encoded certificate (chain). Max 64 KB. + pub cert_pem: String, + /// PEM-encoded private key. Required for custom certs; omit for Cloudflare Origin. + pub key_pem: Option, + /// "cloudflare" or "custom" + pub cert_type: String, +} diff --git a/lynx/dashboard/server/src/domain/router.rs b/lynx/dashboard/server/src/domain/router.rs new file mode 100644 index 0000000..25a9211 --- /dev/null +++ b/lynx/dashboard/server/src/domain/router.rs @@ -0,0 +1,15 @@ +use super::handlers; +use crate::state::AppState; +use axum::{ + routing::{get, post}, + Router, +}; + +pub fn router() -> Router { + Router::new() + .route("/", get(handlers::get_domain).post(handlers::set_domain)) + .route("/verify", post(handlers::verify_domain)) + .route("/hsts", post(handlers::set_hsts)) + .route("/close-port", post(handlers::close_port)) + .route("/cert/upload", post(handlers::upload_cert)) +} diff --git a/lynx/dashboard/server/src/error.rs b/lynx/dashboard/server/src/error.rs new file mode 100644 index 0000000..d6040d8 --- /dev/null +++ b/lynx/dashboard/server/src/error.rs @@ -0,0 +1,82 @@ +use axum::{ + http::StatusCode, + response::{IntoResponse, Response}, + Json, +}; +use serde_json::json; + +#[derive(Debug, thiserror::Error)] +pub enum AppError { + #[error("unauthorized")] + Unauthorized, + #[error("invalid credentials")] + InvalidCredentials, + #[error("too many requests")] + RateLimited { retry_after: u64 }, + #[error("validation: {0}")] + Validation(String), + #[error("conflict: {0}")] + Conflict(&'static str), + #[error("not found")] + NotFound, + #[error("forbidden")] + Forbidden, + #[error("bad request: {0}")] + BadRequest(&'static str), + #[error("bad gateway")] + BadGateway, + #[error("agent unavailable")] + AgentUnavailable, + #[error("service unavailable")] + ServiceUnavailable, + #[error("force password change required")] + ForcePasswordChange, + #[error("internal")] + Internal(#[from] anyhow::Error), +} + +impl From for AppError { + fn from(e: sqlx::Error) -> Self { + AppError::Internal(anyhow::Error::from(e)) + } +} + +impl IntoResponse for AppError { + fn into_response(self) -> Response { + let (status, code) = match &self { + AppError::Unauthorized => (StatusCode::UNAUTHORIZED, "unauthorized"), + AppError::InvalidCredentials => (StatusCode::UNAUTHORIZED, "invalid_credentials"), + AppError::RateLimited { .. } => (StatusCode::TOO_MANY_REQUESTS, "too_many_requests"), + AppError::Validation(_) => (StatusCode::UNPROCESSABLE_ENTITY, "validation_error"), + AppError::Conflict(_) => (StatusCode::CONFLICT, "conflict"), + AppError::NotFound => (StatusCode::NOT_FOUND, "not_found"), + AppError::Forbidden => (StatusCode::FORBIDDEN, "forbidden"), + AppError::BadRequest(_) => (StatusCode::BAD_REQUEST, "bad_request"), + AppError::BadGateway => (StatusCode::BAD_GATEWAY, "bad_gateway"), + AppError::AgentUnavailable => (StatusCode::SERVICE_UNAVAILABLE, "agent_unavailable"), + AppError::ServiceUnavailable => { + (StatusCode::SERVICE_UNAVAILABLE, "service_unavailable") + } + AppError::ForcePasswordChange => { + (StatusCode::FORBIDDEN, "force_password_change_required") + } + AppError::Internal(e) => { + tracing::error!("internal: {e:#}"); + (StatusCode::INTERNAL_SERVER_ERROR, "internal_error") + } + }; + + let mut body = json!({ "error": code }); + + match &self { + AppError::Validation(msg) => body["detail"] = json!(msg), + AppError::Conflict(msg) | AppError::BadRequest(msg) => body["detail"] = json!(msg), + AppError::RateLimited { retry_after } => body["retry_after"] = json!(retry_after), + _ => {} + } + + (status, Json(body)).into_response() + } +} + +pub type Result = std::result::Result; diff --git a/lynx/dashboard/server/src/lib.rs b/lynx/dashboard/server/src/lib.rs new file mode 100644 index 0000000..0149a29 --- /dev/null +++ b/lynx/dashboard/server/src/lib.rs @@ -0,0 +1,73 @@ +pub mod admin; +pub mod agents; +pub mod alerts; +pub mod auth; +pub mod branding; +pub mod config; +pub mod crypto; +pub mod domain; +pub mod error; +pub mod migration; +pub mod nftables; +pub mod organizations; +pub mod scheduler; +pub mod state; +pub mod update; + +pub use config::Config; +pub use state::AppState; + +use axum::{ + http::{header, HeaderValue}, + response::IntoResponse, + routing::get, + Router, +}; +use tower_http::set_header::SetResponseHeaderLayer; + +/// Build the full application router from an already-constructed `AppState`. +/// This does not start any background tasks or bind a port — callers do that. +pub fn build_router(state: AppState) -> Router { + use axum::middleware; + + let auth_layer = middleware::from_fn_with_state(state.clone(), auth::middleware::require_auth); + + let agents_router = agents::router::router().route_layer(auth_layer.clone()); + let orgs_router = organizations::router::router().route_layer(auth_layer.clone()); + let admin_router = admin::router::router(state.clone()).route_layer(auth_layer.clone()); + let domain_router = domain::router::router().route_layer(auth_layer.clone()); + let migration_router = migration::router::router().route_layer(auth_layer.clone()); + let nftables_router = nftables::router::router().route_layer(auth_layer.clone()); + let auth_protected_router = auth::router::protected_router().route_layer(auth_layer); + + Router::new() + .route("/health", get(health)) + .route("/branding", get(branding::handlers::get_branding)) + .nest("/auth", auth::router::router()) + .nest("/auth", auth_protected_router) + .nest("/agents", agents_router) + .nest("/agents", agents::router::agent_router()) + .nest("/organizations", orgs_router) + .nest("/admin", admin_router) + .nest("/domain", domain_router) + .nest("/migration", migration_router) + .nest("/migration", migration::router::receive_router()) + .nest("/nftables", nftables_router) + .with_state(state) + .layer(SetResponseHeaderLayer::overriding( + header::HeaderName::from_static("x-frame-options"), + HeaderValue::from_static("DENY"), + )) + .layer(SetResponseHeaderLayer::overriding( + header::HeaderName::from_static("x-content-type-options"), + HeaderValue::from_static("nosniff"), + )) + .layer(SetResponseHeaderLayer::overriding( + header::HeaderName::from_static("referrer-policy"), + HeaderValue::from_static("no-referrer"), + )) +} + +async fn health() -> impl IntoResponse { + axum::http::StatusCode::OK +} diff --git a/lynx/dashboard/server/src/main.rs b/lynx/dashboard/server/src/main.rs new file mode 100644 index 0000000..feaf16d --- /dev/null +++ b/lynx/dashboard/server/src/main.rs @@ -0,0 +1,234 @@ +use lynx_dashboard_server::{ + agents, build_router, config, crypto, scheduler, state::AppState, update, +}; + +use anyhow::Context; +use clap::{Parser, Subcommand}; +use std::sync::Arc; +use tracing::info; + +#[derive(Parser)] +#[command(name = "lynx-dashboard-backend", about = "Lynx Dashboard Backend")] +struct Cli { + #[command(subcommand)] + command: Option, +} + +#[derive(Subcommand)] +enum Command { + /// Reset a user's password (SSH-only; prints a one-time password). + ResetAdminPassword { + #[arg(long)] + username: String, + }, + /// Stream or display backend/frontend container logs. + Logs { + /// Follow log output (tail -f). + #[arg(long, short = 'f')] + follow: bool, + /// Show only error-level lines. + #[arg(long)] + errors: bool, + /// Show logs since duration (e.g. 1h, 30m, 5s). + #[arg(long)] + since: Option, + }, +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + // Install ring as the rustls crypto provider before any TLS code runs. + rustls::crypto::ring::default_provider() + .install_default() + .ok(); // ok() — ignore error if already installed (e.g. in tests) + + tracing_subscriber::fmt() + .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) + .init(); + + let cli = Cli::parse(); + + // Handle logs subcommand before connecting to DB — works even when backend is down. + if let Some(Command::Logs { + follow, + errors, + since, + }) = cli.command + { + return cmd_logs(follow, errors, since); + } + + let config = config::Config::load()?; + let db = sqlx::PgPool::connect(&config.database_url) + .await + .context("connect to PostgreSQL")?; + + sqlx::migrate!("./migrations") + .run(&db) + .await + .context("run migrations")?; + + if let Some(cmd) = cli.command { + return run_cli_command(cmd, &db).await; + } + + let redis = redis::Client::open(config.redis_url.as_str()).context("open Redis client")?; + let redis_manager = redis::aio::ConnectionManager::new(redis) + .await + .context("connect to Redis")?; + + let wg_psks = agents::wg::load_all_psks(); + if !wg_psks.is_empty() { + tracing::info!( + count = wg_psks.len(), + "loaded WireGuard PSKs from secret files" + ); + } + + let state = AppState { + db, + redis: redis_manager, + config: Arc::new(config), + latest_agent_version: Arc::new(tokio::sync::RwLock::new(None)), + wg_psks: Arc::new(tokio::sync::RwLock::new(wg_psks)), + agent_ws_conns: Arc::new(tokio::sync::RwLock::new(std::collections::HashMap::new())), + agent_metric_tx: Arc::new(tokio::sync::RwLock::new(std::collections::HashMap::new())), + events_tx: Arc::new(tokio::sync::broadcast::channel::>(256).0), + }; + + // Record setup_token_issued_at on first boot without an admin (24h TTL window). + record_setup_token_issuance(&state.db).await; + + // Reconcile WireGuard peers against DB at startup. + agents::wg::reconcile_peers(&state.db).await; + + tokio::spawn(agents::heartbeat::run_scheduler(state.clone())); + tokio::spawn(scheduler::run(state.clone())); + + let app = build_router(state); + + let listener = tokio::net::TcpListener::bind("0.0.0.0:8080").await?; + info!("listening on 0.0.0.0:8080"); + update::spawn_startup_health_guard(); + axum::serve(listener, app).await?; + Ok(()) +} + +/// On first boot without any admin, record when the setup token window started. +/// Re-boots don't reset the clock — INSERT ... ON CONFLICT DO NOTHING. +async fn record_setup_token_issuance(db: &sqlx::PgPool) { + let admin_exists: bool = sqlx::query_scalar!( + r#" + SELECT EXISTS( + SELECT 1 FROM user_roles ur + JOIN role_permissions rp ON rp.role_id = ur.role_id + JOIN permissions p ON p.id = rp.permission_id + WHERE p.key = '*:*' + ) + "# + ) + .fetch_one(db) + .await + .unwrap_or(None) + .unwrap_or(false); + + if !admin_exists { + let _ = sqlx::query!( + r#" + INSERT INTO system_config (key, value) + VALUES ('setup_token_issued_at', NOW()::text) + ON CONFLICT (key) DO NOTHING + "# + ) + .execute(db) + .await; + } +} + +fn cmd_logs(follow: bool, errors: bool, since: Option) -> anyhow::Result<()> { + let containers = ["lynx-dashboard-backend", "lynx-dashboard-frontend"]; + + for container in &containers { + let mut args = vec!["logs".to_string()]; + + if follow { + args.push("--follow".to_string()); + } else { + args.push("--tail=100".to_string()); + } + + if let Some(ref s) = since { + args.push(format!("--since={s}")); + } + + args.push(container.to_string()); + + let output = std::process::Command::new("podman") + .args(&args) + .output() + .with_context(|| format!("podman logs {container}"))?; + + let combined = [output.stdout.as_slice(), output.stderr.as_slice()].concat(); + let text = String::from_utf8_lossy(&combined); + + for line in text.lines() { + if errors { + let lower = line.to_lowercase(); + if !lower.contains("error") + && !lower.contains("critical") + && !lower.contains("fatal") + { + continue; + } + } + println!("[{container}] {line}"); + } + } + + Ok(()) +} + +async fn run_cli_command(cmd: Command, db: &sqlx::PgPool) -> anyhow::Result<()> { + match cmd { + // Logs is handled before DB connect — should never reach here. + Command::Logs { .. } => unreachable!(), + Command::ResetAdminPassword { username } => { + let user = sqlx::query!( + "SELECT id FROM users WHERE username = $1", + username.to_lowercase() + ) + .fetch_optional(db) + .await + .context("query user")? + .ok_or_else(|| anyhow::anyhow!("user '{}' not found", username))?; + + let new_password = generate_random_password(); + let hash = crypto::password::hash(&new_password).context("hash password")?; + + sqlx::query!( + "UPDATE users SET password_hash = $1, force_password_change = TRUE WHERE id = $2", + hash, + user.id, + ) + .execute(db) + .await + .context("update password")?; + + println!("Password reset for '{}': {}", username, new_password); + println!("User will be required to change password on next login."); + } + } + Ok(()) +} + +fn generate_random_password() -> String { + use rand::Rng; + let mut rng = rand::thread_rng(); + let charset: Vec = + "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*" + .chars() + .collect(); + (0..24) + .map(|_| charset[rng.gen_range(0..charset.len())]) + .collect() +} diff --git a/lynx/dashboard/server/src/migration/handlers/admin.rs b/lynx/dashboard/server/src/migration/handlers/admin.rs new file mode 100644 index 0000000..e7d60c5 --- /dev/null +++ b/lynx/dashboard/server/src/migration/handlers/admin.rs @@ -0,0 +1,283 @@ +use super::super::{MigrationState, PrepareMigrationResponse, StartMigrationRequest}; +use crate::{ + auth::middleware::AuthUser, crypto::hash::sha256_hex, error::AppError, state::AppState, +}; +use axum::{ + extract::{Extension, State}, + http::StatusCode, + response::IntoResponse, + Json, +}; +use serde_json::json; +use uuid::Uuid; + +pub async fn get_migration_status( + State(state): State, + Extension(_user): Extension, +) -> Result { + let ms = sqlx::query_as!( + MigrationState, + r#"SELECT id, status, role, target_url, agents_total, agents_confirmed, + error_message, started_at, completed_at, updated_at + FROM migration_state WHERE id = 1"# + ) + .fetch_one(&state.db) + .await?; + + Ok(Json(ms)) +} + +pub async fn prepare_receive( + State(state): State, + Extension(_user): Extension, +) -> Result { + let existing = sqlx::query_scalar!("SELECT status FROM migration_state WHERE id = 1") + .fetch_one(&state.db) + .await?; + + if existing != "idle" { + return Err(AppError::Validation( + "migration already in progress — abort or wait for completion".into(), + )); + } + + let token_raw = format!( + "{}{}", + Uuid::now_v7().to_string().replace('-', ""), + Uuid::now_v7().to_string().replace('-', ""), + ); + let token_hash = sha256_hex(token_raw.as_bytes()); + + sqlx::query!( + r#"UPDATE migration_state + SET status='preparing', role='target', migration_token_hash=$1, + started_at=NOW(), updated_at=NOW() + WHERE id=1"#, + token_hash + ) + .execute(&state.db) + .await?; + + Ok(Json(PrepareMigrationResponse { + migration_token: token_raw, + })) +} + +pub async fn start_migration( + State(state): State, + Extension(_user): Extension, + Json(req): Json, +) -> Result { + let existing = sqlx::query_scalar!("SELECT status FROM migration_state WHERE id = 1") + .fetch_one(&state.db) + .await?; + + if existing != "idle" { + return Err(AppError::Validation( + "migration already in progress — abort first".into(), + )); + } + + let target_url = req.target_url.trim().trim_end_matches('/').to_string(); + if target_url.is_empty() { + return Err(AppError::Validation("target_url required".into())); + } + + let agents_total: i64 = sqlx::query_scalar!("SELECT COUNT(*) FROM agents") + .fetch_one(&state.db) + .await? + .unwrap_or(0); + + sqlx::query!( + r#"UPDATE migration_state + SET status='transferring', role='source', target_url=$1, + agents_total=$2, agents_confirmed=0, + started_at=NOW(), updated_at=NOW() + WHERE id=1"#, + target_url, + agents_total as i32, + ) + .execute(&state.db) + .await?; + + let db = state.db.clone(); + let cfg = state.config.clone(); + let migration_token = req.migration_token.clone(); + + tokio::spawn(async move { + let result = run_migration(&db, &cfg, &target_url, &migration_token).await; + match result { + Ok(()) => { + let _ = sqlx::query!( + "UPDATE migration_state SET status='notifying_agents', updated_at=NOW() WHERE id=1" + ) + .execute(&db) + .await; + } + Err(e) => { + let msg = e.to_string(); + tracing::error!("migration failed: {msg}"); + let _ = sqlx::query!( + "UPDATE migration_state SET status='error', error_message=$1, updated_at=NOW() WHERE id=1", + msg + ) + .execute(&db) + .await; + } + } + }); + + Ok(( + StatusCode::ACCEPTED, + Json(json!({ "status": "transferring", "agents_total": agents_total })), + )) +} + +pub async fn abort_migration( + State(state): State, + Extension(_user): Extension, +) -> Result { + let status = sqlx::query_scalar!("SELECT status FROM migration_state WHERE id = 1") + .fetch_one(&state.db) + .await?; + + if status == "completed" || status == "idle" { + return Err(AppError::Validation( + "nothing to abort — migration is idle or already completed".into(), + )); + } + + sqlx::query!("UPDATE migration_state SET status='aborted', updated_at=NOW() WHERE id=1") + .execute(&state.db) + .await?; + + Ok(Json(json!({ "status": "aborted" }))) +} + +pub async fn confirm_shutdown( + State(state): State, + Extension(_user): Extension, +) -> Result { + let ms = sqlx::query!( + "SELECT status, agents_total, agents_confirmed FROM migration_state WHERE id=1" + ) + .fetch_one(&state.db) + .await?; + + if ms.status != "waiting_agents" { + return Err(AppError::Validation( + "shutdown only available after all agents have confirmed".into(), + )); + } + + if ms.agents_confirmed < ms.agents_total { + return Err(AppError::Validation(format!( + "{} of {} agents still pending", + ms.agents_total - ms.agents_confirmed, + ms.agents_total + ))); + } + + sqlx::query!( + "UPDATE migration_state SET status='completed', completed_at=NOW(), updated_at=NOW() WHERE id=1" + ) + .execute(&state.db) + .await?; + + tokio::spawn(async { + tokio::time::sleep(std::time::Duration::from_secs(5)).await; + tracing::info!("migration complete — dashboard shutting down"); + std::process::exit(0); + }); + + Ok(Json( + json!({ "status": "completed", "message": "shutdown initiated" }), + )) +} + +async fn run_migration( + db: &sqlx::PgPool, + cfg: &crate::config::Config, + target_url: &str, + migration_token: &str, +) -> anyhow::Result<()> { + let dump = pg_dump(&cfg.database_url).await?; + + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(300)) + .build()?; + + let resp = client + .post(format!("{target_url}/migration/receive")) + .header("Authorization", format!("Migration {migration_token}")) + .header("Content-Type", "application/octet-stream") + .body(dump) + .send() + .await?; + + if !resp.status().is_success() && resp.status().as_u16() != 202 { + anyhow::bail!("VPS-B rejected migration data: {}", resp.status()); + } + + notify_agents_migrate(db, cfg, target_url).await?; + + sqlx::query!("UPDATE migration_state SET status='waiting_agents', updated_at=NOW() WHERE id=1") + .execute(db) + .await?; + + Ok(()) +} + +async fn pg_dump(database_url: &str) -> anyhow::Result> { + let out = tokio::process::Command::new("pg_dump") + .args(["--format=custom", "--no-owner", "--no-acl", database_url]) + .output() + .await?; + + anyhow::ensure!( + out.status.success(), + "pg_dump failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + Ok(out.stdout) +} + +async fn notify_agents_migrate( + db: &sqlx::PgPool, + cfg: &crate::config::Config, + target_url: &str, +) -> anyhow::Result<()> { + let agents = sqlx::query!("SELECT id, wg_ip, api_port, status FROM agents") + .fetch_all(db) + .await?; + + let http = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .build()?; + + for agent in &agents { + if agent.status != "online" { + continue; + } + + let cmd = serde_json::json!({ + "type": "dashboard.migrate", + "target_url": target_url, + }); + + let user_id = Uuid::nil(); + let signed = crate::crypto::cmd::sign_command(cfg, agent.id, user_id, "write", &cmd); + + if let Ok(signed) = signed { + let url = format!("http://{}:{}/cmd", agent.wg_ip, agent.api_port); + let _ = http + .post(&url) + .header("Authorization", format!("Bearer {}", &*cfg.internal_token)) + .json(&signed) + .send() + .await; + } + } + + Ok(()) +} diff --git a/lynx/dashboard/server/src/migration/handlers/mod.rs b/lynx/dashboard/server/src/migration/handlers/mod.rs new file mode 100644 index 0000000..f0a13bc --- /dev/null +++ b/lynx/dashboard/server/src/migration/handlers/mod.rs @@ -0,0 +1,7 @@ +mod admin; +mod receive; + +pub use admin::{ + abort_migration, confirm_shutdown, get_migration_status, prepare_receive, start_migration, +}; +pub use receive::{agent_confirm, receive_migration}; diff --git a/lynx/dashboard/server/src/migration/handlers/receive.rs b/lynx/dashboard/server/src/migration/handlers/receive.rs new file mode 100644 index 0000000..1289563 --- /dev/null +++ b/lynx/dashboard/server/src/migration/handlers/receive.rs @@ -0,0 +1,172 @@ +use super::super::AgentConfirmRequest; +use crate::{crypto::hash::sha256_hex, error::AppError, state::AppState}; +use axum::{ + extract::State, + http::{header, HeaderMap, StatusCode}, + response::IntoResponse, + Json, +}; +use serde_json::json; +use uuid::Uuid; + +pub async fn receive_migration( + State(state): State, + headers: HeaderMap, + body: axum::body::Bytes, +) -> Result { + let provided_token = headers + .get(header::AUTHORIZATION) + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.strip_prefix("Migration ")) + .unwrap_or(""); + + let token_hash = sha256_hex(provided_token.as_bytes()); + + let stored_hash = + sqlx::query_scalar!("SELECT migration_token_hash FROM migration_state WHERE id=1") + .fetch_one(&state.db) + .await?; + + let valid = stored_hash + .map(|h| { + use subtle::ConstantTimeEq; + h.as_bytes().ct_eq(token_hash.as_bytes()).into() + }) + .unwrap_or(false); + + if !valid { + return Err(AppError::Unauthorized); + } + + let status = sqlx::query_scalar!("SELECT status FROM migration_state WHERE id=1") + .fetch_one(&state.db) + .await?; + + if status != "preparing" { + return Err(AppError::Validation( + "target not in preparing state — call /migration/prepare first".into(), + )); + } + + sqlx::query!("UPDATE migration_state SET status='transferring', updated_at=NOW() WHERE id=1") + .execute(&state.db) + .await?; + + let db = state.db.clone(); + let database_url = state.config.database_url.clone(); + let dump_bytes = body.to_vec(); + + tokio::spawn(async move { + if let Err(e) = restore_dump(&dump_bytes, &database_url).await { + tracing::error!("migration restore failed: {e:#}"); + let _ = sqlx::query!( + "UPDATE migration_state SET status='error', error_message=$1, updated_at=NOW() WHERE id=1", + e.to_string() + ) + .execute(&db) + .await; + return; + } + + let agent_count: i64 = sqlx::query_scalar!("SELECT COUNT(*) FROM agents") + .fetch_one(&db) + .await + .ok() + .flatten() + .unwrap_or(0); + + let _ = sqlx::query!( + "UPDATE migration_state SET status='waiting_agents', agents_total=$1, updated_at=NOW() WHERE id=1", + agent_count as i32 + ) + .execute(&db) + .await; + + tracing::info!( + "migration restore complete — waiting for {} agents", + agent_count + ); + }); + + Ok(StatusCode::ACCEPTED) +} + +pub async fn agent_confirm( + State(state): State, + headers: HeaderMap, + Json(req): Json, +) -> Result { + let agent_id = + Uuid::parse_str(&req.agent_id).map_err(|_| AppError::BadRequest("invalid agent_id"))?; + + let provided_token = headers + .get(header::AUTHORIZATION) + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.strip_prefix("Bearer ")) + .unwrap_or(""); + + let token_hash = sha256_hex(provided_token.as_bytes()); + + let stored = sqlx::query_scalar!("SELECT sync_token_hash FROM agents WHERE id=$1", agent_id) + .fetch_optional(&state.db) + .await? + .flatten(); + + let valid = stored + .map(|h| { + use subtle::ConstantTimeEq; + h.as_bytes().ct_eq(token_hash.as_bytes()).into() + }) + .unwrap_or(false); + + if !valid { + return Err(AppError::Unauthorized); + } + + sqlx::query!( + "UPDATE migration_state SET agents_confirmed = agents_confirmed + 1, updated_at=NOW() WHERE id=1" + ) + .execute(&state.db) + .await?; + + let ms = sqlx::query!("SELECT agents_total, agents_confirmed FROM migration_state WHERE id=1") + .fetch_one(&state.db) + .await?; + + tracing::info!( + agent_id = %agent_id, + confirmed = ms.agents_confirmed, + total = ms.agents_total, + "agent confirmed migration to this dashboard" + ); + + Ok(Json(json!({ + "ok": true, + "confirmed": ms.agents_confirmed, + "total": ms.agents_total, + }))) +} + +async fn restore_dump(dump: &[u8], db_url: &str) -> anyhow::Result<()> { + use tokio::io::AsyncWriteExt; + + let mut child = tokio::process::Command::new("pg_restore") + .args([ + "--clean", + "--if-exists", + "--no-owner", + "--no-acl", + "-d", + db_url, + ]) + .stdin(std::process::Stdio::piped()) + .spawn()?; + + if let Some(stdin) = child.stdin.as_mut() { + stdin.write_all(dump).await?; + } + + let status = child.wait().await?; + anyhow::ensure!(status.success(), "pg_restore failed"); + Ok(()) +} diff --git a/lynx/dashboard/server/src/migration/mod.rs b/lynx/dashboard/server/src/migration/mod.rs new file mode 100644 index 0000000..059af6b --- /dev/null +++ b/lynx/dashboard/server/src/migration/mod.rs @@ -0,0 +1,42 @@ +pub mod handlers; +pub mod router; + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, sqlx::FromRow)] +pub struct MigrationState { + pub id: i32, + pub status: String, + pub role: String, + pub target_url: Option, + pub agents_total: i32, + pub agents_confirmed: i32, + pub error_message: Option, + pub started_at: Option>, + pub completed_at: Option>, + pub updated_at: DateTime, +} + +/// Sent by VPS-A to initiate migration to VPS-B. +#[derive(Debug, Deserialize)] +pub struct StartMigrationRequest { + /// Public URL of VPS-B dashboard (e.g. "https://1.2.3.4:19443") + pub target_url: String, + /// One-time migration token displayed on VPS-B + pub migration_token: String, +} + +/// Sent by admin on VPS-B to put it in receive mode. +#[derive(Debug, Serialize, Deserialize)] +pub struct PrepareMigrationResponse { + /// One-time token to enter into VPS-A + pub migration_token: String, +} + +/// Payload VPS-A sends to VPS-B's /migration/receive endpoint. +/// Body is a raw gzipped pg_dump streamed as multipart. +#[derive(Debug, Deserialize)] +pub struct AgentConfirmRequest { + pub agent_id: String, +} diff --git a/lynx/dashboard/server/src/migration/router.rs b/lynx/dashboard/server/src/migration/router.rs new file mode 100644 index 0000000..c110530 --- /dev/null +++ b/lynx/dashboard/server/src/migration/router.rs @@ -0,0 +1,23 @@ +use super::handlers; +use crate::state::AppState; +use axum::{ + routing::{get, post}, + Router, +}; + +/// Auth-protected routes (require user JWT). +pub fn router() -> Router { + Router::new() + .route("/", get(handlers::get_migration_status)) + .route("/prepare", post(handlers::prepare_receive)) + .route("/start", post(handlers::start_migration)) + .route("/abort", post(handlers::abort_migration)) + .route("/confirm-shutdown", post(handlers::confirm_shutdown)) +} + +/// Unauthenticated routes — token-gated by migration token. +pub fn receive_router() -> Router { + Router::new() + .route("/receive", post(handlers::receive_migration)) + .route("/agent-confirm", post(handlers::agent_confirm)) +} diff --git a/lynx/dashboard/server/src/nftables/handlers/global.rs b/lynx/dashboard/server/src/nftables/handlers/global.rs new file mode 100644 index 0000000..d1f3be9 --- /dev/null +++ b/lynx/dashboard/server/src/nftables/handlers/global.rs @@ -0,0 +1,241 @@ +use crate::{ + agents::ws_hub, + auth::middleware::AuthUser, + crypto::cmd::sign_command, + error::AppError, + nftables::{rules_to_nft_chain, CreateRuleRequest, NftRule}, + state::AppState, +}; +use axum::{ + extract::{Extension, State}, + response::IntoResponse, + Json, +}; +use serde_json::json; +use uuid::Uuid; + +pub async fn list_global_rules( + State(state): State, +) -> Result { + let rules = sqlx::query_as!( + NftRule, + r#" + SELECT id, scope, agent_id, kind, port, protocol, + ip_list, ip_version, rate_per_min, description, + priority, enabled, created_by, created_at, updated_at + FROM nftables_rules + WHERE scope = 'global' + ORDER BY priority ASC, created_at ASC + "# + ) + .fetch_all(&state.db) + .await?; + + Ok(Json(rules)) +} + +pub async fn create_global_rule( + State(state): State, + Extension(user): Extension, + Json(req): Json, +) -> Result { + validate_rule_request(&req)?; + + let id = Uuid::now_v7(); + let ip_list = req.ip_list.unwrap_or_default(); + let ip_version = req.ip_version.unwrap_or_else(|| "both".into()); + let priority = req.priority.unwrap_or(0); + + let rule = sqlx::query_as!( + NftRule, + r#" + INSERT INTO nftables_rules + (id, scope, agent_id, kind, port, protocol, ip_list, ip_version, + rate_per_min, description, priority, created_by) + VALUES ($1, 'global', NULL, $2, $3, $4, $5, $6, $7, $8, $9, $10) + RETURNING id, scope, agent_id, kind, port, protocol, + ip_list, ip_version, rate_per_min, description, + priority, enabled, created_by, created_at, updated_at + "#, + id, + req.kind, + req.port, + req.protocol, + &ip_list, + ip_version, + req.rate_per_min, + req.description, + priority, + user.user_id, + ) + .fetch_one(&state.db) + .await?; + + Ok((axum::http::StatusCode::CREATED, Json(rule))) +} + +pub async fn delete_global_rule( + State(state): State, + axum::extract::Path(id): axum::extract::Path, +) -> Result { + let deleted = sqlx::query!( + "DELETE FROM nftables_rules WHERE id = $1 AND scope = 'global' RETURNING id", + id + ) + .fetch_optional(&state.db) + .await?; + + if deleted.is_none() { + return Err(AppError::NotFound); + } + + Ok(axum::http::StatusCode::NO_CONTENT) +} + +/// Push current global rules to all online agents. +pub async fn push_global_rules( + State(state): State, + Extension(user): Extension, +) -> Result { + let rules = sqlx::query_as!( + NftRule, + r#" + SELECT id, scope, agent_id, kind, port, protocol, + ip_list, ip_version, rate_per_min, description, + priority, enabled, created_by, created_at, updated_at + FROM nftables_rules + WHERE scope = 'global' AND enabled = true + ORDER BY priority ASC + "# + ) + .fetch_all(&state.db) + .await?; + + let chain_body = rules_to_nft_chain(&rules); + + let agents = + sqlx::query!("SELECT id, wg_ip, api_port, status FROM agents WHERE status = 'online'") + .fetch_all(&state.db) + .await?; + + let mut pushed = 0u32; + let mut failed = 0u32; + + for agent in &agents { + let command = json!({ + "type": "nftables.apply", + "chain": "lynx-global", + "rules": chain_body, + }); + + let Ok(signed) = sign_command(&state.config, agent.id, user.user_id, "admin", &command) + else { + failed += 1; + continue; + }; + + let signed_val = serde_json::to_value(&signed).unwrap_or_default(); + let sent = if ws_hub::push_command(&state, agent.id, signed_val) + .await + .is_some() + { + true + } else { + // HTTP fallback + let url = format!("http://{}:{}/cmd", agent.wg_ip, agent.api_port); + let tok = &*state.config.internal_token; + reqwest::Client::new() + .post(&url) + .header("Authorization", format!("Bearer {tok}")) + .json(&signed) + .timeout(std::time::Duration::from_secs(15)) + .send() + .await + .map(|r| r.status().is_success()) + .unwrap_or(false) + }; + + if sent { + for rule in &rules { + let _ = sqlx::query!( + r#" + INSERT INTO global_rule_sync (rule_id, agent_id, synced_at) + VALUES ($1, $2, NOW()) + ON CONFLICT (rule_id, agent_id) DO UPDATE SET synced_at = NOW() + "#, + rule.id, + agent.id, + ) + .execute(&state.db) + .await; + } + pushed += 1; + } else { + failed += 1; + } + } + + // Mark offline agents as pending_sync so they receive the rules on reconnect. + let offline_agents = sqlx::query!("SELECT id FROM agents WHERE status != 'online'") + .fetch_all(&state.db) + .await + .unwrap_or_default(); + + for agent in &offline_agents { + for rule in &rules { + let _ = sqlx::query!( + r#" + INSERT INTO global_rule_sync (rule_id, agent_id) + VALUES ($1, $2) + ON CONFLICT (rule_id, agent_id) DO NOTHING + "#, + rule.id, + agent.id, + ) + .execute(&state.db) + .await; + } + } + + Ok(Json( + json!({ "pushed": pushed, "failed": failed, "pending": offline_agents.len() }), + )) +} + +fn validate_rule_request(req: &CreateRuleRequest) -> Result<(), AppError> { + let valid_kinds = [ + "allow_port", + "block_port", + "allow_ip", + "block_ip", + "rate_limit", + ]; + if !valid_kinds.contains(&req.kind.as_str()) { + return Err(AppError::Validation("invalid rule kind".into())); + } + if matches!( + req.kind.as_str(), + "allow_port" | "block_port" | "rate_limit" + ) && req.port.is_none() + { + return Err(AppError::Validation( + "port required for this rule kind".into(), + )); + } + if req.kind == "rate_limit" && req.rate_per_min.is_none() { + return Err(AppError::Validation( + "rate_per_min required for rate_limit".into(), + )); + } + if let Some(proto) = &req.protocol { + if !["tcp", "udp", "both"].contains(&proto.as_str()) { + return Err(AppError::Validation("invalid protocol".into())); + } + } + if let Some(ver) = &req.ip_version { + if !["ipv4", "ipv6", "both"].contains(&ver.as_str()) { + return Err(AppError::Validation("invalid ip_version".into())); + } + } + Ok(()) +} diff --git a/lynx/dashboard/server/src/nftables/handlers/local.rs b/lynx/dashboard/server/src/nftables/handlers/local.rs new file mode 100644 index 0000000..7b47d12 --- /dev/null +++ b/lynx/dashboard/server/src/nftables/handlers/local.rs @@ -0,0 +1,214 @@ +use crate::{ + agents::ws_hub, + auth::middleware::AuthUser, + crypto::cmd::sign_command, + error::AppError, + nftables::{rules_to_nft_chain, CreateRuleRequest, NftRule}, + state::AppState, +}; +use axum::{ + extract::{Extension, Path, State}, + response::IntoResponse, + Json, +}; +use serde_json::json; +use uuid::Uuid; + +pub async fn list_local_rules( + State(state): State, + Path(agent_id): Path, +) -> Result { + // Verify agent exists. + let exists = sqlx::query!("SELECT id FROM agents WHERE id = $1", agent_id) + .fetch_optional(&state.db) + .await?; + if exists.is_none() { + return Err(AppError::NotFound); + } + + let rules = sqlx::query_as!( + NftRule, + r#" + SELECT id, scope, agent_id, kind, port, protocol, + ip_list, ip_version, rate_per_min, description, + priority, enabled, created_by, created_at, updated_at + FROM nftables_rules + WHERE scope = 'local' AND agent_id = $1 + ORDER BY priority ASC, created_at ASC + "#, + agent_id, + ) + .fetch_all(&state.db) + .await?; + + Ok(Json(rules)) +} + +pub async fn create_local_rule( + State(state): State, + Extension(user): Extension, + Path(agent_id): Path, + Json(req): Json, +) -> Result { + validate_rule_request(&req)?; + + let agent_exists = sqlx::query!("SELECT id FROM agents WHERE id = $1", agent_id) + .fetch_optional(&state.db) + .await?; + if agent_exists.is_none() { + return Err(AppError::NotFound); + } + + let id = Uuid::now_v7(); + let ip_list = req.ip_list.unwrap_or_default(); + let ip_version = req.ip_version.unwrap_or_else(|| "both".into()); + let priority = req.priority.unwrap_or(0); + + let rule = sqlx::query_as!( + NftRule, + r#" + INSERT INTO nftables_rules + (id, scope, agent_id, kind, port, protocol, ip_list, ip_version, + rate_per_min, description, priority, created_by) + VALUES ($1, 'local', $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + RETURNING id, scope, agent_id, kind, port, protocol, + ip_list, ip_version, rate_per_min, description, + priority, enabled, created_by, created_at, updated_at + "#, + id, + agent_id, + req.kind, + req.port, + req.protocol, + &ip_list, + ip_version, + req.rate_per_min, + req.description, + priority, + user.user_id, + ) + .fetch_one(&state.db) + .await?; + + Ok((axum::http::StatusCode::CREATED, Json(rule))) +} + +pub async fn delete_local_rule( + State(state): State, + Path((agent_id, rule_id)): Path<(Uuid, Uuid)>, +) -> Result { + let deleted = sqlx::query!( + "DELETE FROM nftables_rules WHERE id = $1 AND scope = 'local' AND agent_id = $2 RETURNING id", + rule_id, + agent_id, + ) + .fetch_optional(&state.db) + .await?; + + if deleted.is_none() { + return Err(AppError::NotFound); + } + + Ok(axum::http::StatusCode::NO_CONTENT) +} + +/// Push local rules to the specific agent. +pub async fn push_local_rules( + State(state): State, + Extension(user): Extension, + Path(agent_id): Path, +) -> Result { + let agent = sqlx::query!( + "SELECT id, wg_ip, api_port, status FROM agents WHERE id = $1", + agent_id, + ) + .fetch_optional(&state.db) + .await? + .ok_or(AppError::NotFound)?; + + if agent.status == "lockdown" || agent.status == "offline" { + return Err(AppError::AgentUnavailable); + } + + let rules = sqlx::query_as!( + NftRule, + r#" + SELECT id, scope, agent_id, kind, port, protocol, + ip_list, ip_version, rate_per_min, description, + priority, enabled, created_by, created_at, updated_at + FROM nftables_rules + WHERE scope = 'local' AND agent_id = $1 AND enabled = true + ORDER BY priority ASC + "#, + agent_id, + ) + .fetch_all(&state.db) + .await?; + + let chain_body = rules_to_nft_chain(&rules); + + let command = json!({ + "type": "nftables.apply", + "chain": "lynx-local", + "rules": chain_body, + }); + + let signed = sign_command(&state.config, agent.id, user.user_id, "admin", &command)?; + let signed_val = serde_json::to_value(&signed).unwrap_or_default(); + + let ok = if let Some(body) = ws_hub::push_command(&state, agent.id, signed_val).await { + body.get("ok").and_then(|v| v.as_bool()).unwrap_or(false) + } else { + let url = format!("http://{}:{}/cmd", agent.wg_ip, agent.api_port); + let tok = &*state.config.internal_token; + reqwest::Client::new() + .post(&url) + .header("Authorization", format!("Bearer {tok}")) + .json(&signed) + .timeout(std::time::Duration::from_secs(15)) + .send() + .await + .map(|r| r.status().is_success()) + .unwrap_or(false) + }; + + Ok(Json(json!({ "ok": ok }))) +} + +fn validate_rule_request(req: &CreateRuleRequest) -> Result<(), AppError> { + let valid_kinds = [ + "allow_port", + "block_port", + "allow_ip", + "block_ip", + "rate_limit", + ]; + if !valid_kinds.contains(&req.kind.as_str()) { + return Err(AppError::Validation("invalid rule kind".into())); + } + if matches!( + req.kind.as_str(), + "allow_port" | "block_port" | "rate_limit" + ) && req.port.is_none() + { + return Err(AppError::Validation( + "port required for this rule kind".into(), + )); + } + if req.kind == "rate_limit" && req.rate_per_min.is_none() { + return Err(AppError::Validation( + "rate_per_min required for rate_limit".into(), + )); + } + if let Some(proto) = &req.protocol { + if !["tcp", "udp", "both"].contains(&proto.as_str()) { + return Err(AppError::Validation("invalid protocol".into())); + } + } + if let Some(ver) = &req.ip_version { + if !["ipv4", "ipv6", "both"].contains(&ver.as_str()) { + return Err(AppError::Validation("invalid ip_version".into())); + } + } + Ok(()) +} diff --git a/lynx/dashboard/server/src/nftables/handlers/mod.rs b/lynx/dashboard/server/src/nftables/handlers/mod.rs new file mode 100644 index 0000000..6531c0d --- /dev/null +++ b/lynx/dashboard/server/src/nftables/handlers/mod.rs @@ -0,0 +1,5 @@ +mod global; +mod local; + +pub use global::{create_global_rule, delete_global_rule, list_global_rules, push_global_rules}; +pub use local::{create_local_rule, delete_local_rule, list_local_rules, push_local_rules}; diff --git a/lynx/dashboard/server/src/nftables/mod.rs b/lynx/dashboard/server/src/nftables/mod.rs new file mode 100644 index 0000000..72ff6f4 --- /dev/null +++ b/lynx/dashboard/server/src/nftables/mod.rs @@ -0,0 +1,163 @@ +pub mod handlers; +pub mod router; + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct NftRule { + pub id: Uuid, + pub scope: String, + pub agent_id: Option, + pub kind: String, + pub port: Option, + pub protocol: Option, + pub ip_list: Vec, + pub ip_version: String, + pub rate_per_min: Option, + pub description: Option, + pub priority: i32, + pub enabled: bool, + pub created_by: Option, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Debug, Deserialize)] +pub struct CreateRuleRequest { + pub kind: String, + pub port: Option, + pub protocol: Option, + pub ip_list: Option>, + pub ip_version: Option, + pub rate_per_min: Option, + pub description: Option, + pub priority: Option, +} + +/// Convert individual rule fields into nft rule lines. +/// Used for system-level command generation without constructing a full NftRule. +pub fn rule_line( + kind: &str, + port: Option, + protocol: Option<&str>, + ip_list: &[String], + rate_per_min: Option, +) -> String { + let rule = NftRule { + id: Uuid::nil(), + scope: "global".into(), + agent_id: None, + kind: kind.to_string(), + port: port.map(|p| p as i32), + protocol: protocol.map(|s| s.to_string()), + ip_list: ip_list.to_vec(), + ip_version: "both".into(), + rate_per_min: rate_per_min.map(|r| r as i32), + description: None, + priority: 0, + enabled: true, + created_by: None, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + }; + rule_to_nft_lines(&rule).join("\n") +} + +/// Convert a list of NftRules into the body of an nftables chain. +/// Returns lines suitable for insertion inside `chain { ... }`. +pub fn rules_to_nft_chain(rules: &[NftRule]) -> String { + let mut lines: Vec = Vec::new(); + + let mut sorted: Vec<&NftRule> = rules.iter().filter(|r| r.enabled).collect(); + sorted.sort_by_key(|r| r.priority); + + for rule in sorted { + for line in rule_to_nft_lines(rule) { + lines.push(format!(" {line}")); + } + } + + lines.join("\n") +} + +fn rule_to_nft_lines(rule: &NftRule) -> Vec { + match rule.kind.as_str() { + "allow_port" | "block_port" => port_rule_lines(rule), + "allow_ip" | "block_ip" => ip_rule_lines(rule), + "rate_limit" => rate_limit_lines(rule), + _ => vec![], + } +} + +fn verdict(kind: &str) -> &str { + match kind { + "allow_port" | "allow_ip" => "accept", + _ => "drop", + } +} + +fn protocol_match(protocol: &str) -> Vec { + match protocol { + "tcp" => vec!["tcp".into()], + "udp" => vec!["udp".into()], + _ => vec!["tcp".into(), "udp".into()], + } +} + +fn ip_saddr_matches(rule: &NftRule) -> Vec { + if rule.ip_list.is_empty() { + return vec![String::new()]; + } + rule.ip_list + .iter() + .map(|ip| { + let family = if ip.contains(':') { "ip6" } else { "ip" }; + format!("{family} saddr {ip} ") + }) + .collect() +} + +fn port_rule_lines(rule: &NftRule) -> Vec { + let Some(port) = rule.port else { return vec![] }; + let proto = rule.protocol.as_deref().unwrap_or("both"); + let verd = verdict(&rule.kind); + let mut lines = Vec::new(); + + for proto_kw in protocol_match(proto) { + for saddr in ip_saddr_matches(rule) { + lines.push(format!("{saddr}{proto_kw} dport {port} {verd}")); + } + } + lines +} + +fn ip_rule_lines(rule: &NftRule) -> Vec { + let verd = verdict(&rule.kind); + rule.ip_list + .iter() + .map(|ip| { + let family = if ip.contains(':') { "ip6" } else { "ip" }; + format!("{family} saddr {ip} {verd}") + }) + .collect() +} + +fn rate_limit_lines(rule: &NftRule) -> Vec { + let Some(port) = rule.port else { return vec![] }; + let Some(rate) = rule.rate_per_min else { + return vec![]; + }; + let proto = rule.protocol.as_deref().unwrap_or("both"); + let mut lines = Vec::new(); + + for proto_kw in protocol_match(proto) { + for saddr in ip_saddr_matches(rule) { + lines.push(format!( + "{saddr}{proto_kw} dport {port} limit rate {rate}/minute accept" + )); + } + } + lines +} diff --git a/lynx/dashboard/server/src/nftables/router.rs b/lynx/dashboard/server/src/nftables/router.rs new file mode 100644 index 0000000..6997730 --- /dev/null +++ b/lynx/dashboard/server/src/nftables/router.rs @@ -0,0 +1,30 @@ +use super::handlers; +use crate::state::AppState; +use axum::{ + routing::{delete, get, post}, + Router, +}; + +pub fn router() -> Router { + Router::new() + // Global rules + .route( + "/global", + get(handlers::list_global_rules).post(handlers::create_global_rule), + ) + .route("/global/push", post(handlers::push_global_rules)) + .route("/global/{id}", delete(handlers::delete_global_rule)) + // Local rules (per agent) + .route( + "/agents/{agent_id}/local", + get(handlers::list_local_rules).post(handlers::create_local_rule), + ) + .route( + "/agents/{agent_id}/local/push", + post(handlers::push_local_rules), + ) + .route( + "/agents/{agent_id}/local/{rule_id}", + delete(handlers::delete_local_rule), + ) +} diff --git a/lynx/dashboard/server/src/organizations/handlers/containers.rs b/lynx/dashboard/server/src/organizations/handlers/containers.rs new file mode 100644 index 0000000..09002df --- /dev/null +++ b/lynx/dashboard/server/src/organizations/handlers/containers.rs @@ -0,0 +1,246 @@ +use super::super::{DeployContainerRequest, UpdateResourcesRequest}; +use crate::{ + auth::middleware::AuthUser, crypto::cmd::sign_command, error::AppError, state::AppState, +}; +use axum::{ + extract::{Extension, Path, State}, + response::IntoResponse, + Json, +}; +use serde_json::json; +use uuid::Uuid; + +async fn relay_project_command( + state: &AppState, + org_id: Uuid, + proj_id: Uuid, + user_id: Uuid, + permission: &str, + command: serde_json::Value, +) -> Result { + let project = sqlx::query!( + "SELECT agent_id FROM projects WHERE id = $1 AND organization_id = $2", + proj_id, + org_id + ) + .fetch_optional(&state.db) + .await? + .ok_or(AppError::NotFound)?; + + let agent = sqlx::query!( + "SELECT wg_ip, api_port, status FROM agents WHERE id = $1", + project.agent_id + ) + .fetch_optional(&state.db) + .await? + .ok_or(AppError::NotFound)?; + + if agent.status == "lockdown" || agent.status == "offline" { + return Err(AppError::AgentUnavailable); + } + + let signed = sign_command( + &state.config, + project.agent_id, + user_id, + permission, + &command, + )?; + + let url = format!("http://{}:{}/cmd", agent.wg_ip, agent.api_port); + let tok = &*state.config.internal_token; + let client = reqwest::Client::new(); + let resp = client + .post(&url) + .header("Authorization", format!("Bearer {tok}")) + .json(&signed) + .timeout(std::time::Duration::from_secs(30)) + .send() + .await + .map_err(|_| AppError::BadGateway)?; + + let status = resp.status(); + let body: serde_json::Value = resp.json().await.unwrap_or(json!({})); + + Ok(( + axum::http::StatusCode::from_u16(status.as_u16()) + .unwrap_or(axum::http::StatusCode::BAD_GATEWAY), + Json(body), + )) +} + +pub async fn update_container_resources( + State(state): State, + Extension(user): Extension, + Path((org_id, proj_id)): Path<(Uuid, Uuid)>, + Json(req): Json, +) -> Result { + let role = sqlx::query_scalar!( + "SELECT role FROM organization_members WHERE organization_id = $1 AND user_id = $2", + org_id, + user.user_id + ) + .fetch_optional(&state.db) + .await? + .ok_or(AppError::NotFound)?; + + if role == "viewer" { + return Err(AppError::Forbidden); + } + + let project = sqlx::query!( + "SELECT agent_id FROM projects WHERE id = $1 AND organization_id = $2", + proj_id, + org_id + ) + .fetch_optional(&state.db) + .await? + .ok_or(AppError::NotFound)?; + + let agent = sqlx::query!( + "SELECT wg_ip, api_port, status FROM agents WHERE id = $1", + project.agent_id + ) + .fetch_optional(&state.db) + .await? + .ok_or(AppError::NotFound)?; + + if agent.status == "lockdown" || agent.status == "offline" { + return Err(AppError::AgentUnavailable); + } + + let command = json!({ + "type": "container.update", + "tenant_id": org_id.to_string(), + "name": req.container_name, + "cpus": req.cpus, + "memory_mb": req.memory_mb, + }); + + let signed = sign_command( + &state.config, + project.agent_id, + user.user_id, + "write", + &command, + )?; + + let url = format!("http://{}:{}/cmd", agent.wg_ip, agent.api_port); + let tok = &*state.config.internal_token; + let client = reqwest::Client::new(); + let resp = client + .post(&url) + .header("Authorization", format!("Bearer {tok}")) + .json(&signed) + .timeout(std::time::Duration::from_secs(30)) + .send() + .await + .map_err(|_| AppError::BadGateway)?; + + let status = resp.status(); + let body: serde_json::Value = resp.json().await.unwrap_or(json!({})); + + Ok(( + axum::http::StatusCode::from_u16(status.as_u16()) + .unwrap_or(axum::http::StatusCode::BAD_GATEWAY), + Json(body), + )) +} + +pub async fn deploy_container( + State(state): State, + Extension(user): Extension, + Path((org_id, proj_id)): Path<(Uuid, Uuid)>, + Json(req): Json, +) -> Result { + let role = sqlx::query_scalar!( + "SELECT role FROM organization_members WHERE organization_id = $1 AND user_id = $2", + org_id, + user.user_id + ) + .fetch_optional(&state.db) + .await? + .ok_or(AppError::NotFound)?; + + if role == "viewer" { + return Err(AppError::Forbidden); + } + + let command = json!({ + "type": "container.deploy", + "tenant_id": org_id.to_string(), + "name": req.name, + "image": req.image, + "ports": req.ports.unwrap_or_default(), + "env": req.env.unwrap_or_default(), + "cpus": req.cpus, + "memory_mb": req.memory_mb, + }); + + relay_project_command(&state, org_id, proj_id, user.user_id, "write", command).await +} + +pub async fn list_containers( + State(state): State, + Extension(user): Extension, + Path((org_id, proj_id)): Path<(Uuid, Uuid)>, +) -> Result { + let is_member = sqlx::query_scalar!( + "SELECT 1 FROM organization_members WHERE organization_id = $1 AND user_id = $2", + org_id, + user.user_id + ) + .fetch_optional(&state.db) + .await? + .is_some(); + + if !is_member { + return Err(AppError::NotFound); + } + + let command = json!({ + "type": "container.list", + "tenant_id": org_id.to_string(), + }); + + relay_project_command(&state, org_id, proj_id, user.user_id, "read", command).await +} + +pub async fn container_action( + State(state): State, + Extension(user): Extension, + Path((org_id, proj_id, name, action)): Path<(Uuid, Uuid, String, String)>, +) -> Result { + let role = sqlx::query_scalar!( + "SELECT role FROM organization_members WHERE organization_id = $1 AND user_id = $2", + org_id, + user.user_id + ) + .fetch_optional(&state.db) + .await? + .ok_or(AppError::NotFound)?; + + if role == "viewer" { + return Err(AppError::Forbidden); + } + + let cmd_type = match action.as_str() { + "start" => "container.start", + "stop" => "container.stop", + "restart" => "container.restart", + "remove" => "container.remove", + _ => { + return Err(AppError::BadRequest( + "action must be start, stop, restart, or remove", + )) + } + }; + + let command = json!({ + "type": cmd_type, + "tenant_id": org_id.to_string(), + "name": name, + }); + + relay_project_command(&state, org_id, proj_id, user.user_id, "write", command).await +} diff --git a/lynx/dashboard/server/src/organizations/handlers/members.rs b/lynx/dashboard/server/src/organizations/handlers/members.rs new file mode 100644 index 0000000..8f1e073 --- /dev/null +++ b/lynx/dashboard/server/src/organizations/handlers/members.rs @@ -0,0 +1,138 @@ +use super::super::{InviteMemberRequest, OrgMember}; +use crate::{auth::middleware::AuthUser, error::AppError, state::AppState}; +use axum::{ + extract::{Extension, Path, State}, + http::StatusCode, + response::IntoResponse, + Json, +}; +use uuid::Uuid; + +pub async fn list_members( + State(state): State, + Extension(user): Extension, + Path(org_id): Path, +) -> Result { + let is_member = sqlx::query_scalar!( + "SELECT 1 FROM organization_members WHERE organization_id = $1 AND user_id = $2", + org_id, + user.user_id + ) + .fetch_optional(&state.db) + .await? + .is_some(); + + if !is_member { + return Err(AppError::NotFound); + } + + let members = sqlx::query_as!( + OrgMember, + r#" + SELECT m.user_id, u.username, m.role, m.joined_at + FROM organization_members m + JOIN users u ON u.id = m.user_id + WHERE m.organization_id = $1 + ORDER BY m.joined_at ASC + "#, + org_id + ) + .fetch_all(&state.db) + .await?; + + Ok(Json(members)) +} + +pub async fn invite_member( + State(state): State, + Extension(user): Extension, + Path(org_id): Path, + Json(req): Json, +) -> Result { + let caller_role = sqlx::query_scalar!( + "SELECT role FROM organization_members WHERE organization_id = $1 AND user_id = $2", + org_id, + user.user_id + ) + .fetch_optional(&state.db) + .await? + .ok_or(AppError::NotFound)?; + + if caller_role != "owner" && caller_role != "admin" { + return Err(AppError::Forbidden); + } + + let role = req.role.unwrap_or_else(|| "member".to_string()); + if !["owner", "admin", "member", "viewer"].contains(&role.as_str()) { + return Err(AppError::Validation( + "role: must be owner, admin, member, or viewer".into(), + )); + } + + let invitee_id = sqlx::query_scalar!( + "SELECT id FROM users WHERE username = $1", + req.username.to_lowercase() + ) + .fetch_optional(&state.db) + .await? + .ok_or_else(|| AppError::Validation("user not found".into()))?; + + sqlx::query!( + r#" + INSERT INTO organization_members (organization_id, user_id, role) + VALUES ($1, $2, $3) + ON CONFLICT (organization_id, user_id) DO NOTHING + "#, + org_id, + invitee_id, + role + ) + .execute(&state.db) + .await?; + + Ok(StatusCode::NO_CONTENT) +} + +pub async fn remove_member( + State(state): State, + Extension(user): Extension, + Path((org_id, target_user_id)): Path<(Uuid, Uuid)>, +) -> Result { + let caller_role = sqlx::query_scalar!( + "SELECT role FROM organization_members WHERE organization_id = $1 AND user_id = $2", + org_id, + user.user_id + ) + .fetch_optional(&state.db) + .await? + .ok_or(AppError::NotFound)?; + + if caller_role != "owner" && caller_role != "admin" { + return Err(AppError::Forbidden); + } + + let target_role = sqlx::query_scalar!( + "SELECT role FROM organization_members WHERE organization_id = $1 AND user_id = $2", + org_id, + target_user_id + ) + .fetch_optional(&state.db) + .await? + .ok_or(AppError::NotFound)?; + + if target_role == "owner" { + return Err(AppError::Validation( + "cannot remove the organization owner".into(), + )); + } + + sqlx::query!( + "DELETE FROM organization_members WHERE organization_id = $1 AND user_id = $2", + org_id, + target_user_id + ) + .execute(&state.db) + .await?; + + Ok(StatusCode::NO_CONTENT) +} diff --git a/lynx/dashboard/server/src/organizations/handlers/mod.rs b/lynx/dashboard/server/src/organizations/handlers/mod.rs new file mode 100644 index 0000000..3e2288f --- /dev/null +++ b/lynx/dashboard/server/src/organizations/handlers/mod.rs @@ -0,0 +1,13 @@ +mod containers; +mod members; +mod orgs; +mod projects; +mod scaling; + +pub use containers::{ + container_action, deploy_container, list_containers, update_container_resources, +}; +pub use members::{invite_member, list_members, remove_member}; +pub use orgs::{create_org, delete_org, get_org, list_orgs}; +pub use projects::{create_project, get_project, list_projects}; +pub use scaling::{horizontal_scale, list_horizontal_scale, teardown_horizontal_scale}; diff --git a/lynx/dashboard/server/src/organizations/handlers/orgs.rs b/lynx/dashboard/server/src/organizations/handlers/orgs.rs new file mode 100644 index 0000000..3842726 --- /dev/null +++ b/lynx/dashboard/server/src/organizations/handlers/orgs.rs @@ -0,0 +1,124 @@ +use super::super::{CreateOrgRequest, OrgWithMemberCount, Organization}; +use crate::{auth::middleware::AuthUser, error::AppError, state::AppState}; +use axum::{ + extract::{Extension, Path, State}, + http::StatusCode, + response::IntoResponse, + Json, +}; +use uuid::Uuid; + +pub async fn list_orgs( + State(state): State, + Extension(user): Extension, +) -> Result { + let orgs = sqlx::query_as!( + OrgWithMemberCount, + r#" + SELECT o.id, o.name, o.slug, o.owner_id, o.created_at, + COUNT(m.user_id) AS "member_count!" + FROM organizations o + JOIN organization_members m ON m.organization_id = o.id + WHERE m.user_id = $1 + GROUP BY o.id + ORDER BY o.created_at ASC + "#, + user.user_id + ) + .fetch_all(&state.db) + .await?; + + Ok(Json(orgs)) +} + +pub async fn create_org( + State(state): State, + Extension(user): Extension, + Json(req): Json, +) -> Result { + let slug = req.slug.to_lowercase(); + + if !slug.chars().all(|c| c.is_alphanumeric() || c == '-') || slug.is_empty() { + return Err(AppError::Validation( + "slug: only lowercase letters, numbers, and hyphens".into(), + )); + } + + let org_id = Uuid::now_v7(); + + let org = sqlx::query_as!( + Organization, + r#" + WITH new_org AS ( + INSERT INTO organizations (id, name, slug, owner_id) + VALUES ($1, $2, $3, $4) + RETURNING * + ), + _ AS ( + INSERT INTO organization_members (organization_id, user_id, role) + VALUES ($1, $4, 'owner') + ) + SELECT id, name, slug, owner_id, created_at FROM new_org + "#, + org_id, + req.name, + slug, + user.user_id, + ) + .fetch_one(&state.db) + .await + .map_err(|e| { + if let sqlx::Error::Database(ref db_err) = e { + if db_err.constraint() == Some("organizations_slug_key") { + return AppError::Conflict("slug already taken"); + } + } + AppError::Internal(e.into()) + })?; + + Ok((StatusCode::CREATED, Json(org))) +} + +pub async fn get_org( + State(state): State, + Extension(user): Extension, + Path(id): Path, +) -> Result { + let org = sqlx::query_as!( + Organization, + r#" + SELECT o.id, o.name, o.slug, o.owner_id, o.created_at + FROM organizations o + JOIN organization_members m ON m.organization_id = o.id + WHERE o.id = $1 AND m.user_id = $2 + "#, + id, + user.user_id + ) + .fetch_optional(&state.db) + .await? + .ok_or(AppError::NotFound)?; + + Ok(Json(org)) +} + +pub async fn delete_org( + State(state): State, + Extension(user): Extension, + Path(id): Path, +) -> Result { + let rows = sqlx::query!( + "DELETE FROM organizations WHERE id = $1 AND owner_id = $2", + id, + user.user_id + ) + .execute(&state.db) + .await? + .rows_affected(); + + if rows == 0 { + return Err(AppError::NotFound); + } + + Ok(StatusCode::NO_CONTENT) +} diff --git a/lynx/dashboard/server/src/organizations/handlers/projects.rs b/lynx/dashboard/server/src/organizations/handlers/projects.rs new file mode 100644 index 0000000..8f589f1 --- /dev/null +++ b/lynx/dashboard/server/src/organizations/handlers/projects.rs @@ -0,0 +1,131 @@ +use super::super::{CreateProjectRequest, Project}; +use crate::{auth::middleware::AuthUser, error::AppError, state::AppState}; +use axum::{ + extract::{Extension, Path, State}, + http::StatusCode, + response::IntoResponse, + Json, +}; +use uuid::Uuid; + +pub async fn list_projects( + State(state): State, + Extension(user): Extension, + Path(org_id): Path, +) -> Result { + let is_member = sqlx::query_scalar!( + "SELECT 1 FROM organization_members WHERE organization_id = $1 AND user_id = $2", + org_id, + user.user_id + ) + .fetch_optional(&state.db) + .await? + .is_some(); + + if !is_member { + return Err(AppError::NotFound); + } + + let projects = sqlx::query_as!( + Project, + "SELECT id, organization_id, agent_id, name, slug, created_at FROM projects WHERE organization_id = $1 ORDER BY created_at ASC", + org_id + ) + .fetch_all(&state.db) + .await?; + + Ok(Json(projects)) +} + +pub async fn get_project( + State(state): State, + Extension(user): Extension, + Path((org_id, proj_id)): Path<(Uuid, Uuid)>, +) -> Result { + let is_member = sqlx::query_scalar!( + "SELECT 1 FROM organization_members WHERE organization_id = $1 AND user_id = $2", + org_id, + user.user_id + ) + .fetch_optional(&state.db) + .await? + .is_some(); + + if !is_member { + return Err(AppError::NotFound); + } + + let project = sqlx::query_as!( + Project, + "SELECT id, organization_id, agent_id, name, slug, created_at FROM projects WHERE id = $1 AND organization_id = $2", + proj_id, + org_id + ) + .fetch_optional(&state.db) + .await? + .ok_or(AppError::NotFound)?; + + Ok(Json(project)) +} + +pub async fn create_project( + State(state): State, + Extension(user): Extension, + Path(org_id): Path, + Json(req): Json, +) -> Result { + let role = sqlx::query_scalar!( + "SELECT role FROM organization_members WHERE organization_id = $1 AND user_id = $2", + org_id, + user.user_id + ) + .fetch_optional(&state.db) + .await? + .ok_or(AppError::NotFound)?; + + if role == "viewer" { + return Err(AppError::Forbidden); + } + + let slug = req.slug.to_lowercase(); + if !slug.chars().all(|c| c.is_alphanumeric() || c == '-') || slug.is_empty() { + return Err(AppError::Validation( + "slug: only lowercase letters, numbers, and hyphens".into(), + )); + } + + let agent_exists = sqlx::query_scalar!("SELECT 1 FROM agents WHERE id = $1", req.agent_id) + .fetch_optional(&state.db) + .await? + .is_some(); + + if !agent_exists { + return Err(AppError::Validation("agent not found".into())); + } + + let project = sqlx::query_as!( + Project, + r#" + INSERT INTO projects (id, organization_id, agent_id, name, slug) + VALUES ($1, $2, $3, $4, $5) + RETURNING id, organization_id, agent_id, name, slug, created_at + "#, + Uuid::now_v7(), + org_id, + req.agent_id, + req.name, + slug, + ) + .fetch_one(&state.db) + .await + .map_err(|e| { + if let sqlx::Error::Database(ref db_err) = e { + if db_err.constraint() == Some("projects_organization_id_slug_key") { + return AppError::Conflict("slug already taken in this organization"); + } + } + AppError::Internal(e.into()) + })?; + + Ok((StatusCode::CREATED, Json(project))) +} diff --git a/lynx/dashboard/server/src/organizations/handlers/scaling.rs b/lynx/dashboard/server/src/organizations/handlers/scaling.rs new file mode 100644 index 0000000..7e94464 --- /dev/null +++ b/lynx/dashboard/server/src/organizations/handlers/scaling.rs @@ -0,0 +1,336 @@ +use super::super::{DataPlaneTunnel, HorizontalScaleRequest}; +use crate::{ + auth::middleware::AuthUser, crypto::cmd::sign_command, error::AppError, state::AppState, +}; +use axum::{ + extract::{Extension, Path, State}, + http::StatusCode, + response::IntoResponse, + Json, +}; +use serde_json::json; +use uuid::Uuid; + +pub async fn list_horizontal_scale( + State(state): State, + Extension(user): Extension, + Path((org_id, proj_id)): Path<(Uuid, Uuid)>, +) -> Result { + let is_member = sqlx::query_scalar!( + "SELECT 1 FROM organization_members WHERE organization_id = $1 AND user_id = $2", + org_id, + user.user_id + ) + .fetch_optional(&state.db) + .await? + .is_some(); + + if !is_member { + return Err(AppError::NotFound); + } + + sqlx::query_scalar!( + "SELECT 1 FROM projects WHERE id = $1 AND organization_id = $2", + proj_id, + org_id + ) + .fetch_optional(&state.db) + .await? + .ok_or(AppError::NotFound)?; + + let tunnels = sqlx::query_as!( + DataPlaneTunnel, + r#"SELECT id, project_id, agent_a_id, agent_b_id, agent_a_wg_ip, agent_b_wg_ip, + wg_port, replica_count, status, created_at + FROM data_plane_tunnels + WHERE project_id = $1 AND status != 'torn_down' + ORDER BY created_at ASC"#, + proj_id + ) + .fetch_all(&state.db) + .await?; + + Ok(Json(tunnels)) +} + +pub async fn horizontal_scale( + State(state): State, + Extension(user): Extension, + Path((org_id, proj_id)): Path<(Uuid, Uuid)>, + Json(req): Json, +) -> Result { + let role = sqlx::query_scalar!( + "SELECT role FROM organization_members WHERE organization_id = $1 AND user_id = $2", + org_id, + user.user_id + ) + .fetch_optional(&state.db) + .await? + .ok_or(AppError::NotFound)?; + + if role == "viewer" { + return Err(AppError::Forbidden); + } + + let project = sqlx::query!( + "SELECT agent_id FROM projects WHERE id = $1 AND organization_id = $2", + proj_id, + org_id + ) + .fetch_optional(&state.db) + .await? + .ok_or(AppError::NotFound)?; + + let agent_a_id = project.agent_id; + let agent_b_id = req.target_agent_id; + + if agent_a_id == agent_b_id { + return Err(AppError::Validation( + "target agent must differ from project's primary agent".into(), + )); + } + + let agent_a = sqlx::query!( + "SELECT wg_ip, api_port, status FROM agents WHERE id = $1", + agent_a_id + ) + .fetch_optional(&state.db) + .await? + .ok_or(AppError::NotFound)?; + + let agent_b = sqlx::query!( + "SELECT wg_ip, api_port, status FROM agents WHERE id = $1", + agent_b_id + ) + .fetch_optional(&state.db) + .await? + .ok_or(AppError::NotFound)?; + + if agent_a.status != "online" || agent_b.status != "online" { + return Err(AppError::AgentUnavailable); + } + + let wg_port = req.wg_port.unwrap_or(51821) as i32; + + let tunnel_count = sqlx::query_scalar!("SELECT COUNT(*) FROM data_plane_tunnels") + .fetch_one(&state.db) + .await? + .unwrap_or(0); + + let subnet_idx = (tunnel_count % 254) + 1; + let agent_a_dp_ip = format!("10.200.{}.1", subnet_idx); + let agent_b_dp_ip = format!("10.200.{}.2", subnet_idx); + + let gen_key = |label: &str| -> Result { + let out = std::process::Command::new("wg") + .arg("genkey") + .output() + .map_err(|e| AppError::Internal(anyhow::anyhow!("wg genkey ({label}): {e}")))?; + Ok(String::from_utf8_lossy(&out.stdout).trim().to_string()) + }; + let pub_key = |priv_key: &str| -> Result { + use std::io::Write; + let mut child = std::process::Command::new("wg") + .arg("pubkey") + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .spawn() + .map_err(|e| AppError::Internal(anyhow::anyhow!("wg pubkey: {e}")))?; + child + .stdin + .take() + .unwrap() + .write_all(priv_key.as_bytes()) + .ok(); + let out = child + .wait_with_output() + .map_err(|e| AppError::Internal(anyhow::anyhow!("{e}")))?; + Ok(String::from_utf8_lossy(&out.stdout).trim().to_string()) + }; + let gen_psk = || -> Result { + let out = std::process::Command::new("wg") + .arg("genpsk") + .output() + .map_err(|e| AppError::Internal(anyhow::anyhow!("wg genpsk: {e}")))?; + Ok(String::from_utf8_lossy(&out.stdout).trim().to_string()) + }; + + let privkey_a = gen_key("agent_a")?; + let pubkey_a = pub_key(&privkey_a)?; + let privkey_b = gen_key("agent_b")?; + let pubkey_b = pub_key(&privkey_b)?; + let psk = gen_psk()?; + + let tunnel_id = Uuid::now_v7(); + sqlx::query!( + r#"INSERT INTO data_plane_tunnels + (id, project_id, agent_a_id, agent_b_id, + agent_a_pubkey, agent_b_pubkey, agent_a_wg_ip, agent_b_wg_ip, + wg_port, replica_count, status) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,'pending')"#, + tunnel_id, + proj_id, + agent_a_id, + agent_b_id, + pubkey_a, + pubkey_b, + agent_a_dp_ip, + agent_b_dp_ip, + wg_port, + req.replica_count as i32, + ) + .execute(&state.db) + .await?; + + let tok = &*state.config.internal_token; + let http = reqwest::Client::new(); + + let setup_a = json!({ + "type": "wg.data_plane.setup", + "tunnel_id": tunnel_id.to_string(), + "role": "initiator", + "private_key": privkey_a, + "peer_pubkey": pubkey_b, + "psk": psk, + "local_ip": format!("{}/30", agent_a_dp_ip), + "peer_endpoint": format!("{}:{}", agent_b.wg_ip, wg_port), + "wg_port": wg_port, + }); + let signed_a = sign_command(&state.config, agent_a_id, user.user_id, "write", &setup_a)?; + let url_a = format!("http://{}:{}/cmd", agent_a.wg_ip, agent_a.api_port); + http.post(&url_a) + .header("Authorization", format!("Bearer {tok}")) + .json(&signed_a) + .send() + .await + .map_err(|_| AppError::BadGateway)?; + + let setup_b = json!({ + "type": "wg.data_plane.setup", + "tunnel_id": tunnel_id.to_string(), + "role": "responder", + "private_key": privkey_b, + "peer_pubkey": pubkey_a, + "psk": psk, + "local_ip": format!("{}/30", agent_b_dp_ip), + "peer_endpoint": format!("{}:{}", agent_a.wg_ip, wg_port), + "wg_port": wg_port, + }); + let signed_b = sign_command(&state.config, agent_b_id, user.user_id, "write", &setup_b)?; + let url_b = format!("http://{}:{}/cmd", agent_b.wg_ip, agent_b.api_port); + http.post(&url_b) + .header("Authorization", format!("Bearer {tok}")) + .json(&signed_b) + .send() + .await + .map_err(|_| AppError::BadGateway)?; + + for i in 0..req.replica_count { + let replica_cmd = json!({ + "type": "container.deploy", + "tenant_id": org_id.to_string(), + "name": format!("{}-replica-{}", proj_id.to_string().split('-').next().unwrap_or("r"), i), + "image": req.image, + "ports": [], + "env": [], + }); + let signed_replica = sign_command( + &state.config, + agent_b_id, + user.user_id, + "write", + &replica_cmd, + )?; + http.post(&url_b) + .header("Authorization", format!("Bearer {tok}")) + .json(&signed_replica) + .timeout(std::time::Duration::from_secs(30)) + .send() + .await + .map_err(|_| AppError::BadGateway)?; + } + + sqlx::query!( + "UPDATE data_plane_tunnels SET status='active', updated_at=NOW() WHERE id=$1", + tunnel_id + ) + .execute(&state.db) + .await?; + + Ok(( + StatusCode::CREATED, + Json(json!({ + "tunnel_id": tunnel_id, + "agent_a_ip": agent_a_dp_ip, + "agent_b_ip": agent_b_dp_ip, + "replicas": req.replica_count, + })), + )) +} + +pub async fn teardown_horizontal_scale( + State(state): State, + Extension(user): Extension, + Path((org_id, proj_id, tunnel_id)): Path<(Uuid, Uuid, Uuid)>, +) -> Result { + let role = sqlx::query_scalar!( + "SELECT role FROM organization_members WHERE organization_id = $1 AND user_id = $2", + org_id, + user.user_id + ) + .fetch_optional(&state.db) + .await? + .ok_or(AppError::NotFound)?; + + if role == "viewer" { + return Err(AppError::Forbidden); + } + + let tunnel = sqlx::query!( + "SELECT agent_a_id, agent_b_id, replica_count FROM data_plane_tunnels WHERE id=$1 AND project_id=$2", + tunnel_id, + proj_id + ) + .fetch_optional(&state.db) + .await? + .ok_or(AppError::NotFound)?; + + let tok = &*state.config.internal_token; + let http = reqwest::Client::new(); + + for agent_id in [tunnel.agent_a_id, tunnel.agent_b_id] { + let agent = sqlx::query!( + "SELECT wg_ip, api_port, status FROM agents WHERE id=$1", + agent_id + ) + .fetch_optional(&state.db) + .await?; + + if let Some(a) = agent { + if a.status == "online" { + let teardown = json!({ + "type": "wg.data_plane.teardown", + "tunnel_id": tunnel_id.to_string(), + }); + let signed = + sign_command(&state.config, agent_id, user.user_id, "write", &teardown)?; + let _ = http + .post(format!("http://{}:{}/cmd", a.wg_ip, a.api_port)) + .header("Authorization", format!("Bearer {tok}")) + .json(&signed) + .timeout(std::time::Duration::from_secs(15)) + .send() + .await; + } + } + } + + sqlx::query!( + "UPDATE data_plane_tunnels SET status='torn_down', updated_at=NOW() WHERE id=$1", + tunnel_id + ) + .execute(&state.db) + .await?; + + Ok(StatusCode::NO_CONTENT) +} diff --git a/lynx/dashboard/server/src/organizations/mod.rs b/lynx/dashboard/server/src/organizations/mod.rs new file mode 100644 index 0000000..6737278 --- /dev/null +++ b/lynx/dashboard/server/src/organizations/mod.rs @@ -0,0 +1,113 @@ +pub mod handlers; +pub mod router; + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Serialize, sqlx::FromRow)] +pub struct Organization { + pub id: Uuid, + pub name: String, + pub slug: String, + pub owner_id: Uuid, + pub created_at: DateTime, +} + +#[derive(Debug, Deserialize)] +pub struct CreateOrgRequest { + pub name: String, + pub slug: String, +} + +#[derive(Debug, Serialize)] +pub struct OrgWithMemberCount { + pub id: Uuid, + pub name: String, + pub slug: String, + pub owner_id: Uuid, + pub created_at: DateTime, + pub member_count: i64, +} + +#[derive(Debug, Serialize, sqlx::FromRow)] +pub struct OrgMember { + pub user_id: Uuid, + pub username: String, + pub role: String, + pub joined_at: DateTime, +} + +#[derive(Debug, Deserialize)] +pub struct InviteMemberRequest { + pub username: String, + pub role: Option, +} + +#[derive(Debug, Serialize, sqlx::FromRow)] +pub struct Project { + pub id: Uuid, + pub organization_id: Uuid, + pub agent_id: Uuid, + pub name: String, + pub slug: String, + pub created_at: DateTime, +} + +#[derive(Debug, Deserialize)] +pub struct CreateProjectRequest { + pub name: String, + pub slug: String, + pub agent_id: Uuid, +} + +#[derive(Debug, Deserialize)] +pub struct UpdateResourcesRequest { + pub container_name: String, + pub cpus: Option, + pub memory_mb: Option, +} + +#[derive(Debug, Deserialize)] +pub struct DeployContainerRequest { + pub name: String, + pub image: String, + pub ports: Option>, + pub env: Option>, + pub cpus: Option, + pub memory_mb: Option, +} + +#[derive(Debug, Deserialize)] +pub struct ContainerActionPath { + pub id: Uuid, + pub proj_id: Uuid, + pub name: String, + pub action: String, +} + +#[derive(Debug, Deserialize)] +pub struct HorizontalScaleRequest { + /// Agent to deploy replicas on (Agent-B) + pub target_agent_id: Uuid, + /// Container image to run as replica + pub image: String, + /// Number of replicas to run on Agent-B + pub replica_count: u32, + /// Data-plane WireGuard port on both agents (default 51821) + pub wg_port: Option, +} + +#[derive(Debug, Serialize, sqlx::FromRow)] +pub struct DataPlaneTunnel { + pub id: Uuid, + pub project_id: Uuid, + pub agent_a_id: Uuid, + pub agent_b_id: Uuid, + pub agent_a_wg_ip: String, + pub agent_b_wg_ip: String, + pub wg_port: i32, + pub replica_count: i32, + pub status: String, + pub created_at: chrono::DateTime, +} diff --git a/lynx/dashboard/server/src/organizations/router.rs b/lynx/dashboard/server/src/organizations/router.rs new file mode 100644 index 0000000..8c05eda --- /dev/null +++ b/lynx/dashboard/server/src/organizations/router.rs @@ -0,0 +1,42 @@ +use super::handlers; +use crate::state::AppState; +use axum::{ + routing::{delete, get, post, put}, + Router, +}; + +pub fn router() -> Router { + Router::new() + .route("/", get(handlers::list_orgs).post(handlers::create_org)) + .route("/{id}", get(handlers::get_org).delete(handlers::delete_org)) + .route( + "/{id}/members", + get(handlers::list_members).post(handlers::invite_member), + ) + .route("/{id}/members/{user_id}", delete(handlers::remove_member)) + .route( + "/{id}/projects", + get(handlers::list_projects).post(handlers::create_project), + ) + .route("/{id}/projects/{proj_id}", get(handlers::get_project)) + .route( + "/{id}/projects/{proj_id}/resources", + put(handlers::update_container_resources), + ) + .route( + "/{id}/projects/{proj_id}/containers", + get(handlers::list_containers).post(handlers::deploy_container), + ) + .route( + "/{id}/projects/{proj_id}/containers/{name}/{action}", + post(handlers::container_action), + ) + .route( + "/{id}/projects/{proj_id}/scale/horizontal", + get(handlers::list_horizontal_scale).post(handlers::horizontal_scale), + ) + .route( + "/{id}/projects/{proj_id}/scale/horizontal/{tunnel_id}", + delete(handlers::teardown_horizontal_scale), + ) +} diff --git a/lynx/dashboard/server/src/scheduler.rs b/lynx/dashboard/server/src/scheduler.rs new file mode 100644 index 0000000..da51aa7 --- /dev/null +++ b/lynx/dashboard/server/src/scheduler.rs @@ -0,0 +1,381 @@ +use crate::{admin::handlers::rotation, crypto::cmd, state::AppState}; +use std::time::Duration; +use tokio::time::interval; +use uuid::Uuid; + +const GITHUB_REPO: &str = "Jaro-c/Lynx"; +const GITHUB_API_RELEASES: &str = "https://api.github.com/repos/Jaro-c/Lynx/releases"; + +const CHECK_INTERVAL_SECS: u64 = 3600; +const ROTATION_INTERVAL_DAYS: i64 = 90; + +/// Top-level scheduler: runs hourly GitHub release check + periodic cert/key rotation. +pub async fn run(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_releases(&state).await; + check_rotation(&state).await; + } +} + +// --------------------------------------------------------------------------- +// GitHub release check +// --------------------------------------------------------------------------- + +async fn check_releases(state: &AppState) { + let client = match reqwest::Client::builder() + .user_agent("lynx-dashboard/1.0") + .timeout(Duration::from_secs(30)) + .build() + { + Ok(c) => c, + Err(e) => { + tracing::warn!("scheduler: failed to build HTTP client: {e}"); + return; + } + }; + + let releases: Vec = match client + .get(GITHUB_API_RELEASES) + .send() + .await + .and_then(|r| r.error_for_status()) + .map(|r| r.json()) + { + Ok(f) => match f.await { + Ok(v) => v, + Err(e) => { + tracing::warn!("scheduler: failed to parse GitHub releases: {e}"); + return; + } + }, + Err(e) => { + tracing::warn!("scheduler: GitHub API request failed: {e}"); + return; + } + }; + + let latest_agent = releases + .iter() + .filter_map(|r| r["tag_name"].as_str()) + .filter(|t| t.starts_with("agent@")) + .map(|t| t.trim_start_matches("agent@")) + .next() + .map(|s| s.to_string()); + + let latest_dashboard = releases + .iter() + .filter_map(|r| r["tag_name"].as_str()) + .filter(|t| t.starts_with("dashboard@")) + .map(|t| t.trim_start_matches("dashboard@")) + .next() + .map(|s| s.to_string()); + + if let Some(ref ver) = latest_agent { + tracing::info!(version = %ver, "scheduler: latest agent release detected"); + *state.latest_agent_version.write().await = Some(ver.clone()); + dispatch_updates_if_needed(state, ver).await; + } + + if let Some(ref ver) = latest_dashboard { + let current = env!("CARGO_PKG_VERSION"); + if ver.as_str() != current { + tracing::info!(latest = %ver, current, "scheduler: dashboard update available"); + trigger_dashboard_update(state, ver).await; + } + } +} + +async fn dispatch_updates_if_needed(state: &AppState, latest: &str) { + let outdated = match sqlx::query!( + "SELECT id, wg_ip::text AS wg_ip, api_port FROM agents \ + WHERE status = 'online' AND (version IS NULL OR version != $1)", + latest + ) + .fetch_all(&state.db) + .await + { + Ok(rows) => rows, + Err(e) => { + tracing::warn!("scheduler: failed to query outdated agents: {e}"); + return; + } + }; + + if outdated.is_empty() { + return; + } + + tracing::info!( + count = outdated.len(), + version = %latest, + "scheduler: dispatching update.self to outdated agents" + ); + + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(30)) + .build() + .expect("build reqwest client"); + + // Use a system user_id sentinel (nil UUID) for scheduler-triggered commands. + let system_user_id = Uuid::nil(); + + for agent in &outdated { + let download_url = format!( + "https://github.com/{GITHUB_REPO}/releases/download/agent@{latest}/lynx-agent-linux-x86_64" + ); + let sig_url = format!( + "https://github.com/{GITHUB_REPO}/releases/download/agent@{latest}/lynx-agent-linux-x86_64.sig" + ); + let command = serde_json::json!({ + "type": "update.self", + "version": latest, + "download_url": download_url, + "sig_url": sig_url, + }); + + let signed = + match cmd::sign_command(&state.config, agent.id, system_user_id, "write", &command) { + Ok(s) => s, + Err(e) => { + tracing::warn!(agent_id = %agent.id, "scheduler: sign_command failed: {e}"); + continue; + } + }; + + let url = format!("http://{}:{}/cmd", agent.wg_ip, agent.api_port); + let result = client + .post(&url) + .header( + "Authorization", + format!("Bearer {}", &*state.config.internal_token), + ) + .json(&signed) + .send() + .await; + + let log_id = Uuid::now_v7(); + let status = match result { + Ok(r) if r.status().is_success() => "success", + _ => "failed", + }; + + let _ = sqlx::query!( + r#" + INSERT INTO update_log (id, triggered_by, version, channel, scope, agent_id, status) + VALUES ($1, NULL, $2, 'stable', 'agent', $3, $4) + "#, + log_id, + latest, + agent.id, + status, + ) + .execute(&state.db) + .await; + + tracing::info!(agent_id = %agent.id, status, "scheduler: update.self dispatched"); + } +} + +async fn trigger_dashboard_update(state: &AppState, version: &str) { + let github_repo = "Jaro-c/Lynx"; + let backend_url = format!( + "https://github.com/{github_repo}/releases/download/dashboard@{version}/lynx-dashboard-backend-linux-x86_64" + ); + let backend_sig = format!("{backend_url}.sig"); + let frontend_url = format!( + "https://github.com/{github_repo}/releases/download/dashboard@{version}/lynx-dashboard-frontend-linux-x86_64" + ); + let frontend_sig = format!("{frontend_url}.sig"); + + let log_id = Uuid::now_v7(); + let _ = sqlx::query!( + r#" + INSERT INTO update_log (id, triggered_by, version, channel, scope, agent_id, status) + VALUES ($1, NULL, $2, 'stable', 'dashboard', NULL, 'pending') + "#, + log_id, + version, + ) + .execute(&state.db) + .await; + + tracing::info!( + version, + backend_url = %backend_url, + backend_sig = %backend_sig, + frontend_url = %frontend_url, + frontend_sig = %frontend_sig, + "scheduler: dashboard self-update initiated" + ); + + // Actual binary swap happens in crate::update (agent-side pattern). + // For dashboard: download + verify + swap is triggered here, restart handled by Podman. + tokio::spawn(crate::update::perform_dashboard_update( + version.to_string(), + backend_url, + backend_sig, + frontend_url, + frontend_sig, + log_id, + state.db.clone(), + )); +} + +// --------------------------------------------------------------------------- +// Scheduled key / cert rotation (every 90 days) +// --------------------------------------------------------------------------- + +/// Returns true if a scheduled rotation should run. +/// +/// Both 'scheduled' and 'update' entries reset the 90-day clock. This +/// prevents a double JWT rotation when an update-triggered rotation and the +/// 90-day timer both fire in the same scheduler cycle. +pub(crate) async fn needs_scheduled_rotation(db: &sqlx::PgPool) -> bool { + let last = sqlx::query_scalar!( + "SELECT MAX(created_at) FROM rotation_log WHERE reason IN ('scheduled', 'update')" + ) + .fetch_one(db) + .await + .unwrap_or(None); + + match last { + None => true, + Some(ts) => (chrono::Utc::now() - ts).num_days() >= ROTATION_INTERVAL_DAYS, + } +} + +async fn check_rotation(state: &AppState) { + if !needs_scheduled_rotation(&state.db).await { + return; + } + + tracing::info!("scheduler: 90-day key rotation triggered"); + + // JWT session flush — forces all users to re-login with new tokens. + if let Err(e) = rotation::rotate_jwt_sessions(state).await { + tracing::warn!("scheduler: JWT session rotation failed: {e}"); + } + + // WireGuard PSK rotation — coordinated without dropping tunnels. + if let Err(e) = rotation::rotate_wireguard_psks(state, Uuid::nil()).await { + tracing::warn!("scheduler: WireGuard PSK rotation failed: {e}"); + } + + // mTLS cert rotation — renews certs expiring within 30 days. + if let Err(e) = rotation::rotate_expiring_certs(state, 30).await { + tracing::warn!("scheduler: cert rotation failed: {e}"); + } + + // PostgreSQL app-user password rotation. + if let Err(e) = rotation::rotate_pg_app_password(state).await { + tracing::warn!("scheduler: PostgreSQL password rotation failed: {e}"); + } + + // Redis password rotation. + if let Err(e) = rotation::rotate_redis_password(state).await { + tracing::warn!("scheduler: Redis password rotation failed: {e}"); + } + + let log_id = Uuid::now_v7(); + let _ = sqlx::query!( + "INSERT INTO rotation_log (id, triggered_by, reason, scope) VALUES ($1, NULL, 'scheduled', 'all')", + log_id + ) + .execute(&state.db) + .await; + + tracing::info!("scheduler: scheduled rotation complete"); +} + +// --------------------------------------------------------------------------- +// Unit tests for needs_scheduled_rotation +// +// These tests run against the shared integration DB (DATABASE_URL). Only +// "expect false" cases are tested inline because inserting a recent entry +// makes needs_scheduled_rotation deterministically false regardless of other +// concurrent test entries. "Expect true" cases (empty table, old entries +// only) require an isolated DB and are covered by the HTTP-level tests in +// tests/rotation.rs. +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + async fn test_pool() -> sqlx::PgPool { + let url = std::env::var("DATABASE_URL").unwrap_or_else(|_| { + "postgresql://lynx:lynx_dev@localhost:5433/lynx_dashboard".to_string() + }); + let pool = sqlx::PgPool::connect(&url) + .await + .expect("connect to test DB"); + sqlx::migrate!("./migrations") + .run(&pool) + .await + .expect("migrate test DB"); + pool + } + + // Recent 'update' entry → needs_scheduled_rotation returns false. + // This is the core bug fix: update rotations must suppress the 90-day + // scheduled rotation so JWT keys are not rotated twice in one cycle. + #[tokio::test] + async fn recent_update_suppresses_scheduled_rotation() { + let pool = test_pool().await; + let id = Uuid::now_v7(); + + sqlx::query!( + "INSERT INTO rotation_log (id, triggered_by, reason, scope) \ + VALUES ($1, NULL, 'update', 'all')", + id, + ) + .execute(&pool) + .await + .unwrap(); + + let result = needs_scheduled_rotation(&pool).await; + + let _ = sqlx::query!("DELETE FROM rotation_log WHERE id = $1", id) + .execute(&pool) + .await; + + assert!( + !result, + "recent 'update' entry must suppress the scheduled rotation (prevents double JWT rotation in same cycle)" + ); + } + + // Recent 'scheduled' entry → needs_scheduled_rotation returns false. + // Idempotency: calling the scheduler twice in one 90-day window only + // rotates once. + #[tokio::test] + async fn recent_scheduled_suppresses_next_scheduled() { + let pool = test_pool().await; + let id = Uuid::now_v7(); + + sqlx::query!( + "INSERT INTO rotation_log (id, triggered_by, reason, scope) \ + VALUES ($1, NULL, 'scheduled', 'all')", + id, + ) + .execute(&pool) + .await + .unwrap(); + + let result = needs_scheduled_rotation(&pool).await; + + let _ = sqlx::query!("DELETE FROM rotation_log WHERE id = $1", id) + .execute(&pool) + .await; + + assert!( + !result, + "recent 'scheduled' entry must suppress the next scheduled rotation (90-day idempotency)" + ); + } +} diff --git a/lynx/dashboard/server/src/state.rs b/lynx/dashboard/server/src/state.rs new file mode 100644 index 0000000..63e6c9f --- /dev/null +++ b/lynx/dashboard/server/src/state.rs @@ -0,0 +1,34 @@ +use crate::config::Config; +use redis::aio::ConnectionManager; +use sqlx::PgPool; +use std::{collections::HashMap, sync::Arc}; +use tokio::sync::{broadcast, oneshot, RwLock}; +use uuid::Uuid; +use zeroize::Zeroizing; + +/// Live WebSocket connection from a connected agent. +pub struct AgentWsConn { + /// Send outbound messages (text frames) to the agent. + pub sender: tokio::sync::mpsc::UnboundedSender, + /// Pending command responses keyed by request UUID. + pub pending: Arc>>>, +} + +#[derive(Clone)] +pub struct AppState { + pub db: PgPool, + pub redis: ConnectionManager, + pub config: Arc, + /// Latest agent version known from GitHub, refreshed hourly. + pub latest_agent_version: Arc>>, + /// Per-agent WireGuard PSKs in memory (agent_id → base64 PSK). + /// Loaded at startup from Podman secret files; updated when agents register/rotate. + pub wg_psks: Arc>>>, + /// Active WebSocket connections from agents (agent_id → connection). + pub agent_ws_conns: Arc>>>, + /// Per-agent broadcast channels for real-time metric fan-out to frontend WS clients. + /// Keyed by agent_id. Channels are created on agent connect, dropped on disconnect. + pub agent_metric_tx: Arc>>>>, + /// Global broadcast channel for agent events pushed to all subscribed admin browser sessions. + pub events_tx: Arc>>, +} diff --git a/lynx/dashboard/server/src/update.rs b/lynx/dashboard/server/src/update.rs new file mode 100644 index 0000000..0ac830d --- /dev/null +++ b/lynx/dashboard/server/src/update.rs @@ -0,0 +1,287 @@ +use anyhow::{Context, Result}; +use ed25519_dalek::{Signature, Verifier, VerifyingKey}; +use std::net::IpAddr; +use std::path::PathBuf; +use uuid::Uuid; + +const BINARY_PATH: &str = "/etc/lynx/bin/lynx-dashboard-backend"; +const MAX_DOWNLOAD_BYTES: usize = 200 * 1024 * 1024; + +pub async fn perform_dashboard_update( + version: String, + backend_url: String, + backend_sig_url: String, + _frontend_url: String, + _frontend_sig_url: String, + log_id: Uuid, + db: sqlx::PgPool, +) { + let result = do_backend_swap(&version, &backend_url, &backend_sig_url).await; + + let status = match result { + Ok(()) => "success", + Err(ref e) => { + tracing::error!(version, "dashboard self-update failed: {e:#}"); + "failed" + } + }; + + let _ = sqlx::query!( + "UPDATE update_log SET status = $1 WHERE id = $2", + status, + log_id + ) + .execute(&db) + .await; + + if result.is_ok() { + tracing::info!( + version, + "dashboard backend swap complete — exiting for Podman restart" + ); + std::process::exit(0); + } +} + +async fn do_backend_swap(version: &str, url: &str, sig_url: &str) -> Result<()> { + tracing::info!(version, "downloading dashboard backend binary"); + + validate_github_url(url)?; + validate_github_url(sig_url)?; + + let binary = download_bytes(url).await.context("download binary")?; + let sig = download_bytes(sig_url) + .await + .context("download signature")?; + + verify_signature(&binary, &sig).context("signature verification failed — update aborted")?; + tracing::info!(version, bytes = binary.len(), "signature verified"); + + let target = PathBuf::from(BINARY_PATH); + let prev = PathBuf::from(format!("{BINARY_PATH}.prev")); + let tmp = PathBuf::from(format!("{BINARY_PATH}.new")); + + // Write new binary to temp path + std::fs::write(&tmp, &binary).context("write new binary to .new")?; + + #[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 + if target.exists() { + std::fs::copy(&target, &prev).context("backup current binary to .prev")?; + } + + // Atomic swap + std::fs::rename(&tmp, &target).context("atomic rename .new → binary")?; + + Ok(()) +} + +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}") + } +} + +/// Resolve the hostname in `url` once, validate all returned IPs against +/// RFC1918/loopback/link-local ranges (SSRF prevention), and return a +/// reqwest Client pre-configured to connect to the first valid resolved IP. +/// This prevents TOCTOU: we resolve once and pin to that IP — no second DNS lookup. +async fn build_ssrf_safe_client(url: &str) -> Result { + let parsed = url::Url::parse(url).with_context(|| format!("parse URL {url}"))?; + let host = parsed + .host_str() + .ok_or_else(|| anyhow::anyhow!("no host in URL {url}"))?; + let port = parsed.port_or_known_default().unwrap_or(443); + + let addrs: Vec = tokio::net::lookup_host(format!("{host}:{port}")) + .await + .with_context(|| format!("DNS lookup for {host}"))? + .map(|s| s.ip()) + .collect(); + + if addrs.is_empty() { + anyhow::bail!("DNS lookup returned no addresses for {host}"); + } + + for ip in &addrs { + if is_blocked_ip(ip) { + anyhow::bail!("DNS resolved {host} to blocked IP {ip} — SSRF check failed"); + } + } + + // Build client with pinned resolver to avoid second DNS lookup (TOCTOU). + let pinned_ip = addrs[0]; + let client_builder = reqwest::Client::builder() + .user_agent(format!("lynx-dashboard/{}", env!("CARGO_PKG_VERSION"))) + .timeout(std::time::Duration::from_secs(300)) + .resolve(host, std::net::SocketAddr::new(pinned_ip, port)); + + client_builder + .build() + .context("build SSRF-safe HTTP client") +} + +fn is_blocked_ip(ip: &IpAddr) -> bool { + match ip { + IpAddr::V4(v4) => { + let octets = v4.octets(); + // 10.0.0.0/8 + if octets[0] == 10 { + return true; + } + // 172.16.0.0/12 + if octets[0] == 172 && (octets[1] & 0xF0) == 16 { + return true; + } + // 192.168.0.0/16 + if octets[0] == 192 && octets[1] == 168 { + return true; + } + // 127.0.0.0/8 loopback + if octets[0] == 127 { + return true; + } + // 169.254.0.0/16 link-local + if octets[0] == 169 && octets[1] == 254 { + return true; + } + false + } + IpAddr::V6(v6) => { + // ::1 loopback + if v6.is_loopback() { + return true; + } + let segs = v6.segments(); + // fc00::/7 unique local + if (segs[0] & 0xFE00) == 0xFC00 { + return true; + } + // fe80::/10 link-local + if (segs[0] & 0xFFC0) == 0xFE80 { + return true; + } + false + } + } +} + +async fn download_bytes(url: &str) -> Result> { + let client = build_ssrf_safe_client(url).await?; + + let resp = client + .get(url) + .send() + .await + .with_context(|| format!("GET {url}"))? + .error_for_status() + .with_context(|| format!("HTTP error for {url}"))?; + + if let Some(len) = resp.content_length() { + if len as usize > MAX_DOWNLOAD_BYTES { + anyhow::bail!("Content-Length {len} exceeds safety limit"); + } + } + + let bytes = resp.bytes().await.context("read response body")?; + if bytes.len() > MAX_DOWNLOAD_BYTES { + anyhow::bail!("download exceeded safety limit"); + } + Ok(bytes.to_vec()) +} + +/// Called at startup to detect a failed update and restore `.prev` if needed. +/// Spawns a background task: polls `/health` every 2s for 30s. +/// If still unhealthy → restores `.prev`, writes `/etc/lynx/CRITICAL`, exits. +pub fn spawn_startup_health_guard() { + const CRITICAL_FILE: &str = "/etc/lynx/CRITICAL"; + + 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:8080/health") + .send() + .await + .map(|r| r.status().is_success()) + .unwrap_or(false) + { + return; // healthy — nothing to do + } + } + + // Still unhealthy after 30s — restore .prev + tracing::error!("startup health check failed — restoring .prev binary"); + let target = PathBuf::from(BINARY_PATH); + let prev = PathBuf::from(format!("{BINARY_PATH}.prev")); + + let restore_ok = if prev.exists() { + std::fs::copy(&prev, &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-dashboard-backend\nreason={reason}\n"), + ); + + tracing::error!(reason, "critical state — exiting"); + std::process::exit(1); + }); +} + +fn verify_signature(binary: &[u8], sig_bytes: &[u8]) -> Result<()> { + let key_bytes = load_release_verify_key()?; + let key = VerifyingKey::from_bytes(&key_bytes).context("parse release 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") +} + +fn load_release_verify_key() -> Result<[u8; 32]> { + use base64ct::{Base64, Encoding}; + let raw = if let Ok(path) = std::env::var("RELEASE_VERIFY_KEY_FILE") { + std::fs::read_to_string(&path) + .with_context(|| format!("read RELEASE_VERIFY_KEY_FILE={path}"))? + } else { + std::env::var("RELEASE_VERIFY_KEY") + .or_else(|_| std::env::var("DASHBOARD_VERIFY_KEY")) + .context("RELEASE_VERIFY_KEY not configured")? + }; + let bytes = Base64::decode_vec(raw.trim()).context("base64 decode release verify key")?; + bytes + .try_into() + .map_err(|_| anyhow::anyhow!("release verify key must be 32 bytes")) +} diff --git a/lynx/dashboard/server/tests/admin.rs b/lynx/dashboard/server/tests/admin.rs new file mode 100644 index 0000000..4cdfb40 --- /dev/null +++ b/lynx/dashboard/server/tests/admin.rs @@ -0,0 +1,455 @@ +mod helpers; + +use serde_json::{json, Value}; + +fn unique_username() -> String { + let id = uuid::Uuid::now_v7().simple().to_string(); + format!("t{}", &id[..16]) +} + +fn unique_ip() -> String { + let id = uuid::Uuid::now_v7(); + let b = id.as_bytes(); + format!("{}.{}.{}.{}", b[8], b[9], b[10], b[11]) +} + +/// Login as the pre-seeded testadmin. Returns (access_token, refresh_token, ip). +async fn admin_login(server: &axum_test::TestServer) -> (String, String, String) { + let ip = unique_ip(); + let res = server + .post("/auth/login") + .add_header("x-real-ip", &ip) + .json(&json!({ "username": "testadmin", "password": "AdminP@ss12!" })) + .await; + res.assert_status_ok(); + let body: Value = res.json(); + ( + body["access_token"].as_str().unwrap().to_string(), + body["refresh_token"].as_str().unwrap().to_string(), + ip, + ) +} + +/// Register a new user and return (username, password, user_id). +async fn register_and_get_id(server: &axum_test::TestServer) -> (String, String, String) { + let username = unique_username(); + let password = "ValidP@ss12!"; + + server + .post("/auth/register") + .add_header("x-real-ip", &unique_ip()) + .json(&json!({ + "username": username, + "email": format!("{}@example.com", username), + "password": password, + "setup_token": "test-setup-token", + })) + .await; + + // Login to get access token and then /auth/me for user_id + let res = server + .post("/auth/login") + .add_header("x-real-ip", &unique_ip()) + .json(&json!({ "username": username, "password": password })) + .await; + res.assert_status_ok(); + let token = res.json::()["access_token"] + .as_str() + .unwrap() + .to_string(); + + // /auth/me is on the public router — no IP check, no require_auth + let me = server + .get("/auth/me") + .add_header("authorization", format!("Bearer {}", token)) + .await; + me.assert_status_ok(); + let user_id = me.json::()["id"].as_str().unwrap().to_string(); + + (username, password.to_string(), user_id) +} + +// --------------------------------------------------------------------------- +// GET /admin/users — list users +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn admin_list_users_returns_array() { + let server = helpers::test_server().await; + let (admin_token, _, admin_ip) = admin_login(&server).await; + + let res = server + .get("/admin/users") + .add_header("x-real-ip", &admin_ip) + .add_header("authorization", format!("Bearer {}", admin_token)) + .await; + + res.assert_status_ok(); + let body: Value = res.json(); + assert!( + body.is_array(), + "list users must return an array; got {body}" + ); +} + +#[tokio::test] +async fn admin_list_users_requires_admin() { + let server = helpers::test_server().await; + + // Regular user login — capture IP for subsequent require_auth calls + let username = unique_username(); + server + .post("/auth/register") + .add_header("x-real-ip", &unique_ip()) + .json(&json!({ + "username": username, + "email": format!("{}@example.com", username), + "password": "ValidP@ss12!", + })) + .await; + let user_ip = unique_ip(); + let res = server + .post("/auth/login") + .add_header("x-real-ip", &user_ip) + .json(&json!({ "username": username, "password": "ValidP@ss12!" })) + .await; + let token = res.json::()["access_token"] + .as_str() + .unwrap() + .to_string(); + + let res = server + .get("/admin/users") + .add_header("x-real-ip", &user_ip) + .add_header("authorization", format!("Bearer {}", token)) + .await; + + assert_eq!( + res.status_code().as_u16(), + 403, + "/admin/users must require admin; got {}", + res.status_code() + ); +} + +// --------------------------------------------------------------------------- +// GET /admin/permissions and GET /admin/roles +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn admin_list_permissions_includes_star_star() { + let server = helpers::test_server().await; + let (admin_token, _, admin_ip) = admin_login(&server).await; + + let res = server + .get("/admin/permissions") + .add_header("x-real-ip", &admin_ip) + .add_header("authorization", format!("Bearer {}", admin_token)) + .await; + + res.assert_status_ok(); + let body: Value = res.json(); + assert!(body.is_array(), "permissions must be an array"); + let perms: Vec<&str> = body + .as_array() + .unwrap() + .iter() + .filter_map(|p| p["key"].as_str()) + .collect(); + assert!( + perms.contains(&"*:*"), + "permissions must include *:*; got {perms:?}" + ); +} + +#[tokio::test] +async fn admin_list_roles_returns_array() { + let server = helpers::test_server().await; + let (admin_token, _, admin_ip) = admin_login(&server).await; + + let res = server + .get("/admin/roles") + .add_header("x-real-ip", &admin_ip) + .add_header("authorization", format!("Bearer {}", admin_token)) + .await; + + res.assert_status_ok(); + let body: Value = res.json(); + assert!(body.is_array(), "roles must be an array"); +} + +// --------------------------------------------------------------------------- +// POST /admin/roles — create role, assign permission, assign to user +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn admin_create_role_and_assign_to_user() { + let server = helpers::test_server().await; + let (admin_token, _, admin_ip) = admin_login(&server).await; + let (_, _, user_id) = register_and_get_id(&server).await; + + // Create a new role + let role_name = format!( + "testrole-{}", + &uuid::Uuid::now_v7().simple().to_string()[..16] + ); + let create_res = server + .post("/admin/roles") + .add_header("x-real-ip", &admin_ip) + .add_header("authorization", format!("Bearer {}", admin_token)) + .json(&json!({ "name": role_name })) + .await; + assert!( + create_res.status_code().is_success(), + "create role must succeed; got {} — {}", + create_res.status_code(), + create_res.text() + ); + + // Fetch role id from roles list + let roles: Value = server + .get("/admin/roles") + .add_header("x-real-ip", &admin_ip) + .add_header("authorization", format!("Bearer {}", admin_token)) + .await + .json(); + let role_id = roles + .as_array() + .unwrap() + .iter() + .find(|r| r["name"].as_str() == Some(&role_name)) + .and_then(|r| r["id"].as_str()) + .expect("newly created role must appear in list"); + + // Fetch vps:read permission id + let perms: Value = server + .get("/admin/permissions") + .add_header("x-real-ip", &admin_ip) + .add_header("authorization", format!("Bearer {}", admin_token)) + .await + .json(); + let perm_id = perms + .as_array() + .unwrap() + .iter() + .find(|p| p["key"].as_str() == Some("vps:read")) + .and_then(|p| p["id"].as_str()) + .expect("vps:read permission must exist"); + + // Assign permission to role + let assign_perm_res = server + .post(&format!("/admin/roles/{}/permissions/{}", role_id, perm_id)) + .add_header("x-real-ip", &admin_ip) + .add_header("authorization", format!("Bearer {}", admin_token)) + .await; + assert!( + assign_perm_res.status_code().is_success(), + "assign permission to role must succeed; got {}", + assign_perm_res.status_code() + ); + + // Assign role to user + let assign_role_res = server + .post(&format!("/admin/users/{}/roles/{}", user_id, role_id)) + .add_header("x-real-ip", &admin_ip) + .add_header("authorization", format!("Bearer {}", admin_token)) + .await; + assert!( + assign_role_res.status_code().is_success(), + "assign role to user must succeed; got {}", + assign_role_res.status_code() + ); +} + +#[tokio::test] +async fn admin_create_duplicate_role_rejected() { + let server = helpers::test_server().await; + let (admin_token, _, admin_ip) = admin_login(&server).await; + + let role_name = format!("dup-{}", &uuid::Uuid::now_v7().simple().to_string()[..16]); + + server + .post("/admin/roles") + .add_header("x-real-ip", &admin_ip) + .add_header("authorization", format!("Bearer {}", admin_token)) + .json(&json!({ "name": role_name })) + .await; + + let dup_res = server + .post("/admin/roles") + .add_header("x-real-ip", &admin_ip) + .add_header("authorization", format!("Bearer {}", admin_token)) + .json(&json!({ "name": role_name })) + .await; + + assert!( + dup_res.status_code().is_client_error(), + "duplicate role name must be rejected; got {}", + dup_res.status_code() + ); +} + +// --------------------------------------------------------------------------- +// POST /admin/users/:id/force-password-change — block protected routes +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn admin_force_password_change_blocks_protected_routes() { + let server = helpers::test_server().await; + let (admin_token, _, admin_ip) = admin_login(&server).await; + let (username, password, user_id) = register_and_get_id(&server).await; + + // Get the user's access token — capture IP for subsequent protected calls + let user_ip = unique_ip(); + let (user_token, _) = { + let res = server + .post("/auth/login") + .add_header("x-real-ip", &user_ip) + .json(&json!({ "username": username, "password": password })) + .await; + res.assert_status_ok(); + let body: Value = res.json(); + ( + body["access_token"].as_str().unwrap().to_string(), + body["refresh_token"].as_str().unwrap().to_string(), + ) + }; + + // Admin forces password change + let res = server + .post(&format!("/admin/users/{}/force-password-change", user_id)) + .add_header("x-real-ip", &admin_ip) + .add_header("authorization", format!("Bearer {}", admin_token)) + .await; + assert!( + res.status_code().is_success(), + "admin force_password_change must succeed; got {}", + res.status_code() + ); + + // User's protected requests must now return 403 force_password_change_required + let blocked = server + .get("/agents") + .add_header("x-real-ip", &user_ip) + .add_header("authorization", format!("Bearer {}", user_token)) + .await; + assert_eq!(blocked.status_code().as_u16(), 403); + assert_eq!( + blocked.json::()["error"].as_str(), + Some("force_password_change_required") + ); +} + +// --------------------------------------------------------------------------- +// DELETE /admin/users/:id/sessions — revoke one user's sessions +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn admin_revoke_user_sessions_invalidates_tokens() { + let server = helpers::test_server().await; + let (admin_token, _, admin_ip) = admin_login(&server).await; + let (username, password, user_id) = register_and_get_id(&server).await; + + let user_ip = unique_ip(); + let user_res = server + .post("/auth/login") + .add_header("x-real-ip", &user_ip) + .json(&json!({ "username": username, "password": password })) + .await; + let user_token = user_res.json::()["access_token"] + .as_str() + .unwrap() + .to_string(); + + // Admin revokes all sessions for the user + let revoke_res = server + .delete(&format!("/admin/users/{}/sessions", user_id)) + .add_header("x-real-ip", &admin_ip) + .add_header("authorization", format!("Bearer {}", admin_token)) + .await; + assert!( + revoke_res.status_code().is_success(), + "admin revoke sessions must succeed; got {}", + revoke_res.status_code() + ); + + // User token must now be invalid — JTI was removed from Redis + // (IP doesn't matter here since JTI check fires first in require_auth) + let me_res = server + .get("/auth/me/preferences") + .add_header("x-real-ip", &user_ip) + .add_header("authorization", format!("Bearer {}", user_token)) + .await; + assert_eq!( + me_res.status_code().as_u16(), + 401, + "revoked token must return 401; got {}", + me_res.status_code() + ); +} + +// --------------------------------------------------------------------------- +// POST /admin/users/force-password-change-all — blocks all users +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn force_password_change_all_blocks_existing_sessions() { + let server = helpers::test_server().await; + let (admin_token, _, admin_ip) = admin_login(&server).await; + let (username, password, _) = register_and_get_id(&server).await; + + let user_ip = unique_ip(); + let user_res = server + .post("/auth/login") + .add_header("x-real-ip", &user_ip) + .json(&json!({ "username": username, "password": password })) + .await; + let user_token = user_res.json::()["access_token"] + .as_str() + .unwrap() + .to_string(); + + // Flag all users (including this one) + server + .post("/admin/users/force-password-change-all") + .add_header("x-real-ip", &admin_ip) + .add_header("authorization", format!("Bearer {}", admin_token)) + .await; + + // User's protected route must return 403 force_password_change_required + let res = server + .get("/agents") + .add_header("x-real-ip", &user_ip) + .add_header("authorization", format!("Bearer {}", user_token)) + .await; + assert_eq!( + res.status_code().as_u16(), + 403, + "force_password_change_all must block user's protected routes; got {}", + res.status_code() + ); +} + +// --------------------------------------------------------------------------- +// GET /admin/sessions — list own sessions, DELETE /admin/sessions/:id +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn admin_list_own_sessions_returns_current() { + let server = helpers::test_server().await; + let (admin_token, _, admin_ip) = admin_login(&server).await; + + let res = server + .get("/admin/sessions") + .add_header("x-real-ip", &admin_ip) + .add_header("authorization", format!("Bearer {}", admin_token)) + .await; + + res.assert_status_ok(); + let body: Value = res.json(); + assert!(body.is_array(), "sessions must be an array"); + assert!( + !body.as_array().unwrap().is_empty(), + "must have at least one active session" + ); +} diff --git a/lynx/dashboard/server/tests/auth.rs b/lynx/dashboard/server/tests/auth.rs new file mode 100644 index 0000000..58bd92c --- /dev/null +++ b/lynx/dashboard/server/tests/auth.rs @@ -0,0 +1,834 @@ +mod helpers; + +use futures_util::future::join_all; +use serde_json::{json, Value}; +use std::sync::Arc; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +fn unique_username() -> String { + // Use first 16 hex chars of UUID v7 — unique enough, within 32-char max. + let id = uuid::Uuid::now_v7().simple().to_string(); + format!("t{}", &id[..16]) +} + +/// Generate a unique fake IP so each test invocation has its own rate-limit bucket. +/// Uses random bytes from UUID v7 (bytes 8-11 are fully random, not timestamp). +fn unique_ip() -> String { + let id = uuid::Uuid::now_v7(); + let b = id.as_bytes(); + // bytes[8..12] are the random node bits — avoid timestamp-prefix collisions. + format!("{}.{}.{}.{}", b[8], b[9], b[10], b[11]) +} + +/// Register a fresh user and return (username, password). +async fn register_user(server: &axum_test::TestServer) -> (String, String) { + let username = unique_username(); + let password = "ValidP@ss12!"; + let ip = unique_ip(); + + let res = server + .post("/auth/register") + .add_header("x-real-ip", &ip) + .json(&json!({ + "username": username, + "email": format!("{}@example.com", username), + "password": password, + // Always included — ignored once an admin exists, required during bootstrap. + "setup_token": "test-setup-token", + })) + .await; + + assert!( + res.status_code().is_success(), + "register helper failed: {} — {}", + res.status_code(), + res.text() + ); + + (username, password.to_string()) +} + +/// Login and return (access_token, refresh_token, ip). +async fn login_user( + server: &axum_test::TestServer, + username: &str, + password: &str, +) -> (String, String, String) { + let ip = unique_ip(); + let res = server + .post("/auth/login") + .add_header("x-real-ip", &ip) + .json(&json!({ "username": username, "password": password })) + .await; + res.assert_status_ok(); + let body: Value = res.json(); + ( + body["access_token"].as_str().unwrap().to_string(), + body["refresh_token"].as_str().unwrap().to_string(), + ip, + ) +} + +// --------------------------------------------------------------------------- +// POST /auth/register +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn register_valid_creates_user() { + let server = helpers::test_server().await; + let (username, _) = register_user(&server).await; + // If we get here without panic, registration succeeded. + assert!(!username.is_empty()); +} + +#[tokio::test] +async fn register_duplicate_username_rejected() { + let server = helpers::test_server().await; + let username = unique_username(); + let email = format!("{}@example.com", username); + + server + .post("/auth/register") + .add_header("x-real-ip", &unique_ip()) + .json(&json!({ + "username": username, + "email": email, + "password": "ValidPass1!", + })) + .await; + + let res = server + .post("/auth/register") + .add_header("x-real-ip", &unique_ip()) + .json(&json!({ + "username": username, + "email": format!("other{}@example.com", username), + "password": "ValidPass1!", + })) + .await; + + assert!( + res.status_code().is_client_error(), + "duplicate username must fail; got {}", + res.status_code() + ); +} + +#[tokio::test] +async fn register_weak_password_rejected() { + let server = helpers::test_server().await; + let username = unique_username(); + + let res = server + .post("/auth/register") + .add_header("x-real-ip", &unique_ip()) + .json(&json!({ + "username": username, + "email": format!("{}@example.com", username), + "password": "weak", + })) + .await; + + assert!( + res.status_code().is_client_error(), + "weak password must be rejected; got {}", + res.status_code() + ); +} + +#[tokio::test] +async fn register_short_username_rejected() { + let server = helpers::test_server().await; + + let res = server + .post("/auth/register") + .add_header("x-real-ip", &unique_ip()) + .json(&json!({ + "username": "ab", + "email": "ab@example.com", + "password": "ValidPass1!", + })) + .await; + + assert!( + res.status_code().is_client_error(), + "short username must be rejected; got {}", + res.status_code() + ); +} + +#[tokio::test] +async fn register_reserved_username_rejected() { + let server = helpers::test_server().await; + + let res = server + .post("/auth/register") + .add_header("x-real-ip", &unique_ip()) + .json(&json!({ + "username": "admin", + "email": "admin@example.com", + "password": "ValidPass1!", + })) + .await; + + assert!( + res.status_code().is_client_error(), + "reserved username must be rejected; got {}", + res.status_code() + ); +} + +// --------------------------------------------------------------------------- +// POST /auth/login +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn login_correct_credentials_returns_tokens() { + let server = helpers::test_server().await; + let (username, password) = register_user(&server).await; + + let res = server + .post("/auth/login") + .add_header("x-real-ip", &unique_ip()) + .json(&json!({ "username": username, "password": password })) + .await; + + res.assert_status_ok(); + let body: Value = res.json(); + assert!(body["access_token"].is_string(), "must have access_token"); + assert!(body["refresh_token"].is_string(), "must have refresh_token"); + assert!(body["expires_in"].is_number(), "must have expires_in"); +} + +#[tokio::test] +async fn login_wrong_password_rejected() { + let server = helpers::test_server().await; + let (username, _) = register_user(&server).await; + + let res = server + .post("/auth/login") + .add_header("x-real-ip", &unique_ip()) + .json(&json!({ "username": username, "password": "WrongPass1!" })) + .await; + + assert_eq!( + res.status_code().as_u16(), + 401, + "wrong password must return 401" + ); +} + +#[tokio::test] +async fn login_nonexistent_user_same_error_as_wrong_password() { + let server = helpers::test_server().await; + + let ip1 = unique_ip(); + let ip2 = unique_ip(); + + let res_nonexistent = server + .post("/auth/login") + .add_header("x-real-ip", &ip1) + .json(&json!({ "username": "doesnotexist_xyzxyz", "password": "ValidPass1!" })) + .await; + + let res_wrong_pw = server + .post("/auth/login") + .add_header("x-real-ip", &ip2) + .json(&json!({ "username": "doesnotexist_xyzxyz", "password": "AlsoWrong1!" })) + .await; + + // Both must return the same status — anti-enumeration. + assert_eq!( + res_nonexistent.status_code(), + res_wrong_pw.status_code(), + "user-not-found and wrong-password must return same status code" + ); +} + +// --------------------------------------------------------------------------- +// POST /auth/refresh +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn refresh_rotates_tokens() { + let server = helpers::test_server().await; + let (username, password) = register_user(&server).await; + let (_, refresh_token, _) = login_user(&server, &username, &password).await; + + let res = server + .post("/auth/refresh") + .add_header("x-real-ip", &unique_ip()) + .json(&json!({ "refresh_token": refresh_token })) + .await; + + res.assert_status_ok(); + let body: Value = res.json(); + assert!( + body["access_token"].is_string(), + "refresh must return access_token" + ); + assert!( + body["refresh_token"].is_string(), + "refresh must return new refresh_token" + ); + assert_ne!( + body["refresh_token"].as_str().unwrap(), + refresh_token.as_str(), + "refresh must rotate the token" + ); +} + +#[tokio::test] +async fn refresh_old_token_rejected_after_rotation() { + let server = helpers::test_server().await; + let (username, password) = register_user(&server).await; + let (_, original_refresh, _) = login_user(&server, &username, &password).await; + + let ip = unique_ip(); + + // Consume the refresh token once. + server + .post("/auth/refresh") + .add_header("x-real-ip", &ip) + .json(&json!({ "refresh_token": original_refresh })) + .await; + + // Replay the old refresh token — must be rejected. + let res = server + .post("/auth/refresh") + .add_header("x-real-ip", &ip) + .json(&json!({ "refresh_token": original_refresh })) + .await; + + assert_eq!( + res.status_code().as_u16(), + 401, + "replayed refresh token must return 401" + ); +} + +#[tokio::test] +async fn refresh_invalid_token_rejected() { + let server = helpers::test_server().await; + + let res = server + .post("/auth/refresh") + .add_header("x-real-ip", &unique_ip()) + .json(&json!({ "refresh_token": "not.a.real.token" })) + .await; + + assert!( + res.status_code().is_client_error(), + "invalid refresh token must be rejected; got {}", + res.status_code() + ); +} + +// --------------------------------------------------------------------------- +// POST /auth/logout +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn logout_invalidates_access_token() { + let server = helpers::test_server().await; + let (username, password) = register_user(&server).await; + let (access_token, _, _) = login_user(&server, &username, &password).await; + + // Logout. + let logout_res = server + .post("/auth/logout") + .add_header("x-real-ip", &unique_ip()) + .add_header("authorization", format!("Bearer {}", access_token)) + .await; + + assert!( + logout_res.status_code().is_success(), + "logout must succeed; got {}", + logout_res.status_code() + ); + + // The revoked access token must no longer work for /auth/me. + let me_res = server + .get("/auth/me") + .add_header("authorization", format!("Bearer {}", access_token)) + .await; + + assert_eq!( + me_res.status_code().as_u16(), + 401, + "revoked access token must return 401 on /auth/me" + ); +} + +// --------------------------------------------------------------------------- +// GET /auth/me +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn me_returns_user_info() { + let server = helpers::test_server().await; + let (username, password) = register_user(&server).await; + let (access_token, _, _) = login_user(&server, &username, &password).await; + + let res = server + .get("/auth/me") + .add_header("authorization", format!("Bearer {}", access_token)) + .await; + + res.assert_status_ok(); + let body: Value = res.json(); + assert_eq!( + body["username"].as_str().unwrap(), + username, + "me must return username" + ); + assert!(body["id"].is_string(), "me must return id"); +} + +#[tokio::test] +async fn me_without_token_returns_401() { + let server = helpers::test_server().await; + + let res = server.get("/auth/me").await; + + assert_eq!( + res.status_code().as_u16(), + 401, + "unauthenticated /me must return 401" + ); +} + +// --------------------------------------------------------------------------- +// GET /health +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn health_returns_ok() { + let server = helpers::test_server().await; + let res = server.get("/health").await; + res.assert_status_ok(); +} + +// --------------------------------------------------------------------------- +// force_password_change — backend enforcement (§5.6 test.md) +// --------------------------------------------------------------------------- + +/// Log in as the pre-seeded testadmin. Returns (access_token, refresh_token, ip). +async fn admin_login(server: &axum_test::TestServer) -> (String, String, String) { + login_user(server, "testadmin", "AdminP@ss12!").await +} + +#[tokio::test] +async fn force_password_change_blocks_protected_routes() { + let server = helpers::test_server().await; + let (username, password) = register_user(&server).await; + let (access_token, _, user_ip) = login_user(&server, &username, &password).await; + + // Get user id via /auth/me + let me = server + .get("/auth/me") + .add_header("authorization", format!("Bearer {}", access_token)) + .await; + me.assert_status_ok(); + let user_id = me.json::()["id"].as_str().unwrap().to_string(); + + // Admin sets force_password_change = true on this user + let (admin_token, _, admin_ip) = admin_login(&server).await; + let force_res = server + .post(&format!("/admin/users/{}/force-password-change", user_id)) + .add_header("x-real-ip", &admin_ip) + .add_header("authorization", format!("Bearer {}", admin_token)) + .await; + assert!( + force_res.status_code().is_success(), + "admin force_password_change must succeed; got {}", + force_res.status_code() + ); + + // Protected route must return 403 with force_password_change_required + let res = server + .get("/agents") + .add_header("x-real-ip", &user_ip) + .add_header("authorization", format!("Bearer {}", access_token)) + .await; + assert_eq!( + res.status_code().as_u16(), + 403, + "force_password_change must block /agents with 403; got {}", + res.status_code() + ); + let body: Value = res.json(); + assert_eq!( + body["error"].as_str(), + Some("force_password_change_required"), + "error code must be force_password_change_required" + ); +} + +#[tokio::test] +async fn force_password_change_allows_change_password_endpoint() { + let server = helpers::test_server().await; + let (username, password) = register_user(&server).await; + let (access_token, _, _) = login_user(&server, &username, &password).await; + + let me = server + .get("/auth/me") + .add_header("authorization", format!("Bearer {}", access_token)) + .await; + let user_id = me.json::()["id"].as_str().unwrap().to_string(); + + let (admin_token, _, admin_ip) = admin_login(&server).await; + server + .post(&format!("/admin/users/{}/force-password-change", user_id)) + .add_header("x-real-ip", &admin_ip) + .add_header("authorization", format!("Bearer {}", admin_token)) + .await; + + // /auth/change-password must NOT be blocked — it's the only bypass + let change_res = server + .post("/auth/change-password") + .add_header("authorization", format!("Bearer {}", access_token)) + .json(&json!({ + "current_password": password, + "new_password": "NewValidP@ss12!", + })) + .await; + assert_eq!( + change_res.status_code().as_u16(), + 204, + "change-password must succeed even with force_password_change; got {} — {}", + change_res.status_code(), + change_res.text() + ); +} + +#[tokio::test] +async fn force_password_change_cleared_after_change() { + let server = helpers::test_server().await; + let (username, password) = register_user(&server).await; + let (access_token, _, _) = login_user(&server, &username, &password).await; + + let me = server + .get("/auth/me") + .add_header("authorization", format!("Bearer {}", access_token)) + .await; + let user_id = me.json::()["id"].as_str().unwrap().to_string(); + + let (admin_token, _, admin_ip) = admin_login(&server).await; + server + .post(&format!("/admin/users/{}/force-password-change", user_id)) + .add_header("x-real-ip", &admin_ip) + .add_header("authorization", format!("Bearer {}", admin_token)) + .await; + + // Change password (clears flag + invalidates all sessions) + let new_password = "NewValidP@ss12!"; + server + .post("/auth/change-password") + .add_header("authorization", format!("Bearer {}", access_token)) + .json(&json!({ + "current_password": password, + "new_password": new_password, + })) + .await; + + // Login again with new password + let login_res = server + .post("/auth/login") + .add_header("x-real-ip", &unique_ip()) + .json(&json!({ "username": username, "password": new_password })) + .await; + login_res.assert_status_ok(); + let body: Value = login_res.json(); + assert_eq!( + body["force_password_change"].as_bool(), + Some(false), + "force_password_change must be false after password change" + ); +} + +// --------------------------------------------------------------------------- +// POST /auth/change-password +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn change_password_wrong_current_rejected() { + let server = helpers::test_server().await; + let (username, password) = register_user(&server).await; + let (access_token, _, _) = login_user(&server, &username, &password).await; + + let res = server + .post("/auth/change-password") + .add_header("authorization", format!("Bearer {}", access_token)) + .json(&json!({ + "current_password": "WrongCurrent1!", + "new_password": "NewValidP@ss12!", + })) + .await; + + assert_eq!( + res.status_code().as_u16(), + 401, + "wrong current password must be rejected with 401; got {}", + res.status_code() + ); +} + +#[tokio::test] +async fn change_password_weak_new_password_rejected() { + let server = helpers::test_server().await; + let (username, password) = register_user(&server).await; + let (access_token, _, _) = login_user(&server, &username, &password).await; + + let res = server + .post("/auth/change-password") + .add_header("authorization", format!("Bearer {}", access_token)) + .json(&json!({ + "current_password": password, + "new_password": "weak", + })) + .await; + + assert!( + res.status_code().is_client_error(), + "weak new password must be rejected; got {}", + res.status_code() + ); +} + +#[tokio::test] +async fn change_password_invalidates_old_sessions() { + let server = helpers::test_server().await; + let (username, password) = register_user(&server).await; + let (access_token, _, _) = login_user(&server, &username, &password).await; + + // Change password + server + .post("/auth/change-password") + .add_header("authorization", format!("Bearer {}", access_token)) + .json(&json!({ + "current_password": password, + "new_password": "NewValidP@ss12!", + })) + .await; + + // Old access token must now be invalid + let me_res = server + .get("/auth/me") + .add_header("authorization", format!("Bearer {}", access_token)) + .await; + assert_eq!( + me_res.status_code().as_u16(), + 401, + "old access token must be revoked after password change" + ); +} + +// --------------------------------------------------------------------------- +// Single-session mode (§5.3 test.md) +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn single_session_second_login_invalidates_first() { + let server = helpers::test_server().await; + let (username, password) = register_user(&server).await; + let (token_a, _, ip_a) = login_user(&server, &username, &password).await; + + // Enable single_session mode — must use same IP as login for require_auth to pass + server + .post("/auth/me/single-session") + .add_header("x-real-ip", &ip_a) + .add_header("authorization", format!("Bearer {}", token_a)) + .json(&json!({ "enabled": true })) + .await; + + // Second login — should invalidate token_a + let (token_b, _, _) = login_user(&server, &username, &password).await; + + // token_a must now be rejected + let res_a = server + .get("/auth/me") + .add_header("x-real-ip", &unique_ip()) + .add_header("authorization", format!("Bearer {}", token_a)) + .await; + assert_eq!( + res_a.status_code().as_u16(), + 401, + "first session token must be revoked after second login with single_session=true" + ); + + // token_b must still work + let res_b = server + .get("/auth/me") + .add_header("x-real-ip", &unique_ip()) + .add_header("authorization", format!("Bearer {}", token_b)) + .await; + assert_eq!( + res_b.status_code().as_u16(), + 200, + "second login token must still be valid" + ); +} + +// --------------------------------------------------------------------------- +// Session intercepted — IP mismatch (§5.5 test.md) +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn ip_mismatch_invalidates_session() { + let server = helpers::test_server().await; + let (username, password) = register_user(&server).await; + + // Login from IP A + let ip_a = unique_ip(); + let res = server + .post("/auth/login") + .add_header("x-real-ip", &ip_a) + .json(&json!({ "username": username, "password": password })) + .await; + res.assert_status_ok(); + let body: Value = res.json(); + let access_token = body["access_token"].as_str().unwrap().to_string(); + + // Use token from a DIFFERENT IP — middleware must reject and fire intercepted + let ip_b = unique_ip(); + let res = server + .get("/auth/me/preferences") + .add_header("x-real-ip", &ip_b) + .add_header("authorization", format!("Bearer {}", access_token)) + .await; + + assert_eq!( + res.status_code().as_u16(), + 401, + "token used from different IP must return 401 (intercepted); got {}", + res.status_code() + ); + + // Subsequent use of the same token from the original IP must also fail (session deleted) + let res2 = server + .get("/auth/me/preferences") + .add_header("x-real-ip", &ip_a) + .add_header("authorization", format!("Bearer {}", access_token)) + .await; + assert_eq!( + res2.status_code().as_u16(), + 401, + "token must remain invalid after intercepted event" + ); +} + +// --------------------------------------------------------------------------- +// Concurrent refresh — §5.4 test.md +// +// When 10 requests simultaneously submit the same refresh token, exactly one +// must succeed and the remaining nine must receive 401. The atomic UPDATE +// WHERE refresh_token_hash = old_hash guarantees only the first writer wins. +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn concurrent_refresh_only_one_wins() { + let server = Arc::new(helpers::test_server().await); + let (username, password) = register_user(&server).await; + let (_, refresh_token, _) = login_user(&server, &username, &password).await; + + // 10 concurrent refresh requests, each from a distinct IP to avoid + // hitting the per-IP rate limit (10 / 5min window). + let futures: Vec<_> = (0..10u8) + .map(|i| { + let server = Arc::clone(&server); + let token = refresh_token.clone(); + async move { + let ip = format!("172.31.{i}.1"); + server + .post("/auth/refresh") + .add_header("x-real-ip", &ip) + .json(&json!({ "refresh_token": token })) + .await + .status_code() + .as_u16() + } + }) + .collect(); + + let statuses: Vec = join_all(futures).await; + + let successes = statuses.iter().filter(|&&s| s == 200).count(); + let unauthorized = statuses.iter().filter(|&&s| s == 401).count(); + + assert_eq!( + successes, 1, + "exactly 1 concurrent refresh must succeed; got statuses: {statuses:?}" + ); + assert_eq!( + unauthorized, 9, + "exactly 9 concurrent refreshes must return 401; got statuses: {statuses:?}" + ); +} + +#[tokio::test] +async fn concurrent_refresh_winner_returns_valid_tokens() { + let server = Arc::new(helpers::test_server().await); + let (username, password) = register_user(&server).await; + let (_, refresh_token, _) = login_user(&server, &username, &password).await; + + let futures: Vec<_> = (0..10u8) + .map(|i| { + let server = Arc::clone(&server); + let token = refresh_token.clone(); + async move { + let ip = format!("192.0.2.{i}"); + let res = server + .post("/auth/refresh") + .add_header("x-real-ip", &ip) + .json(&json!({ "refresh_token": token })) + .await; + let status = res.status_code().as_u16(); + if status == 200 { + let body: Value = res.json(); + Some(( + body["access_token"].as_str().unwrap_or("").to_string(), + body["refresh_token"].as_str().unwrap_or("").to_string(), + )) + } else { + None + } + } + }) + .collect(); + + let results: Vec> = join_all(futures).await; + + let winners: Vec<_> = results.into_iter().flatten().collect(); + assert_eq!( + winners.len(), + 1, + "exactly one response must contain tokens; got {} winners", + winners.len() + ); + + let (new_access, new_refresh) = &winners[0]; + assert!( + !new_access.is_empty(), + "winner access_token must not be empty" + ); + assert!( + !new_refresh.is_empty(), + "winner refresh_token must not be empty" + ); + + // The new access token must work for authenticated endpoints. + let me = server + .get("/auth/me") + .add_header("x-real-ip", "192.0.2.99") + .add_header("authorization", format!("Bearer {new_access}")) + .await; + assert!( + me.status_code().is_success(), + "access token from winning refresh must be valid; got {}", + me.status_code() + ); +} diff --git a/lynx/dashboard/server/tests/helpers.rs b/lynx/dashboard/server/tests/helpers.rs new file mode 100644 index 0000000..cf69d0f --- /dev/null +++ b/lynx/dashboard/server/tests/helpers.rs @@ -0,0 +1,377 @@ +use axum_test::TestServer; +use ed25519_dalek::SigningKey; +use lynx_dashboard_server::{build_router, crypto::pki, AppState}; +use redis::aio::ConnectionManager; +use rustls; +use sqlx::PgPool; +use std::{collections::HashMap, sync::Arc}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::sync::{broadcast, RwLock}; +use x25519_dalek::{PublicKey as X25519Public, StaticSecret}; +use zeroize::Zeroizing; + +/// Build a deterministic test `AppState` backed by the CI/dev postgres and Redis. +pub async fn test_state() -> AppState { + // Install the ring CryptoProvider exactly once per process. + // Multiple parallel tokio::test tasks would race on this — install_default returns + // Err if already set, which we safely ignore. + let _ = rustls::crypto::ring::default_provider().install_default(); + + let db_url = std::env::var("DATABASE_URL") + .unwrap_or_else(|_| "postgresql://lynx:lynx_dev@localhost:5433/lynx_dashboard".to_string()); + let redis_url = + std::env::var("REDIS_URL").unwrap_or_else(|_| "redis://localhost:6379".to_string()); + + let db = PgPool::connect(&db_url).await.expect("connect to test DB"); + sqlx::migrate!("./migrations") + .run(&db) + .await + .expect("migrate test DB"); + + // Ensure bootstrap token window is open (idempotent — ON CONFLICT DO NOTHING). + sqlx::query!( + "INSERT INTO system_config (key, value) VALUES ('setup_token_issued_at', NOW()::text) ON CONFLICT (key) DO NOTHING" + ) + .execute(&db) + .await + .expect("seed setup_token_issued_at"); + + // Ensure a test admin exists so register tests don't race to bootstrap. + // Uses ON CONFLICT to be idempotent across parallel test invocations. + seed_test_admin(&db).await; + + let redis = redis::Client::open(redis_url.as_str()).expect("open redis"); + let redis_manager = ConnectionManager::new(redis).await.expect("connect redis"); + + let sign_seed = [0x42u8; 32]; + let signing = SigningKey::from_bytes(&sign_seed); + let sign_pub = signing.verifying_key().to_bytes(); + + let enc_priv = [0x77u8; 32]; + let enc_pub = *X25519Public::from(&StaticSecret::from(enc_priv)).as_bytes(); + + let ca_seed = [0x11u8; 32]; + let ca_signing = SigningKey::from_bytes(&ca_seed); + let ca_pub = ca_signing.verifying_key().to_bytes(); + + let (x509_ca_cert_der, x509_ca_key_der) = + pki::generate_x509_ca().expect("generate test X.509 CA"); + let (x509_client_cert_der, x509_client_key_der) = + pki::issue_x509_dashboard_client_cert(&x509_ca_cert_der, &x509_ca_key_der) + .expect("issue test client cert"); + + let config = Arc::new(lynx_dashboard_server::Config { + database_url: db_url, + redis_url, + internal_token: Zeroizing::new("test-internal-token".to_string()), + kek: Zeroizing::new([0xAAu8; 32]), + pepper: Zeroizing::new("test-pepper".to_string()), + setup_token: Some(Zeroizing::new("test-setup-token".to_string())), + jwt_sign_private_seed: Zeroizing::new(sign_seed), + jwt_sign_public_bytes: sign_pub, + jwt_enc_private_bytes: Zeroizing::new(enc_priv), + jwt_enc_public_bytes: enc_pub, + ca_private_seed: Zeroizing::new(ca_seed), + ca_public_bytes: ca_pub, + x509_ca_cert_der, + x509_ca_key_der, + x509_client_cert_der, + x509_client_key_der, + }); + + let (events_tx, _) = broadcast::channel(16); + + AppState { + db, + redis: redis_manager, + config, + latest_agent_version: Arc::new(RwLock::new(None)), + wg_psks: Arc::new(RwLock::new(HashMap::new())), + agent_ws_conns: Arc::new(RwLock::new(HashMap::new())), + agent_metric_tx: Arc::new(RwLock::new(HashMap::new())), + events_tx: Arc::new(events_tx), + } +} + +/// Seed a static test admin user idempotently. +/// Uses a PostgreSQL advisory lock so parallel test processes don't race. +async fn seed_test_admin(db: &PgPool) { + use lynx_dashboard_server::crypto::{kek, password}; + use uuid::Uuid; + + // Advisory lock key — arbitrary constant, unique to this seeding operation. + const LOCK_KEY: i64 = 0x4c796e78_5f746573i64; // "Lynx_tes" + + // Acquire session-level advisory lock — blocks until no other test holds it. + sqlx::query("SELECT pg_advisory_lock($1)") + .bind(LOCK_KEY) + .execute(db) + .await + .expect("acquire advisory lock"); + + // Check specifically that testadmin exists AND has *:* — not just any admin. + // Other test binaries may have bootstrapped their own admin user (satisfying + // "any admin with *:*") causing this function to return early and skip + // inserting testadmin, which would make rotation/admin tests fail. + let testadmin_ready: bool = sqlx::query_scalar!( + r#"SELECT EXISTS( + SELECT 1 FROM users u + JOIN user_roles ur ON ur.user_id = u.id + JOIN role_permissions rp ON rp.role_id = ur.role_id + JOIN permissions p ON p.id = rp.permission_id + WHERE u.username = 'testadmin' AND p.key = '*:*' + ) AS "exists!""# + ) + .fetch_one(db) + .await + .unwrap_or(false); + + if testadmin_ready { + // Reset force_password_change in case a previous test set it on all users. + let _ = sqlx::query!( + "UPDATE users SET force_password_change = false WHERE username = 'testadmin'" + ) + .execute(db) + .await; + + sqlx::query("SELECT pg_advisory_unlock($1)") + .bind(LOCK_KEY) + .execute(db) + .await + .ok(); + return; + } + + let test_kek = [0xAAu8; 32]; + let test_pepper = "test-pepper"; + let user_id = Uuid::now_v7(); + + let pwd_hash = password::hash("AdminP@ss12!").expect("hash admin password"); + let dek = kek::gen_dek(); + let dek_encrypted = kek::encrypt_dek(&dek, &test_kek).expect("encrypt dek"); + let email_lower = "testadmin@example.com"; + let email_encrypted = + kek::encrypt_with_dek(email_lower.as_bytes(), &dek).expect("encrypt email"); + let email_hash = lynx_dashboard_server::crypto::hash::email_hash(email_lower, test_pepper); + + // Insert user — skip if already exists (another parallel test may have inserted it). + let _ = sqlx::query!( + r#"INSERT INTO users (id, username, email_hash, email_encrypted, password_hash, dek_encrypted) + VALUES ($1, 'testadmin', $2, $3, $4, $5) + ON CONFLICT (username) DO NOTHING"#, + user_id, + email_hash, + email_encrypted, + pwd_hash, + dek_encrypted, + ) + .execute(db) + .await; + + // Fetch the actual user_id in case it was already inserted. + let actual_id: Uuid = sqlx::query_scalar!("SELECT id FROM users WHERE username = 'testadmin'") + .fetch_one(db) + .await + .expect("fetch testadmin id"); + + // Create Admin role and assign *:* — use DO NOTHING on conflicts. + let star_perm_id: Uuid = sqlx::query_scalar!("SELECT id FROM permissions WHERE key = '*:*'") + .fetch_one(db) + .await + .expect("fetch *:* permission"); + + let role_id = Uuid::now_v7(); + let _ = sqlx::query!( + "INSERT INTO roles (id, name, created_by) VALUES ($1, 'Admin', $2) ON CONFLICT (name) DO NOTHING", + role_id, + actual_id, + ) + .execute(db) + .await; + + let actual_role_id: Uuid = sqlx::query_scalar!("SELECT id FROM roles WHERE name = 'Admin'") + .fetch_one(db) + .await + .expect("fetch Admin role id"); + + let _ = sqlx::query!( + "INSERT INTO role_permissions (id, role_id, permission_id, created_by) VALUES ($1, $2, $3, $4) ON CONFLICT (role_id, permission_id) DO NOTHING", + Uuid::now_v7(), + actual_role_id, + star_perm_id, + actual_id, + ) + .execute(db) + .await; + + let _ = sqlx::query!( + "INSERT INTO user_roles (id, user_id, role_id, created_by) VALUES ($1, $2, $3, $4) ON CONFLICT (user_id, role_id) DO NOTHING", + Uuid::now_v7(), + actual_id, + actual_role_id, + actual_id, + ) + .execute(db) + .await; + + // Reset force_password_change in case a previous test set it on all users. + let _ = + sqlx::query!("UPDATE users SET force_password_change = false WHERE username = 'testadmin'") + .execute(db) + .await; + + sqlx::query("SELECT pg_advisory_unlock($1)") + .bind(LOCK_KEY) + .execute(db) + .await + .ok(); +} + +pub async fn test_server() -> TestServer { + let state = test_state().await; + let app = build_router(state); + TestServer::new(app) +} + +/// Build an `AppState` identical to `test_state` except the Redis `ConnectionManager` +/// is backed by a TCP socket that accepts the initial connection then immediately +/// closes — making all subsequent Redis commands fail with a connection error. +/// +/// Used to verify that auth endpoints return 503 (fail-closed) when Redis is down. +pub async fn test_state_redis_down() -> AppState { + let _ = rustls::crypto::ring::default_provider().install_default(); + + let db_url = std::env::var("DATABASE_URL") + .unwrap_or_else(|_| "postgresql://lynx:lynx_dev@localhost:5433/lynx_dashboard".to_string()); + + let db = PgPool::connect(&db_url).await.expect("connect to test DB"); + sqlx::migrate!("./migrations") + .run(&db) + .await + .expect("migrate test DB"); + + sqlx::query!( + "INSERT INTO system_config (key, value) VALUES ('setup_token_issued_at', NOW()::text) ON CONFLICT (key) DO NOTHING" + ) + .execute(&db) + .await + .expect("seed setup_token_issued_at"); + + seed_test_admin(&db).await; + + // Minimal fake Redis server. + // + // redis-rs 0.27 sends a CLIENT SETINFO pipeline on every new connection. + // ConnectionManager then keeps the connection alive and retries (with + // exponential backoff) if it closes. To avoid the backoff delay we keep + // each accepted connection open and respond to every incoming RESP2 command + // with a RESP2 error, causing the rate-limit check (first Redis command in + // every auth handler) to fail → 503. + // + // Protocol sketch: + // client → *N\r\n (array of N bulk strings = one command per frame) + // server → +OK\r\n for CLIENT SETINFO frames during setup + // -ERR redis_down\r\n for everything else + let fake_listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind fake redis listener"); + let fake_port = fake_listener.local_addr().unwrap().port(); + let redis_url = format!("redis://127.0.0.1:{fake_port}"); + + tokio::spawn(async move { + while let Ok((mut stream, _)) = fake_listener.accept().await { + tokio::spawn(async move { + let mut buf = vec![0u8; 8192]; + let mut setup_done = false; + + loop { + let n = match tokio::time::timeout( + std::time::Duration::from_secs(30), + stream.read(&mut buf), + ) + .await + { + Ok(Ok(0)) | Err(_) => break, // EOF or idle timeout + Ok(Ok(n)) => n, + Ok(Err(_)) => break, + }; + + // Count RESP arrays received — each *N is one command. + let cmd_count = buf[..n].iter().filter(|&&b| b == b'*').count().max(1); + + let response = if !setup_done { + // First batch: CLIENT SETINFO × 2 — respond OK for setup. + setup_done = true; + "+OK\r\n".repeat(cmd_count) + } else { + // All subsequent commands → error so handlers return 503. + "-ERR redis_down\r\n".repeat(cmd_count) + }; + + if stream.write_all(response.as_bytes()).await.is_err() { + break; + } + } + }); + } + }); + + let redis_client = redis::Client::open(redis_url.as_str()).expect("open fake redis client"); + + let redis_manager = tokio::time::timeout( + std::time::Duration::from_secs(10), + ConnectionManager::new(redis_client), + ) + .await + .expect("ConnectionManager::new timed out") + .expect("ConnectionManager::new failed"); + + let sign_seed = [0x42u8; 32]; + let signing = SigningKey::from_bytes(&sign_seed); + let sign_pub = signing.verifying_key().to_bytes(); + + let enc_priv = [0x77u8; 32]; + let enc_pub = *X25519Public::from(&StaticSecret::from(enc_priv)).as_bytes(); + + let ca_seed = [0x11u8; 32]; + let ca_signing = SigningKey::from_bytes(&ca_seed); + let ca_pub = ca_signing.verifying_key().to_bytes(); + + let (x509_ca_cert_der, x509_ca_key_der) = + pki::generate_x509_ca().expect("generate test X.509 CA"); + let (x509_client_cert_der, x509_client_key_der) = + pki::issue_x509_dashboard_client_cert(&x509_ca_cert_der, &x509_ca_key_der) + .expect("issue test client cert"); + + let config = Arc::new(lynx_dashboard_server::Config { + database_url: db_url, + redis_url, + internal_token: Zeroizing::new("test-internal-token".to_string()), + kek: Zeroizing::new([0xAAu8; 32]), + pepper: Zeroizing::new("test-pepper".to_string()), + setup_token: Some(Zeroizing::new("test-setup-token".to_string())), + jwt_sign_private_seed: Zeroizing::new(sign_seed), + jwt_sign_public_bytes: sign_pub, + jwt_enc_private_bytes: Zeroizing::new(enc_priv), + jwt_enc_public_bytes: enc_pub, + ca_private_seed: Zeroizing::new(ca_seed), + ca_public_bytes: ca_pub, + x509_ca_cert_der, + x509_ca_key_der, + x509_client_cert_der, + x509_client_key_der, + }); + + let (events_tx, _) = broadcast::channel(16); + + AppState { + db, + redis: redis_manager, + config, + latest_agent_version: Arc::new(RwLock::new(None)), + wg_psks: Arc::new(RwLock::new(HashMap::new())), + agent_ws_conns: Arc::new(RwLock::new(HashMap::new())), + agent_metric_tx: Arc::new(RwLock::new(HashMap::new())), + events_tx: Arc::new(events_tx), + } +} diff --git a/lynx/dashboard/server/tests/redis_fail.rs b/lynx/dashboard/server/tests/redis_fail.rs new file mode 100644 index 0000000..82de573 --- /dev/null +++ b/lynx/dashboard/server/tests/redis_fail.rs @@ -0,0 +1,178 @@ +mod helpers; + +use axum_test::TestServer; +use lynx_dashboard_server::build_router; +use serde_json::{json, Value}; + +// --------------------------------------------------------------------------- +// Redis fail-closed — §5.2 test.md +// +// When Redis is unavailable, auth endpoints that depend on it (rate-limit +// checks, JTI storage/validation) must return 503 Service Unavailable. +// The server must never emit tokens while Redis is down. +// --------------------------------------------------------------------------- + +fn unique_ip() -> String { + let id = uuid::Uuid::now_v7(); + let b = id.as_bytes(); + format!("{}.{}.{}.{}", b[8], b[9], b[10], b[11]) +} + +/// Build a `TestServer` whose Redis connection is backed by a fake TCP socket +/// that accepts but immediately drops — simulating Redis being down. +async fn server_with_redis_down() -> TestServer { + let state = helpers::test_state_redis_down().await; + TestServer::new(build_router(state)) +} + +// --------------------------------------------------------------------------- +// Login → 503 when Redis is down +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn login_returns_503_when_redis_down() { + let server = server_with_redis_down().await; + + // testadmin is pre-seeded by helpers — credentials are valid in the DB. + // Redis rate-limit check runs before password verification, so even a valid + // login returns 503 when Redis is unavailable. + let res = server + .post("/auth/login") + .add_header("x-real-ip", &unique_ip()) + .json(&json!({ "username": "testadmin", "password": "AdminP@ss12!" })) + .await; + + assert_eq!( + res.status_code().as_u16(), + 503, + "login must return 503 when Redis is down; got {} — {}", + res.status_code(), + res.text() + ); + + let body: Value = res.json(); + assert_eq!( + body["error"].as_str(), + Some("service_unavailable"), + "error field must be 'service_unavailable'; got {body}" + ); +} + +#[tokio::test] +async fn login_emits_no_token_when_redis_down() { + let server = server_with_redis_down().await; + + let res = server + .post("/auth/login") + .add_header("x-real-ip", &unique_ip()) + .json(&json!({ "username": "testadmin", "password": "AdminP@ss12!" })) + .await; + + // 503 — no token must be present in the body. + let body = res.text(); + assert!( + !body.contains("access_token"), + "no access_token must be emitted when Redis is down; body: {body}" + ); + assert!( + !body.contains("refresh_token"), + "no refresh_token must be emitted when Redis is down; body: {body}" + ); +} + +// --------------------------------------------------------------------------- +// Register → 503 when Redis is down +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn register_returns_503_when_redis_down() { + let server = server_with_redis_down().await; + + let username = format!("rdwn{}", &uuid::Uuid::now_v7().simple().to_string()[..12]); + + let res = server + .post("/auth/register") + .add_header("x-real-ip", &unique_ip()) + .json(&json!({ + "username": username, + "email": format!("{username}@example.com"), + "password": "ValidP@ss12!", + })) + .await; + + assert_eq!( + res.status_code().as_u16(), + 503, + "register must return 503 when Redis is down; got {} — {}", + res.status_code(), + res.text() + ); +} + +// --------------------------------------------------------------------------- +// Refresh → 503 when Redis is down +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn refresh_returns_503_when_redis_down() { + let server = server_with_redis_down().await; + + // The refresh endpoint checks the rate-limit via Redis before anything + // else — so any request returns 503 when Redis is down, regardless of + // whether the refresh token itself is valid. + let res = server + .post("/auth/refresh") + .add_header("x-real-ip", &unique_ip()) + .json(&json!({ + // A syntactically valid base64url-encoded token (32 random bytes + // base64url-encoded). The rate-limit check fires before token + // decoding, so 503 is expected. + "refresh_token": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + })) + .await; + + assert_eq!( + res.status_code().as_u16(), + 503, + "refresh must return 503 when Redis is down; got {} — {}", + res.status_code(), + res.text() + ); + + let body: Value = res.json(); + assert_eq!( + body["error"].as_str(), + Some("service_unavailable"), + "error must be 'service_unavailable'; got {body}" + ); +} + +// --------------------------------------------------------------------------- +// Normal flow resumes when Redis is back (healthy server still works) +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn login_succeeds_when_redis_is_healthy() { + // Confirm the normal server (with real Redis) still works — guards against + // test infrastructure issues bleeding into the redis-down tests. + let server = helpers::test_server().await; + + let res = server + .post("/auth/login") + .add_header("x-real-ip", &unique_ip()) + .json(&json!({ "username": "testadmin", "password": "AdminP@ss12!" })) + .await; + + assert!( + res.status_code().is_success(), + "login must succeed when Redis is healthy; got {} — {}", + res.status_code(), + res.text() + ); + + let body: Value = res.json(); + assert!( + body["access_token"].is_string(), + "access_token must be present in healthy response; got {body}" + ); +} diff --git a/lynx/dashboard/server/tests/rotation.rs b/lynx/dashboard/server/tests/rotation.rs new file mode 100644 index 0000000..14effd1 --- /dev/null +++ b/lynx/dashboard/server/tests/rotation.rs @@ -0,0 +1,252 @@ +mod helpers; + +use serde_json::{json, Value}; + +fn unique_ip() -> String { + let id = uuid::Uuid::now_v7(); + let b = id.as_bytes(); + format!("{}.{}.{}.{}", b[8], b[9], b[10], b[11]) +} + +async fn admin_login(server: &axum_test::TestServer) -> (String, String) { + let ip = unique_ip(); + let res = server + .post("/auth/login") + .add_header("x-real-ip", &ip) + .json(&serde_json::json!({ "username": "testadmin", "password": "AdminP@ss12!" })) + .await; + res.assert_status_ok(); + let token = res.json::()["access_token"] + .as_str() + .unwrap() + .to_string(); + (token, ip) +} + +// --------------------------------------------------------------------------- +// §9.4 — Update + scheduled rotation in same cycle (test.md) +// +// Unit tests for the scheduler guard logic (needs_scheduled_rotation) live +// in src/scheduler.rs as inline #[tokio::test] tests. +// +// These integration tests verify the HTTP-level rotation_log behavior: +// - Entries carry correct reasons and scopes +// - Distinct rotation events produce distinct IDs (no duplicates) +// - Invalid reasons are rejected +// +// NOTE: Tests here use scope='certificates' to avoid flushing Redis / deleting +// sessions (which would interfere with concurrent tests sharing the same DB). +// Session-invalidation-by-JWT-rotation is already covered in tests/auth.rs. +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn rotation_log_stores_update_reason_correctly() { + let server = helpers::test_server().await; + let (token, ip) = admin_login(&server).await; + + // 'certificates' scope: issues agent certs — no JWT flush, no Redis writes. + // Safe to run in parallel with other tests. + let res = server + .post("/admin/rotate") + .add_header("x-real-ip", &ip) + .add_header("authorization", format!("Bearer {token}")) + .json(&json!({ "scope": "certificates", "reason": "update" })) + .await; + + assert!( + res.status_code().is_success(), + "POST /admin/rotate with reason='update' must succeed; got {} — {}", + res.status_code(), + res.text() + ); + + let body: Value = res.json(); + let rotation_id = body["rotation_id"] + .as_str() + .expect("response must include rotation_id") + .to_string(); + + // Token still valid (no JWT rotation happened) + let log: Value = server + .get("/admin/rotation-log") + .add_header("x-real-ip", &ip) + .add_header("authorization", format!("Bearer {token}")) + .await + .json(); + + let entry = log + .as_array() + .unwrap() + .iter() + .find(|e| e["id"].as_str() == Some(&rotation_id)) + .expect("rotation_log must contain the entry we just created"); + + assert_eq!( + entry["reason"].as_str(), + Some("update"), + "rotation_log entry must have reason='update'" + ); + assert_eq!( + entry["scope"].as_str(), + Some("certificates"), + "rotation_log entry must have scope='certificates'" + ); +} + +#[tokio::test] +async fn rotation_log_update_and_scheduled_produce_distinct_entries() { + let server = helpers::test_server().await; + let (token, ip) = admin_login(&server).await; + + // Trigger 'update' rotation (simulates post-update cert rotation) + let res_update = server + .post("/admin/rotate") + .add_header("x-real-ip", &ip) + .add_header("authorization", format!("Bearer {token}")) + .json(&json!({ "scope": "certificates", "reason": "update" })) + .await; + assert!( + res_update.status_code().is_success(), + "update rotation must succeed; got {} — {}", + res_update.status_code(), + res_update.text() + ); + let update_id = res_update.json::()["rotation_id"] + .as_str() + .unwrap() + .to_string(); + + // Token still valid — trigger 'scheduled' rotation (simulates 90-day scheduler) + let res_sched = server + .post("/admin/rotate") + .add_header("x-real-ip", &ip) + .add_header("authorization", format!("Bearer {token}")) + .json(&json!({ "scope": "certificates", "reason": "scheduled" })) + .await; + assert!( + res_sched.status_code().is_success(), + "scheduled rotation must succeed; got {} — {}", + res_sched.status_code(), + res_sched.text() + ); + let sched_id = res_sched.json::()["rotation_id"] + .as_str() + .unwrap() + .to_string(); + + // Must produce two distinct rotation_log entries + assert_ne!( + update_id, sched_id, + "update and scheduled rotations must create distinct rotation_log entries" + ); + + let log: Value = server + .get("/admin/rotation-log") + .add_header("x-real-ip", &ip) + .add_header("authorization", format!("Bearer {token}")) + .await + .json(); + + let entries = log.as_array().unwrap(); + + let found_update = entries + .iter() + .any(|e| e["id"].as_str() == Some(&update_id) && e["reason"].as_str() == Some("update")); + let found_sched = entries + .iter() + .any(|e| e["id"].as_str() == Some(&sched_id) && e["reason"].as_str() == Some("scheduled")); + + assert!(found_update, "rotation_log must contain the 'update' entry"); + assert!( + found_sched, + "rotation_log must contain the 'scheduled' entry" + ); + + // No duplicate IDs in the log + let ids: Vec<&str> = entries.iter().filter_map(|e| e["id"].as_str()).collect(); + let unique_count = ids.iter().collect::>().len(); + assert_eq!( + ids.len(), + unique_count, + "rotation_log must not contain duplicate entries" + ); +} + +#[tokio::test] +async fn rotation_log_invalid_reason_rejected() { + let server = helpers::test_server().await; + let (token, ip) = admin_login(&server).await; + + let res = server + .post("/admin/rotate") + .add_header("x-real-ip", &ip) + .add_header("authorization", format!("Bearer {token}")) + .json(&json!({ "scope": "certificates", "reason": "bogus_reason" })) + .await; + + assert!( + res.status_code().is_client_error(), + "invalid reason must be rejected with 4xx; got {}", + res.status_code() + ); +} + +#[tokio::test] +async fn rotation_log_invalid_scope_rejected() { + let server = helpers::test_server().await; + let (token, ip) = admin_login(&server).await; + + let res = server + .post("/admin/rotate") + .add_header("x-real-ip", &ip) + .add_header("authorization", format!("Bearer {token}")) + .json(&json!({ "scope": "unknown_scope", "reason": "manual" })) + .await; + + assert!( + res.status_code().is_client_error(), + "invalid scope must be rejected with 4xx; got {}", + res.status_code() + ); +} + +#[tokio::test] +async fn rotation_log_non_admin_cannot_rotate() { + let server = helpers::test_server().await; + let username = format!("t{}", &uuid::Uuid::now_v7().simple().to_string()[..16]); + + server + .post("/auth/register") + .add_header("x-real-ip", &unique_ip()) + .json(&json!({ + "username": username, + "email": format!("{}@example.com", username), + "password": "ValidP@ss12!", + })) + .await; + + let user_ip = unique_ip(); + let user_res = server + .post("/auth/login") + .add_header("x-real-ip", &user_ip) + .json(&json!({ "username": username, "password": "ValidP@ss12!" })) + .await; + let user_token = user_res.json::()["access_token"] + .as_str() + .unwrap() + .to_string(); + + let res = server + .post("/admin/rotate") + .add_header("x-real-ip", &user_ip) + .add_header("authorization", format!("Bearer {user_token}")) + .json(&json!({ "scope": "certificates", "reason": "manual" })) + .await; + + assert_eq!( + res.status_code().as_u16(), + 403, + "non-admin must not be able to trigger rotation; got {}", + res.status_code() + ); +} diff --git a/lynx/dashboard/server/tests/security.rs b/lynx/dashboard/server/tests/security.rs new file mode 100644 index 0000000..cf91e86 --- /dev/null +++ b/lynx/dashboard/server/tests/security.rs @@ -0,0 +1,375 @@ +mod helpers; + +use serde_json::json; + +fn unique_ip() -> String { + let id = uuid::Uuid::now_v7(); + let b = id.as_bytes(); + format!("{}.{}.{}.{}", b[8], b[9], b[10], b[11]) +} + +fn unique_username() -> String { + let id = uuid::Uuid::now_v7().simple().to_string(); + format!("t{}", &id[..16]) +} + +// --------------------------------------------------------------------------- +// Security headers — present on all responses (§16.1 of test.md) +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn security_headers_present_on_health() { + let server = helpers::test_server().await; + let res = server.get("/health").await; + + let headers = res.headers(); + assert_eq!( + headers.get("x-frame-options").and_then(|v| v.to_str().ok()), + Some("DENY"), + "X-Frame-Options: DENY missing" + ); + assert_eq!( + headers + .get("x-content-type-options") + .and_then(|v| v.to_str().ok()), + Some("nosniff"), + "X-Content-Type-Options: nosniff missing" + ); + assert_eq!( + headers.get("referrer-policy").and_then(|v| v.to_str().ok()), + Some("no-referrer"), + "Referrer-Policy: no-referrer missing" + ); +} + +#[tokio::test] +async fn security_headers_present_on_login() { + let server = helpers::test_server().await; + let res = server + .post("/auth/login") + .add_header("x-real-ip", &unique_ip()) + .json(&json!({ "username": "any", "password": "any" })) + .await; + + let headers = res.headers(); + assert!( + headers.contains_key("x-frame-options"), + "X-Frame-Options missing on 401 response" + ); + assert!( + headers.contains_key("x-content-type-options"), + "X-Content-Type-Options missing on 401 response" + ); + assert!( + headers.contains_key("referrer-policy"), + "Referrer-Policy missing on 401 response" + ); +} + +// --------------------------------------------------------------------------- +// Anti-enumeration — login must return same status for unknown user vs wrong pw +// (§5.2 test.md — also in auth.rs but we verify status *and* body structure) +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn login_anti_enumeration_status_matches() { + let server = helpers::test_server().await; + + let res_missing = server + .post("/auth/login") + .add_header("x-real-ip", &unique_ip()) + .json(&json!({ "username": "totally_nonexistent_xyz", "password": "WrongPass1!" })) + .await; + + let res_wrong_pw = server + .post("/auth/login") + .add_header("x-real-ip", &unique_ip()) + .json(&json!({ "username": "totally_nonexistent_xyz", "password": "AlsoWrong1!" })) + .await; + + assert_eq!( + res_missing.status_code(), + res_wrong_pw.status_code(), + "user-not-found and wrong-password must return identical status" + ); + assert_eq!(res_missing.status_code().as_u16(), 401); +} + +#[tokio::test] +async fn login_error_body_does_not_reveal_username_existence() { + let server = helpers::test_server().await; + + let res = server + .post("/auth/login") + .add_header("x-real-ip", &unique_ip()) + .json(&json!({ "username": "definitely_does_not_exist_abc123", "password": "Pass1!" })) + .await; + + let body = res.text(); + // Body must not mention "not found", "no user", "doesn't exist", etc. + let lower = body.to_lowercase(); + assert!( + !lower.contains("not found") + && !lower.contains("no user") + && !lower.contains("doesn't exist"), + "error body must not reveal user existence: {body}" + ); +} + +// --------------------------------------------------------------------------- +// Open redirect (§12.5 test.md) +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn login_absolute_external_redirect_to_is_blocked() { + let server = helpers::test_server().await; + let username = unique_username(); + + // Register user first + server + .post("/auth/register") + .add_header("x-real-ip", &unique_ip()) + .json(&json!({ + "username": username, + "email": format!("{}@example.com", username), + "password": "ValidP@ss12!", + "setup_token": "test-setup-token", + })) + .await; + + // Login with an external redirect_to — backend must ignore or sanitize it + let res = server + .post("/auth/login") + .add_header("x-real-ip", &unique_ip()) + .json(&json!({ + "username": username, + "password": "ValidP@ss12!", + "redirect_to": "https://evil.com/phish", + })) + .await; + + // Login must succeed (200) — the redirect_to is handled by the frontend + // The backend should return tokens, not a redirect to the external URL + assert!( + res.status_code().is_success(), + "login with external redirect_to must still succeed: {}", + res.status_code() + ); + + // Response must not contain the evil.com URL in any meaningful way + let body = res.text(); + assert!( + !body.contains("evil.com"), + "response must not echo external redirect URL: {body}" + ); +} + +// --------------------------------------------------------------------------- +// Pepper — never exposed in responses (§16.2 test.md) +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn login_response_does_not_contain_pepper() { + let server = helpers::test_server().await; + + let res = server + .post("/auth/login") + .add_header("x-real-ip", &unique_ip()) + .json(&json!({ "username": "any", "password": "any" })) + .await; + + let body = res.text(); + // The test pepper configured in helpers is "test-pepper" + assert!( + !body.contains("test-pepper"), + "response must not expose the pepper: {body}" + ); +} + +#[tokio::test] +async fn register_response_does_not_contain_pepper() { + let server = helpers::test_server().await; + let username = unique_username(); + + let res = server + .post("/auth/register") + .add_header("x-real-ip", &unique_ip()) + .json(&json!({ + "username": username, + "email": format!("{}@example.com", username), + "password": "ValidP@ss12!", + "setup_token": "test-setup-token", + })) + .await; + + let body = res.text(); + assert!( + !body.contains("test-pepper"), + "register response must not expose the pepper: {body}" + ); +} + +// --------------------------------------------------------------------------- +// Unauthenticated access — protected endpoints return 401 (not 404 or 500) +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn agents_endpoint_requires_auth() { + let server = helpers::test_server().await; + let res = server.get("/agents").await; + assert_eq!(res.status_code().as_u16(), 401, "/agents must require auth"); +} + +#[tokio::test] +async fn organizations_endpoint_requires_auth() { + let server = helpers::test_server().await; + let res = server.get("/organizations").await; + assert_eq!( + res.status_code().as_u16(), + 401, + "/organizations must require auth" + ); +} + +#[tokio::test] +async fn admin_endpoint_requires_auth() { + let server = helpers::test_server().await; + let res = server.get("/admin/users").await; + assert_eq!( + res.status_code().as_u16(), + 401, + "/admin/* must require auth" + ); +} + +// --------------------------------------------------------------------------- +// Anti-enumeration — 404 vs 403 (§security.md, §12.3 pattern) +// +// For post-auth resources (orgs, projects, agents), the server must return +// 404 both when the resource does not exist AND when the requester lacks +// access. Returning 403 would confirm existence to an unauthorized caller. +// --------------------------------------------------------------------------- + +/// Register a user, log them in, return (bearer_token, login_ip). +async fn register_and_login(server: &axum_test::TestServer) -> (String, String) { + let username = unique_username(); + let password = "ValidP@ss12!"; + + server + .post("/auth/register") + .add_header("x-real-ip", &unique_ip()) + .json(&json!({ + "username": username, + "email": format!("{username}@example.com"), + "password": password, + "setup_token": "test-setup-token", + })) + .await; + + let login_ip = unique_ip(); + let res = server + .post("/auth/login") + .add_header("x-real-ip", &login_ip) + .json(&json!({ "username": username, "password": password })) + .await; + res.assert_status_ok(); + let token = res.json::()["access_token"] + .as_str() + .unwrap() + .to_string(); + (token, login_ip) +} + +#[tokio::test] +async fn org_not_member_returns_404_not_403() { + let server = helpers::test_server().await; + + // User A creates an organisation. + let (token_a, ip_a) = register_and_login(&server).await; + let create_res = server + .post("/organizations") + .add_header("x-real-ip", &ip_a) + .add_header("authorization", format!("Bearer {token_a}")) + .json(&json!({ + "name": "Secret Corp", + "slug": format!("secret-{}", &uuid::Uuid::now_v7().simple().to_string()[..16]), + })) + .await; + assert!(create_res.status_code().is_success(), "org create failed"); + let org_id = create_res.json::()["id"] + .as_str() + .unwrap() + .to_string(); + + // User B (different account, NOT a member) tries to read the org. + let (token_b, ip_b) = register_and_login(&server).await; + let res = server + .get(&format!("/organizations/{org_id}")) + .add_header("x-real-ip", &ip_b) + .add_header("authorization", format!("Bearer {token_b}")) + .await; + + assert_eq!( + res.status_code().as_u16(), + 404, + "org GET by non-member must return 404, not 403; got {}", + res.status_code() + ); +} + +#[tokio::test] +async fn org_nonexistent_returns_404() { + let server = helpers::test_server().await; + let (token, ip) = register_and_login(&server).await; + + // UUID v7 that was never inserted — must return 404, not 500. + let fake_id = uuid::Uuid::now_v7(); + let res = server + .get(&format!("/organizations/{fake_id}")) + .add_header("x-real-ip", &ip) + .add_header("authorization", format!("Bearer {token}")) + .await; + + assert_eq!( + res.status_code().as_u16(), + 404, + "GET non-existent org must return 404; got {}", + res.status_code() + ); +} + +#[tokio::test] +async fn org_delete_by_non_owner_returns_404_not_403() { + let server = helpers::test_server().await; + + // Owner creates org. + let (token_owner, ip_owner) = register_and_login(&server).await; + let create_res = server + .post("/organizations") + .add_header("x-real-ip", &ip_owner) + .add_header("authorization", format!("Bearer {token_owner}")) + .json(&json!({ + "name": "Owned Corp", + "slug": format!("owned-{}", &uuid::Uuid::now_v7().simple().to_string()[..16]), + })) + .await; + let org_id = create_res.json::()["id"] + .as_str() + .unwrap() + .to_string(); + + // Non-owner tries to delete — must get 404, not 403. + let (token_other, ip_other) = register_and_login(&server).await; + let res = server + .delete(&format!("/organizations/{org_id}")) + .add_header("x-real-ip", &ip_other) + .add_header("authorization", format!("Bearer {token_other}")) + .await; + + assert_eq!( + res.status_code().as_u16(), + 404, + "DELETE org by non-owner must return 404, not 403; got {}", + res.status_code() + ); +} diff --git a/lynx/dashboard/setup-dashboard.sh b/lynx/dashboard/setup-dashboard.sh index e69de29..82f2999 100644 --- a/lynx/dashboard/setup-dashboard.sh +++ b/lynx/dashboard/setup-dashboard.sh @@ -0,0 +1,710 @@ +#!/usr/bin/env bash +# ----------------------------------------------------------------------------- +# setup-dashboard.sh — Lynx Dashboard install / reinstall script +# +# Description: +# Installs the Lynx Dashboard on a VPS. Sets up: +# - Podman networks (3 isolated: db, cache, app) +# - Podman secrets (randomly generated, no trace) +# - PostgreSQL 18 container with isolated app user +# - Redis 8 container with password auth +# - Backend (Rust) + Frontend (Next.js) containers +# - nftables rules (ports 22 + 19443) +# - Self-signed TLS certificate (90-day, auto-rotated via systemd timer) +# - WireGuard tunnel to local agent +# +# Usage: +# curl -sSL https://get.lynx.example/dashboard | bash +# OR +# ./setup-dashboard.sh +# +# Requirements: +# - Debian/Ubuntu or RHEL-based Linux (amd64 / arm64) +# - Run as root or with sudo +# - Internet access for container images +# ----------------------------------------------------------------------------- + +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" +CERTS_DIR="/etc/lynx/certs" +WG_DIR="/etc/wireguard" +COMPOSE_FILE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/docker-compose.yml" +LISTEN_PORT=19443 +AGENT_WG_PORT=51820 +AGENT_WG_IP="10.100.0.2" +DASHBOARD_WG_IP="10.100.0.1" +WG_SUBNET="10.100.0.0/16" +CONTAINER_RUNTIME="podman" + +# --- Root check ------------------------------------------------------------- + +if [[ $EUID -ne 0 ]]; then + log_error "Must run as root: sudo $0" + exit 1 +fi + +# --- Cleanup function (used by reinstall) ----------------------------------- + +_cleanup_existing() { + log_section "Removing existing installation" + + # Stop and remove containers + for ctr in lynx-dashboard-frontend lynx-dashboard-backend lynx-dashboard-postgres lynx-dashboard-redis; do + if podman container exists "$ctr" 2>/dev/null; then + log_info "Removing container: $ctr" + podman rm -f "$ctr" 2>/dev/null || true + fi + done + + # Remove volumes + podman volume rm lynx-dashboard_postgres_data 2>/dev/null || true + + # Remove networks + for net in lynx-dashboard-db lynx-dashboard-cache lynx-dashboard-app; do + podman network rm "$net" 2>/dev/null || true + done + + # Remove secrets + for secret in lynx-dashboard-pg-root lynx-dashboard-pg-pass lynx-dashboard-redis-pass \ + lynx-dashboard-database-url lynx-dashboard-redis-url \ + lynx-dashboard-api-token lynx-dashboard-kek lynx-dashboard-pepper \ + lynx-dashboard-jwt-sign-private lynx-dashboard-jwt-sign-public \ + lynx-dashboard-jwt-enc-private lynx-dashboard-jwt-enc-public \ + lynx-dashboard-ca-private lynx-dashboard-ca-public \ + lynx-dashboard-setup-token \ + lynx-dashboard-local-agent-psk; do + podman secret rm "$secret" 2>/dev/null || true + done + + # Remove WireGuard interface + if ip link show wg-lynx-dashboard &>/dev/null; then + ip link delete wg-lynx-dashboard 2>/dev/null || true + fi + rm -f "$WG_DIR/wg-lynx-dashboard.conf" + + # Remove systemd units + systemctl disable --now lynx-dashboard-rotate-certs.timer 2>/dev/null || true + rm -f /etc/systemd/system/lynx-dashboard-rotate-certs.{service,timer} + systemctl daemon-reload + + rm -rf "$LYNX_DIR" + 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 Dashboard requires at least 512 MB RAM for PostgreSQL to operate correctly" + exit 1 +fi +log_ok "RAM: ${TOTAL_RAM_MB} MB (minimum 512 MB satisfied)" + +# --- Incompatible software -------------------------------------------------- + +log_section "Checking for incompatible software" + +log_info "Lynx manages containers via Podman and firewall via nftables." +log_info "The following software is incompatible and will be removed if found:" +log_info " Docker / Docker Engine, 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 — only block the legacy binary, not the nftables compat layer (iptables-nft) +if command -v iptables &>/dev/null && ! iptables --version 2>/dev/null | grep -q 'nf_tables'; then + _incompatible_found=true + log_warn "Removing incompatible: iptables (legacy binary, not nftables-compat)" + log_info " Reason: ${_REASON_FW}" + case "$DISTRO" in + debian) apt-get purge -y iptables 2>/dev/null || true ;; + rhel) { dnf remove -y iptables 2>/dev/null || yum remove -y iptables 2>/dev/null; } || true ;; + *) log_warn "Unknown distro — remove iptables manually" ;; + esac + log_ok "Removed: iptables (legacy)" +fi + +if $_incompatible_found; then + # Flush any residual kernel rules left behind by Docker / iptables + if command -v iptables-legacy &>/dev/null; then + iptables-legacy -F 2>/dev/null || true + iptables-legacy -X 2>/dev/null || true + iptables-legacy -t nat -F 2>/dev/null || true + iptables-legacy -t nat -X 2>/dev/null || true + iptables-legacy -t mangle -F 2>/dev/null || true + iptables-legacy -t mangle -X 2>/dev/null || true + fi + log_ok "Incompatible software removed — residual firewall rules cleared" +else + log_ok "No incompatible software found" +fi + +unset _REASON_DOCKER _REASON_CTR _REASON_FW + +# --- Detect existing installation ------------------------------------------- + +log_section "Checking for existing installation" + +existing=false +existing_reason="" + +if podman network ls --format '{{.Name}}' 2>/dev/null | grep -q '^lynx-dashboard'; then + existing=true + existing_reason+=" Podman networks lynx-dashboard-* found." +fi +if podman ps -a --format '{{.Names}}' 2>/dev/null | grep -q '^lynx-dashboard'; then + existing=true + existing_reason+=" Containers lynx-dashboard-* found." +fi +if podman secret ls --format '{{.Name}}' 2>/dev/null | grep -q '^lynx-'; then + existing=true + existing_reason+=" Secrets lynx-* found." +fi +if [[ -d "$LYNX_DIR" ]]; then + existing=true + existing_reason+=" Directory $LYNX_DIR exists." +fi + +if $existing; then + log_warn "Existing installation detected:${existing_reason}" + echo "" + echo -e " ${BOLD}1)${RESET} Abort (default)" + echo -e " ${BOLD}2)${RESET} Update → runs auto-update instead" + echo -e " ${BOLD}3)${RESET} Reinstall clean → destroys all data" + echo "" + read -rp "Choice [1/2/3]: " choice + choice="${choice:-1}" + + case "$choice" in + 2) + log_info "Redirecting to auto-update..." + exec "$(dirname "${BASH_SOURCE[0]}")/update-dashboard.sh" + ;; + 3) + echo "" + log_warn "This will permanently destroy all Lynx data on this machine." + read -rp "Type 'reinstall lynx-dashboard' to confirm: " confirm + if [[ "$confirm" != "reinstall lynx-dashboard" ]]; then + log_error "Confirmation phrase mismatch. Aborting." + exit 1 + fi + log_info "Proceeding with clean reinstall..." + _cleanup_existing + ;; + *) + log_info "Aborting. No changes made." + exit 0 + ;; + esac +fi + +# --- Check dependencies ----------------------------------------------------- + +log_section "Checking system dependencies" + +_require_cmd() { + if ! command -v "$1" &>/dev/null; then + log_error "Required command not found: $1" + log_info "Install it with: $2" + exit 1 + fi + log_ok "$1 found" +} + +_require_cmd podman "apt install podman" +_require_cmd openssl "apt install openssl" +_require_cmd nft "apt install nftables" +_require_cmd wg "apt install wireguard-tools" +_require_cmd curl "apt install curl" +_require_cmd systemctl "systemd required" +_require_cmd free "procps required" + +# --- 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" "$CERTS_DIR" "$WG_DIR" +chmod 700 "$LYNX_DIR" "$CERTS_DIR" "$WG_DIR" +log_ok "Directories created" + +# --- Podman networks -------------------------------------------------------- + +log_section "Creating Podman networks" + +for net in lynx-dashboard-db lynx-dashboard-cache lynx-dashboard-app; do + if podman network exists "$net" 2>/dev/null; then + log_warn "Network $net already exists — skipping" + else + podman network create "$net" + log_ok "Network created: $net" + fi +done + +# --- Generate secrets ------------------------------------------------------- +# +# Secrets flow directly via pipe — never stored in files or shell history. +# Subshells ensure vars don't leak to parent environment. +# Passwords are overwritten in memory before the subshell exits. + +log_section "Generating secrets" + +log_info "Generating PostgreSQL root password..." +( + PG_ROOT=$(openssl rand -hex 32) + printf '%s' "$PG_ROOT" | podman secret create lynx-dashboard-pg-root - + PG_ROOT="$(openssl rand -hex 32)" # overwrite +) + +log_info "Generating PostgreSQL app password and database URL..." +( + PG_PASS=$(openssl rand -hex 32) + printf '%s' "$PG_PASS" | podman secret create lynx-dashboard-pg-pass - + printf 'postgresql://lynx_dashboard_app:%s@lynx-dashboard-postgres:5432/lynx_dashboard' "$PG_PASS" \ + | podman secret create lynx-dashboard-database-url - + PG_PASS="$(openssl rand -hex 32)" # overwrite +) + +log_info "Generating Redis password and URL..." +( + REDIS_PASS=$(openssl rand -hex 32) + printf '%s' "$REDIS_PASS" | podman secret create lynx-dashboard-redis-pass - + printf 'redis://:%s@lynx-dashboard-redis:6379' "$REDIS_PASS" \ + | podman secret create lynx-dashboard-redis-url - + REDIS_PASS="$(openssl rand -hex 32)" # overwrite +) + +log_info "Generating API token..." +openssl rand -hex 32 | podman secret create lynx-dashboard-api-token - + +log_info "Generating KEK (Key Encryption Key)..." +openssl rand -base64 32 | tr -d '\n' | podman secret create lynx-dashboard-kek - + +log_info "Generating pepper..." +openssl rand -hex 32 | podman secret create lynx-dashboard-pepper - + +log_info "Generating JWT signing keypair (Ed25519)..." +( + PRIV_PEM=$(openssl genpkey -algorithm ed25519 2>/dev/null) + PRIV_SEED=$(printf '%s' "$PRIV_PEM" | openssl pkey -outform DER 2>/dev/null | tail -c 32 | base64 -w0) + PUB_BYTES=$(printf '%s' "$PRIV_PEM" | openssl pkey -pubout -outform DER 2>/dev/null | tail -c 32 | base64 -w0) + printf '%s' "$PRIV_SEED" | podman secret create lynx-dashboard-jwt-sign-private - + printf '%s' "$PUB_BYTES" | podman secret create lynx-dashboard-jwt-sign-public - +) + +log_info "Generating JWT encryption keypair (X25519)..." +( + PRIV_PEM=$(openssl genpkey -algorithm x25519 2>/dev/null) + PRIV_BYTES=$(printf '%s' "$PRIV_PEM" | openssl pkey -outform DER 2>/dev/null | tail -c 32 | base64 -w0) + PUB_BYTES=$(printf '%s' "$PRIV_PEM" | openssl pkey -pubout -outform DER 2>/dev/null | tail -c 32 | base64 -w0) + printf '%s' "$PRIV_BYTES" | podman secret create lynx-dashboard-jwt-enc-private - + printf '%s' "$PUB_BYTES" | podman secret create lynx-dashboard-jwt-enc-public - +) + +log_info "Generating CA keypair (Ed25519)..." +( + PRIV_PEM=$(openssl genpkey -algorithm ed25519 2>/dev/null) + PRIV_SEED=$(printf '%s' "$PRIV_PEM" | openssl pkey -outform DER 2>/dev/null | tail -c 32 | base64 -w0) + PUB_BYTES=$(printf '%s' "$PRIV_PEM" | openssl pkey -pubout -outform DER 2>/dev/null | tail -c 32 | base64 -w0) + printf '%s' "$PRIV_SEED" | podman secret create lynx-dashboard-ca-private - + printf '%s' "$PUB_BYTES" | podman secret create lynx-dashboard-ca-public - +) + +log_info "Generating setup token (one-time bootstrap)..." +SETUP_TOKEN=$(openssl rand -hex 32) +printf '%s' "$SETUP_TOKEN" | podman secret create lynx-dashboard-setup-token - + +log_ok "All secrets generated — values purged from memory" + +# --- Start services --------------------------------------------------------- + +log_section "Starting services" + +COMPOSE_DIR="$(dirname "$COMPOSE_FILE")" + +# 1. PostgreSQL +log_info "Starting PostgreSQL..." +podman compose -f "$COMPOSE_FILE" up -d postgres + +log_info "Waiting for PostgreSQL to be healthy..." +for i in $(seq 1 30); do + if podman healthcheck run lynx-dashboard-postgres 2>/dev/null | grep -q healthy || + podman inspect lynx-dashboard-postgres --format '{{.State.Health.Status}}' 2>/dev/null | grep -q healthy; then + log_ok "PostgreSQL is healthy" + break + fi + if [[ $i -eq 30 ]]; then + log_error "PostgreSQL did not become healthy in time" + exit 1 + fi + sleep 2 +done + +# 2. Redis +log_info "Starting Redis..." +podman compose -f "$COMPOSE_FILE" up -d redis + +log_info "Waiting for Redis to be healthy..." +for i in $(seq 1 30); do + if podman inspect lynx-dashboard-redis --format '{{.State.Health.Status}}' 2>/dev/null | grep -q healthy; then + log_ok "Redis is healthy" + break + fi + if [[ $i -eq 30 ]]; then + log_error "Redis did not become healthy in time" + exit 1 + fi + sleep 2 +done + +# 3. Backend +log_info "Starting backend..." +podman compose -f "$COMPOSE_FILE" up -d backend + +log_info "Waiting for backend to be healthy..." +for i in $(seq 1 40); do + if podman inspect lynx-dashboard-backend --format '{{.State.Health.Status}}' 2>/dev/null | grep -q healthy; then + log_ok "Backend is healthy" + break + fi + if [[ $i -eq 40 ]]; then + log_error "Backend did not become healthy in time" + podman logs lynx-dashboard-backend --tail 50 + exit 1 + fi + sleep 3 +done + +# 4. Frontend +log_info "Starting frontend..." +podman compose -f "$COMPOSE_FILE" up -d frontend +log_ok "Frontend started" + +# --- TLS certificate -------------------------------------------------------- + +log_section "Generating TLS certificate" + +CERT="$CERTS_DIR/dashboard.crt" +KEY="$CERTS_DIR/dashboard.key" + +_generate_cert() { + local cn + cn=$(hostname -f 2>/dev/null || hostname) + openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:P-256 \ + -keyout "$KEY" -out "$CERT" \ + -days 90 -nodes -sha256 \ + -subj "/CN=${cn}/O=Lynx/OU=Dashboard" \ + -addext "subjectAltName=DNS:${cn},IP:$(hostname -I | awk '{print $1}')" \ + 2>/dev/null + chmod 600 "$KEY" + chmod 644 "$CERT" + log_ok "Certificate generated: $CERT (90 days, P-256)" +} + +_generate_cert + +# Systemd timer for certificate rotation (90-day renewal) +cat > /etc/systemd/system/lynx-dashboard-rotate-certs.service << 'EOF' +[Unit] +Description=Lynx Dashboard — rotate TLS certificate +After=network.target + +[Service] +Type=oneshot +ExecStart=/bin/bash -c 'openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:P-256 \ + -keyout /etc/lynx/certs/dashboard.key -out /etc/lynx/certs/dashboard.crt \ + -days 90 -nodes -sha256 \ + -subj "/CN=$(hostname -f)/O=Lynx/OU=Dashboard" \ + -addext "subjectAltName=DNS:$(hostname -f),IP:$(hostname -I | awk '"'"'{print $1}'"'"')" \ + && chmod 600 /etc/lynx/certs/dashboard.key \ + && podman kill -s HUP lynx-dashboard-frontend' +EOF + +cat > /etc/systemd/system/lynx-dashboard-rotate-certs.timer << 'EOF' +[Unit] +Description=Lynx Dashboard — TLS certificate rotation (every 80 days) + +[Timer] +OnCalendar=*-*-* 03:00:00 +Persistent=true +AccuracySec=1h +RandomizedDelaySec=1h +OnBootSec=10min + +[Install] +WantedBy=timers.target +EOF + +systemctl daemon-reload +systemctl enable --now lynx-dashboard-rotate-certs.timer +log_ok "Certificate rotation timer enabled (every 80 days)" + +# --- WireGuard — local agent tunnel ----------------------------------------- + +log_section "Setting up WireGuard tunnel (dashboard ↔ local agent)" + +WG_CONF="$WG_DIR/wg-lynx-dashboard.conf" + +# Generate dashboard keypair + PSK +DASHBOARD_PRIV=$(wg genkey) +DASHBOARD_PUB=$(printf '%s' "$DASHBOARD_PRIV" | wg pubkey) +# PSK is kept in scope until final output so admin can copy it for the agent install script. +# It is also stored as a Podman secret for the dashboard backend (peer management). +AGENT_PSK=$(wg genpsk) +printf '%s' "$AGENT_PSK" | podman secret create lynx-dashboard-local-agent-psk - + +# The local agent keypair is generated by the agent install script. +# Peer block is written by the agent install script after bootstrap. +cat > "$WG_CONF" << EOF +[Interface] +PrivateKey = ${DASHBOARD_PRIV} +Address = ${DASHBOARD_WG_IP}/16 +ListenPort = ${AGENT_WG_PORT} + +# Peer block added by agent install script after bootstrap: +# [Peer] +# PublicKey = +# PresharedKey = ${AGENT_PSK} +# AllowedIPs = ${AGENT_WG_IP}/32 +EOF + +chmod 600 "$WG_CONF" +DASHBOARD_PRIV="$(openssl rand -hex 32)" # overwrite in memory + +log_ok "WireGuard config written: $WG_CONF" +log_ok "Dashboard WireGuard pubkey: ${DASHBOARD_PUB}" +printf '%s' "$DASHBOARD_PUB" > "$LYNX_DIR/dashboard-wg-pubkey" + +# --- nftables --------------------------------------------------------------- + +log_section "Configuring nftables" + +cat > /etc/nftables-lynx-dashboard.conf << 'EOF' +table inet lynx-dashboard { + chain input { + type filter hook input priority filter; policy drop; + + # Loopback + iifname "lo" accept + + # Drop invalid state immediately + ct state invalid drop + + # Established / related + ct state established,related accept + + # Required ICMP types (path MTU, traceroute, diagnostics) + ip protocol icmp icmp type { destination-unreachable, time-exceeded, parameter-problem } accept + ip6 nexthdr icmpv6 icmpv6 type { destination-unreachable, packet-too-big, time-exceeded, parameter-problem } accept + + # ICMPv6 NDP — required for IPv6 neighbor discovery + ip6 nexthdr ipv6-icmp icmpv6 type { nd-router-advert, nd-neighbor-solicit, nd-neighbor-advert } accept + + # ICMP echo — rate limited + ip protocol icmp icmp type echo-request ct state new limit rate 3/second burst 10 packets accept + ip6 nexthdr icmpv6 icmpv6 type echo-request ct state new limit rate 3/second burst 10 packets accept + + # TCP flag anomalies — drop port scans and malformed packets + tcp flags == 0x0 drop + tcp flags & (fin | psh | urg) == fin | psh | urg drop + tcp flags ack ct state new drop + + # SSH — rate limited + tcp dport 22 ct state new limit rate 10/minute burst 5 packets accept + + # Dashboard panel + tcp dport 19443 ct state new accept + + # WireGuard (agent tunnels) + udp dport 51820 accept + + drop + } + + chain forward { + type filter hook forward priority filter; policy drop; + + ct state established,related accept + + # Allow backend container traffic to/from WireGuard (dashboard ↔ agents) + oifname "wg-lynx-dashboard" accept + iifname "wg-lynx-dashboard" accept + } + + chain output { + type filter hook output priority filter; policy accept; + + # Ports this server should never initiate outbound connections to + tcp dport { 25, 465, 587 } drop # SMTP — no email relay + tcp dport 23 drop # Telnet + tcp dport { 20, 21 } drop # FTP + udp dport { 137, 138 } drop # NetBIOS + tcp dport { 139, 445 } drop # SMB + tcp dport 6667 drop # IRC (common botnet C2) + tcp dport 111 drop # RPC + udp dport 111 drop + udp dport 69 drop # TFTP + tcp dport 6000-6063 drop # X11 + tcp dport 1080 drop # SOCKS proxy + udp dport 5353 drop # mDNS + tcp dport 6881-6889 drop # BitTorrent + udp dport 6881-6889 drop + } +} +EOF + +# Apply ruleset +nft -f /etc/nftables-lynx-dashboard.conf +log_ok "nftables rules applied (ports: 22 rate-limited, 19443, 51820 UDP)" + +# Persist across reboots +if [[ -f /etc/nftables.conf ]]; then + if ! grep -q "lynx-dashboard" /etc/nftables.conf; then + echo 'include "/etc/nftables-lynx-dashboard.conf"' >> /etc/nftables.conf + fi +fi +systemctl enable nftables 2>/dev/null || true + +# --- Done ------------------------------------------------------------------- + +log_section "Installation complete" + +HOST_IP=$(hostname -I | awk '{print $1}') +CERT_EXPIRY=$(openssl x509 -in "$CERT" -noout -enddate | cut -d= -f2) + +echo "" +echo -e "${GREEN}${BOLD}Lynx Dashboard is running!${RESET}" +echo "" +echo -e " ${BOLD}URL:${RESET} ${CYAN}https://${HOST_IP}:${LISTEN_PORT}${RESET}" +echo -e " ${BOLD}Cert expires:${RESET} ${CERT_EXPIRY}" +echo "" +echo -e "${BOLD}${YELLOW}=== Create your admin account ===${RESET}" +echo -e " Open this URL in your browser ${BOLD}(one-time use, expires in 24 hours):${RESET}" +echo -e " ${CYAN}https://${HOST_IP}:${LISTEN_PORT}/register?setup_token=${SETUP_TOKEN}${RESET}" +echo -e "${YELLOW}This is the only time the setup token is shown. Save the link now.${RESET}" +echo "" +SETUP_TOKEN="$(openssl rand -hex 32)" # overwrite in memory +unset SETUP_TOKEN +echo "" +echo -e "${BOLD}${YELLOW}=== WireGuard bootstrap data (copy for agent install) ===${RESET}" +echo -e " ${BOLD}Dashboard endpoint:${RESET} ${HOST_IP}:${AGENT_WG_PORT}" +echo -e " ${BOLD}Dashboard pubkey:${RESET} ${DASHBOARD_PUB}" +echo -e " ${BOLD}Preshared key:${RESET} ${AGENT_PSK}" +echo -e "${YELLOW}This is the only time the PSK is shown. Copy it now.${RESET}" +echo "" +# Clear PSK from memory after display +AGENT_PSK="$(openssl rand -hex 32)" +unset AGENT_PSK DASHBOARD_PUB +echo -e "${YELLOW}Next step:${RESET} Run the agent install script on this VPS to complete the local WireGuard tunnel." +echo "" +echo -e " ${BOLD}Made with love by Jaroc${RESET} — https://github.com/Jaro-c/Lynx" +echo "" diff --git a/lynx/dashboard/ui/.dockerignore b/lynx/dashboard/ui/.dockerignore new file mode 100644 index 0000000..f2096b4 --- /dev/null +++ b/lynx/dashboard/ui/.dockerignore @@ -0,0 +1,5 @@ +.next +node_modules +.git +*.md +.env* diff --git a/lynx/dashboard/ui/.gitignore b/lynx/dashboard/ui/.gitignore index 5ef6a52..5f166a9 100644 --- a/lynx/dashboard/ui/.gitignore +++ b/lynx/dashboard/ui/.gitignore @@ -32,6 +32,7 @@ yarn-error.log* # env files (can opt-in for committing if needed) .env* +!.env.example # vercel .vercel @@ -39,3 +40,7 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts + +# playwright +/playwright-report/ +/test-results/ diff --git a/lynx/dashboard/ui/AGENTS.md b/lynx/dashboard/ui/AGENTS.md new file mode 100644 index 0000000..8bd0e39 --- /dev/null +++ b/lynx/dashboard/ui/AGENTS.md @@ -0,0 +1,5 @@ + +# This is NOT the Next.js you know + +This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices. + diff --git a/lynx/dashboard/ui/Dockerfile b/lynx/dashboard/ui/Dockerfile new file mode 100644 index 0000000..0c9666a --- /dev/null +++ b/lynx/dashboard/ui/Dockerfile @@ -0,0 +1,31 @@ +FROM oven/bun:1-alpine AS base +WORKDIR /app + +FROM base AS deps +COPY package.json bun.lock bunfig.toml ./ +RUN bun install --frozen-lockfile + +FROM base AS builder +COPY --from=deps /app/node_modules ./node_modules +COPY . . +ENV NEXT_TELEMETRY_DISABLED=1 +RUN bun run build + +FROM base AS runner +WORKDIR /app +ENV NODE_ENV=production +ENV NEXT_TELEMETRY_DISABLED=1 + +RUN addgroup --system --gid 1001 nodejs && \ + adduser --system --uid 1001 nextjs + +COPY --from=builder /app/public ./public +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static + +USER nextjs +EXPOSE 3000 +ENV PORT=3000 +ENV HOSTNAME="0.0.0.0" + +CMD ["bun", "server.js"] diff --git a/lynx/dashboard/ui/biome.json b/lynx/dashboard/ui/biome.json index c20ca85..deecfdc 100644 --- a/lynx/dashboard/ui/biome.json +++ b/lynx/dashboard/ui/biome.json @@ -1,50 +1,82 @@ { - "$schema": "https://biomejs.dev/schemas/2.2.0/schema.json", - "vcs": { - "enabled": true, - "clientKind": "git", - "useIgnoreFile": true + "$schema": "https://biomejs.dev/schemas/2.4.15/schema.json", + "assist": { + "actions": { + "recommended": true, + "source": { + "organizeImports": "on", + "useSortedAttributes": "on", + "useSortedInterfaceMembers": "on", + "useSortedKeys": "on" + } + }, + "enabled": true + }, + "css": { + "formatter": { + "enabled": true, + "indentStyle": "tab", + "indentWidth": 4, + "lineEnding": "lf", + "lineWidth": 120, + "quoteStyle": "double", + "trailingNewline": true + }, + "linter": { + "enabled": true + }, + "parser": { + "tailwindDirectives": true + } }, "files": { "ignoreUnknown": true, "includes": ["**", "!node_modules", "!.next", "!dist", "!build"] }, "formatter": { + "bracketSpacing": true, "enabled": true, "indentStyle": "tab", "indentWidth": 4, + "lineEnding": "lf", "lineWidth": 120, - "lineEnding": "lf" + "trailingNewline": true }, "javascript": { "formatter": { - "quoteStyle": "double" + "bracketSpacing": true, + "jsxQuoteStyle": "double", + "quoteStyle": "double", + "semicolons": "always", + "trailingCommas": "all" + }, + "jsxRuntime": "transparent" + }, + "json": { + "parser": { + "allowComments": true, + "allowTrailingCommas": true } }, "linter": { + "domains": { + "next": "recommended", + "react": "recommended", + "tailwind": "recommended", + "test": "recommended" + }, "enabled": true, "rules": { "recommended": true, "suspicious": { "noUnknownAtRules": "off" - }, - "performance": { - "recommended": true - }, - "style": { - "recommended": true } - }, - "domains": { - "next": "recommended", - "react": "recommended" } }, - "assist": { - "actions": { - "source": { - "organizeImports": "on" - } - } + "vcs": { + "clientKind": "git", + "defaultBranch": "develop", + "enabled": true, + "useIgnoreFile": true } } diff --git a/lynx/dashboard/ui/bun.lock b/lynx/dashboard/ui/bun.lock index b6982e3..82b4e06 100644 --- a/lynx/dashboard/ui/bun.lock +++ b/lynx/dashboard/ui/bun.lock @@ -5,26 +5,48 @@ "": { "name": "test", "dependencies": { + "@base-ui/react": "^1.4.1", + "@hookform/resolvers": "5.2.2", + "@tanstack/react-table": "^8.21.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "date-fns": "^4.1.0", + "embla-carousel-react": "^8.6.0", + "input-otp": "^1.4.2", "lucide-react": "^1.14.0", "next": "^16.2.6", + "next-intl": "4.12.0", + "next-themes": "^0.4.6", "radix-ui": "^1.4.3", "react": "^19.2.6", + "react-day-picker": "^10.0.0", "react-dom": "^19.2.6", + "react-hook-form": "7.75.0", + "react-resizable-panels": "^4.11.0", + "recharts": "^3.8.1", "shadcn": "^4.7.0", + "sonner": "^2.0.7", "tailwind-merge": "^3.6.0", "tw-animate-css": "^1.4.0", + "vaul": "^1.1.2", + "zod": "4.4.3", }, "devDependencies": { "@biomejs/biome": "^2.4.15", + "@playwright/test": "^1.52.0", "@tailwindcss/postcss": "^4.3.0", + "@testing-library/jest-dom": "6.9.1", + "@testing-library/react": "16.3.2", + "@testing-library/user-event": "14.6.1", "@types/node": "^24.12.4", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "6.0.2", "babel-plugin-react-compiler": "^1.0.0", + "jsdom": "29.1.1", "tailwindcss": "^4.3.0", "typescript": "^6.0.3", + "vitest": "4.1.6", }, }, }, @@ -32,8 +54,18 @@ "sharp", ], "packages": { + "@adobe/css-tools": ["@adobe/css-tools@4.4.4", "", {}, "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg=="], + "@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="], + "@asamuzakjp/css-color": ["@asamuzakjp/css-color@5.1.11", "", { "dependencies": { "@asamuzakjp/generational-cache": "^1.0.1", "@csstools/css-calc": "^3.2.0", "@csstools/css-color-parser": "^4.1.0", "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg=="], + + "@asamuzakjp/dom-selector": ["@asamuzakjp/dom-selector@7.1.1", "", { "dependencies": { "@asamuzakjp/generational-cache": "^1.0.1", "@asamuzakjp/nwsapi": "^2.3.9", "bidi-js": "^1.0.3", "css-tree": "^3.2.1", "is-potential-custom-element-name": "^1.0.1" } }, "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ=="], + + "@asamuzakjp/generational-cache": ["@asamuzakjp/generational-cache@1.0.1", "", {}, "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg=="], + + "@asamuzakjp/nwsapi": ["@asamuzakjp/nwsapi@2.3.9", "", {}, "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q=="], + "@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], "@babel/compat-data": ["@babel/compat-data@7.29.3", "", {}, "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg=="], @@ -84,12 +116,18 @@ "@babel/preset-typescript": ["@babel/preset-typescript@7.28.5", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-validator-option": "^7.27.1", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-transform-modules-commonjs": "^7.27.1", "@babel/plugin-transform-typescript": "^7.28.5" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-+bQy5WOI2V6LJZpPVxY+yp66XdZ2yifu0Mc1aP5CQKgjn4QM5IN2i5fAZ4xKop47pr8rpVhiAeu+nDQa12C8+g=="], + "@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="], + "@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="], "@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="], "@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], + "@base-ui/react": ["@base-ui/react@1.4.1", "", { "dependencies": { "@babel/runtime": "^7.29.2", "@base-ui/utils": "0.2.8", "@floating-ui/react-dom": "^2.1.8", "@floating-ui/utils": "^0.2.11", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@date-fns/tz": "^1.2.0", "@types/react": "^17 || ^18 || ^19", "date-fns": "^4.0.0", "react": "^17 || ^18 || ^19", "react-dom": "^17 || ^18 || ^19" }, "optionalPeers": ["@date-fns/tz", "@types/react", "date-fns"] }, "sha512-Ab5/LIhcmL8BQcsBUYiOfkSDRdLpvgUBzMK30cu684JPcLclYlztharvCZyNNgzJtbAiREzI9q0pI5erHCMgCw=="], + + "@base-ui/utils": ["@base-ui/utils@0.2.8", "", { "dependencies": { "@babel/runtime": "^7.29.2", "@floating-ui/utils": "^0.2.11", "reselect": "^5.1.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@types/react": "^17 || ^18 || ^19", "react": "^17 || ^18 || ^19", "react-dom": "^17 || ^18 || ^19" }, "optionalPeers": ["@types/react"] }, "sha512-jvOi+c+ftGlGotNcKnzPVg2IhCaDTB6/6R3JeqdjdXktuAJi3wKH9T7+svuaKh1mmfVU11UWzUZVH74JDfi/wQ=="], + "@biomejs/biome": ["@biomejs/biome@2.4.15", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.15", "@biomejs/cli-darwin-x64": "2.4.15", "@biomejs/cli-linux-arm64": "2.4.15", "@biomejs/cli-linux-arm64-musl": "2.4.15", "@biomejs/cli-linux-x64": "2.4.15", "@biomejs/cli-linux-x64-musl": "2.4.15", "@biomejs/cli-win32-arm64": "2.4.15", "@biomejs/cli-win32-x64": "2.4.15" }, "bin": { "biome": "bin/biome" } }, "sha512-j5VH3a/h/HXTKBM50MDMxRCzkeLv9S2XJcW2WgnZT1+xyisi+0bISrXR82gCX+8S9lvK0skEvHJRN+3Ktr2hlw=="], "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.4.15", "", { "os": "darwin", "cpu": "arm64" }, "sha512-rF3PPqLq1yoST79zaQbDjVJwsuIeci/O+9bgNmC5QpgOqz6aqYuzA4abyAGx+mgyiDXn4A049xAN8gijbuR1Qg=="], @@ -108,12 +146,34 @@ "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.15", "", { "os": "win32", "cpu": "x64" }, "sha512-zBrGq5mx5wwpnow4+2BxUvleDM+GNd4sLbPaMapsSLQLD0NGRCquqPBTgN+7XkUteHvj7M+BstuI8tmnV7+HgQ=="], + "@bramus/specificity": ["@bramus/specificity@2.4.2", "", { "dependencies": { "css-tree": "^3.0.0" }, "bin": { "specificity": "bin/cli.js" } }, "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw=="], + + "@csstools/color-helpers": ["@csstools/color-helpers@6.0.2", "", {}, "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q=="], + + "@csstools/css-calc": ["@csstools/css-calc@3.2.1", "", { "peerDependencies": { "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg=="], + + "@csstools/css-color-parser": ["@csstools/css-color-parser@4.1.1", "", { "dependencies": { "@csstools/color-helpers": "^6.0.2", "@csstools/css-calc": "^3.2.1" }, "peerDependencies": { "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-eZ5XOtyhK+mggRafYUWzA0tvaYOFgdY8AkgQiCJF9qNAePnUo/zmsqqYubBBb3sQ8uNUaSKTY9s9klfRaAXL0g=="], + + "@csstools/css-parser-algorithms": ["@csstools/css-parser-algorithms@4.0.0", "", { "peerDependencies": { "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w=="], + + "@csstools/css-syntax-patches-for-csstree": ["@csstools/css-syntax-patches-for-csstree@1.1.4", "", { "peerDependencies": { "css-tree": "^3.2.1" }, "optionalPeers": ["css-tree"] }, "sha512-wgsqt92b7C7tQhIdPNxj0n9zuUbQlvAuI1exyzeNrOKOi62SD7ren8zqszmpVREjAOqg8cD2FqYhQfAuKjk4sw=="], + + "@csstools/css-tokenizer": ["@csstools/css-tokenizer@4.0.0", "", {}, "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA=="], + + "@date-fns/tz": ["@date-fns/tz@1.4.1", "", {}, "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA=="], + "@dotenvx/dotenvx": ["@dotenvx/dotenvx@1.65.0", "", { "dependencies": { "commander": "^11.1.0", "dotenv": "^17.2.1", "eciesjs": "^0.4.10", "execa": "^5.1.1", "fdir": "^6.2.0", "ignore": "^5.3.0", "object-treeify": "1.1.33", "picomatch": "^4.0.4", "which": "^4.0.0", "yocto-spinner": "^1.1.0" }, "bin": { "dotenvx": "src/cli/dotenvx.js" } }, "sha512-v4FA/Lw3pTEloLxBqTOaYDX6MNo0Jo7lGBsPZhwnJBqRJp0AzQg1ZZNxrFsh6HVC6QWeWrfIKLn0y2eyIXaVDg=="], "@ecies/ciphers": ["@ecies/ciphers@0.2.6", "", { "peerDependencies": { "@noble/ciphers": "^1.0.0" } }, "sha512-patgsRPKGkhhoBjETV4XxD0En4ui5fbX0hzayqI3M8tvNMGUoUvmyYAIWwlxBc1KX5cturfqByYdj5bYGRpN9g=="], + "@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="], + "@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], + "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], + + "@exodus/bytes": ["@exodus/bytes@1.15.0", "", { "peerDependencies": { "@noble/hashes": "^1.8.0 || ^2.0.0" }, "optionalPeers": ["@noble/hashes"] }, "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ=="], + "@floating-ui/core": ["@floating-ui/core@1.7.5", "", { "dependencies": { "@floating-ui/utils": "^0.2.11" } }, "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ=="], "@floating-ui/dom": ["@floating-ui/dom@1.7.6", "", { "dependencies": { "@floating-ui/core": "^1.7.5", "@floating-ui/utils": "^0.2.11" } }, "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ=="], @@ -122,8 +182,18 @@ "@floating-ui/utils": ["@floating-ui/utils@0.2.11", "", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="], + "@formatjs/fast-memoize": ["@formatjs/fast-memoize@3.1.5", "", {}, "sha512-KLi3fan6WnCHmigd9pmEEN8Hid0v4wiFBW576M/d07KMWYecf1CvyMI3n34vCmHT4AoVqG2n702kiHbXjzZX2A=="], + + "@formatjs/icu-messageformat-parser": ["@formatjs/icu-messageformat-parser@3.5.9", "", { "dependencies": { "@formatjs/icu-skeleton-parser": "2.1.9" } }, "sha512-PZm6O9JI/gUPtQV9r2eaMuLb4yWqV2vz+ot03ORHWTKO343LSpZi0TqeXLB2ZZGDXLCw2SbfgsQ0GxoxXMl79g=="], + + "@formatjs/icu-skeleton-parser": ["@formatjs/icu-skeleton-parser@2.1.9", "", {}, "sha512-rsxswgHMfU1zUgB2byc08fesf83wLGjFnzLCEtuf00mx2doiqc6pYrf67raI37XqdRcGUviQepk2UKGqpng74Q=="], + + "@formatjs/intl-localematcher": ["@formatjs/intl-localematcher@0.8.8", "", { "dependencies": { "@formatjs/fast-memoize": "3.1.5" } }, "sha512-pBr2hVKWvkHVnfXegW+53NT9U2uaVQCc+EgzLPCCwXqBA3nvM5fPbK9IcJlNjV+NMKGyZ2F3ZSG78iGdxAAqbA=="], + "@hono/node-server": ["@hono/node-server@1.19.14", "", { "peerDependencies": { "hono": "^4" } }, "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw=="], + "@hookform/resolvers": ["@hookform/resolvers@5.2.2", "", { "dependencies": { "@standard-schema/utils": "^0.3.0" }, "peerDependencies": { "react-hook-form": "^7.55.0" } }, "sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA=="], + "@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="], "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="], @@ -198,6 +268,8 @@ "@mswjs/interceptors": ["@mswjs/interceptors@0.41.9", "", { "dependencies": { "@open-draft/deferred-promise": "^2.2.0", "@open-draft/logger": "^0.3.0", "@open-draft/until": "^2.0.0", "is-node-process": "^1.2.0", "outvariant": "^1.4.3", "strict-event-emitter": "^0.5.1" } }, "sha512-VVPPgHyQ6ShqnrmDWuxjmUIsO9gWyOZFmuOfLd9LfBGQJwZfy0gvv9pbHSJuoFNIYC7ZDX9aoFwowjcdSC4E8w=="], + "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="], + "@next/env": ["@next/env@16.2.6", "", {}, "sha512-gd8HoHN4ufj73WmR3JmVolrpJR47ILK6LouP5xElPglaVxir6e1a7VzvTvDWkOoPXT9rkkTzyCxBu4yeZfZwcw=="], "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.2.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ZJGkkcNfYgrrMkqOdZ7zoLa1TOy0qpcMfk/z4Mh/FKUz40gVO+HNQWqmLxf67Z5WB64DRp0dhEbyHfel+6sJUg=="], @@ -234,6 +306,38 @@ "@open-draft/until": ["@open-draft/until@2.1.0", "", {}, "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg=="], + "@oxc-project/types": ["@oxc-project/types@0.130.0", "", {}, "sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q=="], + + "@parcel/watcher": ["@parcel/watcher@2.5.6", "", { "dependencies": { "detect-libc": "^2.0.3", "is-glob": "^4.0.3", "node-addon-api": "^7.0.0", "picomatch": "^4.0.3" }, "optionalDependencies": { "@parcel/watcher-android-arm64": "2.5.6", "@parcel/watcher-darwin-arm64": "2.5.6", "@parcel/watcher-darwin-x64": "2.5.6", "@parcel/watcher-freebsd-x64": "2.5.6", "@parcel/watcher-linux-arm-glibc": "2.5.6", "@parcel/watcher-linux-arm-musl": "2.5.6", "@parcel/watcher-linux-arm64-glibc": "2.5.6", "@parcel/watcher-linux-arm64-musl": "2.5.6", "@parcel/watcher-linux-x64-glibc": "2.5.6", "@parcel/watcher-linux-x64-musl": "2.5.6", "@parcel/watcher-win32-arm64": "2.5.6", "@parcel/watcher-win32-ia32": "2.5.6", "@parcel/watcher-win32-x64": "2.5.6" } }, "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ=="], + + "@parcel/watcher-android-arm64": ["@parcel/watcher-android-arm64@2.5.6", "", { "os": "android", "cpu": "arm64" }, "sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A=="], + + "@parcel/watcher-darwin-arm64": ["@parcel/watcher-darwin-arm64@2.5.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA=="], + + "@parcel/watcher-darwin-x64": ["@parcel/watcher-darwin-x64@2.5.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg=="], + + "@parcel/watcher-freebsd-x64": ["@parcel/watcher-freebsd-x64@2.5.6", "", { "os": "freebsd", "cpu": "x64" }, "sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng=="], + + "@parcel/watcher-linux-arm-glibc": ["@parcel/watcher-linux-arm-glibc@2.5.6", "", { "os": "linux", "cpu": "arm" }, "sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ=="], + + "@parcel/watcher-linux-arm-musl": ["@parcel/watcher-linux-arm-musl@2.5.6", "", { "os": "linux", "cpu": "arm" }, "sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg=="], + + "@parcel/watcher-linux-arm64-glibc": ["@parcel/watcher-linux-arm64-glibc@2.5.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA=="], + + "@parcel/watcher-linux-arm64-musl": ["@parcel/watcher-linux-arm64-musl@2.5.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA=="], + + "@parcel/watcher-linux-x64-glibc": ["@parcel/watcher-linux-x64-glibc@2.5.6", "", { "os": "linux", "cpu": "x64" }, "sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ=="], + + "@parcel/watcher-linux-x64-musl": ["@parcel/watcher-linux-x64-musl@2.5.6", "", { "os": "linux", "cpu": "x64" }, "sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg=="], + + "@parcel/watcher-win32-arm64": ["@parcel/watcher-win32-arm64@2.5.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q=="], + + "@parcel/watcher-win32-ia32": ["@parcel/watcher-win32-ia32@2.5.6", "", { "os": "win32", "cpu": "ia32" }, "sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g=="], + + "@parcel/watcher-win32-x64": ["@parcel/watcher-win32-x64@2.5.6", "", { "os": "win32", "cpu": "x64" }, "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw=="], + + "@playwright/test": ["@playwright/test@1.60.0", "", { "dependencies": { "playwright": "1.60.0" }, "bin": { "playwright": "cli.js" } }, "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag=="], + "@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="], "@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="], @@ -354,12 +458,82 @@ "@radix-ui/rect": ["@radix-ui/rect@1.1.1", "", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="], + "@reduxjs/toolkit": ["@reduxjs/toolkit@2.11.2", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@standard-schema/utils": "^0.3.0", "immer": "^11.0.0", "redux": "^5.0.1", "redux-thunk": "^3.1.0", "reselect": "^5.1.0" }, "peerDependencies": { "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" }, "optionalPeers": ["react", "react-redux"] }, "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ=="], + + "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.1", "", { "os": "android", "cpu": "arm64" }, "sha512-fJI3I0r3C3Oj/zdBCpaCmBRZYf07xpaq4yCfDDoSFm+beWNzbIl26puW8RraUdugoJw/95zerNOn6jasAhzSmg=="], + + "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-cKnAhWEsV7TPcA/5EAteDp6KcJZBQ2G+BqE7zayMMi7kMvwRsbv7WT9aOnn0WNl4SKEIf43vjS31iUPu80nzXg=="], + + "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-YKrVwQjIRBPo+5G/u03wGjbdy4q7pyzCe93DK9VJ7zkVmeg8LJ7GbgsiHWdR4xSoe4CAXRD7Bcjgbtr64bkXNg=="], + + "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-z/oBsREo46SsFqBwYtFe0kpJeBijAT48O/WXLI4suiCLBkr03RTtTJMCzSdDd2znlh8VJizL09XVkQgk8IZonw=="], + + "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.1", "", { "os": "linux", "cpu": "arm" }, "sha512-ik8q7GM11zxvYxFc2PeDcT6TBvhCQMaUxfph/M5l9sKuTs/Sjg3L+Byw0F7w0ZVLBZmx30P+gG0ECzzN+MFcmQ=="], + + "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-QoSx2EkyrrdZ6kcyE8stqZ62t0Yra8Fs5ia9lOxJrh6TMQJK7gQKmscdTHf7pOXKREKrVwOtJcQG3qVSfc866A=="], + + "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-uwNwFpwKeNiZawfAWBgg0VIztPTV3ihhh1vV334h9ivnNLorxnQMU6Fz8wG1Zb4Qh9LC1/MkcyT3YlDXG3Rsgg=="], + + "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-zY1bul7OWr7DFBiJ++wofXvnr8B45ce3QsQUhKrIhXsygAh7bTkwyeM1bi1a2g5C/yC/N8TZyGDEoMfm/l9mpg=="], + + "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-0frlsT/f4Ft6I7SMESTKnF3cZsdicQn1dCMkF/jT9wDLE+gGoiQfv1nmT9e+s7s/fekvvy6tZM2jHvI2tkbJDQ=="], + + "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.1", "", { "os": "linux", "cpu": "x64" }, "sha512-XABVmGp9Tg0WspTVvwduTc4fpqy6JnAUrSQe6OuyqD/03nI7r0O9OWUkMIwFrjKAIqolvqoA4ZrJppgwE0Gxmw=="], + + "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.1", "", { "os": "linux", "cpu": "x64" }, "sha512-bV4fzswuzVcKD90o/VM6QqKxnxlDq0g2BISDLNVmxrnhpv1DDbyPhCIjYfvzYLV+MvkKKnQt2Q6AO86SEBULUQ=="], + + "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.1", "", { "os": "none", "cpu": "arm64" }, "sha512-/Mh0Zhq3OP7fVs0kcQHZP6lZEthMGTaSf8UBQYSFEZDWGXXlEC+nJ6EqenaK2t4LBXMe3A+K/G2BVXXdtOr4PQ=="], + + "@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.1", "", { "dependencies": { "@emnapi/core": "1.10.0", "@emnapi/runtime": "1.10.0", "@napi-rs/wasm-runtime": "^1.1.4" }, "cpu": "none" }, "sha512-+1xc9X45l8ufsBAm6Gjvx2qDRIY9lTVt0cgWNcJ+1gdhXvkbxePA60yRTwSTuXL09CMhyJmjpV7E3NoyxbqFQQ=="], + + "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-1D+UqZdfnuR+Jy1GgMJwi85bD40H21uNmOPRWQhw4oRSuolZ/B5rixZ45DK2KXOTCvmVCecauWgEhbw8bI7tOw=="], + + "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.1", "", { "os": "win32", "cpu": "x64" }, "sha512-INAycaWuhlOK3wk4mRHGsdgwYWmd9cChdPdE9bwWmy6rn9VqVNYNFGhOdXrofXUxwHIncSiPNb8tNm8knDVIeQ=="], + + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="], + + "@schummar/icu-type-parser": ["@schummar/icu-type-parser@1.21.5", "", {}, "sha512-bXHSaW5jRTmke9Vd0h5P7BtWZG9Znqb8gSDxZnxaGSJnGwPLDPfS+3g0BKzeWqzgZPsIVZkM7m2tbo18cm5HBw=="], + "@sec-ant/readable-stream": ["@sec-ant/readable-stream@0.4.1", "", {}, "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg=="], "@sindresorhus/merge-streams": ["@sindresorhus/merge-streams@4.0.0", "", {}, "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ=="], + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + + "@standard-schema/utils": ["@standard-schema/utils@0.3.0", "", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="], + + "@swc/core": ["@swc/core@1.15.33", "", { "dependencies": { "@swc/counter": "^0.1.3", "@swc/types": "^0.1.26" }, "optionalDependencies": { "@swc/core-darwin-arm64": "1.15.33", "@swc/core-darwin-x64": "1.15.33", "@swc/core-linux-arm-gnueabihf": "1.15.33", "@swc/core-linux-arm64-gnu": "1.15.33", "@swc/core-linux-arm64-musl": "1.15.33", "@swc/core-linux-ppc64-gnu": "1.15.33", "@swc/core-linux-s390x-gnu": "1.15.33", "@swc/core-linux-x64-gnu": "1.15.33", "@swc/core-linux-x64-musl": "1.15.33", "@swc/core-win32-arm64-msvc": "1.15.33", "@swc/core-win32-ia32-msvc": "1.15.33", "@swc/core-win32-x64-msvc": "1.15.33" }, "peerDependencies": { "@swc/helpers": ">=0.5.17" }, "optionalPeers": ["@swc/helpers"] }, "sha512-jOlwnFV2xhuuZeAUILGFULeR6vDPfijEJ57evfocwznQldLU3w2cZ9bSDryY9ip+AsM3r1NJKzf47V2NXebkeQ=="], + + "@swc/core-darwin-arm64": ["@swc/core-darwin-arm64@1.15.33", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N+L0uXhuO7FIfzqwgxmzv0zIpV0qEp8wPX3QQs2p4atjMoywup2JTeDlXPw+z9pWJGCae3JjM+tZ6myclI+2gA=="], + + "@swc/core-darwin-x64": ["@swc/core-darwin-x64@1.15.33", "", { "os": "darwin", "cpu": "x64" }, "sha512-/Il4QHSOhV4FekbsDtkrNmKbsX26oSysvgrRswa/RYOHXAkwXDbB4jaeKq6PsJLSPkzJ2KzQ061gtBnk0vNHfA=="], + + "@swc/core-linux-arm-gnueabihf": ["@swc/core-linux-arm-gnueabihf@1.15.33", "", { "os": "linux", "cpu": "arm" }, "sha512-C64hBnBxq4viOPQ8hlx+2lJ23bzZBGnjw7ryALmS+0Q3zHmwO8lw1/DArLENw4Q18/0w5wdEO1k3m1wWNtKGqQ=="], + + "@swc/core-linux-arm64-gnu": ["@swc/core-linux-arm64-gnu@1.15.33", "", { "os": "linux", "cpu": "arm64" }, "sha512-TRJfnJbX3jqpxRDRoieMzRiCBS5jOmXNb3iQXmcgjFEHKLnAgK1RZRU8Cq1MsPqO4jAJp/ld1G4O3fXuxv85uw=="], + + "@swc/core-linux-arm64-musl": ["@swc/core-linux-arm64-musl@1.15.33", "", { "os": "linux", "cpu": "arm64" }, "sha512-il7tYM+CpUNzieQbwAjFT1P8zqAhmGWNAGhQZBnxurXZ0aNn+5nqYFTEUKNZl7QibtT0uQXzTZrNGHCIj6Y1Og=="], + + "@swc/core-linux-ppc64-gnu": ["@swc/core-linux-ppc64-gnu@1.15.33", "", { "os": "linux", "cpu": "ppc64" }, "sha512-ZtNBwN0Z7CFj9Il0FcPaKdjgP7URyKu/3RfH46vq+0paOBqLj4NYldD6Qo//Duif/7IOtAraUfDOmp0PLAufog=="], + + "@swc/core-linux-s390x-gnu": ["@swc/core-linux-s390x-gnu@1.15.33", "", { "os": "linux", "cpu": "s390x" }, "sha512-De1IyajoOmhOYYjw/lx66bKlyDpHZTueqwpDrWgf5O7T6d1ODeJJO9/OqMBmrBQc5C+dNnlmIufHsp4QVCWufA=="], + + "@swc/core-linux-x64-gnu": ["@swc/core-linux-x64-gnu@1.15.33", "", { "os": "linux", "cpu": "x64" }, "sha512-mGTH0YxmUN+x6vRN/I6NOk5X0ogNktkwPnJ94IMvR7QjhRDwL0O8RXEDhyUM0YtwWrryBOqaJQBX4zruxEPRGw=="], + + "@swc/core-linux-x64-musl": ["@swc/core-linux-x64-musl@1.15.33", "", { "os": "linux", "cpu": "x64" }, "sha512-hj628ZkSEJf6zMf5VMbYrG2O6QqyTIp2qwY6VlCjvIa9lAEZ5c2lfPblCLVGYubTeLJDxadLB/CxqQYOQABeEQ=="], + + "@swc/core-win32-arm64-msvc": ["@swc/core-win32-arm64-msvc@1.15.33", "", { "os": "win32", "cpu": "arm64" }, "sha512-GV2oohtN2/5+KSccl86VULu3aT+LrISC8uzgSq0FRnikpD+Zwc+sBlXmoKQ+Db6jI57ITUOIB8jRkdGMABC29g=="], + + "@swc/core-win32-ia32-msvc": ["@swc/core-win32-ia32-msvc@1.15.33", "", { "os": "win32", "cpu": "ia32" }, "sha512-gtyvzSNR8DHKfFEA2uqb8Ld1myqi6uEg2jyeUq3ikn5ytYs7H8RpZYC8mdy4NXr8hfcdJfCLXPlYaqqfBXpoEQ=="], + + "@swc/core-win32-x64-msvc": ["@swc/core-win32-x64-msvc@1.15.33", "", { "os": "win32", "cpu": "x64" }, "sha512-d6fRqQSkJI+kmMEBWaDQ7TMl8+YjLYbwRUPZQ9DY0ORBJeTzOrG0twvfvlZ2xgw6jA0ScQKgfBm4vHLSLl5Hqg=="], + + "@swc/counter": ["@swc/counter@0.1.3", "", {}, "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ=="], + "@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="], + "@swc/types": ["@swc/types@0.1.26", "", { "dependencies": { "@swc/counter": "^0.1.3" } }, "sha512-lyMwd7WGgG79RS7EERZV3T8wMdmPq3xwyg+1nmAM64kIhx5yl+juO2PYIHb7vTiPgPCj8LYjsNV2T5wiQHUEaw=="], + "@tailwindcss/node": ["@tailwindcss/node@4.3.0", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.21.0", "jiti": "^2.6.1", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.0" } }, "sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g=="], "@tailwindcss/oxide": ["@tailwindcss/oxide@4.3.0", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.3.0", "@tailwindcss/oxide-darwin-arm64": "4.3.0", "@tailwindcss/oxide-darwin-x64": "4.3.0", "@tailwindcss/oxide-freebsd-x64": "4.3.0", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.0", "@tailwindcss/oxide-linux-arm64-gnu": "4.3.0", "@tailwindcss/oxide-linux-arm64-musl": "4.3.0", "@tailwindcss/oxide-linux-x64-gnu": "4.3.0", "@tailwindcss/oxide-linux-x64-musl": "4.3.0", "@tailwindcss/oxide-wasm32-wasi": "4.3.0", "@tailwindcss/oxide-win32-arm64-msvc": "4.3.0", "@tailwindcss/oxide-win32-x64-msvc": "4.3.0" } }, "sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg=="], @@ -390,8 +564,48 @@ "@tailwindcss/postcss": ["@tailwindcss/postcss@4.3.0", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.3.0", "@tailwindcss/oxide": "4.3.0", "postcss": "^8.5.10", "tailwindcss": "4.3.0" } }, "sha512-Jm05Tjx+9yCLGv5qw1c+84Psds8MnyrEQYCB+FFk2lgGiUjlRqdxke4mVTuYrj2xnVZqKim2Apr5ySuQRYAw/w=="], + "@tanstack/react-table": ["@tanstack/react-table@8.21.3", "", { "dependencies": { "@tanstack/table-core": "8.21.3" }, "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww=="], + + "@tanstack/table-core": ["@tanstack/table-core@8.21.3", "", {}, "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg=="], + + "@testing-library/dom": ["@testing-library/dom@10.4.1", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg=="], + + "@testing-library/jest-dom": ["@testing-library/jest-dom@6.9.1", "", { "dependencies": { "@adobe/css-tools": "^4.4.0", "aria-query": "^5.0.0", "css.escape": "^1.5.1", "dom-accessibility-api": "^0.6.3", "picocolors": "^1.1.1", "redent": "^3.0.0" } }, "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA=="], + + "@testing-library/react": ["@testing-library/react@16.3.2", "", { "dependencies": { "@babel/runtime": "^7.12.5" }, "peerDependencies": { "@testing-library/dom": "^10.0.0", "@types/react": "^18.0.0 || ^19.0.0", "@types/react-dom": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g=="], + + "@testing-library/user-event": ["@testing-library/user-event@14.6.1", "", { "peerDependencies": { "@testing-library/dom": ">=7.21.4" } }, "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw=="], + "@ts-morph/common": ["@ts-morph/common@0.27.0", "", { "dependencies": { "fast-glob": "^3.3.3", "minimatch": "^10.0.1", "path-browserify": "^1.0.1" } }, "sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ=="], + "@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="], + + "@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="], + + "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], + + "@types/d3-array": ["@types/d3-array@3.2.2", "", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="], + + "@types/d3-color": ["@types/d3-color@3.1.3", "", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="], + + "@types/d3-ease": ["@types/d3-ease@3.0.2", "", {}, "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="], + + "@types/d3-interpolate": ["@types/d3-interpolate@3.0.4", "", { "dependencies": { "@types/d3-color": "*" } }, "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA=="], + + "@types/d3-path": ["@types/d3-path@3.1.1", "", {}, "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="], + + "@types/d3-scale": ["@types/d3-scale@4.0.9", "", { "dependencies": { "@types/d3-time": "*" } }, "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw=="], + + "@types/d3-shape": ["@types/d3-shape@3.1.8", "", { "dependencies": { "@types/d3-path": "*" } }, "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w=="], + + "@types/d3-time": ["@types/d3-time@3.0.4", "", {}, "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="], + + "@types/d3-timer": ["@types/d3-timer@3.0.2", "", {}, "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="], + + "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="], + + "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], + "@types/node": ["@types/node@24.12.4", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA=="], "@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="], @@ -402,8 +616,26 @@ "@types/statuses": ["@types/statuses@2.0.6", "", {}, "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA=="], + "@types/use-sync-external-store": ["@types/use-sync-external-store@0.0.6", "", {}, "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="], + "@types/validate-npm-package-name": ["@types/validate-npm-package-name@4.0.2", "", {}, "sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw=="], + "@vitejs/plugin-react": ["@vitejs/plugin-react@6.0.2", "", { "dependencies": { "@rolldown/pluginutils": "^1.0.0" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler"] }, "sha512-DlSMqo4WhThw4vB8Mpn0Woe9J+Jfq1geJ61AKW0QEgLzGMNwtIMdxbDUzLxcun8W7NbJO0e2Jg/Nxm3cCSVzzg=="], + + "@vitest/expect": ["@vitest/expect@4.1.6", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.6", "@vitest/utils": "4.1.6", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-7EHDquPthALSV0jhhjgEW8FXaviMx7rSqu8W6oqCoAuOhKov814P99QDV1pxMA3QPv21YudvJngIhjrNI4opLg=="], + + "@vitest/mocker": ["@vitest/mocker@4.1.6", "", { "dependencies": { "@vitest/spy": "4.1.6", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-MCFc63czMjEInOlcY2cpQCvCN+KgbAn+60xu9cMgP4sKaLC5JNAKw7JH8QdAnoAC88hW1IiSNZ+GgVXlN1UcMQ=="], + + "@vitest/pretty-format": ["@vitest/pretty-format@4.1.6", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-h5SxD/IzNhZYnrSZRsUZQIC+vD0GY8cUvq0iwsmkFKixRCKLLWqCXa/FIQ4S1R+sI+PGoojkHsdNrbZiM9Qpgw=="], + + "@vitest/runner": ["@vitest/runner@4.1.6", "", { "dependencies": { "@vitest/utils": "4.1.6", "pathe": "^2.0.3" } }, "sha512-nOPCmn2+yD0ZNmKdsXGv/UxMMWbMuKeD6GyYncNwdkYDxpQvrPSKYj2rWuDjC2Y4b6w6hjip5dBKFzEUuZe3vA=="], + + "@vitest/snapshot": ["@vitest/snapshot@4.1.6", "", { "dependencies": { "@vitest/pretty-format": "4.1.6", "@vitest/utils": "4.1.6", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-YhsdE6xAVfTDmzjxL2ZDUvjj+ZsgyOKe+TdQzqkD72wIOmHka8NuGQ6NpTNZv9D2Z63fbwWKJPeVpEw4EQgYxw=="], + + "@vitest/spy": ["@vitest/spy@4.1.6", "", {}, "sha512-JFKxMx6udhwKh/Ldo270e17QX710vgunMkuPAvXjHSvC6oqLWAHhVhjg/I71q0u0CBSErIODV1Kjv0FQNSWjdg=="], + + "@vitest/utils": ["@vitest/utils@4.1.6", "", { "dependencies": { "@vitest/pretty-format": "4.1.6", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-FxIY+U81R3LGKCxaHHFRQ5+g6/iRgGLmeHWdp2Amj4ljQRrEIWHmZyDfDYBRZlpyqA7qKxtS9DD1dhk8RnRIVQ=="], + "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], @@ -412,14 +644,18 @@ "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], - "ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], + "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + "ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], "aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="], + "aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="], + + "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], + "ast-types": ["ast-types@0.16.1", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg=="], "babel-plugin-react-compiler": ["babel-plugin-react-compiler@1.0.0", "", { "dependencies": { "@babel/types": "^7.26.0" } }, "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw=="], @@ -428,6 +664,8 @@ "baseline-browser-mapping": ["baseline-browser-mapping@2.10.29", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-Asa2krT+XTPZINCS+2QcyS8WTkObE77RwkydwF7h6DmnKqbvlalz93m/dnphUyCa6SWSP51VgtEUf2FN+gelFQ=="], + "bidi-js": ["bidi-js@1.0.3", "", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw=="], + "body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="], "brace-expansion": ["brace-expansion@5.0.6", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g=="], @@ -448,6 +686,8 @@ "caniuse-lite": ["caniuse-lite@1.0.30001792", "", {}, "sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw=="], + "chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], + "chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], "class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="], @@ -488,14 +728,48 @@ "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + "css-tree": ["css-tree@3.2.1", "", { "dependencies": { "mdn-data": "2.27.1", "source-map-js": "^1.2.1" } }, "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA=="], + + "css.escape": ["css.escape@1.5.1", "", {}, "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg=="], + "cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="], "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + "d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="], + + "d3-color": ["d3-color@3.1.0", "", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="], + + "d3-ease": ["d3-ease@3.0.1", "", {}, "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w=="], + + "d3-format": ["d3-format@3.1.2", "", {}, "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg=="], + + "d3-interpolate": ["d3-interpolate@3.0.1", "", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="], + + "d3-path": ["d3-path@3.1.0", "", {}, "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ=="], + + "d3-scale": ["d3-scale@4.0.2", "", { "dependencies": { "d3-array": "2.10.0 - 3", "d3-format": "1 - 3", "d3-interpolate": "1.2.0 - 3", "d3-time": "2.1.1 - 3", "d3-time-format": "2 - 4" } }, "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ=="], + + "d3-shape": ["d3-shape@3.2.0", "", { "dependencies": { "d3-path": "^3.1.0" } }, "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA=="], + + "d3-time": ["d3-time@3.1.0", "", { "dependencies": { "d3-array": "2 - 3" } }, "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q=="], + + "d3-time-format": ["d3-time-format@4.1.0", "", { "dependencies": { "d3-time": "1 - 3" } }, "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg=="], + + "d3-timer": ["d3-timer@3.0.1", "", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="], + "data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="], + "data-urls": ["data-urls@7.0.0", "", { "dependencies": { "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.0" } }, "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA=="], + + "date-fns": ["date-fns@4.1.0", "", {}, "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg=="], + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + "decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="], + + "decimal.js-light": ["decimal.js-light@2.5.1", "", {}, "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="], + "dedent": ["dedent@1.7.2", "", { "peerDependencies": { "babel-plugin-macros": "^3.1.0" }, "optionalPeers": ["babel-plugin-macros"] }, "sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA=="], "deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="], @@ -508,12 +782,16 @@ "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], + "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], "detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="], "diff": ["diff@8.0.4", "", {}, "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw=="], + "dom-accessibility-api": ["dom-accessibility-api@0.6.3", "", {}, "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w=="], + "dotenv": ["dotenv@17.4.2", "", {}, "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw=="], "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], @@ -524,12 +802,20 @@ "electron-to-chromium": ["electron-to-chromium@1.5.353", "", {}, "sha512-kOrWphBi8TOZyiJZqsgqIle0lw+tzmnQK83pV9dZUd01Nm2POECSyFQMAuarzZdYqQW7FH9RaYOuaRo3h+bQ3w=="], + "embla-carousel": ["embla-carousel@8.6.0", "", {}, "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA=="], + + "embla-carousel-react": ["embla-carousel-react@8.6.0", "", { "dependencies": { "embla-carousel": "8.6.0", "embla-carousel-reactive-utils": "8.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.1 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-0/PjqU7geVmo6F734pmPqpyHqiM99olvyecY7zdweCw+6tKEXnrE90pBiBbMMU8s5tICemzpQ3hi5EpxzGW+JA=="], + + "embla-carousel-reactive-utils": ["embla-carousel-reactive-utils@8.6.0", "", { "peerDependencies": { "embla-carousel": "8.6.0" } }, "sha512-fMVUDUEx0/uIEDM0Mz3dHznDhfX+znCCDCeIophYb1QGVM7YThSWX+wz11zlYwWFOr74b4QLGg0hrGPJeG2s4A=="], + "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], "enhanced-resolve": ["enhanced-resolve@5.21.3", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-QyL119InA+XXEkNLNTPCXPugSvOfhwv0JOlGNzvxs0hZaiHLNvXSpudUWsOlsXGWJh8G6ckCScEkVHfX3kw/2Q=="], + "entities": ["entities@8.0.0", "", {}, "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA=="], + "env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="], "error-ex": ["error-ex@1.3.4", "", { "dependencies": { "is-arrayish": "^0.2.1" } }, "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ=="], @@ -538,22 +824,32 @@ "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + "es-module-lexer": ["es-module-lexer@2.1.0", "", {}, "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ=="], + "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], + "es-toolkit": ["es-toolkit@1.46.1", "", {}, "sha512-5eNtXOs3tbfxXOj04tjjseeWkRWaoCjdEI+96DgwzZoe6c9juL49pXlzAFTI72aWC9Y8p7168g6XIKjh7k6pyQ=="], + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], "esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="], + "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], + "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], + "eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="], + "eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="], "eventsource-parser": ["eventsource-parser@3.0.8", "", {}, "sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ=="], "execa": ["execa@9.6.1", "", { "dependencies": { "@sindresorhus/merge-streams": "^4.0.0", "cross-spawn": "^7.0.6", "figures": "^6.1.0", "get-stream": "^9.0.0", "human-signals": "^8.0.1", "is-plain-obj": "^4.1.0", "is-stream": "^4.0.1", "npm-run-path": "^6.0.0", "pretty-ms": "^9.2.0", "signal-exit": "^4.1.0", "strip-final-newline": "^4.0.0", "yoctocolors": "^2.1.1" } }, "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA=="], + "expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="], + "express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], "express-rate-limit": ["express-rate-limit@8.5.1", "", { "dependencies": { "ip-address": "^10.2.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-5O6KYmyJEpuPJV5hNTXKbAHWRqrzyu+OI3vUnSd2kXFubIVpG7ezpgxQy76Zo5GQZtrQBg86hF+CM/NX+cioiQ=="], @@ -590,6 +886,8 @@ "fs-extra": ["fs-extra@11.3.5", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg=="], + "fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], "fuzzysort": ["fuzzysort@3.1.0", "", {}, "sha512-sR9BNCjBg6LNgwvxlBd0sBABvQitkLzoVY9MYYROQVX/FvfJ4Mai9LsGhDgd8qYdds0bY77VzYd5iuB+v5rwQQ=="], @@ -626,6 +924,8 @@ "hono": ["hono@4.12.18", "", {}, "sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ=="], + "html-encoding-sniffer": ["html-encoding-sniffer@6.0.0", "", { "dependencies": { "@exodus/bytes": "^1.6.0" } }, "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg=="], + "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], @@ -634,12 +934,24 @@ "iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], + "icu-minify": ["icu-minify@4.12.0", "", { "dependencies": { "@formatjs/icu-messageformat-parser": "^3.4.0" } }, "sha512-zDmM05uav3t3+kxSfRrNlmyXOdj2b+uHA+p04CG32eJabtaHbugXujuL+YfRkwP9joAnf0Uh+RMGCKD5NLa5rQ=="], + "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], + "immer": ["immer@10.2.0", "", {}, "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw=="], + "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], + "indent-string": ["indent-string@4.0.0", "", {}, "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg=="], + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + "input-otp": ["input-otp@1.4.2", "", { "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-l3jWwYNvrEa6NTCt7BECfCm48GvwuZzkoeG3gBL2w4CHeOXW3eKFmf9UNYkNfYc3mxMrthMnxjIE07MT0zLBQA=="], + + "internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="], + + "intl-messageformat": ["intl-messageformat@11.2.6", "", { "dependencies": { "@formatjs/fast-memoize": "3.1.5", "@formatjs/icu-messageformat-parser": "3.5.9" } }, "sha512-afAN2yNN7zjB77G1ZC5L8GtLrEshyBvOQXz88flxCO/ocTIQist98gu0r/O6H/SSiQhQsOOtWPxmCEvtDABXXQ=="], + "ip-address": ["ip-address@10.2.0", "", {}, "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA=="], "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], @@ -668,6 +980,8 @@ "is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="], + "is-potential-custom-element-name": ["is-potential-custom-element-name@1.0.1", "", {}, "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="], + "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], "is-regexp": ["is-regexp@3.1.0", "", {}, "sha512-rbku49cWloU5bSMI+zaRaXdQHXnthP6DZ/vLnfdSKyL4zUzuWnomtOEiZZOd+ioQ+avFo/qau3KPTc7Fjy1uPA=="], @@ -688,6 +1002,8 @@ "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], + "jsdom": ["jsdom@29.1.1", "", { "dependencies": { "@asamuzakjp/css-color": "^5.1.11", "@asamuzakjp/dom-selector": "^7.1.1", "@bramus/specificity": "^2.4.2", "@csstools/css-syntax-patches-for-csstree": "^1.1.3", "@exodus/bytes": "^1.15.0", "css-tree": "^3.2.1", "data-urls": "^7.0.0", "decimal.js": "^10.6.0", "html-encoding-sniffer": "^6.0.0", "is-potential-custom-element-name": "^1.0.1", "lru-cache": "^11.3.5", "parse5": "^8.0.1", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^6.0.1", "undici": "^7.25.0", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^8.0.1", "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.1", "xml-name-validator": "^5.0.0" }, "peerDependencies": { "canvas": "^3.0.0" }, "optionalPeers": ["canvas"] }, "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q=="], + "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], "json-parse-even-better-errors": ["json-parse-even-better-errors@2.3.1", "", {}, "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="], @@ -730,14 +1046,18 @@ "log-symbols": ["log-symbols@6.0.0", "", { "dependencies": { "chalk": "^5.3.0", "is-unicode-supported": "^1.3.0" } }, "sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw=="], - "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + "lru-cache": ["lru-cache@11.3.6", "", {}, "sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A=="], "lucide-react": ["lucide-react@1.14.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-+1mdWcfSJVUsaTIjN9zoezmUhfXo5l0vP7ekBMPo3jcS/aIkxHnXqAPsByszMZx/Y8oQBRJxJx5xg+RH3urzxA=="], + "lz-string": ["lz-string@1.5.0", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="], + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + "mdn-data": ["mdn-data@2.27.1", "", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="], + "media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="], "merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="], @@ -756,6 +1076,8 @@ "mimic-function": ["mimic-function@5.0.1", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="], + "min-indent": ["min-indent@1.0.1", "", {}, "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg=="], + "minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], @@ -772,6 +1094,14 @@ "next": ["next@16.2.6", "", { "dependencies": { "@next/env": "16.2.6", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.2.6", "@next/swc-darwin-x64": "16.2.6", "@next/swc-linux-arm64-gnu": "16.2.6", "@next/swc-linux-arm64-musl": "16.2.6", "@next/swc-linux-x64-gnu": "16.2.6", "@next/swc-linux-x64-musl": "16.2.6", "@next/swc-win32-arm64-msvc": "16.2.6", "@next/swc-win32-x64-msvc": "16.2.6", "sharp": "^0.34.5" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-qOVgKJg1+At15NpeUP+eJgCHvTCgXsogweq87Ri/Ix7PkqQHg4sdaXmSFqKlgaIXE4kW0g25LE68W87UANlHtw=="], + "next-intl": ["next-intl@4.12.0", "", { "dependencies": { "@formatjs/intl-localematcher": "^0.8.1", "@parcel/watcher": "^2.4.1", "@swc/core": "^1.15.2", "icu-minify": "^4.12.0", "negotiator": "^1.0.0", "next-intl-swc-plugin-extractor": "^4.12.0", "po-parser": "^2.1.1", "use-intl": "^4.12.0" }, "peerDependencies": { "next": "^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0 || ^16.0.0", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0" } }, "sha512-v8KpppWG0yLLlChJ3Of6uoPew9LeRDBAtY6vpJmF7YJmBZlHEzzoEL4w1g1dAU+VleEPNoXNm9hg1eEsKWV5hw=="], + + "next-intl-swc-plugin-extractor": ["next-intl-swc-plugin-extractor@4.12.0", "", {}, "sha512-jUxVEu1Nryjt4YgaDktSys7ioOgQfcNPF/SF2dbPNxbVb6U+P1INRgHeCVN+EC59H2rnTFIQwbddmOCrUWFr3g=="], + + "next-themes": ["next-themes@0.4.6", "", { "peerDependencies": { "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA=="], + + "node-addon-api": ["node-addon-api@7.1.1", "", {}, "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="], + "node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="], "node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="], @@ -786,6 +1116,8 @@ "object-treeify": ["object-treeify@1.1.33", "", {}, "sha512-EFVjAYfzWqWsBMRHPMAXLCDIJnpMhdWAqR7xG6M6a2cs6PMFpl/+Z20w9zDW4vkxOFfddegBKq9Rehd0bxWE7A=="], + "obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="], + "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], @@ -804,6 +1136,8 @@ "parse-ms": ["parse-ms@4.0.0", "", {}, "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw=="], + "parse5": ["parse5@8.0.1", "", { "dependencies": { "entities": "^8.0.0" } }, "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw=="], + "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], "path-browserify": ["path-browserify@1.0.1", "", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="], @@ -812,24 +1146,36 @@ "path-to-regexp": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="], + "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], "pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="], + "playwright": ["playwright@1.60.0", "", { "dependencies": { "playwright-core": "1.60.0" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA=="], + + "playwright-core": ["playwright-core@1.60.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA=="], + + "po-parser": ["po-parser@2.1.1", "", {}, "sha512-ECF4zHLbUItpUgE3OTtLKlPjeBN+fKEczj2zYjDfCGOzicNs0GK3Vg2IoAYwx7LH/XYw43fZQP6xnZ4TkNxSLQ=="], + "postcss": ["postcss@8.5.14", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg=="], "postcss-selector-parser": ["postcss-selector-parser@7.1.1", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg=="], "powershell-utils": ["powershell-utils@0.1.0", "", {}, "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A=="], + "pretty-format": ["pretty-format@27.5.1", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="], + "pretty-ms": ["pretty-ms@9.3.0", "", { "dependencies": { "parse-ms": "^4.0.0" } }, "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ=="], "prompts": ["prompts@2.4.2", "", { "dependencies": { "kleur": "^3.0.3", "sisteransi": "^1.0.5" } }, "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q=="], "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], + "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + "qs": ["qs@6.15.1", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg=="], "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], @@ -842,20 +1188,40 @@ "react": ["react@19.2.6", "", {}, "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q=="], + "react-day-picker": ["react-day-picker@10.0.0", "", { "dependencies": { "@date-fns/tz": "^1.4.1", "date-fns": "^4.1.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-lrEXo5wFPsq5LTcayelM3BPueD00v7zbdipAY+EIdPcseVykYwkOWx4Ujn/EtbBvpnp8ZPUHol17HXH6kVbZoA=="], + "react-dom": ["react-dom@19.2.6", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.6" } }, "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g=="], + "react-hook-form": ["react-hook-form@7.75.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-Ovv94H+0p3sJ7B9B5QxPuCP1u8V/cHuVGyH55cSwodYDtoJwK+fqk3vjfIgSX59I2U/bU4z0nRJ9HMLpNiWEmw=="], + + "react-is": ["react-is@19.2.6", "", {}, "sha512-XjBR15BhXuylgWGuslhDKqlSayuqvqBX91BP8pauG8kd1zY8kotkNWbXksTCNRarse4kuGbe2kIY05ARtwNIvw=="], + + "react-redux": ["react-redux@9.2.0", "", { "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" }, "peerDependencies": { "@types/react": "^18.2.25 || ^19", "react": "^18.0 || ^19", "redux": "^5.0.0" }, "optionalPeers": ["@types/react", "redux"] }, "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g=="], + "react-remove-scroll": ["react-remove-scroll@2.7.2", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="], "react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="], + "react-resizable-panels": ["react-resizable-panels@4.11.0", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-LPk/AkFDGkg7SsbOyL93ojrE6E7lhrxxDwnYNjfmnSeI6BE7Sje6dB24PXgZk8DeugdeXNk1LO+ohRqIjhxiLw=="], + "react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="], "recast": ["recast@0.23.11", "", { "dependencies": { "ast-types": "^0.16.1", "esprima": "~4.0.0", "source-map": "~0.6.1", "tiny-invariant": "^1.3.3", "tslib": "^2.0.1" } }, "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA=="], + "recharts": ["recharts@3.8.1", "", { "dependencies": { "@reduxjs/toolkit": "^1.9.0 || 2.x.x", "clsx": "^2.1.1", "decimal.js-light": "^2.5.1", "es-toolkit": "^1.39.3", "eventemitter3": "^5.0.1", "immer": "^10.1.1", "react-redux": "8.x.x || 9.x.x", "reselect": "5.1.1", "tiny-invariant": "^1.3.3", "use-sync-external-store": "^1.2.2", "victory-vendor": "^37.0.2" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg=="], + + "redent": ["redent@3.0.0", "", { "dependencies": { "indent-string": "^4.0.0", "strip-indent": "^3.0.0" } }, "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg=="], + + "redux": ["redux@5.0.1", "", {}, "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w=="], + + "redux-thunk": ["redux-thunk@3.1.0", "", { "peerDependencies": { "redux": "^5.0.0" } }, "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw=="], + "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], + "reselect": ["reselect@5.1.1", "", {}, "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w=="], + "resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], "restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="], @@ -864,6 +1230,8 @@ "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], + "rolldown": ["rolldown@1.0.1", "", { "dependencies": { "@oxc-project/types": "=0.130.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.1", "@rolldown/binding-darwin-arm64": "1.0.1", "@rolldown/binding-darwin-x64": "1.0.1", "@rolldown/binding-freebsd-x64": "1.0.1", "@rolldown/binding-linux-arm-gnueabihf": "1.0.1", "@rolldown/binding-linux-arm64-gnu": "1.0.1", "@rolldown/binding-linux-arm64-musl": "1.0.1", "@rolldown/binding-linux-ppc64-gnu": "1.0.1", "@rolldown/binding-linux-s390x-gnu": "1.0.1", "@rolldown/binding-linux-x64-gnu": "1.0.1", "@rolldown/binding-linux-x64-musl": "1.0.1", "@rolldown/binding-openharmony-arm64": "1.0.1", "@rolldown/binding-wasm32-wasi": "1.0.1", "@rolldown/binding-win32-arm64-msvc": "1.0.1", "@rolldown/binding-win32-x64-msvc": "1.0.1" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-X0KQHljNnEkWNqqiz9zJrGunh1B0HgOxLXvnFpCOcadzcy5qohZ3tqMEUg00vncoRovXuK3ZqCT9KnnKzoInFQ=="], + "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="], "run-applescript": ["run-applescript@7.1.0", "", {}, "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q=="], @@ -872,6 +1240,8 @@ "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + "saxes": ["saxes@6.0.0", "", { "dependencies": { "xmlchars": "^2.2.0" } }, "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA=="], + "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], "semver": ["semver@7.8.0", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA=="], @@ -900,16 +1270,24 @@ "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], + "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], + "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], + "sonner": ["sonner@2.0.7", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w=="], + "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], + "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], + "std-env": ["std-env@4.1.0", "", {}, "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ=="], + "stdin-discarder": ["stdin-discarder@0.2.2", "", {}, "sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ=="], "strict-event-emitter": ["strict-event-emitter@0.5.1", "", {}, "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ=="], @@ -924,8 +1302,12 @@ "strip-final-newline": ["strip-final-newline@4.0.0", "", {}, "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw=="], + "strip-indent": ["strip-indent@3.0.0", "", { "dependencies": { "min-indent": "^1.0.0" } }, "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ=="], + "styled-jsx": ["styled-jsx@5.1.6", "", { "dependencies": { "client-only": "0.0.1" }, "peerDependencies": { "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" } }, "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA=="], + "symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="], + "tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="], "tailwind-merge": ["tailwind-merge@3.6.0", "", {}, "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w=="], @@ -936,6 +1318,14 @@ "tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="], + "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], + + "tinyexec": ["tinyexec@1.1.2", "", {}, "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA=="], + + "tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="], + + "tinyrainbow": ["tinyrainbow@3.1.0", "", {}, "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw=="], + "tldts": ["tldts@7.0.30", "", { "dependencies": { "tldts-core": "^7.0.30" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-ELrFxuqsDdHUwoh0XxDbxuLD3Wnz49Z57IFvTtvWy1hJdcMZjXLIuonjilCiWHlT2GbE4Wlv1wKVTzDFnXH1aw=="], "tldts-core": ["tldts-core@7.0.30", "", {}, "sha512-uiHN8PIB1VmWyS98eZYja4xzlYqeFZVjb4OuYlJQnZAuJhMw4PbKQOKgHKhBdJR3FE/t5mUQ1Kd80++B+qhD1Q=="], @@ -946,6 +1336,8 @@ "tough-cookie": ["tough-cookie@6.0.1", "", { "dependencies": { "tldts": "^7.0.5" } }, "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw=="], + "tr46": ["tr46@6.0.0", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw=="], + "ts-morph": ["ts-morph@26.0.0", "", { "dependencies": { "@ts-morph/common": "~0.27.0", "code-block-writer": "^13.0.3" } }, "sha512-ztMO++owQnz8c/gIENcM9XfCEzgoGphTv+nKpYNM1bgsdOVC/jRZuEBf6N+mLLDNg68Kl+GgUZfOySaRiG1/Ug=="], "tsconfig-paths": ["tsconfig-paths@4.2.0", "", { "dependencies": { "json5": "^2.2.2", "minimist": "^1.2.6", "strip-bom": "^3.0.0" } }, "sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg=="], @@ -960,6 +1352,8 @@ "typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], + "undici": ["undici@7.25.0", "", {}, "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ=="], + "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], "unicorn-magic": ["unicorn-magic@0.3.0", "", {}, "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA=="], @@ -974,6 +1368,8 @@ "use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="], + "use-intl": ["use-intl@4.12.0", "", { "dependencies": { "@formatjs/fast-memoize": "^3.1.0", "@schummar/icu-type-parser": "1.21.5", "icu-minify": "^4.12.0", "intl-messageformat": "^11.1.0" }, "peerDependencies": { "react": "^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0" } }, "sha512-r+qVb7UI1+kiOhjYsmsNUCY+jrnjVopwGeFlmMyQj4YInlwZzgMeMSv9n8MqnWWy77HL5BVM8K2WgX50SbtcpA=="], + "use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="], "use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="], @@ -984,16 +1380,38 @@ "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], + "vaul": ["vaul@1.1.2", "", { "dependencies": { "@radix-ui/react-dialog": "^1.1.1" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA=="], + + "victory-vendor": ["victory-vendor@37.3.6", "", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ=="], + + "vite": ["vite@8.0.13", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.14", "rolldown": "1.0.1", "tinyglobby": "^0.2.16" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.18", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-MFtjBYgzmSxmgA4RAfjIyXWpGe1oALnjgUTzzV7QLx/TKxCzjtMH6Fd9/eVK+5Fg1qNoz5VAwsmMs/NofrmJvw=="], + + "vitest": ["vitest@4.1.6", "", { "dependencies": { "@vitest/expect": "4.1.6", "@vitest/mocker": "4.1.6", "@vitest/pretty-format": "4.1.6", "@vitest/runner": "4.1.6", "@vitest/snapshot": "4.1.6", "@vitest/spy": "4.1.6", "@vitest/utils": "4.1.6", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.6", "@vitest/browser-preview": "4.1.6", "@vitest/browser-webdriverio": "4.1.6", "@vitest/coverage-istanbul": "4.1.6", "@vitest/coverage-v8": "4.1.6", "@vitest/ui": "4.1.6", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-6lvjbS3p9b4CrdCmguzbh2/4uoXhGE2q71R4OX5sqF9R1bo9Xd6fGrMAfvp5wnCzlBnFVdCOp6onuTQVbo8iUQ=="], + + "w3c-xmlserializer": ["w3c-xmlserializer@5.0.0", "", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA=="], + "web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="], + "webidl-conversions": ["webidl-conversions@8.0.1", "", {}, "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ=="], + + "whatwg-mimetype": ["whatwg-mimetype@5.0.0", "", {}, "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw=="], + + "whatwg-url": ["whatwg-url@16.0.1", "", { "dependencies": { "@exodus/bytes": "^1.11.0", "tr46": "^6.0.0", "webidl-conversions": "^8.0.1" } }, "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw=="], + "which": ["which@4.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg=="], + "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], + "wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], "wsl-utils": ["wsl-utils@0.3.1", "", { "dependencies": { "is-wsl": "^3.1.0", "powershell-utils": "^0.1.0" } }, "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg=="], + "xml-name-validator": ["xml-name-validator@5.0.0", "", {}, "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg=="], + + "xmlchars": ["xmlchars@2.2.0", "", {}, "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="], + "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], @@ -1006,12 +1424,14 @@ "yoctocolors": ["yoctocolors@2.1.2", "", {}, "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug=="], - "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], "zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="], "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "@babel/helper-create-class-features-plugin/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], @@ -1020,8 +1440,12 @@ "@dotenvx/dotenvx/execa": ["execa@5.1.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="], + "@modelcontextprotocol/sdk/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "@mswjs/interceptors/@open-draft/deferred-promise": ["@open-draft/deferred-promise@2.2.0", "", {}, "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA=="], + "@reduxjs/toolkit/immer": ["immer@11.1.8", "", {}, "sha512-/tbkHMW7y10Lx6i1crLjD4/OhNkRG+Fo7byZHtah0547nIeXYcpIXaUh0IAQY6gO5459qpGGYapcEOHtFXkIuA=="], + "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" }, "bundled": true }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], @@ -1034,6 +1458,10 @@ "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "@testing-library/dom/aria-query": ["aria-query@5.3.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="], + + "@testing-library/dom/dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="], + "@types/set-cookie-parser/@types/node": ["@types/node@20.19.41", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ=="], "cliui/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], @@ -1052,12 +1480,22 @@ "npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="], + "pretty-format/react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="], + "prompts/kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="], "restore-cursor/onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="], "router/path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="], + "shadcn/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], + + "vite/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + "wrap-ansi/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], "wrap-ansi/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], @@ -1080,18 +1518,12 @@ "cliui/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], - "cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - "cross-spawn/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], "wrap-ansi/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], - "wrap-ansi/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - "yargs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], "yargs/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], - - "yargs/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], } } diff --git a/lynx/dashboard/ui/components.json b/lynx/dashboard/ui/components.json new file mode 100644 index 0000000..2cfca44 --- /dev/null +++ b/lynx/dashboard/ui/components.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "radix-nova", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/app/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "rtl": false, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "menuColor": "default", + "menuAccent": "subtle", + "registries": {} +} diff --git a/lynx/dashboard/ui/messages/en.json b/lynx/dashboard/ui/messages/en.json new file mode 100644 index 0000000..14bd6e6 --- /dev/null +++ b/lynx/dashboard/ui/messages/en.json @@ -0,0 +1,417 @@ +{ + "auth": { + "login": { + "title": "Sign in to {company}", + "subtitle": "Distributed infrastructure orchestration", + "username": "Username", + "password": "Password", + "submit": "Sign in", + "submitting": "Signing in...", + "noAccount": "Don't have an account?", + "register": "Create account", + "invalidCredentials": "Invalid username or password", + "rateLimited": "Too many attempts. Try again in {minutes} minutes.", + "serverError": "Something went wrong. Please try again." + }, + "register": { + "title": "Create your account", + "subtitle": "Start orchestrating your infrastructure", + "username": "Username", + "email": "Email", + "password": "Password", + "submit": "Create account", + "submitting": "Creating account...", + "hasAccount": "Already have an account?", + "login": "Sign in", + "usernameTaken": "Username already taken", + "emailTaken": "Email already registered", + "serverError": "Something went wrong. Please try again.", + "validation": { + "usernameMin": "Username must be at least 3 characters", + "usernameMax": "Username cannot exceed 32 characters", + "usernameChars": "Only lowercase letters, numbers, - and _ allowed", + "usernameEdge": "Cannot start or end with - or _", + "usernameReserved": "This username is reserved", + "emailInvalid": "Enter a valid email address", + "passwordMin": "Password must be at least 12 characters", + "passwordMax": "Password cannot exceed 30 characters", + "passwordUppercase": "Must contain at least one uppercase letter", + "passwordLowercase": "Must contain at least one lowercase letter", + "passwordNumber": "Must contain at least one number", + "passwordSpecial": "Must contain at least one special character" + } + } + }, + "app": { + "nav": { + "overview": "Overview", + "agents": "Agents", + "organizations": "Organizations", + "settings": "Settings", + "admin": "Admin", + "signOut": "Sign out" + }, + "organizations": { + "title": "Organizations", + "create": "New Organization", + "slugConflict": "Slug already taken. Choose a different name.", + "createError": "Failed to create organization.", + "noOrgs": "No organizations yet", + "noOrgsDesc": "Create an organization to start managing projects and containers.", + "slug": "Slug:", + "members": "Members:", + "invite": "Invite member", + "inviteTitle": "Invite a member", + "inviteUsername": "Username", + "inviteRole": "Role", + "inviteSubmit": "Send invite", + "inviteSuccess": "Member invited.", + "inviteError": "Failed to invite member.", + "removeMember": "Remove", + "removeMemberSuccess": "Member removed.", + "removeMemberError": "Failed to remove member.", + "noMembers": "No members yet.", + "orgId": "ID:", + "projects": "Projects", + "noProjects": "No projects yet.", + "createProject": "New project", + "createProjectTitle": "Create project", + "projectName": "Name", + "projectSlug": "Slug", + "projectAgent": "Target agent", + "projectNoAgents": "No agents available. Register an agent first.", + "projectCreate": "Create project", + "projectCreateSuccess": "Project created.", + "projectSlugConflict": "Slug already taken in this organization.", + "projectCreateError": "Failed to create project." + }, + "projects": { + "orgs": "Organizations", + "org": "Organization", + "slug": "Slug:", + "verticalScale": "Vertical Scaling", + "verticalScaleDesc": "Update CPU and memory limits for a container in this project. The container will be updated live without restart when possible.", + "containerName": "Container name", + "cpus": "CPUs", + "memoryMb": "Memory (MB)", + "apply": "Apply limits", + "applySuccess": "Container limits updated.", + "applyError": "Failed to update container limits.", + "projectId": "Project ID:", + "containers": "Containers", + "noContainers": "No containers running.", + "cStart": "Start", + "cStop": "Stop", + "cRestart": "Restart", + "cRemove": "Remove", + "cActionSuccess": "done", + "cActionError": "Container action failed.", + "deploy": "Deploy container", + "deployBtn": "Deploy", + "deploySuccess": "Container deployed.", + "deployError": "Deploy failed.", + "cName": "Container name", + "cImage": "Image", + "cPorts": "Ports (comma-separated)", + "cEnv": "Env vars (comma-separated)", + "horizontalScale": "Horizontal Scaling", + "horizontalScaleDesc": "Deploy replicas on another agent over a direct WireGuard data-plane tunnel. Traffic stays agent-to-agent — never routed through the dashboard.", + "addTunnel": "Add tunnel", + "addTunnelTitle": "Scale to another agent", + "tunnelTargetAgent": "Target agent", + "tunnelImage": "Container image", + "tunnelReplicas": "Replica count", + "tunnelConfirm": "Establish tunnel & deploy", + "tunnelSuccess": "Tunnel established and replicas deployed.", + "tunnelError": "Failed to establish tunnel.", + "tunnelTeardownSuccess": "Tunnel torn down.", + "tunnelTeardownError": "Failed to tear down tunnel.", + "noTunnels": "No active tunnels.", + "tunnelReplicaCount": "Replicas" + }, + "settings": { + "title": "Settings", + "profile": "Profile", + "profileUsername": "Username", + "changePassword": "Change password", + "currentPassword": "Current password", + "newPassword": "New password", + "changePasswordBtn": "Change password", + "changePasswordSuccess": "Password changed. You have been signed out.", + "changePasswordWrong": "Current password is incorrect.", + "changePasswordError": "Failed to change password.", + "singleSession": "Single active session", + "singleSessionDesc": "When enabled, signing in on a new device will invalidate all other active sessions.", + "singleSessionSuccess": "Single session setting updated.", + "singleSessionError": "Failed to update setting.", + "domain": "Domain", + "domainDesc": "Configure a custom domain with Let's Encrypt TLS. Requires DNS A record pointing to this server before setup.", + "domainCurrent": "Current domain:", + "domainNone": "Using IP address (no custom domain)", + "domainInput": "Domain (e.g. panel.example.com)", + "domainEmail": "Email for Let's Encrypt", + "domainSetup": "Configure domain", + "domainPending": "Setup in progress…", + "domainActive": "Active", + "domainError": "Error", + "domainUnconfigured": "Not configured", + "domainVerify": "Verify DNS", + "domainDnsOk": "DNS resolved correctly.", + "domainDnsFail": "Domain does not resolve. Check your DNS A record.", + "domainVerifyError": "Failed to verify.", + "domainSetupError": "Failed to start domain setup.", + "domainHsts": "HSTS", + "domainHstsDesc": "Force HTTPS for all visitors. Only enable after domain and TLS cert are confirmed working.", + "domainHstsEnable": "Enable HSTS", + "domainHstsDisable": "Disable HSTS", + "domainHstsSuccess": "HSTS updated.", + "domainHstsError": "Failed to update HSTS.", + "domainClosePort": "Close port 19443", + "domainClosePortDesc": "Remove direct IP access on port 19443. Only possible after domain is active. Requires SSH to re-enable.", + "domainClosePortBtn": "Close port 19443", + "domainClosePortConfirm": "This will remove access on port 19443. You will only be able to reach the panel via your configured domain. This cannot be undone remotely. Continue?", + "domainClosePortSuccess": "Port 19443 closed.", + "domainClosePortError": "Failed to close port.", + "domainCert": "Certificate:", + "domainCertSelfSigned": "Self-signed", + "domainCertLE": "Let's Encrypt", + "domainCertCloudflare": "Cloudflare Origin", + "domainCertCustom": "Custom", + "domainCertExpires": "Expires:", + "domainCertUpload": "Upload Certificate", + "domainCertUploadCloudflare": "Cloudflare Origin", + "domainCertUploadCustom": "Custom Certificate", + "domainCertPem": "Certificate (PEM)", + "domainCertPemPlaceholder": "-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----", + "domainCertKeyPem": "Private Key (PEM)", + "domainCertKeyPemPlaceholder": "-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----", + "domainCertKeyOptional": "Private key is not required for Cloudflare Origin certificates.", + "domainCertUploadSuccess": "Certificate uploaded and applied.", + "domainCertUploadError": "Failed to upload certificate.", + "security": "Security", + "rotationLog": "Key rotation history", + "rotationLogEmpty": "No rotations recorded.", + "rotationLogReason": "Reason", + "rotationLogScope": "Scope", + "rotateKeys": "Rotate JWT Keys", + "rotateKeysDesc": "Invalidates all active sessions. Everyone must sign in again.", + "rotateKeysBtn": "Rotate & sign out", + "rotateKeysConfirm": "This will invalidate all active sessions, including yours. Continue?", + "sessions": "Active Sessions", + "noSessions": "No active sessions found.", + "lastUsed": "Last used:", + "revoke": "Revoke", + "revokeSuccess": "Session revoked.", + "revokeError": "Failed to revoke session.", + "updates": "Updates", + "updatesDesc": "Check GitHub Releases for new versions and push updates to all connected agents.", + "updateCheck": "Check for updates", + "updateCurrent": "Current:", + "updateLatest": "Latest:", + "updateUpToDate": "Up to date", + "updateAvailable": "Update available", + "updateTrigger": "Trigger update on all agents", + "updateTriggerSuccess": "Update triggered. Agents will update shortly.", + "updateTriggerError": "Failed to trigger update.", + "updateCheckError": "Failed to check for updates.", + "migration": "Dashboard Migration", + "migrationDesc": "Move this dashboard to another VPS. Requires a fresh dashboard installation on the target VPS. Agent SSH access may be needed if any agent fails to reconnect automatically.", + "migrationSourceTitle": "Migrate to another VPS (this is VPS-A)", + "migrationSourceDesc": "Enter the target VPS URL and the migration token from VPS-B to start migration.", + "migrationTargetUrl": "Target VPS URL", + "migrationToken": "Migration token (from VPS-B)", + "migrationStart": "Start migration", + "migrationTargetTitle": "Receive migration (this is VPS-B)", + "migrationTargetDesc": "Generate a migration token to give to VPS-A.", + "migrationPrepare": "Generate migration token", + "migrationPreparedToken": "Copy this token to VPS-A:", + "migrationCopyToken": "Copy", + "migrationAbort": "Abort migration", + "migrationConfirmShutdown": "Confirm shutdown", + "migrationConfirmShutdownMsg": "All agents have confirmed. This will shut down the current dashboard. Make sure VPS-B is fully operational. Continue?", + "migrationStatusIdle": "Idle", + "migrationStatusPreparing": "Preparing", + "migrationStatusTransferring": "Transferring", + "migrationStatusNotifying": "Notifying agents", + "migrationStatusWaiting": "Waiting for agents", + "migrationStatusCompleted": "Completed", + "migrationStatusAborted": "Aborted", + "migrationStatusError": "Error", + "migrationAgentsProgress": "{confirmed} of {total} agents confirmed", + "migrationPrepareError": "Failed to prepare migration.", + "migrationStartError": "Failed to start migration.", + "migrationAbortSuccess": "Migration aborted.", + "migrationAbortError": "Failed to abort migration.", + "migrationShutdownError": "Failed to confirm shutdown.", + "branding": "White-label Branding", + "brandingCompanyName": "Company Name", + "brandingLogoUrl": "Logo URL", + "brandingPrimaryColor": "Primary", + "brandingSecondaryColor": "Secondary", + "brandingAccentColor": "Accent", + "brandingSave": "Save branding", + "brandingSaved": "Branding updated.", + "brandingError": "Failed to update branding." + }, + "auditLog": { + "title": "Audit Log", + "noEntries": "No audit log entries yet.", + "command": "Command", + "result": "Result", + "user": "User", + "org": "Org", + "time": "Time", + "hash": "Hash", + "error": "Error", + "loadMore": "Load more", + "back": "Back to agents", + "resultSuccess": "success", + "resultRejected": "rejected", + "resultFailed": "failed" + }, + "agents": { + "title": "Agents", + "register": "Register Agent", + "registerSuccess": "Agent registered", + "registerSuccessDesc": "Copy the credentials below. The sync token is shown only once.", + "agentId": "Agent ID", + "wgIp": "WireGuard IP:", + "syncToken": "Sync Token", + "warnOnce": "The sync token will never be shown again. Copy it now.", + "done": "Done", + "noAgents": "No agents connected", + "noAgentsDesc": "Run the agent install script on a VPS to connect it here.", + "nftDiverged": "nftables ruleset modified outside Lynx", + "nftRestore": "Restore Lynx rules", + "nftAccept": "Accept changes", + "nftRestoreSuccess": "nftables ruleset restored.", + "nftAcceptSuccess": "Manual changes accepted.", + "nftError": "Failed to resolve nftables divergence.", + "auditLog": "Audit log", + "version": "Version:", + "lastHeartbeat": "Last heartbeat:", + "wgIpLabel": "WireGuard IP:", + "name": "Name:", + "notFound": "Agent not found.", + "backToAgents": "← Agents", + "detailTitle": "Agent Detail", + "reboot": "Reboot VPS", + "rebootConfirm": "This will reboot the VPS. The agent will reconnect automatically after boot. Continue?", + "rebootSuccess": "Reboot command sent.", + "rebootError": "Failed to send reboot command.", + "deleteAgent": "Remove agent", + "deleteConfirm": "Remove this agent from the dashboard? The agent will lose its WireGuard connection. Continue?", + "deleteSuccess": "Agent removed.", + "deleteError": "Failed to remove agent.", + "nftRules": "Firewall Rules", + "nftRulesDesc": "nftables rules applied to this agent. Changes pushed automatically.", + "nftGlobal": "Global rules", + "nftLocal": "Local rules (this agent)", + "nftStatus": "Status", + "nftChecksumOk": "In sync", + "nftChecksumMismatch": "Diverged", + "nftAddRule": "Add rule", + "nftKind": "Type", + "nftPort": "Port", + "nftProtocol": "Protocol", + "nftIpList": "IP list (CIDRs, comma-separated)", + "nftRatePerMin": "Connections per minute", + "nftDescription": "Description (optional)", + "nftPriority": "Priority", + "nftCreate": "Create rule", + "nftCreateSuccess": "Rule created.", + "nftCreateError": "Failed to create rule.", + "nftDeleteSuccess": "Rule deleted.", + "nftDeleteError": "Failed to delete rule.", + "nftPush": "Push to agent", + "nftPushSuccess": "Rules pushed.", + "nftPushError": "Failed to push rules.", + "nftNoRules": "No rules defined.", + "nftKindAllowPort": "Allow port", + "nftKindBlockPort": "Block port", + "nftKindAllowIp": "Allow IP", + "nftKindBlockIp": "Block IP", + "nftKindRateLimit": "Rate limit", + "nftProtoTcp": "TCP", + "nftProtoUdp": "UDP", + "nftProtoBoth": "TCP + UDP", + "metricsLive": "Live metrics", + "metricsCpu": "CPU", + "metricsMemory": "Memory", + "metricsDisk": "Disk", + "metricsConnecting": "Connecting to agent...", + "metricsAgentOffline": "Agent offline", + "metricsOffline": "Metrics unavailable", + "status": { + "online": "Online", + "lockdown": "Lockdown", + "offline": "Offline" + } + }, + "notifications": { + "label": "Notifications", + "title": "Notifications", + "clearAll": "Clear all", + "empty": "No alerts", + "event": { + "heartbeat_lost": "Heartbeat lost", + "lockdown": "Agent in lockdown", + "nftables_divergence": "nftables divergence", + "conflicting_software_detected": "Conflicting software" + } + }, + "overview": { + "title": "Overview", + "agentsOnline": "Agents Online", + "organizations": "Organizations", + "recentEvents": "Recent Events", + "noEvents": "No recent events.", + "noAgents": "No agents connected", + "noAgentsDesc": "Connect a VPS agent to start managing your infrastructure.", + "securityAlerts": "Security Alerts", + "noAlerts": "No active alerts.", + "acknowledge": "Dismiss", + "acknowledged": "Alert dismissed.", + "acknowledgeError": "Failed to dismiss alert." + } + }, + "branding": { + "madeWith": "Made with love by", + "author": "Jaroc" + }, + "admin": { + "title": "Admin", + "users": "Users", + "roles": "Roles", + "deleteUser": "Delete user", + "deleteConfirm": "Delete user", + "deleteSuccess": "User deleted.", + "deleteError": "Failed to delete user.", + "forcePasswordChange": "Force password reset", + "forcePasswordChangeSuccess": "Password reset forced.", + "forcePasswordChangeError": "Failed to force password reset.", + "addRole": "Add role", + "addRoleSuccess": "Role assigned.", + "addRoleError": "Failed to assign role.", + "removeRole": "Remove role", + "removeRoleSuccess": "Role removed.", + "removeRoleError": "Failed to remove role.", + "noRoles": "No roles assigned.", + "selectRole": "Select role", + "createRole": "New role", + "createRoleSuccess": "Role created.", + "createRoleError": "Failed to create role.", + "deleteRole": "Delete role", + "deleteRoleConfirm": "Delete role", + "deleteRoleSuccess": "Role deleted.", + "deleteRoleError": "Failed to delete role.", + "addPermission": "Add permission", + "addPermissionSuccess": "Permission added.", + "addPermissionError": "Failed to add permission.", + "removePermission": "Remove permission", + "removePermissionSuccess": "Permission removed.", + "removePermissionError": "Failed to remove permission.", + "roleName": "Role name", + "noPermissions": "No permissions assigned." + } +} diff --git a/lynx/dashboard/ui/messages/es.json b/lynx/dashboard/ui/messages/es.json new file mode 100644 index 0000000..8100532 --- /dev/null +++ b/lynx/dashboard/ui/messages/es.json @@ -0,0 +1,417 @@ +{ + "auth": { + "login": { + "title": "Iniciar sesión en {company}", + "subtitle": "Orquestación de infraestructura distribuida", + "username": "Usuario", + "password": "Contraseña", + "submit": "Iniciar sesión", + "submitting": "Iniciando sesión...", + "noAccount": "¿No tienes cuenta?", + "register": "Crear cuenta", + "invalidCredentials": "Usuario o contraseña inválidos", + "rateLimited": "Demasiados intentos. Vuelve a intentarlo en {minutes} minutos.", + "serverError": "Algo salió mal. Por favor inténtalo de nuevo." + }, + "register": { + "title": "Crear tu cuenta", + "subtitle": "Empieza a orquestar tu infraestructura", + "username": "Usuario", + "email": "Correo electrónico", + "password": "Contraseña", + "submit": "Crear cuenta", + "submitting": "Creando cuenta...", + "hasAccount": "¿Ya tienes cuenta?", + "login": "Iniciar sesión", + "usernameTaken": "Nombre de usuario ya en uso", + "emailTaken": "Correo electrónico ya registrado", + "serverError": "Algo salió mal. Por favor inténtalo de nuevo.", + "validation": { + "usernameMin": "El usuario debe tener al menos 3 caracteres", + "usernameMax": "El usuario no puede superar 32 caracteres", + "usernameChars": "Solo se permiten letras minúsculas, números, - y _", + "usernameEdge": "No puede comenzar ni terminar con - o _", + "usernameReserved": "Este nombre de usuario está reservado", + "emailInvalid": "Introduce una dirección de correo válida", + "passwordMin": "La contraseña debe tener al menos 12 caracteres", + "passwordMax": "La contraseña no puede superar 30 caracteres", + "passwordUppercase": "Debe contener al menos una letra mayúscula", + "passwordLowercase": "Debe contener al menos una letra minúscula", + "passwordNumber": "Debe contener al menos un número", + "passwordSpecial": "Debe contener al menos un carácter especial" + } + } + }, + "app": { + "nav": { + "overview": "Resumen", + "agents": "Agentes", + "organizations": "Organizaciones", + "settings": "Ajustes", + "admin": "Admin", + "signOut": "Cerrar sesión" + }, + "organizations": { + "title": "Organizaciones", + "create": "Nueva organización", + "slugConflict": "Slug ya en uso. Elige un nombre diferente.", + "createError": "Error al crear la organización.", + "noOrgs": "Sin organizaciones aún", + "noOrgsDesc": "Crea una organización para empezar a gestionar proyectos y contenedores.", + "slug": "Slug:", + "members": "Miembros:", + "invite": "Invitar miembro", + "inviteTitle": "Invitar un miembro", + "inviteUsername": "Usuario", + "inviteRole": "Rol", + "inviteSubmit": "Enviar invitación", + "inviteSuccess": "Miembro invitado.", + "inviteError": "Error al invitar miembro.", + "removeMember": "Eliminar", + "removeMemberSuccess": "Miembro eliminado.", + "removeMemberError": "Error al eliminar miembro.", + "noMembers": "Sin miembros aún.", + "orgId": "ID:", + "projects": "Proyectos", + "noProjects": "Sin proyectos aún.", + "createProject": "Nuevo proyecto", + "createProjectTitle": "Crear proyecto", + "projectName": "Nombre", + "projectSlug": "Slug", + "projectAgent": "Agente destino", + "projectNoAgents": "No hay agentes disponibles. Registra un agente primero.", + "projectCreate": "Crear proyecto", + "projectCreateSuccess": "Proyecto creado.", + "projectSlugConflict": "Slug ya en uso en esta organización.", + "projectCreateError": "Error al crear el proyecto." + }, + "projects": { + "orgs": "Organizaciones", + "org": "Organización", + "slug": "Slug:", + "verticalScale": "Escalado vertical", + "verticalScaleDesc": "Actualiza los límites de CPU y memoria de un contenedor en este proyecto. El contenedor se actualiza en vivo sin reinicio cuando es posible.", + "containerName": "Nombre del contenedor", + "cpus": "CPUs", + "memoryMb": "Memoria (MB)", + "apply": "Aplicar límites", + "applySuccess": "Límites del contenedor actualizados.", + "applyError": "Error al actualizar los límites del contenedor.", + "projectId": "ID del proyecto:", + "containers": "Contenedores", + "noContainers": "Sin contenedores en ejecución.", + "cStart": "Iniciar", + "cStop": "Detener", + "cRestart": "Reiniciar", + "cRemove": "Eliminar", + "cActionSuccess": "hecho", + "cActionError": "Acción de contenedor fallida.", + "deploy": "Desplegar contenedor", + "deployBtn": "Desplegar", + "deploySuccess": "Contenedor desplegado.", + "deployError": "Error al desplegar.", + "cName": "Nombre del contenedor", + "cImage": "Imagen", + "cPorts": "Puertos (separados por coma)", + "cEnv": "Variables de entorno (separadas por coma)", + "horizontalScale": "Escalado horizontal", + "horizontalScaleDesc": "Despliega réplicas en otro agente mediante un tunnel WireGuard directo de plano de datos. El tráfico va directo entre agentes, nunca pasa por el dashboard.", + "addTunnel": "Añadir tunnel", + "addTunnelTitle": "Escalar a otro agente", + "tunnelTargetAgent": "Agente destino", + "tunnelImage": "Imagen del contenedor", + "tunnelReplicas": "Número de réplicas", + "tunnelConfirm": "Establecer tunnel y desplegar", + "tunnelSuccess": "Tunnel establecido y réplicas desplegadas.", + "tunnelError": "Error al establecer el tunnel.", + "tunnelTeardownSuccess": "Tunnel eliminado.", + "tunnelTeardownError": "Error al eliminar el tunnel.", + "noTunnels": "Sin tunnels activos.", + "tunnelReplicaCount": "Réplicas" + }, + "settings": { + "title": "Ajustes", + "profile": "Perfil", + "profileUsername": "Usuario", + "changePassword": "Cambiar contraseña", + "currentPassword": "Contraseña actual", + "newPassword": "Nueva contraseña", + "changePasswordBtn": "Cambiar contraseña", + "changePasswordSuccess": "Contraseña cambiada. Has cerrado sesión.", + "changePasswordWrong": "La contraseña actual es incorrecta.", + "changePasswordError": "Error al cambiar la contraseña.", + "singleSession": "Sesión única activa", + "singleSessionDesc": "Al activarlo, iniciar sesión en un nuevo dispositivo invalidará todas las demás sesiones activas.", + "singleSessionSuccess": "Configuración de sesión única actualizada.", + "singleSessionError": "Error al actualizar la configuración.", + "domain": "Dominio", + "domainDesc": "Configura un dominio personalizado con TLS de Let's Encrypt. Requiere registro DNS A apuntando a este servidor antes de la configuración.", + "domainCurrent": "Dominio actual:", + "domainNone": "Usando dirección IP (sin dominio personalizado)", + "domainInput": "Dominio (ej: panel.ejemplo.com)", + "domainEmail": "Email para Let's Encrypt", + "domainSetup": "Configurar dominio", + "domainPending": "Configuración en progreso…", + "domainActive": "Activo", + "domainError": "Error", + "domainUnconfigured": "Sin configurar", + "domainVerify": "Verificar DNS", + "domainDnsOk": "DNS resuelto correctamente.", + "domainDnsFail": "El dominio no resuelve. Comprueba tu registro DNS A.", + "domainVerifyError": "Error al verificar.", + "domainSetupError": "Error al iniciar la configuración del dominio.", + "domainHsts": "HSTS", + "domainHstsDesc": "Fuerza HTTPS para todos los visitantes. Activar solo después de confirmar dominio y cert TLS funcionando.", + "domainHstsEnable": "Activar HSTS", + "domainHstsDisable": "Desactivar HSTS", + "domainHstsSuccess": "HSTS actualizado.", + "domainHstsError": "Error al actualizar HSTS.", + "domainClosePort": "Cerrar puerto 19443", + "domainClosePortDesc": "Elimina el acceso directo por IP en el puerto 19443. Solo posible cuando el dominio está activo. Requiere SSH para reactivar.", + "domainClosePortBtn": "Cerrar puerto 19443", + "domainClosePortConfirm": "Esto eliminará el acceso en el puerto 19443. Solo podrás acceder al panel desde tu dominio configurado. No se puede deshacer remotamente. ¿Continuar?", + "domainClosePortSuccess": "Puerto 19443 cerrado.", + "domainClosePortError": "Error al cerrar el puerto.", + "domainCert": "Certificado:", + "domainCertSelfSigned": "Autofirmado", + "domainCertLE": "Let's Encrypt", + "domainCertCloudflare": "Cloudflare Origin", + "domainCertCustom": "Personalizado", + "domainCertExpires": "Expira:", + "domainCertUpload": "Subir certificado", + "domainCertUploadCloudflare": "Cloudflare Origin", + "domainCertUploadCustom": "Certificado personalizado", + "domainCertPem": "Certificado (PEM)", + "domainCertPemPlaceholder": "-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----", + "domainCertKeyPem": "Clave privada (PEM)", + "domainCertKeyPemPlaceholder": "-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----", + "domainCertKeyOptional": "La clave privada no es necesaria para certificados Cloudflare Origin.", + "domainCertUploadSuccess": "Certificado subido y aplicado.", + "domainCertUploadError": "Error al subir el certificado.", + "security": "Seguridad", + "rotationLog": "Historial de rotación de claves", + "rotationLogEmpty": "Sin rotaciones registradas.", + "rotationLogReason": "Motivo", + "rotationLogScope": "Ámbito", + "rotateKeys": "Rotar claves JWT", + "rotateKeysDesc": "Invalida todas las sesiones activas. Todos deben iniciar sesión de nuevo.", + "rotateKeysBtn": "Rotar y cerrar sesión", + "rotateKeysConfirm": "Esto invalidará todas las sesiones activas, incluida la tuya. ¿Continuar?", + "sessions": "Sesiones activas", + "noSessions": "No se encontraron sesiones activas.", + "lastUsed": "Último uso:", + "revoke": "Revocar", + "revokeSuccess": "Sesión revocada.", + "revokeError": "Error al revocar la sesión.", + "updates": "Actualizaciones", + "updatesDesc": "Consulta GitHub Releases para nuevas versiones y envía actualizaciones a todos los agentes conectados.", + "updateCheck": "Buscar actualizaciones", + "updateCurrent": "Actual:", + "updateLatest": "Última:", + "updateUpToDate": "Al día", + "updateAvailable": "Actualización disponible", + "updateTrigger": "Actualizar todos los agentes", + "updateTriggerSuccess": "Actualización iniciada. Los agentes actualizarán pronto.", + "updateTriggerError": "Error al iniciar la actualización.", + "updateCheckError": "Error al buscar actualizaciones.", + "migration": "Migración del dashboard", + "migrationDesc": "Mueve este dashboard a otro VPS. Requiere instalación fresca del dashboard en el VPS destino. Puede requerirse acceso SSH al agente si alguno no reconecta automáticamente.", + "migrationSourceTitle": "Migrar a otro VPS (este es VPS-A)", + "migrationSourceDesc": "Introduce la URL del VPS destino y el token de migración del VPS-B para iniciar la migración.", + "migrationTargetUrl": "URL del VPS destino", + "migrationToken": "Token de migración (del VPS-B)", + "migrationStart": "Iniciar migración", + "migrationTargetTitle": "Recibir migración (este es VPS-B)", + "migrationTargetDesc": "Genera un token de migración para dar al VPS-A.", + "migrationPrepare": "Generar token de migración", + "migrationPreparedToken": "Copia este token en VPS-A:", + "migrationCopyToken": "Copiar", + "migrationAbort": "Abortar migración", + "migrationConfirmShutdown": "Confirmar apagado", + "migrationConfirmShutdownMsg": "Todos los agentes han confirmado. Esto apagará el dashboard actual. Asegúrate de que el VPS-B esté completamente operativo. ¿Continuar?", + "migrationStatusIdle": "Inactivo", + "migrationStatusPreparing": "Preparando", + "migrationStatusTransferring": "Transfiriendo", + "migrationStatusNotifying": "Notificando agentes", + "migrationStatusWaiting": "Esperando agentes", + "migrationStatusCompleted": "Completado", + "migrationStatusAborted": "Abortado", + "migrationStatusError": "Error", + "migrationAgentsProgress": "{confirmed} de {total} agentes confirmados", + "migrationPrepareError": "Error al preparar la migración.", + "migrationStartError": "Error al iniciar la migración.", + "migrationAbortSuccess": "Migración abortada.", + "migrationAbortError": "Error al abortar la migración.", + "migrationShutdownError": "Error al confirmar el apagado.", + "branding": "Marca blanca", + "brandingCompanyName": "Nombre de empresa", + "brandingLogoUrl": "URL del logotipo", + "brandingPrimaryColor": "Primario", + "brandingSecondaryColor": "Secundario", + "brandingAccentColor": "Acento", + "brandingSave": "Guardar marca", + "brandingSaved": "Marca actualizada.", + "brandingError": "Error al actualizar la marca." + }, + "auditLog": { + "title": "Registro de auditoría", + "noEntries": "Sin entradas de auditoría aún.", + "command": "Comando", + "result": "Resultado", + "user": "Usuario", + "org": "Org", + "time": "Hora", + "hash": "Hash", + "error": "Error", + "loadMore": "Cargar más", + "back": "Volver a agentes", + "resultSuccess": "éxito", + "resultRejected": "rechazado", + "resultFailed": "fallido" + }, + "agents": { + "title": "Agentes", + "register": "Registrar agente", + "registerSuccess": "Agente registrado", + "registerSuccessDesc": "Copia las credenciales. El token de sincronización solo se muestra una vez.", + "agentId": "ID del agente", + "wgIp": "IP WireGuard:", + "syncToken": "Token de sincronización", + "warnOnce": "El token de sincronización no volverá a mostrarse. Cópialo ahora.", + "done": "Hecho", + "noAgents": "No hay agentes conectados", + "noAgentsDesc": "Ejecuta el script de instalación del agente en un VPS para conectarlo.", + "nftDiverged": "Ruleset de nftables modificado fuera de Lynx", + "nftRestore": "Restaurar reglas Lynx", + "nftAccept": "Aceptar cambios", + "nftRestoreSuccess": "Ruleset de nftables restaurado.", + "nftAcceptSuccess": "Cambios manuales aceptados.", + "nftError": "Error al resolver divergencia de nftables.", + "auditLog": "Registro de auditoría", + "version": "Versión:", + "lastHeartbeat": "Último latido:", + "wgIpLabel": "IP WireGuard:", + "name": "Nombre:", + "notFound": "Agente no encontrado.", + "backToAgents": "← Agentes", + "detailTitle": "Detalle del agente", + "reboot": "Reiniciar VPS", + "rebootConfirm": "Esto reiniciará el VPS. El agente se reconectará automáticamente tras el arranque. ¿Continuar?", + "rebootSuccess": "Comando de reinicio enviado.", + "rebootError": "Error al enviar el comando de reinicio.", + "deleteAgent": "Eliminar agente", + "deleteConfirm": "¿Eliminar este agente del dashboard? El agente perderá su conexión WireGuard. ¿Continuar?", + "deleteSuccess": "Agente eliminado.", + "deleteError": "Error al eliminar el agente.", + "nftRules": "Reglas de firewall", + "nftRulesDesc": "Reglas nftables aplicadas a este agente. Los cambios se envían automáticamente.", + "nftGlobal": "Reglas globales", + "nftLocal": "Reglas locales (este agente)", + "nftStatus": "Estado", + "nftChecksumOk": "Sincronizado", + "nftChecksumMismatch": "Divergido", + "nftAddRule": "Añadir regla", + "nftKind": "Tipo", + "nftPort": "Puerto", + "nftProtocol": "Protocolo", + "nftIpList": "Lista de IPs (CIDRs, separados por coma)", + "nftRatePerMin": "Conexiones por minuto", + "nftDescription": "Descripción (opcional)", + "nftPriority": "Prioridad", + "nftCreate": "Crear regla", + "nftCreateSuccess": "Regla creada.", + "nftCreateError": "Error al crear la regla.", + "nftDeleteSuccess": "Regla eliminada.", + "nftDeleteError": "Error al eliminar la regla.", + "nftPush": "Enviar al agente", + "nftPushSuccess": "Reglas enviadas.", + "nftPushError": "Error al enviar las reglas.", + "nftNoRules": "Sin reglas definidas.", + "nftKindAllowPort": "Permitir puerto", + "nftKindBlockPort": "Bloquear puerto", + "nftKindAllowIp": "Permitir IP", + "nftKindBlockIp": "Bloquear IP", + "nftKindRateLimit": "Limitar tasa", + "nftProtoTcp": "TCP", + "nftProtoUdp": "UDP", + "nftProtoBoth": "TCP + UDP", + "metricsLive": "Métricas en vivo", + "metricsCpu": "CPU", + "metricsMemory": "Memoria", + "metricsDisk": "Disco", + "metricsConnecting": "Conectando al agente...", + "metricsAgentOffline": "Agente desconectado", + "metricsOffline": "Métricas no disponibles", + "status": { + "online": "En línea", + "lockdown": "Bloqueado", + "offline": "Desconectado" + } + }, + "notifications": { + "label": "Notificaciones", + "title": "Notificaciones", + "clearAll": "Limpiar todo", + "empty": "Sin alertas", + "event": { + "heartbeat_lost": "Latido perdido", + "lockdown": "Agente en bloqueo", + "nftables_divergence": "Divergencia nftables", + "conflicting_software_detected": "Software incompatible" + } + }, + "overview": { + "title": "Resumen", + "agentsOnline": "Agentes en línea", + "organizations": "Organizaciones", + "recentEvents": "Eventos recientes", + "noEvents": "Sin eventos recientes.", + "noAgents": "No hay agentes conectados", + "noAgentsDesc": "Conecta un VPS agente para empezar a gestionar tu infraestructura.", + "securityAlerts": "Alertas de seguridad", + "noAlerts": "Sin alertas activas.", + "acknowledge": "Descartar", + "acknowledged": "Alerta descartada.", + "acknowledgeError": "Error al descartar la alerta." + } + }, + "branding": { + "madeWith": "Hecho con amor por", + "author": "Jaroc" + }, + "admin": { + "title": "Admin", + "users": "Usuarios", + "roles": "Roles", + "deleteUser": "Eliminar usuario", + "deleteConfirm": "Eliminar usuario", + "deleteSuccess": "Usuario eliminado.", + "deleteError": "Error al eliminar usuario.", + "forcePasswordChange": "Forzar restablecimiento de contraseña", + "forcePasswordChangeSuccess": "Restablecimiento forzado.", + "forcePasswordChangeError": "Error al forzar restablecimiento.", + "addRole": "Asignar rol", + "addRoleSuccess": "Rol asignado.", + "addRoleError": "Error al asignar rol.", + "removeRole": "Quitar rol", + "removeRoleSuccess": "Rol eliminado.", + "removeRoleError": "Error al quitar rol.", + "noRoles": "Sin roles asignados.", + "selectRole": "Seleccionar rol", + "createRole": "Nuevo rol", + "createRoleSuccess": "Rol creado.", + "createRoleError": "Error al crear rol.", + "deleteRole": "Eliminar rol", + "deleteRoleConfirm": "Eliminar rol", + "deleteRoleSuccess": "Rol eliminado.", + "deleteRoleError": "Error al eliminar rol.", + "addPermission": "Añadir permiso", + "addPermissionSuccess": "Permiso añadido.", + "addPermissionError": "Error al añadir permiso.", + "removePermission": "Quitar permiso", + "removePermissionSuccess": "Permiso eliminado.", + "removePermissionError": "Error al quitar permiso.", + "roleName": "Nombre del rol", + "noPermissions": "Sin permisos asignados." + } +} diff --git a/lynx/dashboard/ui/next.config.ts b/lynx/dashboard/ui/next.config.ts new file mode 100644 index 0000000..06a5e74 --- /dev/null +++ b/lynx/dashboard/ui/next.config.ts @@ -0,0 +1,21 @@ +import type { NextConfig } from "next"; +import createNextIntlPlugin from "next-intl/plugin"; + +const withNextIntl = createNextIntlPlugin("./src/i18n/request.ts"); + +const backendUrl = process.env.BACKEND_URL ?? "http://localhost:8080"; + +const nextConfig: NextConfig = { + reactCompiler: true, + output: "standalone", + async rewrites() { + return [ + { + source: "/api/:path*", + destination: `${backendUrl}/:path*`, + }, + ]; + }, +}; + +export default withNextIntl(nextConfig); diff --git a/lynx/dashboard/ui/package.json b/lynx/dashboard/ui/package.json index d969f92..37a7a50 100644 --- a/lynx/dashboard/ui/package.json +++ b/lynx/dashboard/ui/package.json @@ -9,29 +9,55 @@ "check": "biome check", "check:fix": "biome check --write", "typecheck": "tsc --noEmit", - "audit": "bun audit" + "audit": "bun audit", + "test": "vitest run", + "test:watch": "vitest", + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui" }, "dependencies": { + "@base-ui/react": "^1.4.1", + "@hookform/resolvers": "5.2.2", + "@tanstack/react-table": "^8.21.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "date-fns": "^4.1.0", + "embla-carousel-react": "^8.6.0", + "input-otp": "^1.4.2", "lucide-react": "^1.14.0", "next": "^16.2.6", + "next-intl": "4.12.0", + "next-themes": "^0.4.6", "radix-ui": "^1.4.3", "react": "^19.2.6", + "react-day-picker": "^10.0.0", "react-dom": "^19.2.6", + "react-hook-form": "7.75.0", + "react-resizable-panels": "^4.11.0", + "recharts": "^3.8.1", "shadcn": "^4.7.0", + "sonner": "^2.0.7", "tailwind-merge": "^3.6.0", - "tw-animate-css": "^1.4.0" + "tw-animate-css": "^1.4.0", + "vaul": "^1.1.2", + "zod": "4.4.3" }, "devDependencies": { "@biomejs/biome": "^2.4.15", + "@playwright/test": "^1.52.0", "@tailwindcss/postcss": "^4.3.0", + "@testing-library/jest-dom": "6.9.1", + "@testing-library/react": "16.3.2", + "@testing-library/user-event": "14.6.1", "@types/node": "^24.12.4", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "6.0.2", "babel-plugin-react-compiler": "^1.0.0", + "jsdom": "29.1.1", "tailwindcss": "^4.3.0", - "typescript": "^6.0.3" + "typescript": "^6.0.3", + "vitest": "4.1.6" }, "trustedDependencies": [ "sharp", diff --git a/lynx/dashboard/ui/playwright.config.ts b/lynx/dashboard/ui/playwright.config.ts new file mode 100644 index 0000000..339c77a --- /dev/null +++ b/lynx/dashboard/ui/playwright.config.ts @@ -0,0 +1,35 @@ +import { defineConfig, devices } from "@playwright/test"; + +export default defineConfig({ + testDir: "./src/e2e", + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: process.env.CI ? "github" : "list", + + use: { + baseURL: process.env.PLAYWRIGHT_BASE_URL ?? "http://localhost:3000", + trace: "on-first-retry", + }, + + projects: [ + { name: "chromium", use: { ...devices["Desktop Chrome"] } }, + ], + + webServer: process.env.CI + ? { + command: "bun run start", + url: "http://localhost:3000", + reuseExistingServer: false, + timeout: 120_000, + } + : process.env.PLAYWRIGHT_NO_SERVER + ? undefined + : { + command: "bun run dev", + url: "http://localhost:3000", + reuseExistingServer: true, + timeout: 120_000, + }, +}); diff --git a/lynx/dashboard/ui/public/apple-icon.png b/lynx/dashboard/ui/public/apple-icon.png new file mode 100644 index 0000000..fc6a94c Binary files /dev/null and b/lynx/dashboard/ui/public/apple-icon.png differ diff --git a/lynx/dashboard/ui/public/favicon.ico b/lynx/dashboard/ui/public/favicon.ico new file mode 100644 index 0000000..d346417 Binary files /dev/null and b/lynx/dashboard/ui/public/favicon.ico differ diff --git a/lynx/dashboard/ui/public/flags/en.svg b/lynx/dashboard/ui/public/flags/en.svg new file mode 100644 index 0000000..e3560c6 --- /dev/null +++ b/lynx/dashboard/ui/public/flags/en.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/lynx/dashboard/ui/public/flags/es.svg b/lynx/dashboard/ui/public/flags/es.svg new file mode 100644 index 0000000..ac562e3 --- /dev/null +++ b/lynx/dashboard/ui/public/flags/es.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/lynx/dashboard/ui/public/icon.png b/lynx/dashboard/ui/public/icon.png new file mode 100644 index 0000000..45ac40a Binary files /dev/null and b/lynx/dashboard/ui/public/icon.png differ diff --git a/lynx/dashboard/ui/public/logo.webp b/lynx/dashboard/ui/public/logo.webp new file mode 100644 index 0000000..41ddf12 Binary files /dev/null and b/lynx/dashboard/ui/public/logo.webp differ diff --git a/lynx/dashboard/ui/src/__tests__/(auth)/schemas.test.ts b/lynx/dashboard/ui/src/__tests__/(auth)/schemas.test.ts new file mode 100644 index 0000000..f3ac87b --- /dev/null +++ b/lynx/dashboard/ui/src/__tests__/(auth)/schemas.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from "vitest"; +import { loginSchema } from "@/schemas/(auth)/login"; +import { registerSchema } from "@/schemas/(auth)/register"; + +// --------------------------------------------------------------------------- +// Login schema +// --------------------------------------------------------------------------- + +describe("loginSchema", () => { + it("accepts valid credentials", () => { + expect(loginSchema.safeParse({ username: "alice", password: "secret" }).success).toBe(true); + }); + + it("rejects empty username", () => { + expect(loginSchema.safeParse({ username: "", password: "secret" }).success).toBe(false); + }); + + it("rejects empty password", () => { + expect(loginSchema.safeParse({ username: "alice", password: "" }).success).toBe(false); + }); + + it("rejects missing fields", () => { + expect(loginSchema.safeParse({}).success).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// Register schema +// --------------------------------------------------------------------------- + +describe("registerSchema", () => { + const valid = { + username: "alice42", + email: "alice@example.com", + password: "ValidP@ss12!", + }; + + it("accepts valid registration data", () => { + expect(registerSchema.safeParse(valid).success).toBe(true); + }); + + it("rejects username shorter than 3 chars", () => { + expect(registerSchema.safeParse({ ...valid, username: "ab" }).success).toBe(false); + }); + + it("rejects username longer than 32 chars", () => { + expect(registerSchema.safeParse({ ...valid, username: "a".repeat(33) }).success).toBe(false); + }); + + it("rejects username with uppercase", () => { + expect(registerSchema.safeParse({ ...valid, username: "Alice" }).success).toBe(false); + }); + + it("rejects username with spaces", () => { + expect(registerSchema.safeParse({ ...valid, username: "al ice" }).success).toBe(false); + }); + + it("rejects username starting with dash", () => { + expect(registerSchema.safeParse({ ...valid, username: "-alice" }).success).toBe(false); + }); + + it("rejects username ending with underscore", () => { + expect(registerSchema.safeParse({ ...valid, username: "alice_" }).success).toBe(false); + }); + + it("rejects reserved username 'admin'", () => { + expect(registerSchema.safeParse({ ...valid, username: "admin" }).success).toBe(false); + }); + + it("rejects reserved username 'root'", () => { + expect(registerSchema.safeParse({ ...valid, username: "root" }).success).toBe(false); + }); + + it("rejects reserved username 'null'", () => { + expect(registerSchema.safeParse({ ...valid, username: "null" }).success).toBe(false); + }); + + it("rejects invalid email", () => { + expect(registerSchema.safeParse({ ...valid, email: "not-an-email" }).success).toBe(false); + }); + + it("rejects password shorter than 12 chars", () => { + expect(registerSchema.safeParse({ ...valid, password: "Short1!" }).success).toBe(false); + }); + + it("rejects password longer than 30 chars", () => { + expect(registerSchema.safeParse({ ...valid, password: "ValidP@ss12!" + "x".repeat(20) }).success).toBe(false); + }); + + it("rejects password without uppercase", () => { + expect(registerSchema.safeParse({ ...valid, password: "validp@ss12!" }).success).toBe(false); + }); + + it("rejects password without lowercase", () => { + expect(registerSchema.safeParse({ ...valid, password: "VALIDP@SS12!" }).success).toBe(false); + }); + + it("rejects password without digit", () => { + expect(registerSchema.safeParse({ ...valid, password: "ValidP@ssword!" }).success).toBe(false); + }); + + it("rejects password without special char", () => { + expect(registerSchema.safeParse({ ...valid, password: "ValidPass12ab" }).success).toBe(false); + }); + + it("accepts password exactly 12 chars with all requirements", () => { + expect(registerSchema.safeParse({ ...valid, password: "ValidP@ss12!" }).success).toBe(true); + }); + + it("accepts password exactly 30 chars", () => { + const p = "ValidP@ss12!" + "a".repeat(18); // 12 + 18 = 30 + expect(registerSchema.safeParse({ ...valid, password: p }).success).toBe(true); + }); +}); diff --git a/lynx/dashboard/ui/src/__tests__/(dashboard)/app/organizations/schemas.test.ts b/lynx/dashboard/ui/src/__tests__/(dashboard)/app/organizations/schemas.test.ts new file mode 100644 index 0000000..9159234 --- /dev/null +++ b/lynx/dashboard/ui/src/__tests__/(dashboard)/app/organizations/schemas.test.ts @@ -0,0 +1,274 @@ +import { describe, expect, it } from "vitest"; +import { registerAgentSchema } from "@/schemas/(dashboard)/app/agents"; +import { createOrgSchema } from "@/schemas/(dashboard)/app/organizations"; +import { + createProjectSchema, + inviteMemberSchema, +} from "@/schemas/(dashboard)/app/organizations/[id]"; +import { + addTunnelSchema, + deployContainerSchema, + resourceFormSchema, +} from "@/schemas/(dashboard)/app/organizations/[id]/projects/[proj_id]"; + +// --------------------------------------------------------------------------- +// registerAgentSchema +// --------------------------------------------------------------------------- + +describe("registerAgentSchema", () => { + it("accepts valid agent name", () => { + expect(registerAgentSchema.safeParse({ name: "my-vps-01" }).success).toBe(true); + }); + + it("rejects empty name", () => { + expect(registerAgentSchema.safeParse({ name: "" }).success).toBe(false); + }); + + it("rejects name over 100 chars", () => { + expect(registerAgentSchema.safeParse({ name: "a".repeat(101) }).success).toBe(false); + }); + + it("accepts name at exactly 100 chars", () => { + expect(registerAgentSchema.safeParse({ name: "a".repeat(100) }).success).toBe(true); + }); + + it("rejects missing name field", () => { + expect(registerAgentSchema.safeParse({}).success).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// createOrgSchema +// --------------------------------------------------------------------------- + +describe("createOrgSchema", () => { + const valid = { name: "Acme Corp", slug: "acme-corp" }; + + it("accepts valid org data", () => { + expect(createOrgSchema.safeParse(valid).success).toBe(true); + }); + + it("rejects empty name", () => { + expect(createOrgSchema.safeParse({ ...valid, name: "" }).success).toBe(false); + }); + + it("rejects name over 100 chars", () => { + expect(createOrgSchema.safeParse({ ...valid, name: "a".repeat(101) }).success).toBe(false); + }); + + it("rejects empty slug", () => { + expect(createOrgSchema.safeParse({ ...valid, slug: "" }).success).toBe(false); + }); + + it("rejects slug with uppercase letters", () => { + expect(createOrgSchema.safeParse({ ...valid, slug: "Acme-Corp" }).success).toBe(false); + }); + + it("rejects slug with spaces", () => { + expect(createOrgSchema.safeParse({ ...valid, slug: "acme corp" }).success).toBe(false); + }); + + it("rejects slug with underscores", () => { + expect(createOrgSchema.safeParse({ ...valid, slug: "acme_corp" }).success).toBe(false); + }); + + it("accepts slug with hyphens and digits", () => { + expect(createOrgSchema.safeParse({ ...valid, slug: "acme-corp-123" }).success).toBe(true); + }); + + it("rejects slug with special chars", () => { + expect(createOrgSchema.safeParse({ ...valid, slug: "acme@corp" }).success).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// inviteMemberSchema +// --------------------------------------------------------------------------- + +describe("inviteMemberSchema", () => { + it("accepts valid viewer invite", () => { + expect(inviteMemberSchema.safeParse({ username: "alice", role: "viewer" }).success).toBe(true); + }); + + it("accepts valid member invite", () => { + expect(inviteMemberSchema.safeParse({ username: "bob", role: "member" }).success).toBe(true); + }); + + it("accepts valid admin invite", () => { + expect(inviteMemberSchema.safeParse({ username: "carol", role: "admin" }).success).toBe(true); + }); + + it("rejects unknown role", () => { + expect( + inviteMemberSchema.safeParse({ username: "dave", role: "superadmin" }).success, + ).toBe(false); + }); + + it("rejects empty username", () => { + expect(inviteMemberSchema.safeParse({ username: "", role: "viewer" }).success).toBe(false); + }); + + it("rejects missing role", () => { + expect(inviteMemberSchema.safeParse({ username: "alice" }).success).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// createProjectSchema +// --------------------------------------------------------------------------- + +describe("createProjectSchema", () => { + const valid = { name: "Web App", slug: "web-app", agent_id: "some-uuid" }; + + it("accepts valid project data", () => { + expect(createProjectSchema.safeParse(valid).success).toBe(true); + }); + + it("rejects empty name", () => { + expect(createProjectSchema.safeParse({ ...valid, name: "" }).success).toBe(false); + }); + + it("rejects name over 100 chars", () => { + expect(createProjectSchema.safeParse({ ...valid, name: "a".repeat(101) }).success).toBe(false); + }); + + it("rejects slug with dots", () => { + expect(createProjectSchema.safeParse({ ...valid, slug: "web.app" }).success).toBe(false); + }); + + it("rejects slug with uppercase", () => { + expect(createProjectSchema.safeParse({ ...valid, slug: "WebApp" }).success).toBe(false); + }); + + it("accepts slug with hyphens and digits only", () => { + expect(createProjectSchema.safeParse({ ...valid, slug: "web-app-v2" }).success).toBe(true); + }); + + it("rejects empty agent_id", () => { + expect(createProjectSchema.safeParse({ ...valid, agent_id: "" }).success).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// deployContainerSchema +// --------------------------------------------------------------------------- + +describe("deployContainerSchema", () => { + const valid = { name: "nginx", image: "docker.io/library/nginx:latest" }; + + it("accepts minimal valid container data (no optional fields)", () => { + expect(deployContainerSchema.safeParse(valid).success).toBe(true); + }); + + it("accepts full container data with optional fields", () => { + expect( + deployContainerSchema.safeParse({ + ...valid, + ports: "80:80", + env: "FOO=bar", + cpus: 0.5, + memory_mb: 256, + }).success, + ).toBe(true); + }); + + it("rejects empty name", () => { + expect(deployContainerSchema.safeParse({ ...valid, name: "" }).success).toBe(false); + }); + + it("rejects empty image", () => { + expect(deployContainerSchema.safeParse({ ...valid, image: "" }).success).toBe(false); + }); + + it("rejects cpus below 0.1", () => { + expect(deployContainerSchema.safeParse({ ...valid, cpus: 0.05 }).success).toBe(false); + }); + + it("accepts cpus at exactly 0.1", () => { + expect(deployContainerSchema.safeParse({ ...valid, cpus: 0.1 }).success).toBe(true); + }); + + it("rejects memory_mb below 64", () => { + expect(deployContainerSchema.safeParse({ ...valid, memory_mb: 32 }).success).toBe(false); + }); + + it("accepts memory_mb at exactly 64", () => { + expect(deployContainerSchema.safeParse({ ...valid, memory_mb: 64 }).success).toBe(true); + }); + + it("rejects non-integer memory_mb", () => { + expect(deployContainerSchema.safeParse({ ...valid, memory_mb: 128.5 }).success).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// resourceFormSchema +// --------------------------------------------------------------------------- + +describe("resourceFormSchema", () => { + const valid = { container_name: "my-app", cpus: 1, memory_mb: 512 }; + + it("accepts valid resource form data", () => { + expect(resourceFormSchema.safeParse(valid).success).toBe(true); + }); + + it("accepts without optional cpus and memory_mb", () => { + expect(resourceFormSchema.safeParse({ container_name: "my-app" }).success).toBe(true); + }); + + it("rejects empty container_name", () => { + expect(resourceFormSchema.safeParse({ ...valid, container_name: "" }).success).toBe(false); + }); + + it("rejects cpus below 0.1", () => { + expect(resourceFormSchema.safeParse({ ...valid, cpus: 0 }).success).toBe(false); + }); + + it("rejects memory_mb below 64", () => { + expect(resourceFormSchema.safeParse({ ...valid, memory_mb: 63 }).success).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// addTunnelSchema +// --------------------------------------------------------------------------- + +describe("addTunnelSchema", () => { + const valid = { + target_agent_id: "agent-uuid-xyz", + image: "docker.io/library/nginx:latest", + replica_count: 2, + }; + + it("accepts valid tunnel data", () => { + expect(addTunnelSchema.safeParse(valid).success).toBe(true); + }); + + it("rejects empty target_agent_id", () => { + expect(addTunnelSchema.safeParse({ ...valid, target_agent_id: "" }).success).toBe(false); + }); + + it("rejects empty image", () => { + expect(addTunnelSchema.safeParse({ ...valid, image: "" }).success).toBe(false); + }); + + it("rejects replica_count below 1", () => { + expect(addTunnelSchema.safeParse({ ...valid, replica_count: 0 }).success).toBe(false); + }); + + it("accepts replica_count at exactly 1", () => { + expect(addTunnelSchema.safeParse({ ...valid, replica_count: 1 }).success).toBe(true); + }); + + it("rejects replica_count above 20", () => { + expect(addTunnelSchema.safeParse({ ...valid, replica_count: 21 }).success).toBe(false); + }); + + it("accepts replica_count at exactly 20", () => { + expect(addTunnelSchema.safeParse({ ...valid, replica_count: 20 }).success).toBe(true); + }); + + it("rejects non-integer replica_count", () => { + expect(addTunnelSchema.safeParse({ ...valid, replica_count: 1.5 }).success).toBe(false); + }); +}); diff --git a/lynx/dashboard/ui/src/__tests__/(dashboard)/app/settings/schemas.test.ts b/lynx/dashboard/ui/src/__tests__/(dashboard)/app/settings/schemas.test.ts new file mode 100644 index 0000000..e35ace7 --- /dev/null +++ b/lynx/dashboard/ui/src/__tests__/(dashboard)/app/settings/schemas.test.ts @@ -0,0 +1,167 @@ +import { describe, expect, it } from "vitest"; +import { + brandingSchema, + certUploadSchema, + changePasswordSchema, + domainSetupSchema, + migrationStartSchema, +} from "@/schemas/(dashboard)/app/settings"; + +// --------------------------------------------------------------------------- +// changePasswordSchema +// --------------------------------------------------------------------------- + +describe("changePasswordSchema", () => { + const valid = { current_password: "old", new_password: "ValidP@ss12!" }; + + it("accepts valid change password data", () => { + expect(changePasswordSchema.safeParse(valid).success).toBe(true); + }); + + it("rejects empty current_password", () => { + expect(changePasswordSchema.safeParse({ ...valid, current_password: "" }).success).toBe(false); + }); + + it("rejects new_password shorter than 12 chars", () => { + expect(changePasswordSchema.safeParse({ ...valid, new_password: "Short1!" }).success).toBe(false); + }); + + it("rejects new_password without special char", () => { + expect(changePasswordSchema.safeParse({ ...valid, new_password: "ValidPass12ab" }).success).toBe(false); + }); + + it("rejects new_password without digit", () => { + expect(changePasswordSchema.safeParse({ ...valid, new_password: "ValidP@ssword!" }).success).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// brandingSchema +// --------------------------------------------------------------------------- + +describe("brandingSchema", () => { + it("accepts empty branding object", () => { + expect(brandingSchema.safeParse({}).success).toBe(true); + }); + + it("accepts valid hex color", () => { + expect(brandingSchema.safeParse({ primary_color: "#FF5733" }).success).toBe(true); + }); + + it("rejects invalid hex color", () => { + expect(brandingSchema.safeParse({ primary_color: "red" }).success).toBe(false); + }); + + it("accepts empty string for color (clears it)", () => { + expect(brandingSchema.safeParse({ primary_color: "" }).success).toBe(true); + }); + + it("rejects company_name longer than 80 chars", () => { + expect(brandingSchema.safeParse({ company_name: "a".repeat(81) }).success).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// domainSetupSchema +// --------------------------------------------------------------------------- + +describe("domainSetupSchema", () => { + const valid = { domain: "example.com", email: "admin@example.com" }; + + it("accepts valid domain setup", () => { + expect(domainSetupSchema.safeParse(valid).success).toBe(true); + }); + + it("rejects empty domain", () => { + expect(domainSetupSchema.safeParse({ ...valid, domain: "" }).success).toBe(false); + }); + + it("rejects invalid email", () => { + expect(domainSetupSchema.safeParse({ ...valid, email: "notanemail" }).success).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// certUploadSchema +// --------------------------------------------------------------------------- + +describe("certUploadSchema", () => { + const validPem = "-----BEGIN CERTIFICATE-----\nMIIBxxx\n-----END CERTIFICATE-----"; + const validKey = "-----BEGIN PRIVATE KEY-----\nMIIByyy\n-----END PRIVATE KEY-----"; + + it("accepts cloudflare cert with valid PEM", () => { + expect( + certUploadSchema.safeParse({ cert_type: "cloudflare", cert_pem: validPem }).success, + ).toBe(true); + }); + + it("accepts custom cert with cert + key", () => { + expect( + certUploadSchema.safeParse({ + cert_type: "custom", + cert_pem: validPem, + key_pem: validKey, + }).success, + ).toBe(true); + }); + + it("rejects cert without PEM header", () => { + expect( + certUploadSchema.safeParse({ cert_type: "cloudflare", cert_pem: "not a cert" }).success, + ).toBe(false); + }); + + it("rejects empty cert_pem", () => { + expect( + certUploadSchema.safeParse({ cert_type: "cloudflare", cert_pem: "" }).success, + ).toBe(false); + }); + + it("rejects cert exceeding 64 KB", () => { + const big = "-----BEGIN CERTIFICATE-----\n" + "A".repeat(65 * 1024); + expect( + certUploadSchema.safeParse({ cert_type: "cloudflare", cert_pem: big }).success, + ).toBe(false); + }); + + it("rejects unknown cert_type", () => { + expect( + certUploadSchema.safeParse({ cert_type: "letsencrypt", cert_pem: validPem }).success, + ).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// migrationStartSchema +// --------------------------------------------------------------------------- + +describe("migrationStartSchema", () => { + const valid = { target_url: "https://10.0.0.2:19443", migration_token: "tok-abc123" }; + + it("accepts valid migration start data", () => { + expect(migrationStartSchema.safeParse(valid).success).toBe(true); + }); + + it("rejects non-URL target_url", () => { + expect(migrationStartSchema.safeParse({ ...valid, target_url: "not-a-url" }).success).toBe( + false, + ); + }); + + it("rejects empty target_url", () => { + expect(migrationStartSchema.safeParse({ ...valid, target_url: "" }).success).toBe(false); + }); + + it("rejects empty migration_token", () => { + expect(migrationStartSchema.safeParse({ ...valid, migration_token: "" }).success).toBe(false); + }); + + it("accepts https URL with port", () => { + expect( + migrationStartSchema.safeParse({ + ...valid, + target_url: "https://dashboard.example.com:19443", + }).success, + ).toBe(true); + }); +}); diff --git a/lynx/dashboard/ui/src/__tests__/setup.ts b/lynx/dashboard/ui/src/__tests__/setup.ts new file mode 100644 index 0000000..d0de870 --- /dev/null +++ b/lynx/dashboard/ui/src/__tests__/setup.ts @@ -0,0 +1 @@ +import "@testing-library/jest-dom"; diff --git a/lynx/dashboard/ui/src/actions/(auth)/login/index.ts b/lynx/dashboard/ui/src/actions/(auth)/login/index.ts new file mode 100644 index 0000000..6b60b9c --- /dev/null +++ b/lynx/dashboard/ui/src/actions/(auth)/login/index.ts @@ -0,0 +1,63 @@ +"use server"; + +import { apiFetch } from "@/lib/api"; +import { cookies } from "next/headers"; +import type { LoginInput } from "@/schemas/(auth)/login"; + +type LoginResult = + | { success: true; forcePasswordChange?: boolean } + | { success: false; error: string; retryAfter?: number }; + +export async function loginAction( + locale: string, + data: LoginInput, +): Promise { + const result = await apiFetch<{ + access_token: string; + refresh_token: string; + expires_in: number; + force_password_change: boolean; + theme: string; + }>("/auth/login", { + method: "POST", + body: JSON.stringify({ username: data.username, password: data.password }), + }); + + if (!result.ok) { + if (result.error === "invalid_credentials") { + return { success: false, error: "invalidCredentials" }; + } + if (result.error === "rate_limited") { + const minutes = result.retryAfter ? Math.ceil(result.retryAfter / 60) : 15; + return { success: false, error: "rateLimited", retryAfter: minutes }; + } + return { success: false, error: "serverError" }; + } + + const jar = await cookies(); + const secure = process.env.NODE_ENV === "production"; + + jar.set("access_token", result.data.access_token, { + httpOnly: true, + secure, + sameSite: "strict", + maxAge: result.data.expires_in, + path: "/", + }); + jar.set("refresh_token", result.data.refresh_token, { + httpOnly: true, + secure, + sameSite: "strict", + maxAge: 86400, + path: "/", + }); + jar.set("theme_preference", result.data.theme ?? "system", { + httpOnly: false, + secure, + sameSite: "strict", + maxAge: 60 * 60 * 24 * 365, + path: "/", + }); + + return { success: true, forcePasswordChange: result.data.force_password_change }; +} diff --git a/lynx/dashboard/ui/src/actions/(auth)/register/index.ts b/lynx/dashboard/ui/src/actions/(auth)/register/index.ts new file mode 100644 index 0000000..ce9e311 --- /dev/null +++ b/lynx/dashboard/ui/src/actions/(auth)/register/index.ts @@ -0,0 +1,31 @@ +"use server"; + +import { apiFetch } from "@/lib/api"; +import type { RegisterInput } from "@/schemas/(auth)/register"; + +type RegisterResult = + | { success: true } + | { success: false; error: string }; + +export async function registerAction( + _locale: string, + data: RegisterInput, +): Promise { + const result = await apiFetch("/auth/register", { + method: "POST", + body: JSON.stringify({ + username: data.username, + email: data.email, + password: data.password, + }), + }); + + if (!result.ok) { + if (result.error === "conflict") { + return { success: false, error: "usernameTaken" }; + } + return { success: false, error: "serverError" }; + } + + return { success: true }; +} diff --git a/lynx/dashboard/ui/src/actions/(dashboard)/app/admin/alerts.ts b/lynx/dashboard/ui/src/actions/(dashboard)/app/admin/alerts.ts new file mode 100644 index 0000000..0e5d79f --- /dev/null +++ b/lynx/dashboard/ui/src/actions/(dashboard)/app/admin/alerts.ts @@ -0,0 +1,24 @@ +"use server"; + +import { apiFetch } from "@/lib/api"; + +export type SecurityAlert = { + id: string; + kind: string; + detail: string | null; + agent_id: string | null; + created_at: string; +}; + +export async function listAlertsAction(): Promise { + const result = await apiFetch("/admin/alerts"); + if (!result.ok) return []; + return result.data; +} + +export async function acknowledgeAlertAction(id: string): Promise<{ success: boolean }> { + const result = await apiFetch(`/admin/alerts/${id}/acknowledge`, { + method: "POST", + }); + return { success: result.ok }; +} diff --git a/lynx/dashboard/ui/src/actions/(dashboard)/app/admin/users.ts b/lynx/dashboard/ui/src/actions/(dashboard)/app/admin/users.ts new file mode 100644 index 0000000..238b74b --- /dev/null +++ b/lynx/dashboard/ui/src/actions/(dashboard)/app/admin/users.ts @@ -0,0 +1,128 @@ +"use server"; + +import { apiFetch } from "@/lib/api"; +import { cookies } from "next/headers"; + +async function token(): Promise { + const jar = await cookies(); + return jar.get("access_token")?.value ?? ""; +} + +export type RoleRef = { id: string; name: string }; +export type PermRef = { id: string; key: string }; + +export type UserRow = { + id: string; + username: string; + force_password_change: boolean; + created_at: string; + roles: RoleRef[]; +}; + +export type RoleRow = { + id: string; + name: string; + permissions: PermRef[]; +}; + +// ── Users ────────────────────────────────────────────────────────────────── + +export async function listUsersAction(): Promise { + const tok = await token(); + const res = await apiFetch("/admin/users", { + headers: { Authorization: `Bearer ${tok}` }, + }); + return res.ok ? res.data : []; +} + +export async function deleteUserAction(userId: string): Promise<{ success: boolean; error?: string }> { + const tok = await token(); + const res = await apiFetch(`/admin/users/${userId}`, { + method: "DELETE", + headers: { Authorization: `Bearer ${tok}` }, + }); + return res.ok ? { success: true } : { success: false, error: (res as { ok: false; error: string }).error }; +} + +export async function forcePasswordChangeAction(userId: string): Promise<{ success: boolean }> { + const tok = await token(); + const res = await apiFetch(`/admin/users/${userId}/force-password-change`, { + method: "POST", + headers: { Authorization: `Bearer ${tok}` }, + }); + return { success: res.ok }; +} + +export async function addUserRoleAction(userId: string, roleId: string): Promise<{ success: boolean }> { + const tok = await token(); + const res = await apiFetch(`/admin/users/${userId}/roles/${roleId}`, { + method: "POST", + headers: { Authorization: `Bearer ${tok}` }, + }); + return { success: res.ok }; +} + +export async function removeUserRoleAction(userId: string, roleId: string): Promise<{ success: boolean }> { + const tok = await token(); + const res = await apiFetch(`/admin/users/${userId}/roles/${roleId}`, { + method: "DELETE", + headers: { Authorization: `Bearer ${tok}` }, + }); + return { success: res.ok }; +} + +// ── Roles ────────────────────────────────────────────────────────────────── + +export async function listRolesAction(): Promise { + const tok = await token(); + const res = await apiFetch("/admin/roles", { + headers: { Authorization: `Bearer ${tok}` }, + }); + return res.ok ? res.data : []; +} + +export async function listPermissionsAction(): Promise { + const tok = await token(); + const res = await apiFetch("/admin/permissions", { + headers: { Authorization: `Bearer ${tok}` }, + }); + return res.ok ? res.data : []; +} + +export async function createRoleAction(name: string): Promise<{ success: boolean; id?: string; error?: string }> { + const tok = await token(); + const res = await apiFetch<{ id: string; name: string }>("/admin/roles", { + method: "POST", + headers: { Authorization: `Bearer ${tok}` }, + body: JSON.stringify({ name }), + }); + if (res.ok) return { success: true, id: res.data.id }; + return { success: false, error: (res as { ok: false; error: string }).error }; +} + +export async function deleteRoleAction(roleId: string): Promise<{ success: boolean; error?: string }> { + const tok = await token(); + const res = await apiFetch(`/admin/roles/${roleId}`, { + method: "DELETE", + headers: { Authorization: `Bearer ${tok}` }, + }); + return res.ok ? { success: true } : { success: false, error: (res as { ok: false; error: string }).error }; +} + +export async function addRolePermissionAction(roleId: string, permId: string): Promise<{ success: boolean }> { + const tok = await token(); + const res = await apiFetch(`/admin/roles/${roleId}/permissions/${permId}`, { + method: "POST", + headers: { Authorization: `Bearer ${tok}` }, + }); + return { success: res.ok }; +} + +export async function removeRolePermissionAction(roleId: string, permId: string): Promise<{ success: boolean; error?: string }> { + const tok = await token(); + const res = await apiFetch(`/admin/roles/${roleId}/permissions/${permId}`, { + method: "DELETE", + headers: { Authorization: `Bearer ${tok}` }, + }); + return res.ok ? { success: true } : { success: false, error: (res as { ok: false; error: string }).error }; +} diff --git a/lynx/dashboard/ui/src/actions/(dashboard)/app/agents/index.ts b/lynx/dashboard/ui/src/actions/(dashboard)/app/agents/index.ts new file mode 100644 index 0000000..3f49138 --- /dev/null +++ b/lynx/dashboard/ui/src/actions/(dashboard)/app/agents/index.ts @@ -0,0 +1,67 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { cookies } from "next/headers"; +import { redirect } from "next/navigation"; +import { BACKEND_URL, validateId } from "@/lib/api"; + +async function token(): Promise { + const jar = await cookies(); + return jar.get("access_token")?.value ?? ""; +} + +export async function rebootAgent(agentId: string): Promise<{ ok: boolean; error?: string }> { + const jar = await cookies(); + const tok = jar.get("access_token")?.value ?? ""; + try { + const id = validateId(agentId); + const res = await fetch(`${BACKEND_URL}/agents/${id}/reboot`, { + headers: { Authorization: `Bearer ${tok}` }, + method: "POST", + }); + if (!res.ok) { + const body = (await res.json().catch(() => ({}))) as { error?: string }; + return { error: body.error ?? "server_error", ok: false }; + } + return { ok: true }; + } catch { + return { error: "network_error", ok: false }; + } +} + +export async function deleteAgent(agentId: string, locale: string): Promise { + const jar = await cookies(); + const tok = jar.get("access_token")?.value ?? ""; + const id = validateId(agentId); + await fetch(`${BACKEND_URL}/agents/${id}`, { + headers: { Authorization: `Bearer ${tok}` }, + method: "DELETE", + }); + revalidatePath(`/${locale}/app/agents`); + redirect(`/${locale}/app/agents`); +} + +export async function resolveNftables( + agentId: string, + action: "restore" | "accept", +): Promise<{ ok: boolean; error?: string }> { + try { + const id = validateId(agentId); + const res = await fetch(`${BACKEND_URL}/agents/${id}/nftables-resolve`, { + body: JSON.stringify({ action }), + headers: { + Authorization: `Bearer ${await token()}`, + "Content-Type": "application/json", + }, + method: "POST", + }); + if (!res.ok) { + const body = (await res.json()) as { error?: string; detail?: string }; + return { error: body.detail ?? body.error ?? "server_error", ok: false }; + } + revalidatePath("/[locale]/app/agents", "page"); + return { ok: true }; + } catch { + return { error: "network_error", ok: false }; + } +} diff --git a/lynx/dashboard/ui/src/actions/(dashboard)/app/agents/nftables.ts b/lynx/dashboard/ui/src/actions/(dashboard)/app/agents/nftables.ts new file mode 100644 index 0000000..9c39196 --- /dev/null +++ b/lynx/dashboard/ui/src/actions/(dashboard)/app/agents/nftables.ts @@ -0,0 +1,177 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { cookies } from "next/headers"; +import { BACKEND_URL, validateId } from "@/lib/api"; + +async function tok(): Promise { + const jar = await cookies(); + return jar.get("access_token")?.value ?? ""; +} + +export interface NftRule { + agent_id: string | null; + created_at: string; + description: string | null; + enabled: boolean; + id: string; + ip_list: string[]; + ip_version: string; + kind: string; + port: number | null; + priority: number; + protocol: string | null; + rate_per_min: number | null; + scope: "global" | "local"; +} + +export interface CreateRulePayload { + description?: string; + ip_list?: string[]; + kind: string; + port?: number; + priority?: number; + protocol?: string; + rate_per_min?: number; +} + +// --- Global rules --- + +export async function listGlobalRules(): Promise { + try { + const res = await fetch(`${BACKEND_URL}/nftables/global`, { + cache: "no-store", + headers: { Authorization: `Bearer ${await tok()}` }, + }); + if (!res.ok) return []; + return res.json(); + } catch { + return []; + } +} + +export async function createGlobalRule(payload: CreateRulePayload): Promise<{ ok: boolean; error?: string }> { + try { + const res = await fetch(`${BACKEND_URL}/nftables/global`, { + body: JSON.stringify(payload), + headers: { + Authorization: `Bearer ${await tok()}`, + "Content-Type": "application/json", + }, + method: "POST", + }); + if (!res.ok) { + const body = (await res.json().catch(() => ({}))) as { error?: string }; + return { error: body.error ?? "server_error", ok: false }; + } + revalidatePath("/app/agents"); + return { ok: true }; + } catch { + return { error: "network_error", ok: false }; + } +} + +export async function deleteGlobalRule(ruleId: string): Promise<{ ok: boolean; error?: string }> { + try { + const id = validateId(ruleId); + const res = await fetch(`${BACKEND_URL}/nftables/global/${id}`, { + headers: { Authorization: `Bearer ${await tok()}` }, + method: "DELETE", + }); + if (!res.ok) return { error: "server_error", ok: false }; + revalidatePath("/app/agents"); + return { ok: true }; + } catch { + return { error: "network_error", ok: false }; + } +} + +export async function pushGlobalRules(): Promise<{ + ok: boolean; + pushed?: number; + failed?: number; + error?: string; +}> { + try { + const res = await fetch(`${BACKEND_URL}/nftables/global/push`, { + headers: { Authorization: `Bearer ${await tok()}` }, + method: "POST", + }); + if (!res.ok) return { error: "server_error", ok: false }; + const data = (await res.json()) as { pushed: number; failed: number }; + return { failed: data.failed, ok: true, pushed: data.pushed }; + } catch { + return { error: "network_error", ok: false }; + } +} + +// --- Local rules (per agent) --- + +export async function listLocalRules(agentId: string): Promise { + try { + const id = validateId(agentId); + const res = await fetch(`${BACKEND_URL}/nftables/agents/${id}/local`, { + cache: "no-store", + headers: { Authorization: `Bearer ${await tok()}` }, + }); + if (!res.ok) return []; + return res.json(); + } catch { + return []; + } +} + +export async function createLocalRule( + agentId: string, + payload: CreateRulePayload, +): Promise<{ ok: boolean; error?: string }> { + try { + const id = validateId(agentId); + const res = await fetch(`${BACKEND_URL}/nftables/agents/${id}/local`, { + body: JSON.stringify(payload), + headers: { + Authorization: `Bearer ${await tok()}`, + "Content-Type": "application/json", + }, + method: "POST", + }); + if (!res.ok) { + const body = (await res.json().catch(() => ({}))) as { error?: string }; + return { error: body.error ?? "server_error", ok: false }; + } + revalidatePath(`/app/agents`); + return { ok: true }; + } catch { + return { error: "network_error", ok: false }; + } +} + +export async function deleteLocalRule(agentId: string, ruleId: string): Promise<{ ok: boolean; error?: string }> { + try { + const aid = validateId(agentId); + const rid = validateId(ruleId); + const res = await fetch(`${BACKEND_URL}/nftables/agents/${aid}/local/${rid}`, { + headers: { Authorization: `Bearer ${await tok()}` }, + method: "DELETE", + }); + if (!res.ok) return { error: "server_error", ok: false }; + revalidatePath(`/app/agents`); + return { ok: true }; + } catch { + return { error: "network_error", ok: false }; + } +} + +export async function pushLocalRules(agentId: string): Promise<{ ok: boolean; error?: string }> { + try { + const id = validateId(agentId); + const res = await fetch(`${BACKEND_URL}/nftables/agents/${id}/local/push`, { + headers: { Authorization: `Bearer ${await tok()}` }, + method: "POST", + }); + if (!res.ok) return { error: "server_error", ok: false }; + return { ok: true }; + } catch { + return { error: "network_error", ok: false }; + } +} diff --git a/lynx/dashboard/ui/src/actions/(dashboard)/app/logout.ts b/lynx/dashboard/ui/src/actions/(dashboard)/app/logout.ts new file mode 100644 index 0000000..60132a2 --- /dev/null +++ b/lynx/dashboard/ui/src/actions/(dashboard)/app/logout.ts @@ -0,0 +1,21 @@ +"use server"; + +import { apiFetch } from "@/lib/api"; +import { cookies } from "next/headers"; +import { redirect } from "next/navigation"; + +export async function logoutAction(locale: string) { + const jar = await cookies(); + const accessToken = jar.get("access_token")?.value; + + if (accessToken) { + await apiFetch("/auth/logout", { + method: "POST", + headers: { Authorization: `Bearer ${accessToken}` }, + }); + } + + jar.delete("access_token"); + jar.delete("refresh_token"); + redirect(`/${locale}/login`); +} diff --git a/lynx/dashboard/ui/src/actions/(dashboard)/app/organizations/[id]/index.ts b/lynx/dashboard/ui/src/actions/(dashboard)/app/organizations/[id]/index.ts new file mode 100644 index 0000000..b939d6c --- /dev/null +++ b/lynx/dashboard/ui/src/actions/(dashboard)/app/organizations/[id]/index.ts @@ -0,0 +1,55 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { cookies } from "next/headers"; +import { BACKEND_URL, validateId } from "@/lib/api"; + +async function token(): Promise { + const jar = await cookies(); + return jar.get("access_token")?.value ?? ""; +} + +export async function inviteMember( + orgId: string, + username: string, + role: string, +): Promise<{ ok: boolean; error?: string }> { + try { + const oid = validateId(orgId); + const res = await fetch(`${BACKEND_URL}/organizations/${oid}/members`, { + body: JSON.stringify({ role, username }), + headers: { + Authorization: `Bearer ${await token()}`, + "Content-Type": "application/json", + }, + method: "POST", + }); + if (!res.ok) { + const body = (await res.json()) as { error?: string; detail?: string }; + return { error: body.detail ?? body.error ?? "server_error", ok: false }; + } + revalidatePath(`/[locale]/app/organizations/${orgId}`, "page"); + return { ok: true }; + } catch { + return { error: "network_error", ok: false }; + } +} + +export async function removeMember(orgId: string, userId: string): Promise<{ ok: boolean; error?: string }> { + try { + const oid = validateId(orgId); + const uid = validateId(userId); + const res = await fetch(`${BACKEND_URL}/organizations/${oid}/members/${uid}`, { + headers: { Authorization: `Bearer ${await token()}` }, + method: "DELETE", + }); + if (!res.ok) { + const body = (await res.json()) as { error?: string; detail?: string }; + return { error: body.detail ?? body.error ?? "server_error", ok: false }; + } + revalidatePath(`/[locale]/app/organizations/${orgId}`, "page"); + return { ok: true }; + } catch { + return { error: "network_error", ok: false }; + } +} diff --git a/lynx/dashboard/ui/src/actions/(dashboard)/app/organizations/[id]/projects.ts b/lynx/dashboard/ui/src/actions/(dashboard)/app/organizations/[id]/projects.ts new file mode 100644 index 0000000..da5f6cd --- /dev/null +++ b/lynx/dashboard/ui/src/actions/(dashboard)/app/organizations/[id]/projects.ts @@ -0,0 +1,44 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { cookies } from "next/headers"; +import { BACKEND_URL, validateId } from "@/lib/api"; + +async function token(): Promise { + const jar = await cookies(); + return jar.get("access_token")?.value ?? ""; +} + +export async function createProject( + orgId: string, + name: string, + slug: string, + agentId: string, +): Promise<{ ok: boolean; error?: string }> { + try { + const oid = validateId(orgId); + const aid = validateId(agentId); + const res = await fetch(`${BACKEND_URL}/organizations/${oid}/projects`, { + body: JSON.stringify({ agent_id: aid, name, slug }), + headers: { + Authorization: `Bearer ${await token()}`, + "Content-Type": "application/json", + }, + method: "POST", + }); + if (!res.ok) { + const body = (await res.json()) as { + error?: string; + detail?: string; + }; + if (body.error === "conflict") { + return { error: "slug_conflict", ok: false }; + } + return { error: body.detail ?? body.error ?? "server_error", ok: false }; + } + revalidatePath(`/[locale]/app/organizations/${orgId}`, "page"); + return { ok: true }; + } catch { + return { error: "network_error", ok: false }; + } +} diff --git a/lynx/dashboard/ui/src/actions/(dashboard)/app/organizations/[id]/projects/[proj_id]/containers.ts b/lynx/dashboard/ui/src/actions/(dashboard)/app/organizations/[id]/projects/[proj_id]/containers.ts new file mode 100644 index 0000000..665c997 --- /dev/null +++ b/lynx/dashboard/ui/src/actions/(dashboard)/app/organizations/[id]/projects/[proj_id]/containers.ts @@ -0,0 +1,72 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { cookies } from "next/headers"; +import { BACKEND_URL, validateId, validateName } from "@/lib/api"; + +async function token(): Promise { + const jar = await cookies(); + return jar.get("access_token")?.value ?? ""; +} + +const ACTIONS = ["start", "stop", "restart", "remove"] as const; + +export async function containerAction( + orgId: string, + projId: string, + name: string, + action: "start" | "stop" | "restart" | "remove", +): Promise<{ ok: boolean; error?: string }> { + try { + const oid = validateId(orgId); + const pid = validateId(projId); + const cname = validateName(name); + if (!ACTIONS.includes(action)) return { error: "invalid_action", ok: false }; + const res = await fetch(`${BACKEND_URL}/organizations/${oid}/projects/${pid}/containers/${cname}/${action}`, { + headers: { Authorization: `Bearer ${await token()}` }, + method: "POST", + }); + if (!res.ok) { + const body = (await res.json()) as { error?: string; detail?: string }; + return { error: body.detail ?? body.error ?? "server_error", ok: false }; + } + revalidatePath(`/[locale]/app/organizations/${orgId}/projects/${projId}`, "page"); + return { ok: true }; + } catch { + return { error: "network_error", ok: false }; + } +} + +export async function deployContainer( + orgId: string, + projId: string, + payload: { + name: string; + image: string; + ports: string[]; + env: string[]; + cpus: number | null; + memory_mb: number | null; + }, +): Promise<{ ok: boolean; error?: string }> { + try { + const oid = validateId(orgId); + const pid = validateId(projId); + const res = await fetch(`${BACKEND_URL}/organizations/${oid}/projects/${pid}/containers`, { + body: JSON.stringify(payload), + headers: { + Authorization: `Bearer ${await token()}`, + "Content-Type": "application/json", + }, + method: "POST", + }); + if (!res.ok) { + const body = (await res.json()) as { error?: string; detail?: string }; + return { error: body.detail ?? body.error ?? "server_error", ok: false }; + } + revalidatePath(`/[locale]/app/organizations/${orgId}/projects/${projId}`, "page"); + return { ok: true }; + } catch { + return { error: "network_error", ok: false }; + } +} diff --git a/lynx/dashboard/ui/src/actions/(dashboard)/app/organizations/[id]/projects/[proj_id]/index.ts b/lynx/dashboard/ui/src/actions/(dashboard)/app/organizations/[id]/projects/[proj_id]/index.ts new file mode 100644 index 0000000..ef78a57 --- /dev/null +++ b/lynx/dashboard/ui/src/actions/(dashboard)/app/organizations/[id]/projects/[proj_id]/index.ts @@ -0,0 +1,43 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { cookies } from "next/headers"; +import { BACKEND_URL, validateId } from "@/lib/api"; + +async function token(): Promise { + const jar = await cookies(); + return jar.get("access_token")?.value ?? ""; +} + +export async function updateContainerResources( + orgId: string, + projId: string, + containerName: string, + cpus: number | null, + memoryMb: number | null, +): Promise<{ ok: boolean; error?: string }> { + try { + const oid = validateId(orgId); + const pid = validateId(projId); + const res = await fetch(`${BACKEND_URL}/organizations/${oid}/projects/${pid}/resources`, { + body: JSON.stringify({ + container_name: containerName, + cpus: cpus ?? undefined, + memory_mb: memoryMb ?? undefined, + }), + headers: { + Authorization: `Bearer ${await token()}`, + "Content-Type": "application/json", + }, + method: "PUT", + }); + if (!res.ok) { + const body = (await res.json()) as { error?: string; detail?: string }; + return { error: body.detail ?? body.error ?? "server_error", ok: false }; + } + revalidatePath(`/[locale]/app/organizations/${orgId}/projects/${projId}`, "page"); + return { ok: true }; + } catch { + return { error: "network_error", ok: false }; + } +} diff --git a/lynx/dashboard/ui/src/actions/(dashboard)/app/organizations/[id]/projects/[proj_id]/scale.ts b/lynx/dashboard/ui/src/actions/(dashboard)/app/organizations/[id]/projects/[proj_id]/scale.ts new file mode 100644 index 0000000..54e5da8 --- /dev/null +++ b/lynx/dashboard/ui/src/actions/(dashboard)/app/organizations/[id]/projects/[proj_id]/scale.ts @@ -0,0 +1,57 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { cookies } from "next/headers"; +import { BACKEND_URL, validateId } from "@/lib/api"; + +export async function addHorizontalScale( + orgId: string, + projId: string, + targetAgentId: string, + image: string, + replicaCount: number, +): Promise<{ ok: boolean; error?: string }> { + const jar = await cookies(); + const tok = jar.get("access_token")?.value ?? ""; + + const oid = validateId(orgId); + const pid = validateId(projId); + const aid = validateId(targetAgentId); + const res = await fetch(`${BACKEND_URL}/organizations/${oid}/projects/${pid}/scale/horizontal`, { + body: JSON.stringify({ + image, + replica_count: replicaCount, + target_agent_id: aid, + }), + headers: { + Authorization: `Bearer ${tok}`, + "Content-Type": "application/json", + }, + method: "POST", + }); + + if (!res.ok) return { error: `${res.status}`, ok: false }; + revalidatePath(`/app/organizations/${orgId}/projects/${projId}`); + return { ok: true }; +} + +export async function teardownHorizontalScale( + orgId: string, + projId: string, + tunnelId: string, +): Promise<{ ok: boolean; error?: string }> { + const jar = await cookies(); + const tok = jar.get("access_token")?.value ?? ""; + + const oid2 = validateId(orgId); + const pid2 = validateId(projId); + const tid = validateId(tunnelId); + const res = await fetch(`${BACKEND_URL}/organizations/${oid2}/projects/${pid2}/scale/horizontal/${tid}`, { + headers: { Authorization: `Bearer ${tok}` }, + method: "DELETE", + }); + + if (!res.ok) return { error: `${res.status}`, ok: false }; + revalidatePath(`/app/organizations/${orgId}/projects/${projId}`); + return { ok: true }; +} diff --git a/lynx/dashboard/ui/src/actions/(dashboard)/app/settings/index.ts b/lynx/dashboard/ui/src/actions/(dashboard)/app/settings/index.ts new file mode 100644 index 0000000..1b27b0a --- /dev/null +++ b/lynx/dashboard/ui/src/actions/(dashboard)/app/settings/index.ts @@ -0,0 +1,239 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { cookies } from "next/headers"; +import { redirect } from "next/navigation"; +import { BACKEND_URL, validateId } from "@/lib/api"; + +export async function revokeSession(sessionId: string): Promise<{ ok: boolean }> { + const jar = await cookies(); + const token = jar.get("access_token")?.value ?? ""; + + try { + const sid = validateId(sessionId); + const res = await fetch(`${BACKEND_URL}/admin/sessions/${sid}`, { + headers: { Authorization: `Bearer ${token}` }, + method: "DELETE", + }); + return { ok: res.ok }; + } catch { + return { ok: false }; + } +} + +export async function rotateKeys(locale: string): Promise { + const jar = await cookies(); + const token = jar.get("access_token")?.value ?? ""; + + try { + await fetch(`${BACKEND_URL}/admin/rotate`, { + body: JSON.stringify({ reason: "manual", scope: "jwt_keys" }), + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + method: "POST", + }); + } catch { + // Rotation may still have succeeded on the backend + } + + jar.delete("access_token"); + jar.delete("refresh_token"); + redirect(`/${locale}/login`); +} + +export interface BrandingPayload { + accent_color?: string; + company_name?: string; + logo_url?: string | null; + primary_color?: string; + secondary_color?: string; +} + +export interface UpdateCheckResult { + current_version: string; + latest_version: string; + release_url: string | null; + update_available: boolean; +} + +export async function checkForUpdates(): Promise<{ + ok: boolean; + data?: UpdateCheckResult; + error?: string; +}> { + const jar = await cookies(); + const tok = jar.get("access_token")?.value ?? ""; + try { + const res = await fetch(`${BACKEND_URL}/admin/update-check`, { + cache: "no-store", + headers: { Authorization: `Bearer ${tok}` }, + }); + if (!res.ok) return { error: "server_error", ok: false }; + return { data: (await res.json()) as UpdateCheckResult, ok: true }; + } catch { + return { error: "network_error", ok: false }; + } +} + +export async function triggerUpdate(version: string): Promise<{ ok: boolean; error?: string }> { + const jar = await cookies(); + const tok = jar.get("access_token")?.value ?? ""; + try { + const res = await fetch(`${BACKEND_URL}/admin/trigger-update`, { + body: JSON.stringify({ channel: "stable", version }), + headers: { + Authorization: `Bearer ${tok}`, + "Content-Type": "application/json", + }, + method: "POST", + }); + if (!res.ok) { + const body = (await res.json()) as { error?: string }; + return { error: body.error ?? "server_error", ok: false }; + } + return { ok: true }; + } catch { + return { error: "network_error", ok: false }; + } +} + +export async function updateBranding(payload: BrandingPayload): Promise<{ ok: boolean; error?: string }> { + const jar = await cookies(); + const token = jar.get("access_token")?.value ?? ""; + + try { + const res = await fetch(`${BACKEND_URL}/admin/branding`, { + body: JSON.stringify(payload), + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + method: "PUT", + }); + if (!res.ok) { + const body = (await res.json()) as { error?: string }; + return { error: body.error ?? "server_error", ok: false }; + } + revalidatePath("/", "layout"); + return { ok: true }; + } catch { + return { error: "network_error", ok: false }; + } +} + +// Domain actions + +export async function configureDomain(domain: string, email: string): Promise<{ ok: boolean; error?: string }> { + const jar = await cookies(); + const tok = jar.get("access_token")?.value ?? ""; + + try { + const res = await fetch(`${BACKEND_URL}/domain`, { + body: JSON.stringify({ domain, email }), + headers: { + Authorization: `Bearer ${tok}`, + "Content-Type": "application/json", + }, + method: "POST", + }); + if (!res.ok) return { error: "server_error", ok: false }; + revalidatePath("/app/settings"); + return { ok: true }; + } catch { + return { error: "network_error", ok: false }; + } +} + +export async function verifyDomain(): Promise<{ + ok: boolean; + dns_ok?: boolean; + domain?: string; + error?: string; +}> { + const jar = await cookies(); + const tok = jar.get("access_token")?.value ?? ""; + + try { + const res = await fetch(`${BACKEND_URL}/domain/verify`, { + headers: { Authorization: `Bearer ${tok}` }, + method: "POST", + }); + if (!res.ok) return { error: "server_error", ok: false }; + const data = (await res.json()) as { dns_ok: boolean; domain: string }; + return { dns_ok: data.dns_ok, domain: data.domain, ok: true }; + } catch { + return { error: "network_error", ok: false }; + } +} + +export async function setHsts(enabled: boolean): Promise<{ ok: boolean; error?: string }> { + const jar = await cookies(); + const tok = jar.get("access_token")?.value ?? ""; + + try { + const res = await fetch(`${BACKEND_URL}/domain/hsts`, { + body: JSON.stringify({ enabled }), + headers: { + Authorization: `Bearer ${tok}`, + "Content-Type": "application/json", + }, + method: "POST", + }); + if (!res.ok) return { error: "server_error", ok: false }; + revalidatePath("/app/settings"); + return { ok: true }; + } catch { + return { error: "network_error", ok: false }; + } +} + +export async function uploadCert( + certType: "cloudflare" | "custom", + certPem: string, + keyPem?: string, +): Promise<{ ok: boolean; error?: string }> { + const jar = await cookies(); + const tok = jar.get("access_token")?.value ?? ""; + + try { + const res = await fetch(`${BACKEND_URL}/domain/cert/upload`, { + body: JSON.stringify({ + cert_pem: certPem, + cert_type: certType, + key_pem: keyPem ?? null, + }), + headers: { + Authorization: `Bearer ${tok}`, + "Content-Type": "application/json", + }, + method: "POST", + }); + if (!res.ok) { + const body = (await res.json().catch(() => ({}))) as { error?: string }; + return { error: body.error ?? "server_error", ok: false }; + } + revalidatePath("/app/settings"); + return { ok: true }; + } catch { + return { error: "network_error", ok: false }; + } +} + +export async function closePort19443(): Promise<{ ok: boolean; error?: string }> { + const jar = await cookies(); + const tok = jar.get("access_token")?.value ?? ""; + + try { + const res = await fetch(`${BACKEND_URL}/domain/close-port`, { + headers: { Authorization: `Bearer ${tok}` }, + method: "POST", + }); + if (!res.ok) return { error: "server_error", ok: false }; + revalidatePath("/app/settings"); + return { ok: true }; + } catch { + return { error: "network_error", ok: false }; + } +} diff --git a/lynx/dashboard/ui/src/actions/(dashboard)/app/settings/migration.ts b/lynx/dashboard/ui/src/actions/(dashboard)/app/settings/migration.ts new file mode 100644 index 0000000..34e5e52 --- /dev/null +++ b/lynx/dashboard/ui/src/actions/(dashboard)/app/settings/migration.ts @@ -0,0 +1,102 @@ +"use server"; + +import { cookies } from "next/headers"; +import { BACKEND_URL } from "@/lib/api"; + +async function authToken(): Promise { + const jar = await cookies(); + return jar.get("access_token")?.value ?? ""; +} + +export async function getMigrationStatus(): Promise<{ + status: string; + role: string; + target_url: string | null; + agents_total: number; + agents_confirmed: number; + error_message: string | null; + started_at: string | null; +} | null> { + const tok = await authToken(); + try { + const res = await fetch(`${BACKEND_URL}/migration`, { + headers: { Authorization: `Bearer ${tok}` }, + cache: "no-store", + }); + if (!res.ok) return null; + return res.json(); + } catch { + return null; + } +} + +export async function prepareMigration(): Promise<{ + ok: boolean; + migration_token?: string; + error?: string; +}> { + const tok = await authToken(); + try { + const res = await fetch(`${BACKEND_URL}/migration/prepare`, { + method: "POST", + headers: { Authorization: `Bearer ${tok}` }, + }); + if (!res.ok) return { ok: false, error: `${res.status}` }; + const data = (await res.json()) as { migration_token: string }; + return { ok: true, migration_token: data.migration_token }; + } catch { + return { ok: false, error: "network_error" }; + } +} + +export async function startMigration( + targetUrl: string, + migrationToken: string, +): Promise<{ ok: boolean; error?: string }> { + const tok = await authToken(); + try { + const res = await fetch(`${BACKEND_URL}/migration/start`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${tok}`, + }, + body: JSON.stringify({ target_url: targetUrl, migration_token: migrationToken }), + }); + if (!res.ok) return { ok: false, error: `${res.status}` }; + return { ok: true }; + } catch { + return { ok: false, error: "network_error" }; + } +} + +export async function abortMigration(): Promise<{ ok: boolean; error?: string }> { + const tok = await authToken(); + try { + const res = await fetch(`${BACKEND_URL}/migration/abort`, { + method: "POST", + headers: { Authorization: `Bearer ${tok}` }, + }); + if (!res.ok) return { ok: false, error: `${res.status}` }; + return { ok: true }; + } catch { + return { ok: false, error: "network_error" }; + } +} + +export async function confirmMigrationShutdown(): Promise<{ + ok: boolean; + error?: string; +}> { + const tok = await authToken(); + try { + const res = await fetch(`${BACKEND_URL}/migration/confirm-shutdown`, { + method: "POST", + headers: { Authorization: `Bearer ${tok}` }, + }); + if (!res.ok) return { ok: false, error: `${res.status}` }; + return { ok: true }; + } catch { + return { ok: false, error: "network_error" }; + } +} diff --git a/lynx/dashboard/ui/src/actions/(dashboard)/app/settings/preferences.ts b/lynx/dashboard/ui/src/actions/(dashboard)/app/settings/preferences.ts new file mode 100644 index 0000000..a8ced06 --- /dev/null +++ b/lynx/dashboard/ui/src/actions/(dashboard)/app/settings/preferences.ts @@ -0,0 +1,35 @@ +"use server"; + +import { apiFetch } from "@/lib/api"; +import { cookies } from "next/headers"; + +async function token(): Promise { + const jar = await cookies(); + return jar.get("access_token")?.value ?? ""; +} + +export async function updateThemeAction(theme: string): Promise { + const tok = await token(); + await apiFetch("/auth/me/preferences", { + method: "POST", + headers: { Authorization: `Bearer ${tok}` }, + body: JSON.stringify({ theme }), + }); + const jar = await cookies(); + jar.set("theme_preference", theme, { + path: "/", + httpOnly: false, + sameSite: "strict", + secure: process.env.NODE_ENV === "production", + maxAge: 60 * 60 * 24 * 365, + }); +} + +export async function updateLocaleAction(locale: string): Promise { + const tok = await token(); + await apiFetch("/auth/me/preferences", { + method: "POST", + headers: { Authorization: `Bearer ${tok}` }, + body: JSON.stringify({ locale }), + }); +} diff --git a/lynx/dashboard/ui/src/actions/(dashboard)/app/settings/profile.ts b/lynx/dashboard/ui/src/actions/(dashboard)/app/settings/profile.ts new file mode 100644 index 0000000..13df369 --- /dev/null +++ b/lynx/dashboard/ui/src/actions/(dashboard)/app/settings/profile.ts @@ -0,0 +1,76 @@ +"use server"; + +import { cookies } from "next/headers"; +import { BACKEND_URL } from "@/lib/api"; + +export async function changePassword( + currentPassword: string, + newPassword: string, +): Promise<{ ok: boolean; status?: number }> { + const jar = await cookies(); + const tok = jar.get("access_token")?.value ?? ""; + + try { + const res = await fetch(`${BACKEND_URL}/auth/change-password`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${tok}`, + }, + body: JSON.stringify({ + current_password: currentPassword, + new_password: newPassword, + }), + }); + + if (res.ok) { + // Clear cookies — all sessions were invalidated + jar.delete("access_token"); + jar.delete("refresh_token"); + } + + return { ok: res.ok, status: res.status }; + } catch { + return { ok: false }; + } +} + +export async function getMe(): Promise<{ + id: string; + username: string; + is_admin: boolean; + single_session: boolean; +} | null> { + const jar = await cookies(); + const tok = jar.get("access_token")?.value ?? ""; + + try { + const res = await fetch(`${BACKEND_URL}/auth/me`, { + headers: { Authorization: `Bearer ${tok}` }, + cache: "no-store", + }); + if (!res.ok) return null; + return res.json(); + } catch { + return null; + } +} + +export async function toggleSingleSession(enabled: boolean): Promise<{ ok: boolean }> { + const jar = await cookies(); + const tok = jar.get("access_token")?.value ?? ""; + + try { + const res = await fetch(`${BACKEND_URL}/auth/me/single-session`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${tok}`, + }, + body: JSON.stringify({ enabled }), + }); + return { ok: res.ok }; + } catch { + return { ok: false }; + } +} diff --git a/lynx/dashboard/ui/src/app/[locale]/(auth)/login/page.tsx b/lynx/dashboard/ui/src/app/[locale]/(auth)/login/page.tsx new file mode 100644 index 0000000..9210533 --- /dev/null +++ b/lynx/dashboard/ui/src/app/[locale]/(auth)/login/page.tsx @@ -0,0 +1,68 @@ + +import { getTranslations } from "next-intl/server"; +import { BACKEND_URL } from "@/lib/api"; +import { LoginForm } from "@/components/(auth)/login/LoginForm"; + +async function fetchCompanyName(): Promise { + try { + const res = await fetch(`${BACKEND_URL}/branding`, { + next: { revalidate: 60 }, + }); + if (!res.ok) return "Lynx"; + const data = (await res.json()) as { company_name?: string }; + return data.company_name ?? "Lynx"; + } catch { + return "Lynx"; + } +} + +export default async function LoginPage({ params }: { params: Promise<{ locale: string }>; }) { + const { locale } = await params; + const [t, companyName] = await Promise.all([ + getTranslations({ locale, namespace: "auth.login" }), + fetchCompanyName(), + ]); + + return ( +
+
+
+

+ {t("title", { company: companyName })} +

+

{t("subtitle")}

+
+ + + + +
+
+ ); +} + +async function Branding({ locale }: { locale: string }) { + const t = await getTranslations({ locale, namespace: "branding" }); + return ( + + ); +} diff --git a/lynx/dashboard/ui/src/app/[locale]/(auth)/register/page.tsx b/lynx/dashboard/ui/src/app/[locale]/(auth)/register/page.tsx new file mode 100644 index 0000000..bbc8387 --- /dev/null +++ b/lynx/dashboard/ui/src/app/[locale]/(auth)/register/page.tsx @@ -0,0 +1,51 @@ + +import { getTranslations } from "next-intl/server"; +import { RegisterForm } from "@/components/(auth)/register/RegisterForm"; + +export default async function RegisterPage({ + params, +}: { params: Promise<{ locale: string }>; }) { + const { locale } = await params; + const t = await getTranslations({ locale, namespace: "auth.register" }); + + return ( +
+
+
+

{t("title")}

+

{t("subtitle")}

+
+ + + + +
+
+ ); +} + +async function Branding({ locale }: { locale: string }) { + const t = await getTranslations({ locale, namespace: "branding" }); + return ( + + ); +} diff --git a/lynx/dashboard/ui/src/app/[locale]/(dashboard)/app/admin/page.tsx b/lynx/dashboard/ui/src/app/[locale]/(dashboard)/app/admin/page.tsx new file mode 100644 index 0000000..4824aa5 --- /dev/null +++ b/lynx/dashboard/ui/src/app/[locale]/(dashboard)/app/admin/page.tsx @@ -0,0 +1,160 @@ +import { Suspense } from "react"; +import { getTranslations } from "next-intl/server"; +import { redirect } from "next/navigation"; +import { cookies } from "next/headers"; +import { BACKEND_URL } from "@/lib/api"; +import { + listUsersAction, + listRolesAction, + listPermissionsAction, + type UserRow, + type RoleRow, + type PermRef, +} from "@/actions/(dashboard)/app/admin/users"; +import { UsersPanel } from "@/components/(dashboard)/app/admin/UsersPanel"; +import { RolesPanel } from "@/components/(dashboard)/app/admin/RolesPanel"; +import { Skeleton } from "@/components/ui/skeleton"; + +// --------------------------------------------------------------------------- +// Guard: redirect non-admins +// --------------------------------------------------------------------------- + +async function assertAdmin(token: string, locale: string) { + try { + const res = await fetch(`${BACKEND_URL}/auth/me`, { + headers: { Authorization: `Bearer ${token}` }, + cache: "no-store", + }); + if (!res.ok) redirect(`/${locale}/app`); + const data = (await res.json()) as { is_admin?: boolean }; + if (!data.is_admin) redirect(`/${locale}/app`); + } catch { + redirect(`/${locale}/app`); + } +} + +// --------------------------------------------------------------------------- +// Async data loader +// --------------------------------------------------------------------------- + +async function AdminData({ locale }: { locale: string }) { + const t = await getTranslations({ locale, namespace: "admin" }); + + const [users, roles, perms]: [UserRow[], RoleRow[], PermRef[]] = await Promise.all([ + listUsersAction(), + listRolesAction(), + listPermissionsAction(), + ]); + + const userLabels = { + deleteUser: t("deleteUser"), + deleteConfirm: t("deleteConfirm"), + deleteSuccess: t("deleteSuccess"), + deleteError: t("deleteError"), + forcePasswordChange: t("forcePasswordChange"), + forcePasswordChangeSuccess: t("forcePasswordChangeSuccess"), + forcePasswordChangeError: t("forcePasswordChangeError"), + addRole: t("addRole"), + addRoleSuccess: t("addRoleSuccess"), + addRoleError: t("addRoleError"), + removeRole: t("removeRole"), + removeRoleSuccess: t("removeRoleSuccess"), + removeRoleError: t("removeRoleError"), + noRoles: t("noRoles"), + selectRole: t("selectRole"), + }; + + const roleLabels = { + createRole: t("createRole"), + createRoleSuccess: t("createRoleSuccess"), + createRoleError: t("createRoleError"), + deleteRole: t("deleteRole"), + deleteRoleConfirm: t("deleteRoleConfirm"), + deleteRoleSuccess: t("deleteRoleSuccess"), + deleteRoleError: t("deleteRoleError"), + addPermission: t("addPermission"), + addPermissionSuccess: t("addPermissionSuccess"), + addPermissionError: t("addPermissionError"), + removePermission: t("removePermission"), + removePermissionSuccess: t("removePermissionSuccess"), + removePermissionError: t("removePermissionError"), + roleName: t("roleName"), + noPermissions: t("noPermissions"), + }; + + return ( + <> +
+

+ {t("users")} +

+ +
+ +
+

+ {t("roles")} +

+ +
+ + ); +} + +function AdminSkeleton() { + return ( + <> +
+ +
+ {[0, 1, 2].map((i) => ( +
+ + + +
+ ))} +
+
+
+ +
+ {[0, 1].map((i) => ( +
+ +
+ + +
+
+ ))} +
+
+ + ); +} + +// --------------------------------------------------------------------------- +// Page +// --------------------------------------------------------------------------- + +export default async function AdminPage({ + params, +}: { params: Promise<{ locale: string }> }) { + const { locale } = await params; + const t = await getTranslations({ locale, namespace: "admin" }); + + const jar = await cookies(); + const token = jar.get("access_token")?.value ?? ""; + + await assertAdmin(token, locale); + + return ( +
+

{t("title")}

+ }> + + +
+ ); +} diff --git a/lynx/dashboard/ui/src/app/[locale]/(dashboard)/app/agents/[id]/audit-log/page.tsx b/lynx/dashboard/ui/src/app/[locale]/(dashboard)/app/agents/[id]/audit-log/page.tsx new file mode 100644 index 0000000..c8dbdd5 --- /dev/null +++ b/lynx/dashboard/ui/src/app/[locale]/(dashboard)/app/agents/[id]/audit-log/page.tsx @@ -0,0 +1,186 @@ +import { cookies } from "next/headers"; +import { notFound } from "next/navigation"; +import { getTranslations } from "next-intl/server"; +import { BACKEND_URL } from "@/lib/api"; +import Link from "next/link"; +import { ChevronRight } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; + +interface AuditEntry { + id: string; + agent_id: string; + organization_id: string | null; + user_id: string | null; + command_type: string; + result: "success" | "rejected" | "failed"; + error: string | null; + entry_hash: string; + created_at: string; +} + +interface AuditResponse { + entries: AuditEntry[]; + total: number; + limit: number; + offset: number; +} + +async function fetchAuditLog( + token: string, + agentId: string, + limit = 50, + offset = 0, +): Promise { + try { + const res = await fetch( + `${BACKEND_URL}/agents/${agentId}/audit-log?limit=${limit}&offset=${offset}`, + { headers: { Authorization: `Bearer ${token}` }, cache: "no-store" }, + ); + if (res.status === 404) return null; + if (!res.ok) return { entries: [], total: 0, limit, offset }; + return res.json(); + } catch { + return { entries: [], total: 0, limit, offset }; + } +} + +const RESULT_VARIANT: Record< + AuditEntry["result"], + "default" | "destructive" | "secondary" +> = { + success: "default", + rejected: "secondary", + failed: "destructive", +}; + +function formatTime(ts: string): string { + const d = new Date(ts); + return d.toLocaleString("en-GB", { + year: "numeric", + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false, + }); +} + +export default async function AuditLogPage({ + params, + searchParams, +}: { + params: Promise<{ locale: string; id: string }>; + searchParams: Promise<{ offset?: string }>; +}) { + const { locale, id: agentId } = await params; + const { offset: offsetParam } = await searchParams; + const offset = parseInt(offsetParam ?? "0") || 0; + const limit = 50; + + const [t, tAgents, jar] = await Promise.all([ + getTranslations({ locale, namespace: "app.auditLog" }), + getTranslations({ locale, namespace: "app.agents" }), + cookies(), + ]); + const tok = jar.get("access_token")?.value ?? ""; + + const data = await fetchAuditLog(tok, agentId, limit, offset); + if (!data) notFound(); + + const hasMore = offset + limit < data.total; + const hasPrev = offset > 0; + + return ( +
+ + +
+

{t("title")}

+

+ {data.total} entries total +

+
+ + {data.entries.length === 0 ? ( +

{t("noEntries")}

+ ) : ( +
+ + + + + + + + + + + + {data.entries.map((e) => ( + + + + + + + + ))} + +
{t("time")}{t("command")}{t("result")} + {t("user")} + + {t("hash")} +
+ {formatTime(e.created_at)} + + {e.command_type} + {e.error && ( +

+ {e.error} +

+ )} +
+ + {t(`result${e.result.charAt(0).toUpperCase() + e.result.slice(1)}`)} + + + {e.user_id ? e.user_id.slice(0, 8) + "…" : "—"} + + {e.entry_hash}… +
+
+ )} + +
+ {hasPrev && ( + + ← Previous + + )} + {hasMore && ( + + {t("loadMore")} → + + )} +
+
+ ); +} diff --git a/lynx/dashboard/ui/src/app/[locale]/(dashboard)/app/agents/[id]/page.tsx b/lynx/dashboard/ui/src/app/[locale]/(dashboard)/app/agents/[id]/page.tsx new file mode 100644 index 0000000..7229981 --- /dev/null +++ b/lynx/dashboard/ui/src/app/[locale]/(dashboard)/app/agents/[id]/page.tsx @@ -0,0 +1,319 @@ +import { cookies } from "next/headers"; +import { notFound } from "next/navigation"; +import { getTranslations } from "next-intl/server"; +import Link from "next/link"; +import { BACKEND_URL } from "@/lib/api"; +import { Badge } from "@/components/ui/badge"; +import { ChevronRight, Shield } from "lucide-react"; +import { AgentDetailActions } from "@/components/(dashboard)/app/agents/detail/AgentDetailActions"; +import { MetricsPanel } from "@/components/(dashboard)/app/agents/detail/MetricsPanel"; +import { NftablesAlert } from "@/components/(dashboard)/app/agents/NftablesAlert"; +import { NftRulesPanel } from "@/components/(dashboard)/app/agents/detail/NftRulesPanel"; +import { + listGlobalRules, + listLocalRules, + createGlobalRule, + deleteGlobalRule, + pushGlobalRules, + createLocalRule, + deleteLocalRule, + pushLocalRules, +} from "@/actions/(dashboard)/app/agents/nftables"; + +interface Agent { + id: string; + name: string; + wg_ip: string; + wg_endpoint: string | null; + status: "online" | "lockdown" | "offline"; + version: string | null; + last_heartbeat: string | null; + created_at: string; +} + +interface NftStatus { + diverged: boolean; + detail?: string | null; +} + +const STATUS_BADGE: Record< + Agent["status"], + "default" | "destructive" | "secondary" +> = { + online: "default", + lockdown: "destructive", + offline: "secondary", +}; + +async function fetchAgent(token: string, id: string): Promise { + try { + const res = await fetch(`${BACKEND_URL}/agents/${id}`, { + headers: { Authorization: `Bearer ${token}` }, + cache: "no-store", + }); + if (res.status === 404) return null; + if (!res.ok) return null; + return res.json(); + } catch { + return null; + } +} + +async function fetchNftStatus( + token: string, + id: string, +): Promise { + try { + const res = await fetch(`${BACKEND_URL}/agents/${id}/nftables-status`, { + headers: { Authorization: `Bearer ${token}` }, + cache: "no-store", + }); + if (!res.ok) return { diverged: false }; + return res.json(); + } catch { + return { diverged: false }; + } +} + +function formatTime(ts: string | null): string { + if (!ts) return "—"; + return new Date(ts).toLocaleString("en-GB", { + year: "numeric", + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false, + }); +} + +function formatHeartbeat(ts: string | null): string { + if (!ts) return "—"; + const diff = Math.floor((Date.now() - new Date(ts).getTime()) / 1000); + if (diff < 60) return `${diff}s ago`; + if (diff < 3600) return `${Math.floor(diff / 60)}m ago`; + return `${Math.floor(diff / 3600)}h ago`; +} + +export default async function AgentDetailPage({ + params, +}: { + params: Promise<{ locale: string; id: string }>; +}) { + const { locale, id: agentId } = await params; + const [t, jar] = await Promise.all([ + getTranslations({ locale, namespace: "app.agents" }), + cookies(), + ]); + const tok = jar.get("access_token")?.value ?? ""; + + const [agent, nft, globalRules, localRules] = await Promise.all([ + fetchAgent(tok, agentId), + fetchNftStatus(tok, agentId), + listGlobalRules(), + listLocalRules(agentId), + ]); + + if (!agent) notFound(); + + return ( +
+ + +
+

{agent.name}

+ + {t(`status.${agent.status}`)} + +
+ +
+
+
+

{t("wgIpLabel")}

+

{agent.wg_ip}

+
+ {agent.wg_endpoint && ( +
+

Endpoint

+

{agent.wg_endpoint}

+
+ )} +
+

{t("version")}

+

{agent.version ?? "—"}

+
+
+

{t("lastHeartbeat")}

+

{formatHeartbeat(agent.last_heartbeat)}

+ {agent.last_heartbeat && ( +

+ {formatTime(agent.last_heartbeat)} +

+ )} +
+
+

ID

+

+ {agent.id} +

+
+
+
+ + {agent.status === "online" && ( +
+ +
+ )} + +
+
+ + {t("nftRules")} +
+

{t("nftRulesDesc")}

+ + {nft.diverged && ( + + )} + +
+

+ {t("nftGlobal")} +

+ +
+ +
+

+ {t("nftLocal")} +

+ createLocalRule(agentId, payload)} + onDeleteRule={(ruleId) => deleteLocalRule(agentId, ruleId)} + onPush={() => pushLocalRules(agentId)} + /> +
+
+ +
+

{t("auditLog")}

+ + {t("auditLog")} → + +
+ +
+ +
+
+ ); +} diff --git a/lynx/dashboard/ui/src/app/[locale]/(dashboard)/app/agents/page.tsx b/lynx/dashboard/ui/src/app/[locale]/(dashboard)/app/agents/page.tsx new file mode 100644 index 0000000..0adc6c0 --- /dev/null +++ b/lynx/dashboard/ui/src/app/[locale]/(dashboard)/app/agents/page.tsx @@ -0,0 +1,38 @@ +import { Suspense } from "react"; +import { cookies } from "next/headers"; +import { getTranslations } from "next-intl/server"; +import { AgentList } from "@/components/(dashboard)/app/agents/AgentList"; +import { AgentListSkeleton } from "@/components/(dashboard)/app/agents/AgentListSkeleton"; +import { RegisterAgentDialog } from "@/components/(dashboard)/app/agents/RegisterAgentDialog"; + +export default async function AgentsPage({ + params, +}: { params: Promise<{ locale: string }> }) { + const { locale } = await params; + const t = await getTranslations({ locale, namespace: "app.agents" }); + const jar = await cookies(); + const token = jar.get("access_token")?.value ?? ""; + + return ( +
+
+

{t("title")}

+ +
+ + }> + + +
+ ); +} diff --git a/lynx/dashboard/ui/src/app/[locale]/(dashboard)/app/layout.tsx b/lynx/dashboard/ui/src/app/[locale]/(dashboard)/app/layout.tsx new file mode 100644 index 0000000..33d3dca --- /dev/null +++ b/lynx/dashboard/ui/src/app/[locale]/(dashboard)/app/layout.tsx @@ -0,0 +1,81 @@ + +import { cookies } from "next/headers"; +import { redirect } from "next/navigation"; +import { BACKEND_URL } from "@/lib/api"; +import { Sidebar } from "@/components/(dashboard)/app/Sidebar"; + +interface Branding { + company_name: string; + logo_url: string | null; + primary_color: string; + secondary_color: string; + accent_color: string; +} + +const DEFAULTS: Branding = { + company_name: "Lynx", + logo_url: null, + primary_color: "#0f172a", + secondary_color: "#38bdf8", + accent_color: "#6366f1", +}; + +async function fetchBranding(): Promise { + try { + const res = await fetch(`${BACKEND_URL}/branding`, { + next: { revalidate: 60 }, + }); + if (!res.ok) return DEFAULTS; + return (await res.json()) as Branding; + } catch { + return DEFAULTS; + } +} + +async function fetchIsAdmin(token: string): Promise { + try { + const res = await fetch(`${BACKEND_URL}/auth/me`, { + headers: { Authorization: `Bearer ${token}` }, + cache: "no-store", + }); + if (!res.ok) return false; + const data = (await res.json()) as { is_admin?: boolean }; + return data.is_admin === true; + } catch { + return false; + } +} + +export default async function AppLayout({ + children, + params, +}: { children: React.ReactNode; params: Promise<{ locale: string }>; }) { + const { locale } = await params; + + const jar = await cookies(); + const token = jar.get("access_token")?.value; + const hasAccess = !!token || jar.has("refresh_token"); + + if (!hasAccess) { + redirect(`/${locale}/login`); + } + + const [branding, isAdmin] = await Promise.all([ + fetchBranding(), + token ? fetchIsAdmin(token) : Promise.resolve(false), + ]); + + return ( +
+ +
+ {children} +
+
+ ); +} diff --git a/lynx/dashboard/ui/src/app/[locale]/(dashboard)/app/organizations/[id]/page.tsx b/lynx/dashboard/ui/src/app/[locale]/(dashboard)/app/organizations/[id]/page.tsx new file mode 100644 index 0000000..27d3190 --- /dev/null +++ b/lynx/dashboard/ui/src/app/[locale]/(dashboard)/app/organizations/[id]/page.tsx @@ -0,0 +1,233 @@ +import { cookies } from "next/headers"; +import { notFound } from "next/navigation"; +import { getTranslations } from "next-intl/server"; +import { BACKEND_URL } from "@/lib/api"; +import { Badge } from "@/components/ui/badge"; +import Link from "next/link"; +import { InviteDialog } from "@/components/(dashboard)/app/organizations/[id]/InviteDialog"; +import { RemoveMemberButton } from "@/components/(dashboard)/app/organizations/[id]/RemoveMemberButton"; +import { CreateProjectDialog } from "@/components/(dashboard)/app/organizations/[id]/CreateProjectDialog"; + +interface Org { + id: string; + name: string; + slug: string; + owner_id: string; + created_at: string; +} + +interface Member { + user_id: string; + username: string; + role: string; + joined_at: string; +} + +interface Project { + id: string; + name: string; + slug: string; + agent_id: string; + created_at: string; +} + +interface Agent { + id: string; + name: string; + status: string; +} + +async function fetchOrg(token: string, id: string): Promise { + try { + const res = await fetch(`${BACKEND_URL}/organizations/${id}`, { + headers: { Authorization: `Bearer ${token}` }, + cache: "no-store", + }); + if (!res.ok) return null; + return res.json(); + } catch { + return null; + } +} + +async function fetchMembers(token: string, id: string): Promise { + try { + const res = await fetch(`${BACKEND_URL}/organizations/${id}/members`, { + headers: { Authorization: `Bearer ${token}` }, + cache: "no-store", + }); + if (!res.ok) return []; + return res.json(); + } catch { + return []; + } +} + +async function fetchAgents(token: string): Promise { + try { + const res = await fetch(`${BACKEND_URL}/agents`, { + headers: { Authorization: `Bearer ${token}` }, + cache: "no-store", + }); + if (!res.ok) return []; + return res.json(); + } catch { + return []; + } +} + +async function fetchProjects(token: string, id: string): Promise { + try { + const res = await fetch(`${BACKEND_URL}/organizations/${id}/projects`, { + headers: { Authorization: `Bearer ${token}` }, + cache: "no-store", + }); + if (!res.ok) return []; + return res.json(); + } catch { + return []; + } +} + +const ROLE_VARIANT: Record = { + owner: "default", + admin: "secondary", + member: "outline", + viewer: "outline", +}; + +export default async function OrgDetailPage({ + params, +}: { + params: Promise<{ locale: string; id: string }>; +}) { + const { locale, id } = await params; + const [t, jar] = await Promise.all([ + getTranslations({ locale, namespace: "app.organizations" }), + cookies(), + ]); + const tok = jar.get("access_token")?.value ?? ""; + + const [org, members, projects, agents] = await Promise.all([ + fetchOrg(tok, id), + fetchMembers(tok, id), + fetchProjects(tok, id), + fetchAgents(tok), + ]); + + if (!org) notFound(); + + const currentUserId = members.find( + (m) => m.role === "owner" && org.owner_id === m.user_id, + )?.user_id; + + return ( +
+
+

{t("slug")}{" "}{org.slug}

+

{org.name}

+
+ +
+
+

+ {t("members")} ({members.length}) +

+ +
+ +
+ {members.map((m) => ( +
+
+ + {m.username} + + + {m.role} + +
+ {m.role !== "owner" && ( + + )} +
+ ))} + {members.length === 0 && ( +

+ {t("noMembers")} +

+ )} +
+
+ +
+
+

+ {t("projects")} ({projects.length}) +

+ +
+ {projects.length === 0 ? ( +

{t("noProjects")}

+ ) : ( +
+ {projects.map((p) => ( + +
+

{p.name}

+

{p.slug}

+
+ + {p.agent_id.slice(0, 8)} + + + ))} +
+ )} +
+ +

+ {t("orgId")} {id} +

+
+ ); +} diff --git a/lynx/dashboard/ui/src/app/[locale]/(dashboard)/app/organizations/[id]/projects/[proj_id]/page.tsx b/lynx/dashboard/ui/src/app/[locale]/(dashboard)/app/organizations/[id]/projects/[proj_id]/page.tsx new file mode 100644 index 0000000..9c4cef6 --- /dev/null +++ b/lynx/dashboard/ui/src/app/[locale]/(dashboard)/app/organizations/[id]/projects/[proj_id]/page.tsx @@ -0,0 +1,263 @@ +import { cookies } from "next/headers"; +import { notFound } from "next/navigation"; +import { getTranslations } from "next-intl/server"; +import { BACKEND_URL } from "@/lib/api"; +import Link from "next/link"; +import { ChevronRight } from "lucide-react"; +import { ResourceForm } from "@/components/(dashboard)/app/organizations/[id]/projects/[proj_id]/ResourceForm"; +import { ContainerCard } from "@/components/(dashboard)/app/organizations/[id]/projects/[proj_id]/ContainerCard"; +import { DeployForm } from "@/components/(dashboard)/app/organizations/[id]/projects/[proj_id]/DeployForm"; +import { HorizontalScaleSection } from "@/components/(dashboard)/app/organizations/[id]/projects/[proj_id]/HorizontalScaleSection"; + +interface Project { + id: string; + name: string; + slug: string; + agent_id: string; + organization_id: string; + created_at: string; +} + +interface Container { + Names: string[]; + Image: string; + Status: string; + State: string; +} + +interface Agent { + id: string; + name: string; + wg_ip: string; + status: string; +} + +interface Tunnel { + id: string; + agent_b_id: string; + agent_a_wg_ip: string; + agent_b_wg_ip: string; + replica_count: number; + status: string; +} + +async function fetchProject( + token: string, + orgId: string, + projId: string, +): Promise { + try { + const res = await fetch( + `${BACKEND_URL}/organizations/${orgId}/projects/${projId}`, + { headers: { Authorization: `Bearer ${token}` }, cache: "no-store" }, + ); + if (!res.ok) return null; + return res.json(); + } catch { + return null; + } +} + +async function fetchAgents(token: string): Promise { + try { + const res = await fetch(`${BACKEND_URL}/agents`, { + headers: { Authorization: `Bearer ${token}` }, + cache: "no-store", + }); + if (!res.ok) return []; + return res.json(); + } catch { + return []; + } +} + +async function fetchTunnels( + token: string, + orgId: string, + projId: string, +): Promise { + try { + const res = await fetch( + `${BACKEND_URL}/organizations/${orgId}/projects/${projId}/scale/horizontal`, + { headers: { Authorization: `Bearer ${token}` }, cache: "no-store" }, + ); + if (!res.ok) return []; + return res.json(); + } catch { + return []; + } +} + +async function fetchContainers( + token: string, + orgId: string, + projId: string, +): Promise { + try { + const res = await fetch( + `${BACKEND_URL}/organizations/${orgId}/projects/${projId}/containers`, + { headers: { Authorization: `Bearer ${token}` }, cache: "no-store" }, + ); + if (!res.ok) return []; + const data = (await res.json()) as { containers?: Container[] } | Container[]; + return Array.isArray(data) ? data : (data.containers ?? []); + } catch { + return []; + } +} + +export default async function ProjectDetailPage({ + params, +}: { + params: Promise<{ locale: string; id: string; proj_id: string }>; +}) { + const { locale, id: orgId, proj_id: projId } = await params; + const [t, jar] = await Promise.all([ + getTranslations({ locale, namespace: "app.projects" }), + cookies(), + ]); + const tok = jar.get("access_token")?.value ?? ""; + + const [project, containers, tunnels, agents] = await Promise.all([ + fetchProject(tok, orgId, projId), + fetchContainers(tok, orgId, projId), + fetchTunnels(tok, orgId, projId), + fetchAgents(tok), + ]); + + if (!project) notFound(); + + const containerLabels = { + start: t("cStart"), + stop: t("cStop"), + restart: t("cRestart"), + remove: t("cRemove"), + success: t("cActionSuccess"), + error: t("cActionError"), + }; + + return ( +
+ + +
+

+ {t("slug")} {project.slug} +

+

{project.name}

+
+ +
+

+ {t("containers")} ({containers.length}) +

+ {containers.length === 0 ? ( +

{t("noContainers")}

+ ) : ( +
+ {containers.map((c) => ( + + ))} +
+ )} +
+ +
+

+ {t("deploy")} +

+
+ +
+
+ + a.id !== project.agent_id)} + labels={{ + title: t("horizontalScale"), + desc: t("horizontalScaleDesc"), + addBtn: t("addTunnel"), + dialogTitle: t("addTunnelTitle"), + targetAgent: t("tunnelTargetAgent"), + image: t("tunnelImage"), + replicas: t("tunnelReplicas"), + confirm: t("tunnelConfirm"), + success: t("tunnelSuccess"), + error: t("tunnelError"), + teardownSuccess: t("tunnelTeardownSuccess"), + teardownError: t("tunnelTeardownError"), + noTunnels: t("noTunnels"), + agentB: t("tunnelTargetAgent"), + replicaCount: t("tunnelReplicaCount"), + status: "Status", + }} + /> + +
+

+ {t("verticalScale")} +

+
+

+ {t("verticalScaleDesc")} +

+ +
+
+ +

+ {t("projectId")} {projId} +

+
+ ); +} diff --git a/lynx/dashboard/ui/src/app/[locale]/(dashboard)/app/organizations/page.tsx b/lynx/dashboard/ui/src/app/[locale]/(dashboard)/app/organizations/page.tsx new file mode 100644 index 0000000..8a7623b --- /dev/null +++ b/lynx/dashboard/ui/src/app/[locale]/(dashboard)/app/organizations/page.tsx @@ -0,0 +1,33 @@ +import { Suspense } from "react"; +import { cookies } from "next/headers"; +import { getTranslations } from "next-intl/server"; +import { OrgList } from "@/components/(dashboard)/app/organizations/OrgList"; +import { OrgListSkeleton } from "@/components/(dashboard)/app/organizations/OrgListSkeleton"; +import { CreateOrgDialog } from "@/components/(dashboard)/app/organizations/CreateOrgDialog"; + +export default async function OrganizationsPage({ + params, +}: { params: Promise<{ locale: string }> }) { + const { locale } = await params; + const t = await getTranslations({ locale, namespace: "app.organizations" }); + const jar = await cookies(); + const token = jar.get("access_token")?.value ?? ""; + + return ( +
+
+

{t("title")}

+ +
+ + }> + + +
+ ); +} diff --git a/lynx/dashboard/ui/src/app/[locale]/(dashboard)/app/page.tsx b/lynx/dashboard/ui/src/app/[locale]/(dashboard)/app/page.tsx new file mode 100644 index 0000000..db512ac --- /dev/null +++ b/lynx/dashboard/ui/src/app/[locale]/(dashboard)/app/page.tsx @@ -0,0 +1,193 @@ +import { cookies } from "next/headers"; +import { getTranslations } from "next-intl/server"; +import { Suspense } from "react"; +import { listAlertsAction, type SecurityAlert } from "@/actions/(dashboard)/app/admin/alerts"; +import { AlertsPanel } from "@/components/(dashboard)/app/admin/AlertsPanel"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Skeleton } from "@/components/ui/skeleton"; +import { BACKEND_URL } from "@/lib/api"; + +// --------------------------------------------------------------------------- +// Data fetching +// --------------------------------------------------------------------------- + +type AgentSummary = { id: string; status: string }; +type OrgSummary = { id: string }; +type AgentEvent = { + id: string; + agent_id: string; + event: string; + detail: string | null; + created_at: string; +}; + +async function fetchStats(token: string) { + const headers = { Authorization: `Bearer ${token}` }; + try { + const [agentsRes, orgsRes] = await Promise.all([ + fetch(`${BACKEND_URL}/agents`, { headers, next: { revalidate: 30 } }), + fetch(`${BACKEND_URL}/organizations`, { headers, next: { revalidate: 30 } }), + ]); + const agents: AgentSummary[] = agentsRes.ok ? await agentsRes.json() : []; + const orgs: OrgSummary[] = orgsRes.ok ? await orgsRes.json() : []; + return { agents, orgs }; + } catch { + return { agents: [], orgs: [] }; + } +} + +async function fetchRecentEvents(token: string): Promise { + try { + const res = await fetch(`${BACKEND_URL}/agents/events?limit=10`, { + headers: { Authorization: `Bearer ${token}` }, + next: { revalidate: 15 }, + }); + if (!res.ok) return []; + return res.json(); + } catch { + return []; + } +} + +// --------------------------------------------------------------------------- +// Components +// --------------------------------------------------------------------------- + +async function OverviewStats({ token, locale }: { token: string; locale: string }) { + const t = await getTranslations({ locale, namespace: "app.overview" }); + const { agents, orgs } = await fetchStats(token); + const online = agents.filter((a) => a.status === "online").length; + + return ( +
+ + +
+ ); +} + +function StatCard({ title, value, sub }: { title: string; value: string; sub?: string }) { + return ( + + + {title} + + +

+ {value} + {sub && {sub}} +

+
+
+ ); +} + +async function RecentEvents({ token, locale }: { token: string; locale: string }) { + const t = await getTranslations({ locale, namespace: "app.overview" }); + const events = await fetchRecentEvents(token); + + if (events.length === 0) { + return ( +
+

{t("noEvents")}

+
+ ); + } + + return ( +
+ {events.map((ev) => ( +
+ {ev.event} + {ev.detail ?? ev.agent_id} + + {new Date(ev.created_at).toLocaleTimeString(undefined, { + hour: "2-digit", + minute: "2-digit", + })} + +
+ ))} +
+ ); +} + +function StatsSkeleton() { + return ( +
+ {[0, 1].map((i) => ( + + + + + + + + + ))} +
+ ); +} + +function EventsSkeleton() { + return ( +
+ {[0, 1, 2].map((i) => ( +
+ + + +
+ ))} +
+ ); +} + +// --------------------------------------------------------------------------- +// Page +// --------------------------------------------------------------------------- + +export default async function OverviewPage({ params }: { params: Promise<{ locale: string }> }) { + const { locale } = await params; + const t = await getTranslations({ locale, namespace: "app.overview" }); + const jar = await cookies(); + const token = jar.get("access_token")?.value ?? ""; + const alerts: SecurityAlert[] = await listAlertsAction(); + + return ( +
+

{t("title")}

+ + {alerts.length > 0 && ( +
+

+ {t("securityAlerts")} +

+ +
+ )} + + }> + + + +
+

+ {t("recentEvents")} +

+ }> + + +
+
+ ); +} diff --git a/lynx/dashboard/ui/src/app/[locale]/(dashboard)/app/settings/page.tsx b/lynx/dashboard/ui/src/app/[locale]/(dashboard)/app/settings/page.tsx new file mode 100644 index 0000000..aff7b63 --- /dev/null +++ b/lynx/dashboard/ui/src/app/[locale]/(dashboard)/app/settings/page.tsx @@ -0,0 +1,321 @@ +import { Suspense } from "react"; +import { cookies } from "next/headers"; +import { getTranslations } from "next-intl/server"; +import { BACKEND_URL } from "@/lib/api"; +import { SessionList } from "@/components/(dashboard)/app/settings/SessionList"; +import { SessionListSkeleton } from "@/components/(dashboard)/app/settings/SessionListSkeleton"; +import { RotateButton } from "@/components/(dashboard)/app/settings/RotateButton"; +import { BrandingForm } from "@/components/(dashboard)/app/settings/BrandingForm"; +import { UpdateSection } from "@/components/(dashboard)/app/settings/UpdateSection"; +import { DomainSection } from "@/components/(dashboard)/app/settings/DomainSection"; +import { MigrationSection } from "@/components/(dashboard)/app/settings/MigrationSection"; +import { getMigrationStatus } from "@/actions/(dashboard)/app/settings/migration"; +import { ChangePasswordForm } from "@/components/(dashboard)/app/settings/ChangePasswordForm"; +import { SingleSessionToggle } from "@/components/(dashboard)/app/settings/SingleSessionToggle"; +import { getMe } from "@/actions/(dashboard)/app/settings/profile"; +import { RotationLog } from "@/components/(dashboard)/app/settings/RotationLog"; + +interface Branding { + company_name: string; + logo_url: string | null; + primary_color: string; + secondary_color: string; + accent_color: string; +} + +interface DomainConfig { + domain: string | null; + cert_type: string; + cert_expires_at: string | null; + hsts_enabled: boolean; + port_19443_open: boolean; + status: string; + error_message: string | null; +} + +const BRANDING_DEFAULTS: Branding = { + company_name: "Lynx", + logo_url: null, + primary_color: "#0f172a", + secondary_color: "#38bdf8", + accent_color: "#6366f1", +}; + +const DOMAIN_DEFAULTS: DomainConfig = { + domain: null, + cert_type: "self_signed", + cert_expires_at: null, + hsts_enabled: false, + port_19443_open: true, + status: "unconfigured", + error_message: null, +}; + +async function fetchBranding(): Promise { + try { + const res = await fetch(`${BACKEND_URL}/branding`, { + cache: "no-store", + }); + if (!res.ok) return BRANDING_DEFAULTS; + return (await res.json()) as Branding; + } catch { + return BRANDING_DEFAULTS; + } +} + +async function fetchDomainConfig(token: string): Promise { + try { + const res = await fetch(`${BACKEND_URL}/domain`, { + headers: { Authorization: `Bearer ${token}` }, + cache: "no-store", + }); + if (!res.ok) return DOMAIN_DEFAULTS; + return (await res.json()) as DomainConfig; + } catch { + return DOMAIN_DEFAULTS; + } +} + +export default async function SettingsPage({ + params, +}: { params: Promise<{ locale: string }> }) { + const { locale } = await params; + const [t, jar, branding] = await Promise.all([ + getTranslations({ locale, namespace: "app.settings" }), + cookies(), + fetchBranding(), + ]); + const token = jar.get("access_token")?.value ?? ""; + const [domainCfg, migrationState, me] = await Promise.all([ + fetchDomainConfig(token), + getMigrationStatus(), + getMe(), + ]); + + return ( +
+

{t("title")}

+ + {me && ( +
+

+ {t("profile")} +

+
+
+

{t("profileUsername")}

+

{me.username}

+
+
+

{t("changePassword")}

+ +
+
+ +
+
+
+ )} + +
+

+ {t("domain")} +

+
+ +
+
+ +
+

+ {t("security")} +

+
+
+

{t("rotateKeys")}

+

+ {t("rotateKeysDesc")} +

+
+ +
+
+

+ {t("rotationLog")} +

+ }> + + +
+
+ +
+

+ {t("updates")} +

+
+

+ {t("updatesDesc")} +

+ +
+
+ +
+

+ {t("branding")} +

+
+ +
+
+ + {migrationState && ( +
+

+ {t("migration")} +

+
+ +
+
+ )} + +
+

+ {t("sessions")} +

+ }> + + +
+
+ ); +} diff --git a/lynx/dashboard/ui/src/app/[locale]/layout.tsx b/lynx/dashboard/ui/src/app/[locale]/layout.tsx new file mode 100644 index 0000000..c0986d3 --- /dev/null +++ b/lynx/dashboard/ui/src/app/[locale]/layout.tsx @@ -0,0 +1,107 @@ +import { Geist, Geist_Mono } from "next/font/google"; +import { NextIntlClientProvider } from "next-intl"; +import { getMessages } from "next-intl/server"; +import { notFound } from "next/navigation"; +import { cookies } from "next/headers"; +import type { Metadata } from "next"; +import { Toaster } from "@/components/ui/sonner"; +import { ThemeProvider } from "@/components/ThemeProvider"; +import { routing } from "@/i18n/routing"; +import { BACKEND_URL } from "@/lib/api"; +import "../globals.css"; + +const geistSans = Geist({ variable: "--font-geist-sans", subsets: ["latin"] }); +const geistMono = Geist_Mono({ + variable: "--font-geist-mono", + subsets: ["latin"], +}); + +interface Branding { + company_name: string; + logo_url: string | null; + primary_color: string; + secondary_color: string; + accent_color: string; +} + +const BRANDING_DEFAULTS: Branding = { + company_name: "Lynx", + logo_url: null, + primary_color: "#0f172a", + secondary_color: "#38bdf8", + accent_color: "#6366f1", +}; + +async function fetchBranding(): Promise { + try { + const res = await fetch(`${BACKEND_URL}/branding`, { + next: { revalidate: 60 }, + }); + if (!res.ok) return BRANDING_DEFAULTS; + return (await res.json()) as Branding; + } catch { + return BRANDING_DEFAULTS; + } +} + +export async function generateMetadata({ + params, +}: { + params: Promise<{ locale: string }>; +}): Promise { + await params; + const branding = await fetchBranding(); + return { + title: branding.company_name, + description: "Distributed infrastructure orchestration", + robots: { index: false, follow: false }, + }; +} + +export default async function LocaleLayout({ + children, + params, +}: { + children: React.ReactNode; + params: Promise<{ locale: string }>; +}) { + const { locale } = await params; + + if (!routing.locales.includes(locale as "en" | "es")) { + notFound(); + } + + const jar = await cookies(); + // Theme preference is stored in a non-HttpOnly cookie so ThemeProvider can read it. + // Falls back to "system" if not set. + const defaultTheme = jar.get("theme_preference")?.value ?? "system"; + + const [messages, branding] = await Promise.all([ + getMessages(), + fetchBranding(), + ]); + + const brandVars = { + "--brand-primary": branding.primary_color, + "--brand-secondary": branding.secondary_color, + "--brand-accent": branding.accent_color, + } as React.CSSProperties; + + return ( + + + + + {children} + + + + + + ); +} diff --git a/lynx/dashboard/ui/src/app/[locale]/page.tsx b/lynx/dashboard/ui/src/app/[locale]/page.tsx new file mode 100644 index 0000000..28971f8 --- /dev/null +++ b/lynx/dashboard/ui/src/app/[locale]/page.tsx @@ -0,0 +1,10 @@ +import { redirect } from "next/navigation"; + +export default async function LocaleRootPage({ + params, +}: { + params: Promise<{ locale: string }>; +}) { + const { locale } = await params; + redirect(`/${locale}/login`); +} diff --git a/lynx/dashboard/ui/src/app/favicon.ico b/lynx/dashboard/ui/src/app/favicon.ico new file mode 100644 index 0000000..718d6fe Binary files /dev/null and b/lynx/dashboard/ui/src/app/favicon.ico differ diff --git a/lynx/dashboard/ui/src/app/globals.css b/lynx/dashboard/ui/src/app/globals.css new file mode 100644 index 0000000..ed29b07 --- /dev/null +++ b/lynx/dashboard/ui/src/app/globals.css @@ -0,0 +1,136 @@ +@import "tailwindcss"; +@import "tw-animate-css"; +@import "shadcn/tailwind.css"; + +@custom-variant dark (&:is(.dark *)); + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --font-sans: var(--font-sans); + --font-mono: var(--font-geist-mono); + --font-heading: var(--font-sans); + --color-sidebar-ring: var(--sidebar-ring); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar: var(--sidebar); + --color-chart-5: var(--chart-5); + --color-chart-4: var(--chart-4); + --color-chart-3: var(--chart-3); + --color-chart-2: var(--chart-2); + --color-chart-1: var(--chart-1); + --color-ring: var(--ring); + --color-input: var(--input); + --color-border: var(--border); + --color-destructive: var(--destructive); + --color-accent-foreground: var(--accent-foreground); + --color-accent: var(--accent); + --color-muted-foreground: var(--muted-foreground); + --color-muted: var(--muted); + --color-secondary-foreground: var(--secondary-foreground); + --color-secondary: var(--secondary); + --color-primary-foreground: var(--primary-foreground); + --color-primary: var(--primary); + --color-popover-foreground: var(--popover-foreground); + --color-popover: var(--popover); + --color-card-foreground: var(--card-foreground); + --color-card: var(--card); + --radius-sm: calc(var(--radius) * 0.6); + --radius-md: calc(var(--radius) * 0.8); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) * 1.4); + --radius-2xl: calc(var(--radius) * 1.8); + --radius-3xl: calc(var(--radius) * 2.2); + --radius-4xl: calc(var(--radius) * 2.6); +} + +:root { + --background: oklch(1 0 0); + --foreground: oklch(0.145 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + --primary: oklch(0.205 0 0); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.97 0 0); + --secondary-foreground: oklch(0.205 0 0); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.556 0 0); + --accent: oklch(0.97 0 0); + --accent-foreground: oklch(0.205 0 0); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.922 0 0); + --input: oklch(0.922 0 0); + --ring: oklch(0.708 0 0); + --chart-1: oklch(0.87 0 0); + --chart-2: oklch(0.556 0 0); + --chart-3: oklch(0.439 0 0); + --chart-4: oklch(0.371 0 0); + --chart-5: oklch(0.269 0 0); + --radius: 0.625rem; + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.145 0 0); + --sidebar-primary: oklch(0.205 0 0); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.97 0 0); + --sidebar-accent-foreground: oklch(0.205 0 0); + --sidebar-border: oklch(0.922 0 0); + --sidebar-ring: oklch(0.708 0 0); +} + +.dark { + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.205 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.205 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.922 0 0); + --primary-foreground: oklch(0.205 0 0); + --secondary: oklch(0.269 0 0); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.269 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.556 0 0); + --chart-1: oklch(0.87 0 0); + --chart-2: oklch(0.556 0 0); + --chart-3: oklch(0.439 0 0); + --chart-4: oklch(0.371 0 0); + --chart-5: oklch(0.269 0 0); + --sidebar: oklch(0.205 0 0); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.269 0 0); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.556 0 0); +} + +:root { + --brand-primary: #0f172a; + --brand-secondary: #38bdf8; + --brand-accent: #6366f1; +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } + html { + @apply font-sans; + } +} diff --git a/lynx/dashboard/ui/src/app/layout.tsx b/lynx/dashboard/ui/src/app/layout.tsx new file mode 100644 index 0000000..c6795cf --- /dev/null +++ b/lynx/dashboard/ui/src/app/layout.tsx @@ -0,0 +1,7 @@ +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return children; +} diff --git a/lynx/dashboard/ui/src/app/page.tsx b/lynx/dashboard/ui/src/app/page.tsx new file mode 100644 index 0000000..f647a40 --- /dev/null +++ b/lynx/dashboard/ui/src/app/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from "next/navigation"; + +export default function RootPage() { + redirect("/en/login"); +} diff --git a/lynx/dashboard/ui/src/components/(auth)/login/LoginForm.tsx b/lynx/dashboard/ui/src/components/(auth)/login/LoginForm.tsx new file mode 100644 index 0000000..36028a9 --- /dev/null +++ b/lynx/dashboard/ui/src/components/(auth)/login/LoginForm.tsx @@ -0,0 +1,99 @@ +"use client"; + +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useTranslations } from "next-intl"; +import { useRouter } from "next/navigation"; +import Link from "next/link"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Field, FieldLabel, FieldError } from "@/components/ui/field"; +import { loginSchema, type LoginInput } from "@/schemas/(auth)/login"; +import { loginAction } from "@/actions/(auth)/login"; + +type Props = { locale: string }; + +export function LoginForm({ locale }: Props) { + const t = useTranslations("auth.login"); + const router = useRouter(); + + const { + register, + handleSubmit, + formState: { errors, isSubmitting }, + } = useForm({ + resolver: zodResolver(loginSchema), + }); + + const onSubmit = async (data: LoginInput) => { + const promise = loginAction(locale, data).then((r) => { + if (!r.success) throw new Error(r.error); + return r; + }); + + toast.promise(promise, { + loading: t("submitting"), + success: t("submit"), + error: (e: Error) => { + if (e.message === "rateLimited") return t("rateLimited", { minutes: 15 }); + if (e.message === "invalidCredentials") return t("invalidCredentials"); + return t("serverError"); + }, + }); + + try { + const r = await promise; + if (r.forcePasswordChange) { + router.push(`/${locale}/app/settings?change_password=1`); + } else { + router.push(`/${locale}/app`); + } + } catch { + // handled by toast + } + }; + + return ( +
+ + {t("username")} + + + + + + {t("password")} + + + + + + +

+ {t("noAccount")}{" "} + + {t("register")} + +

+
+ ); +} diff --git a/lynx/dashboard/ui/src/components/(auth)/register/RegisterForm.tsx b/lynx/dashboard/ui/src/components/(auth)/register/RegisterForm.tsx new file mode 100644 index 0000000..94cd5e8 --- /dev/null +++ b/lynx/dashboard/ui/src/components/(auth)/register/RegisterForm.tsx @@ -0,0 +1,108 @@ +"use client"; + +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useTranslations } from "next-intl"; +import { useRouter } from "next/navigation"; +import Link from "next/link"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Field, FieldLabel, FieldError } from "@/components/ui/field"; +import { registerSchema, type RegisterInput } from "@/schemas/(auth)/register"; +import { registerAction } from "@/actions/(auth)/register"; + +type Props = { locale: string }; + +export function RegisterForm({ locale }: Props) { + const t = useTranslations("auth.register"); + const router = useRouter(); + + const { + register, + handleSubmit, + formState: { errors, isSubmitting }, + } = useForm({ + resolver: zodResolver(registerSchema), + }); + + const onSubmit = async (data: RegisterInput) => { + const promise = registerAction(locale, data).then((r) => { + if (!r.success) throw new Error(r.error); + return r; + }); + + toast.promise(promise, { + loading: t("submitting"), + success: t("submit"), + error: (e: Error) => { + if (e.message === "usernameTaken") return t("usernameTaken"); + if (e.message === "emailTaken") return t("emailTaken"); + return t("serverError"); + }, + }); + + try { + await promise; + router.push(`/${locale}/login`); + } catch { + // handled by toast + } + }; + + return ( +
+ + {t("username")} + + + + + + {t("email")} + + + + + + {t("password")} + + + + + + +

+ {t("hasAccount")}{" "} + + {t("login")} + +

+
+ ); +} diff --git a/lynx/dashboard/ui/src/components/(dashboard)/app/LocaleSwitcher.tsx b/lynx/dashboard/ui/src/components/(dashboard)/app/LocaleSwitcher.tsx new file mode 100644 index 0000000..3a84277 --- /dev/null +++ b/lynx/dashboard/ui/src/components/(dashboard)/app/LocaleSwitcher.tsx @@ -0,0 +1,74 @@ +"use client"; + +import { usePathname, useRouter } from "next/navigation"; +import { useTransition } from "react"; +import Image from "next/image"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Button } from "@/components/ui/button"; +import { updateLocaleAction } from "@/actions/(dashboard)/app/settings/preferences"; + +const LOCALES: { code: string; label: string; flag: string }[] = [ + { code: "en", label: "English", flag: "/flags/en.svg" }, + { code: "es", label: "Español", flag: "/flags/es.svg" }, +]; + +type Props = { locale: string }; + +export function LocaleSwitcher({ locale }: Props) { + const pathname = usePathname(); + const router = useRouter(); + const [, startTransition] = useTransition(); + + const current = LOCALES.find((l) => l.code === locale) ?? LOCALES[0]!; + + function handleSelect(newLocale: string) { + if (newLocale === locale) return; + // Replace locale segment in pathname: /en/app/… → /es/app/… + const newPath = pathname.replace(`/${locale}/`, `/${newLocale}/`); + startTransition(async () => { + await updateLocaleAction(newLocale); + router.push(newPath); + }); + } + + return ( + + + + + + {LOCALES.map(({ code, label, flag }) => ( + handleSelect(code)} + > + {label} + {label} + {locale === code && ( + + )} + + ))} + + + ); +} diff --git a/lynx/dashboard/ui/src/components/(dashboard)/app/NotificationBell.tsx b/lynx/dashboard/ui/src/components/(dashboard)/app/NotificationBell.tsx new file mode 100644 index 0000000..4e8d9ac --- /dev/null +++ b/lynx/dashboard/ui/src/components/(dashboard)/app/NotificationBell.tsx @@ -0,0 +1,110 @@ +"use client"; + +import { useCallback, useState } from "react"; +import { Bell } from "lucide-react"; +import { useAgentEvents, type AgentEvent, type AgentEventKind } from "@/lib/useAgentEvents"; +import { useTranslations } from "next-intl"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; + +const ALERT_EVENTS: AgentEventKind[] = [ + "heartbeat_lost", + "lockdown", + "nftables_divergence", + "conflicting_software_detected", +]; + +interface Notification { + id: string; + agent_id: string; + event: AgentEventKind; + detail: string | null; + at: Date; +} + +export function NotificationBell() { + const t = useTranslations("app.notifications"); + const [notifications, setNotifications] = useState([]); + const [open, setOpen] = useState(false); + + const handleEvent = useCallback((evt: AgentEvent) => { + if (!ALERT_EVENTS.includes(evt.event)) return; + setNotifications((prev) => [ + { + id: crypto.randomUUID(), + agent_id: evt.agent_id, + event: evt.event, + detail: evt.detail, + at: new Date(), + }, + ...prev.slice(0, 49), // keep last 50 + ]); + }, []); + + useAgentEvents({ onEvent: handleEvent }); + + const unread = notifications.length; + + return ( + + + + + +
+ {t("title")} + {unread > 0 && ( + + )} +
+
+ {notifications.length === 0 ? ( +

+ {t("empty")} +

+ ) : ( + notifications.map((n) => ( +
+
+ + {t(`event.${n.event}`)} + + + {n.at.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })} + +
+

+ {n.agent_id.slice(0, 8)}… + {n.detail && ` · ${n.detail}`} +

+
+ )) + )} +
+
+
+ ); +} diff --git a/lynx/dashboard/ui/src/components/(dashboard)/app/Sidebar.tsx b/lynx/dashboard/ui/src/components/(dashboard)/app/Sidebar.tsx new file mode 100644 index 0000000..67a8e38 --- /dev/null +++ b/lynx/dashboard/ui/src/components/(dashboard)/app/Sidebar.tsx @@ -0,0 +1,136 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { + Building2, + LayoutDashboard, + LogOut, + Monitor, + Settings, + ShieldCheck, +} from "lucide-react"; +import { useTranslations } from "next-intl"; +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { useTransition } from "react"; +import { logoutAction } from "@/actions/(dashboard)/app/logout"; +import { NotificationBell } from "./NotificationBell"; +import { ThemeToggle } from "./ThemeToggle"; +import { LocaleSwitcher } from "./LocaleSwitcher"; + +type Props = { locale: string; companyName: string; logoUrl: string | null; isAdmin: boolean }; + +export function Sidebar({ locale, companyName, logoUrl, isAdmin }: Props) { + const t = useTranslations("app.nav"); + const pathname = usePathname(); + const [, startTransition] = useTransition(); + + const items = [ + { + href: `/${locale}/app`, + label: t("overview"), + icon: LayoutDashboard, + }, + { + href: `/${locale}/app/agents`, + label: t("agents"), + icon: Monitor, + }, + { + href: `/${locale}/app/organizations`, + label: t("organizations"), + icon: Building2, + }, + { + href: `/${locale}/app/settings`, + label: t("settings"), + icon: Settings, + }, + ...(isAdmin + ? [{ href: `/${locale}/app/admin`, label: t("admin"), icon: ShieldCheck }] + : []), + ]; + + return ( + + ); +} diff --git a/lynx/dashboard/ui/src/components/(dashboard)/app/ThemeToggle.tsx b/lynx/dashboard/ui/src/components/(dashboard)/app/ThemeToggle.tsx new file mode 100644 index 0000000..a55c721 --- /dev/null +++ b/lynx/dashboard/ui/src/components/(dashboard)/app/ThemeToggle.tsx @@ -0,0 +1,62 @@ +"use client"; + +import { useTheme } from "next-themes"; +import { useTransition } from "react"; +import { Moon, Sun, Monitor } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { updateThemeAction } from "@/actions/(dashboard)/app/settings/preferences"; + +const THEMES = [ + { value: "light", icon: Sun, label: "Light" }, + { value: "dark", icon: Moon, label: "Dark" }, + { value: "system", icon: Monitor, label: "System" }, +] as const; + +export function ThemeToggle() { + const { theme, setTheme } = useTheme(); + const [, startTransition] = useTransition(); + + function handleSelect(value: string) { + setTheme(value); + startTransition(() => updateThemeAction(value)); + } + + const current = THEMES.find((t) => t.value === theme) ?? THEMES[2]; + const Icon = current.icon; + + return ( + + + + + + {THEMES.map(({ value, icon: ItemIcon, label }) => ( + handleSelect(value)} + > + + {label} + {theme === value && ( + + )} + + ))} + + + ); +} diff --git a/lynx/dashboard/ui/src/components/(dashboard)/app/admin/AlertsPanel.tsx b/lynx/dashboard/ui/src/components/(dashboard)/app/admin/AlertsPanel.tsx new file mode 100644 index 0000000..948c984 --- /dev/null +++ b/lynx/dashboard/ui/src/components/(dashboard)/app/admin/AlertsPanel.tsx @@ -0,0 +1,72 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { toast } from "sonner"; +import { acknowledgeAlertAction, type SecurityAlert } from "@/actions/(dashboard)/app/admin/alerts"; +import { Button } from "@/components/ui/button"; + +type Props = { + initial: SecurityAlert[]; + labels: { + title: string; + noAlerts: string; + acknowledge: string; + acknowledged: string; + error: string; + }; +}; + +export function AlertsPanel({ initial, labels }: Props) { + const [alerts, setAlerts] = useState(initial); + const [pending, startTransition] = useTransition(); + + function dismiss(id: string) { + startTransition(async () => { + const { success } = await acknowledgeAlertAction(id); + if (success) { + setAlerts((prev) => prev.filter((a) => a.id !== id)); + toast.success(labels.acknowledged); + } else { + toast.error(labels.error); + } + }); + } + + if (alerts.length === 0) { + return ( +
+

{labels.noAlerts}

+
+ ); + } + + return ( +
+ {alerts.map((alert) => ( +
+ + {alert.kind} + + + {alert.detail ?? alert.agent_id ?? "—"} + + + {new Date(alert.created_at).toLocaleTimeString(undefined, { + hour: "2-digit", + minute: "2-digit", + })} + + +
+ ))} +
+ ); +} diff --git a/lynx/dashboard/ui/src/components/(dashboard)/app/admin/RolesPanel.tsx b/lynx/dashboard/ui/src/components/(dashboard)/app/admin/RolesPanel.tsx new file mode 100644 index 0000000..404081f --- /dev/null +++ b/lynx/dashboard/ui/src/components/(dashboard)/app/admin/RolesPanel.tsx @@ -0,0 +1,196 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { toast } from "sonner"; +import { + addRolePermissionAction, + createRoleAction, + deleteRoleAction, + removeRolePermissionAction, + type PermRef, + type RoleRow, +} from "@/actions/(dashboard)/app/admin/users"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Badge } from "@/components/ui/badge"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Plus, Trash2 } from "lucide-react"; + +type Props = { + initial: RoleRow[]; + allPerms: PermRef[]; + labels: { + createRole: string; + createRoleSuccess: string; + createRoleError: string; + deleteRole: string; + deleteRoleConfirm: string; + deleteRoleSuccess: string; + deleteRoleError: string; + addPermission: string; + addPermissionSuccess: string; + addPermissionError: string; + removePermission: string; + removePermissionSuccess: string; + removePermissionError: string; + roleName: string; + noPermissions: string; + }; +}; + +export function RolesPanel({ initial, allPerms, labels }: Props) { + const [roles, setRoles] = useState(initial); + const [newName, setNewName] = useState(""); + const [, startTransition] = useTransition(); + + function handleCreate() { + const name = newName.trim(); + if (!name) return; + startTransition(async () => { + const res = await createRoleAction(name); + if (res.success && res.id) { + setRoles((prev) => [...prev, { id: res.id!, name, permissions: [] }]); + setNewName(""); + toast.success(labels.createRoleSuccess); + } else { + toast.error(res.error ?? labels.createRoleError); + } + }); + } + + function handleDelete(roleId: string, roleName: string) { + if (!confirm(`${labels.deleteRoleConfirm} "${roleName}"?`)) return; + startTransition(async () => { + const res = await deleteRoleAction(roleId); + if (res.success) { + setRoles((prev) => prev.filter((r) => r.id !== roleId)); + toast.success(labels.deleteRoleSuccess); + } else { + toast.error(res.error ?? labels.deleteRoleError); + } + }); + } + + function handleAddPerm(roleId: string, permId: string) { + const perm = allPerms.find((p) => p.id === permId); + if (!perm) return; + startTransition(async () => { + const { success } = await addRolePermissionAction(roleId, permId); + if (success) { + setRoles((prev) => + prev.map((r) => + r.id === roleId + ? { ...r, permissions: [...r.permissions, { id: perm.id, key: perm.key }] } + : r, + ), + ); + toast.success(labels.addPermissionSuccess); + } else { + toast.error(labels.addPermissionError); + } + }); + } + + function handleRemovePerm(roleId: string, permId: string) { + startTransition(async () => { + const res = await removeRolePermissionAction(roleId, permId); + if (res.success) { + setRoles((prev) => + prev.map((r) => + r.id === roleId + ? { ...r, permissions: r.permissions.filter((p) => p.id !== permId) } + : r, + ), + ); + toast.success(labels.removePermissionSuccess); + } else { + toast.error(res.error ?? labels.removePermissionError); + } + }); + } + + return ( +
+
+ setNewName(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleCreate()} + placeholder={labels.roleName} + value={newName} + /> + +
+ +
+ {roles.map((role) => { + const assignablePerms = allPerms.filter( + (p) => !role.permissions.some((rp) => rp.id === p.id), + ); + return ( +
+
+ {role.name} + +
+ +
+ {role.permissions.length === 0 && ( + {labels.noPermissions} + )} + {role.permissions.map((perm) => ( + handleRemovePerm(role.id, perm.id)} + title={labels.removePermission} + variant="outline" + > + {perm.key} + × + + ))} + {assignablePerms.length > 0 && ( + + )} +
+
+ ); + })} +
+
+ ); +} diff --git a/lynx/dashboard/ui/src/components/(dashboard)/app/admin/UsersPanel.tsx b/lynx/dashboard/ui/src/components/(dashboard)/app/admin/UsersPanel.tsx new file mode 100644 index 0000000..9209409 --- /dev/null +++ b/lynx/dashboard/ui/src/components/(dashboard)/app/admin/UsersPanel.tsx @@ -0,0 +1,206 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { toast } from "sonner"; +import { + addUserRoleAction, + deleteUserAction, + forcePasswordChangeAction, + removeUserRoleAction, + type RoleRow, + type UserRow, +} from "@/actions/(dashboard)/app/admin/users"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { MoreHorizontal, ShieldAlert, Trash2, UserPlus } from "lucide-react"; + +type Props = { + initial: UserRow[]; + roles: RoleRow[]; + labels: { + deleteUser: string; + deleteConfirm: string; + deleteSuccess: string; + deleteError: string; + forcePasswordChange: string; + forcePasswordChangeSuccess: string; + forcePasswordChangeError: string; + addRole: string; + addRoleSuccess: string; + addRoleError: string; + removeRole: string; + removeRoleSuccess: string; + removeRoleError: string; + noRoles: string; + selectRole: string; + }; +}; + +export function UsersPanel({ initial, roles, labels }: Props) { + const [users, setUsers] = useState(initial); + const [, startTransition] = useTransition(); + + function handleDelete(userId: string, username: string) { + if (!confirm(`${labels.deleteConfirm} "${username}"?`)) return; + startTransition(async () => { + const res = await deleteUserAction(userId); + if (res.success) { + setUsers((prev) => prev.filter((u) => u.id !== userId)); + toast.success(labels.deleteSuccess); + } else { + toast.error(res.error ?? labels.deleteError); + } + }); + } + + function handleForcePasswordChange(userId: string) { + startTransition(async () => { + const { success } = await forcePasswordChangeAction(userId); + if (success) { + setUsers((prev) => + prev.map((u) => + u.id === userId ? { ...u, force_password_change: true } : u, + ), + ); + toast.success(labels.forcePasswordChangeSuccess); + } else { + toast.error(labels.forcePasswordChangeError); + } + }); + } + + function handleAddRole(userId: string, roleId: string) { + const role = roles.find((r) => r.id === roleId); + if (!role) return; + startTransition(async () => { + const { success } = await addUserRoleAction(userId, roleId); + if (success) { + setUsers((prev) => + prev.map((u) => + u.id === userId + ? { ...u, roles: [...u.roles, { id: role.id, name: role.name }] } + : u, + ), + ); + toast.success(labels.addRoleSuccess); + } else { + toast.error(labels.addRoleError); + } + }); + } + + function handleRemoveRole(userId: string, roleId: string) { + startTransition(async () => { + const { success } = await removeUserRoleAction(userId, roleId); + if (success) { + setUsers((prev) => + prev.map((u) => + u.id === userId + ? { ...u, roles: u.roles.filter((r) => r.id !== roleId) } + : u, + ), + ); + toast.success(labels.removeRoleSuccess); + } else { + toast.error(labels.removeRoleError); + } + }); + } + + return ( +
+ {users.map((user) => { + const assignableRoles = roles.filter( + (r) => !user.roles.some((ur) => ur.id === r.id), + ); + return ( +
+
+ {user.username} + {user.force_password_change && ( + + pw reset + + )} + + + + + + handleForcePasswordChange(user.id)} + > + + {labels.forcePasswordChange} + + + handleDelete(user.id, user.username)} + > + + {labels.deleteUser} + + + +
+ +
+ {user.roles.length === 0 && ( + {labels.noRoles} + )} + {user.roles.map((role) => ( + handleRemoveRole(user.id, role.id)} + title={labels.removeRole} + variant="secondary" + > + {role.name} + × + + ))} + {assignableRoles.length > 0 && ( + + )} +
+
+ ); + })} +
+ ); +} diff --git a/lynx/dashboard/ui/src/components/(dashboard)/app/agents/AgentList.tsx b/lynx/dashboard/ui/src/components/(dashboard)/app/agents/AgentList.tsx new file mode 100644 index 0000000..a78279e --- /dev/null +++ b/lynx/dashboard/ui/src/components/(dashboard)/app/agents/AgentList.tsx @@ -0,0 +1,170 @@ +import { getTranslations } from "next-intl/server"; +import { BACKEND_URL } from "@/lib/api"; +import { Badge } from "@/components/ui/badge"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import Link from "next/link"; +import { NftablesAlert } from "./NftablesAlert"; + +type Agent = { + id: string; + name: string; + status: "online" | "lockdown" | "offline"; + wg_ip: string; + version: string | null; + last_heartbeat: string | null; +}; + +interface NftStatus { + diverged: boolean; + detail?: string | null; +} + +async function fetchAgents(token: string): Promise { + if (!token) return []; + try { + const res = await fetch(`${BACKEND_URL}/agents`, { + headers: { Authorization: `Bearer ${token}` }, + next: { revalidate: 30 }, + }); + if (!res.ok) return []; + return res.json(); + } catch { + return []; + } +} + +async function fetchNftStatus(token: string, agentId: string): Promise { + try { + const res = await fetch(`${BACKEND_URL}/agents/${agentId}/nftables-status`, { + headers: { Authorization: `Bearer ${token}` }, + cache: "no-store", + }); + if (!res.ok) return { diverged: false }; + return res.json(); + } catch { + return { diverged: false }; + } +} + +const STATUS_BADGE: Record< + Agent["status"], + "default" | "destructive" | "secondary" +> = { + online: "default", + lockdown: "destructive", + offline: "secondary", +}; + +function formatHeartbeat(ts: string | null): string { + if (!ts) return "—"; + const diff = Math.floor((Date.now() - new Date(ts).getTime()) / 1000); + if (diff < 60) return `${diff}s ago`; + if (diff < 3600) return `${Math.floor(diff / 60)}m ago`; + return `${Math.floor(diff / 3600)}h ago`; +} + +export async function AgentList({ + token, + locale, +}: { + token: string; + locale: string; +}) { + const [agents, t] = await Promise.all([ + fetchAgents(token), + getTranslations({ locale, namespace: "app.agents" }), + ]); + + // Fetch nftables divergence status for online agents in parallel + const nftStatuses = await Promise.all( + agents.map((a) => + a.status === "online" + ? fetchNftStatus(token, a.id) + : Promise.resolve({ diverged: false } as NftStatus), + ), + ); + + if (agents.length === 0) { + return ( +
+
+

{t("noAgents")}

+

+ {t("noAgentsDesc")} +

+
+
+ ); + } + + return ( +
+ {agents.map((agent, i) => { + const nft: NftStatus = nftStatuses[i] ?? { diverged: false }; + return ( + + +
+ + {agent.name} + + + {t(`status.${agent.status}`)} + +
+
+ +

+ + {t("wgIp")} + {" "} + {agent.wg_ip} +

+

+ + {t("version")} + {" "} + {agent.version ?? "—"} +

+

+ + {t("lastHeartbeat")} + {" "} + {formatHeartbeat(agent.last_heartbeat)} +

+

{agent.id}

+
+ + {t("detailTitle")} → + + + {t("auditLog")} + +
+ {nft.diverged && ( + + )} +
+
+ ); + })} +
+ ); +} diff --git a/lynx/dashboard/ui/src/components/(dashboard)/app/agents/AgentListSkeleton.tsx b/lynx/dashboard/ui/src/components/(dashboard)/app/agents/AgentListSkeleton.tsx new file mode 100644 index 0000000..8553db5 --- /dev/null +++ b/lynx/dashboard/ui/src/components/(dashboard)/app/agents/AgentListSkeleton.tsx @@ -0,0 +1,25 @@ +import { Skeleton } from "@/components/ui/skeleton"; +import { Card, CardContent, CardHeader } from "@/components/ui/card"; + +export function AgentListSkeleton() { + return ( +
+ {Array.from({ length: 3 }).map((_, i) => ( + + +
+ + +
+
+ + + + + + +
+ ))} +
+ ); +} diff --git a/lynx/dashboard/ui/src/components/(dashboard)/app/agents/NftablesAlert.tsx b/lynx/dashboard/ui/src/components/(dashboard)/app/agents/NftablesAlert.tsx new file mode 100644 index 0000000..140ad2a --- /dev/null +++ b/lynx/dashboard/ui/src/components/(dashboard)/app/agents/NftablesAlert.tsx @@ -0,0 +1,71 @@ +"use client"; + +import { useTransition } from "react"; +import { toast } from "sonner"; +import { AlertTriangle } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { resolveNftables } from "@/actions/(dashboard)/app/agents"; + +interface Props { + agentId: string; + detail: string | null; + labels: { + title: string; + restore: string; + accept: string; + restoreSuccess: string; + acceptSuccess: string; + error: string; + }; +} + +export function NftablesAlert({ agentId, detail, labels }: Props) { + const [isPending, startTransition] = useTransition(); + + function handle(action: "restore" | "accept") { + startTransition(async () => { + const result = await resolveNftables(agentId, action); + if (result.ok) { + toast.success( + action === "restore" ? labels.restoreSuccess : labels.acceptSuccess, + ); + } else { + toast.error(labels.error, { description: result.error }); + } + }); + } + + return ( +
+
+ +

{labels.title}

+
+ {detail && ( +

+ {detail} +

+ )} +
+ + +
+
+ ); +} diff --git a/lynx/dashboard/ui/src/components/(dashboard)/app/agents/RegisterAgentDialog.tsx b/lynx/dashboard/ui/src/components/(dashboard)/app/agents/RegisterAgentDialog.tsx new file mode 100644 index 0000000..ccdbae1 --- /dev/null +++ b/lynx/dashboard/ui/src/components/(dashboard)/app/agents/RegisterAgentDialog.tsx @@ -0,0 +1,164 @@ +"use client"; + +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useState } from "react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Field, FieldLabel, FieldError } from "@/components/ui/field"; +import { BACKEND_URL } from "@/lib/api"; +import { Plus } from "lucide-react"; +import { registerAgentSchema, type RegisterAgentInput } from "@/schemas/(dashboard)/app/agents"; + +type RegisteredAgent = { + id: string; + wg_ip: string; + sync_token: string; +}; + +type Props = { + token: string; + label: string; + successTitle: string; + successDesc: string; + agentIdLabel: string; + wgIpLabel: string; + syncTokenLabel: string; + warnOnce: string; + doneLabel: string; +}; + +export function RegisterAgentDialog({ + token, + label, + successTitle, + successDesc, + agentIdLabel, + wgIpLabel, + syncTokenLabel, + warnOnce, + doneLabel, +}: Props) { + const [open, setOpen] = useState(false); + const [registered, setRegistered] = useState(null); + + const { + register, + handleSubmit, + reset, + formState: { errors, isSubmitting }, + } = useForm({ + resolver: zodResolver(registerAgentSchema), + }); + + const onSubmit = (data: RegisterAgentInput) => { + toast.promise( + fetch(`${BACKEND_URL}/agents`, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` }, + body: JSON.stringify({ name: data.name }), + }).then(async (res) => { + if (!res.ok) throw new Error("failed"); + const agent = (await res.json()) as RegisteredAgent; + setRegistered(agent); + return agent; + }), + { + loading: "Registering…", + success: successTitle, + error: "Failed to register agent", + }, + ); + }; + + function handleClose() { + setOpen(false); + setRegistered(null); + reset(); + } + + return ( + { if (!v) handleClose(); else setOpen(true); }}> + + + + + {!registered ? ( + <> + + {label} + + Provide a name for the agent. A WireGuard IP will be assigned automatically. + + +
+ + Name + + + + + + +
+ + ) : ( + <> + + {successTitle} + {successDesc} + +
+ + + +
+

{warnOnce}

+ + + + + )} +
+
+ ); +} + +function AgentField({ label, value, secret }: { label: string; value: string; secret?: boolean }) { + const [revealed, setRevealed] = useState(!secret); + return ( +
+

{label}

+
+ + {revealed ? value : "•".repeat(Math.min(value.length, 32))} + + {secret && ( + + )} +
+
+ ); +} diff --git a/lynx/dashboard/ui/src/components/(dashboard)/app/agents/detail/AgentDetailActions.tsx b/lynx/dashboard/ui/src/components/(dashboard)/app/agents/detail/AgentDetailActions.tsx new file mode 100644 index 0000000..49a705f --- /dev/null +++ b/lynx/dashboard/ui/src/components/(dashboard)/app/agents/detail/AgentDetailActions.tsx @@ -0,0 +1,76 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { RotateCcw, Trash2 } from "lucide-react"; +import { rebootAgent, deleteAgent } from "@/actions/(dashboard)/app/agents"; + +interface Labels { + reboot: string; + rebootConfirm: string; + rebootSuccess: string; + rebootError: string; + deleteAgent: string; + deleteConfirm: string; + deleteError: string; +} + +interface Props { + agentId: string; + locale: string; + labels: Labels; +} + +export function AgentDetailActions({ agentId, locale, labels }: Props) { + const [rebootPending, startReboot] = useTransition(); + const [deletePending, startDelete] = useTransition(); + const [deleted, setDeleted] = useState(false); + + const handleReboot = () => { + if (!window.confirm(labels.rebootConfirm)) return; + startReboot(async () => { + const r = await rebootAgent(agentId); + if (r.ok) toast.success(labels.rebootSuccess); + else toast.error(labels.rebootError); + }); + }; + + const handleDelete = () => { + if (!window.confirm(labels.deleteConfirm)) return; + setDeleted(true); + startDelete(async () => { + try { + await deleteAgent(agentId, locale); + } catch { + toast.error(labels.deleteError); + setDeleted(false); + } + }); + }; + + return ( +
+ + +
+ ); +} diff --git a/lynx/dashboard/ui/src/components/(dashboard)/app/agents/detail/MetricsPanel.tsx b/lynx/dashboard/ui/src/components/(dashboard)/app/agents/detail/MetricsPanel.tsx new file mode 100644 index 0000000..7bc7246 --- /dev/null +++ b/lynx/dashboard/ui/src/components/(dashboard)/app/agents/detail/MetricsPanel.tsx @@ -0,0 +1,131 @@ +"use client"; + +import { useAgentMetrics } from "@/lib/useAgentMetrics"; +import { Cpu, HardDrive, MemoryStick, Wifi, WifiOff } from "lucide-react"; + +interface Props { + agentId: string; + labels: { + metrics: string; + cpu: string; + memory: string; + disk: string; + connecting: string; + agentOffline: string; + offline: string; + }; +} + +function pct(used: number, total: number) { + if (total === 0) return 0; + return Math.round((used / total) * 100); +} + +function Bar({ value }: { value: number }) { + const color = + value >= 90 + ? "bg-destructive" + : value >= 70 + ? "bg-yellow-500" + : "bg-primary"; + return ( +
+
+
+ ); +} + +export function MetricsPanel({ agentId, labels }: Props) { + const { metrics, status } = useAgentMetrics(agentId); + + if (status === "connecting") { + return ( +

+ {labels.connecting} +

+ ); + } + + if (status === "agent_offline" || status === "offline") { + return ( +
+ + {status === "agent_offline" ? labels.agentOffline : labels.offline} +
+ ); + } + + if (!metrics) return null; + + const memPct = pct(metrics.mem_used_mb, metrics.mem_total_mb); + const diskPct = pct(metrics.disk_used_gb, metrics.disk_total_gb); + + return ( +
+
+ + {labels.metrics} +
+ +
+ {/* CPU */} +
+
+ + + {labels.cpu} + + + {metrics.cpu_percent.toFixed(1)}% + +
+ +
+ + {/* Memory */} +
+
+ + + {labels.memory} + + + {memPct}%{" "} + + {metrics.mem_used_mb >= 1024 + ? `${(metrics.mem_used_mb / 1024).toFixed(1)}GB` + : `${metrics.mem_used_mb}MB`} + / + {metrics.mem_total_mb >= 1024 + ? `${(metrics.mem_total_mb / 1024).toFixed(1)}GB` + : `${metrics.mem_total_mb}MB`} + + +
+ +
+ + {/* Disk */} +
+
+ + + {labels.disk} + + + {diskPct}%{" "} + + {metrics.disk_used_gb.toFixed(1)}GB/ + {metrics.disk_total_gb.toFixed(1)}GB + + +
+ +
+
+
+ ); +} diff --git a/lynx/dashboard/ui/src/components/(dashboard)/app/agents/detail/NftRulesPanel.tsx b/lynx/dashboard/ui/src/components/(dashboard)/app/agents/detail/NftRulesPanel.tsx new file mode 100644 index 0000000..6b82521 --- /dev/null +++ b/lynx/dashboard/ui/src/components/(dashboard)/app/agents/detail/NftRulesPanel.tsx @@ -0,0 +1,317 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Badge } from "@/components/ui/badge"; +import { Field, FieldLabel } from "@/components/ui/field"; +import { Plus, Trash2, Send } from "lucide-react"; +import { + type NftRule, + type CreateRulePayload, +} from "@/actions/(dashboard)/app/agents/nftables"; + +interface Labels { + addRule: string; + kind: string; + port: string; + protocol: string; + ipList: string; + ratePerMin: string; + description: string; + priority: string; + create: string; + createSuccess: string; + createError: string; + deleteSuccess: string; + deleteError: string; + push: string; + pushSuccess: string; + pushError: string; + noRules: string; + kindAllowPort: string; + kindBlockPort: string; + kindAllowIp: string; + kindBlockIp: string; + kindRateLimit: string; + protoTcp: string; + protoUdp: string; + protoBoth: string; +} + +interface Props { + initialRules: NftRule[]; + labels: Labels; + onCreateRule: (payload: CreateRulePayload) => Promise<{ ok: boolean; error?: string }>; + onDeleteRule: (ruleId: string) => Promise<{ ok: boolean; error?: string }>; + onPush: () => Promise<{ ok: boolean; error?: string; pushed?: number; failed?: number }>; +} + +const KIND_LABELS: Record = { + allow_port: "kindAllowPort", + block_port: "kindBlockPort", + allow_ip: "kindAllowIp", + block_ip: "kindBlockIp", + rate_limit: "kindRateLimit", +}; + +const PROTO_LABELS: Record = { + tcp: "protoTcp", + udp: "protoUdp", + both: "protoBoth", +}; + +const KIND_BADGE: Record = { + allow_port: "default", + allow_ip: "default", + block_port: "destructive", + block_ip: "destructive", + rate_limit: "secondary", +}; + +const PORT_KINDS = new Set(["allow_port", "block_port", "rate_limit"]); +const IP_KINDS = new Set(["allow_ip", "block_ip"]); + +export function NftRulesPanel({ + initialRules, + labels, + onCreateRule, + onDeleteRule, + onPush, +}: Props) { + const [rules, setRules] = useState(initialRules); + const [showForm, setShowForm] = useState(false); + const [kind, setKind] = useState("allow_port"); + const [port, setPort] = useState(""); + const [protocol, setProtocol] = useState("tcp"); + const [ipList, setIpList] = useState(""); + const [ratePerMin, setRatePerMin] = useState(""); + const [description, setDescription] = useState(""); + const [createPending, startCreate] = useTransition(); + const [pushPending, startPush] = useTransition(); + + const handleCreate = () => { + const payload: CreateRulePayload = { + kind, + description: description.trim() || undefined, + }; + if (PORT_KINDS.has(kind)) { + const p = parseInt(port); + if (!p || p < 1 || p > 65535) { + toast.error("Invalid port"); + return; + } + payload.port = p; + payload.protocol = protocol; + } + if (IP_KINDS.has(kind) || ipList.trim()) { + payload.ip_list = ipList + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + } + if (kind === "rate_limit") { + const r = parseInt(ratePerMin); + if (!r || r < 1) { + toast.error("Invalid rate"); + return; + } + payload.rate_per_min = r; + } + + startCreate(async () => { + const result = await onCreateRule(payload); + if (result.ok) { + toast.success(labels.createSuccess); + setShowForm(false); + setPort(""); + setIpList(""); + setRatePerMin(""); + setDescription(""); + // Optimistic: refetch happens via revalidatePath on next navigation + } else { + toast.error(labels.createError); + } + }); + }; + + const handleDelete = (ruleId: string) => { + startCreate(async () => { + const result = await onDeleteRule(ruleId); + if (result.ok) { + setRules((prev) => prev.filter((r) => r.id !== ruleId)); + toast.success(labels.deleteSuccess); + } else { + toast.error(labels.deleteError); + } + }); + }; + + const handlePush = () => { + startPush(async () => { + const result = await onPush(); + if (result.ok) { + toast.success(labels.pushSuccess); + } else { + toast.error(labels.pushError); + } + }); + }; + + return ( +
+ {rules.length === 0 ? ( +

{labels.noRules}

+ ) : ( +
+ + + {rules.map((rule) => ( + + + + + + + ))} + +
+ + {labels[KIND_LABELS[rule.kind] ?? "kindAllowPort"]} + + + {rule.port != null ? `:${rule.port}` : ""} + {rule.protocol ? ` (${labels[PROTO_LABELS[rule.protocol] ?? "protoBoth"]})` : ""} + {rule.ip_list.length > 0 ? ` ${rule.ip_list.join(", ")}` : ""} + {rule.rate_per_min != null ? ` ${rule.rate_per_min}/min` : ""} + + {rule.description ?? ""} + + +
+
+ )} + +
+ + +
+ + {showForm && ( +
+ + {labels.kind} + + + + {PORT_KINDS.has(kind) && ( +
+ + {labels.port} + setPort(e.target.value)} + placeholder="80" + /> + + + {labels.protocol} + + +
+ )} + + {(IP_KINDS.has(kind) || PORT_KINDS.has(kind)) && ( + + {labels.ipList} + setIpList(e.target.value)} + placeholder="0.0.0.0/0, ::/0" + /> + + )} + + {kind === "rate_limit" && ( + + {labels.ratePerMin} + setRatePerMin(e.target.value)} + placeholder="100" + /> + + )} + + + {labels.description} + setDescription(e.target.value)} + placeholder="Allow web traffic" + /> + + + +
+ )} +
+ ); +} diff --git a/lynx/dashboard/ui/src/components/(dashboard)/app/organizations/CreateOrgDialog.tsx b/lynx/dashboard/ui/src/components/(dashboard)/app/organizations/CreateOrgDialog.tsx new file mode 100644 index 0000000..e77b2a5 --- /dev/null +++ b/lynx/dashboard/ui/src/components/(dashboard)/app/organizations/CreateOrgDialog.tsx @@ -0,0 +1,117 @@ +"use client"; + +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Field, FieldLabel, FieldError } from "@/components/ui/field"; +import { BACKEND_URL } from "@/lib/api"; +import { Plus } from "lucide-react"; +import { createOrgSchema, type CreateOrgInput } from "@/schemas/(dashboard)/app/organizations"; + +type Props = { + token: string; + label: string; + slugConflict: string; + errorMsg: string; +}; + +function deriveSlug(n: string) { + return n.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, ""); +} + +export function CreateOrgDialog({ token, label, slugConflict, errorMsg }: Props) { + const [open, setOpen] = useState(false); + const router = useRouter(); + + const { + register, + handleSubmit, + setValue, + reset, + formState: { errors, isSubmitting }, + } = useForm({ + resolver: zodResolver(createOrgSchema), + }); + + const onSubmit = (data: CreateOrgInput) => { + toast.promise( + fetch(`${BACKEND_URL}/organizations`, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` }, + body: JSON.stringify({ name: data.name, slug: data.slug }), + }).then(async (res) => { + if (res.status === 409) throw new Error("conflict"); + if (!res.ok) throw new Error("error"); + setOpen(false); + reset(); + router.refresh(); + }), + { + loading: "Creating…", + success: label, + error: (e: Error) => (e.message === "conflict" ? slugConflict : errorMsg), + }, + ); + }; + + return ( + { setOpen(v); if (!v) reset(); }}> + + + + + + {label} + + Create an organization to group projects and containers. + + +
+ + Name + setValue("slug", deriveSlug(e.target.value)), + })} + disabled={isSubmitting} + /> + + + + Slug + + + + + + +
+
+
+ ); +} diff --git a/lynx/dashboard/ui/src/components/(dashboard)/app/organizations/OrgList.tsx b/lynx/dashboard/ui/src/components/(dashboard)/app/organizations/OrgList.tsx new file mode 100644 index 0000000..68a8bb6 --- /dev/null +++ b/lynx/dashboard/ui/src/components/(dashboard)/app/organizations/OrgList.tsx @@ -0,0 +1,85 @@ +import { getTranslations } from "next-intl/server"; +import { BACKEND_URL } from "@/lib/api"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Building2 } from "lucide-react"; +import Link from "next/link"; + +type Org = { + id: string; + name: string; + slug: string; + owner_id: string; + created_at: string; + member_count: number; +}; + +async function fetchOrgs(token: string): Promise { + if (!token) return []; + try { + const res = await fetch(`${BACKEND_URL}/organizations`, { + headers: { Authorization: `Bearer ${token}` }, + next: { revalidate: 60 }, + }); + if (!res.ok) return []; + return res.json(); + } catch { + return []; + } +} + +export async function OrgList({ + token, + locale, +}: { + token: string; + locale: string; +}) { + const orgs = await fetchOrgs(token); + const t = await getTranslations({ locale, namespace: "app.organizations" }); + + if (orgs.length === 0) { + return ( +
+
+ +

{t("noOrgs")}

+

+ {t("noOrgsDesc")} +

+
+
+ ); + } + + return ( +
+ {orgs.map((org) => ( + + + + + + {org.name} + + + +

+ + {t("slug")} + {" "} + {org.slug} +

+

+ + {t("members")} + {" "} + {org.member_count} +

+

{org.id}

+
+
+ + ))} +
+ ); +} diff --git a/lynx/dashboard/ui/src/components/(dashboard)/app/organizations/OrgListSkeleton.tsx b/lynx/dashboard/ui/src/components/(dashboard)/app/organizations/OrgListSkeleton.tsx new file mode 100644 index 0000000..4ca482f --- /dev/null +++ b/lynx/dashboard/ui/src/components/(dashboard)/app/organizations/OrgListSkeleton.tsx @@ -0,0 +1,21 @@ +import { Skeleton } from "@/components/ui/skeleton"; +import { Card, CardContent, CardHeader } from "@/components/ui/card"; + +export function OrgListSkeleton() { + return ( +
+ {Array.from({ length: 3 }).map((_, i) => ( + + + + + + + + + + + ))} +
+ ); +} diff --git a/lynx/dashboard/ui/src/components/(dashboard)/app/organizations/[id]/CreateProjectDialog.tsx b/lynx/dashboard/ui/src/components/(dashboard)/app/organizations/[id]/CreateProjectDialog.tsx new file mode 100644 index 0000000..b16f7a8 --- /dev/null +++ b/lynx/dashboard/ui/src/components/(dashboard)/app/organizations/[id]/CreateProjectDialog.tsx @@ -0,0 +1,142 @@ +"use client"; + +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useRouter } from "next/navigation"; +import { useRef, useState } from "react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Field, FieldLabel, FieldError } from "@/components/ui/field"; +import { createProjectSchema, type CreateProjectInput } from "@/schemas/(dashboard)/app/organizations/[id]"; +import { createProject } from "@/actions/(dashboard)/app/organizations/[id]/projects"; + +interface Agent { + id: string; + name: string; + status: string; +} + +interface Props { + orgId: string; + agents: Agent[]; + labels: { + trigger: string; + title: string; + name: string; + slug: string; + agent: string; + noAgents: string; + create: string; + success: string; + slugConflict: string; + error: string; + }; +} + +function deriveSlug(name: string): string { + return name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, ""); +} + +export function CreateProjectDialog({ orgId, agents, labels }: Props) { + const router = useRouter(); + const [open, setOpen] = useState(false); + const slugTouched = useRef(false); + + const { + register, + handleSubmit, + setValue, + reset, + formState: { errors, isSubmitting }, + } = useForm({ + resolver: zodResolver(createProjectSchema), + defaultValues: { agent_id: agents[0]?.id ?? "" }, + }); + + const onSubmit = (data: CreateProjectInput) => { + toast.promise( + createProject(orgId, data.name, data.slug, data.agent_id).then((r) => { + if (!r.ok) throw new Error(r.error ?? "error"); + setOpen(false); + slugTouched.current = false; + reset({ agent_id: agents[0]?.id ?? "" }); + router.refresh(); + }), + { + loading: labels.create, + success: labels.success, + error: (e: Error) => (e.message === "slug_conflict" ? labels.slugConflict : labels.error), + }, + ); + }; + + if (agents.length === 0) { + return

{labels.noAgents}

; + } + + return ( + { setOpen(v); if (!v) { slugTouched.current = false; reset({ agent_id: agents[0]?.id ?? "" }); } }}> + + + + + + {labels.title} + +
+ + {labels.name} + { + if (!slugTouched.current) setValue("slug", deriveSlug(e.target.value)); + }, + })} + disabled={isSubmitting} + /> + + + + {labels.slug} + { slugTouched.current = true; }, + })} + disabled={isSubmitting} + /> + + + + {labels.agent} + + + + +
+
+
+ ); +} diff --git a/lynx/dashboard/ui/src/components/(dashboard)/app/organizations/[id]/InviteDialog.tsx b/lynx/dashboard/ui/src/components/(dashboard)/app/organizations/[id]/InviteDialog.tsx new file mode 100644 index 0000000..a3cfc3f --- /dev/null +++ b/lynx/dashboard/ui/src/components/(dashboard)/app/organizations/[id]/InviteDialog.tsx @@ -0,0 +1,105 @@ +"use client"; + +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Field, FieldLabel, FieldError } from "@/components/ui/field"; +import { inviteMemberSchema, type InviteMemberInput } from "@/schemas/(dashboard)/app/organizations/[id]"; +import { inviteMember } from "@/actions/(dashboard)/app/organizations/[id]"; + +interface Props { + orgId: string; + labels: { + trigger: string; + title: string; + username: string; + role: string; + invite: string; + success: string; + error: string; + }; +} + +export function InviteDialog({ orgId, labels }: Props) { + const router = useRouter(); + const [open, setOpen] = useState(false); + + const { + register, + handleSubmit, + reset, + formState: { errors, isSubmitting }, + } = useForm({ + resolver: zodResolver(inviteMemberSchema), + defaultValues: { role: "member" }, + }); + + const onSubmit = (data: InviteMemberInput) => { + toast.promise( + inviteMember(orgId, data.username, data.role).then((r) => { + if (!r.ok) throw new Error(r.error); + setOpen(false); + reset(); + router.refresh(); + }), + { + loading: labels.invite, + success: labels.success, + error: labels.error, + }, + ); + }; + + return ( + { setOpen(v); if (!v) reset(); }}> + + + + + + {labels.title} + +
+ + {labels.username} + + + + + {labels.role} + + + + +
+
+
+ ); +} diff --git a/lynx/dashboard/ui/src/components/(dashboard)/app/organizations/[id]/RemoveMemberButton.tsx b/lynx/dashboard/ui/src/components/(dashboard)/app/organizations/[id]/RemoveMemberButton.tsx new file mode 100644 index 0000000..a2cd5e5 --- /dev/null +++ b/lynx/dashboard/ui/src/components/(dashboard)/app/organizations/[id]/RemoveMemberButton.tsx @@ -0,0 +1,45 @@ +"use client"; + +import { useTransition } from "react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { removeMember } from "@/actions/(dashboard)/app/organizations/[id]"; + +interface Props { + orgId: string; + userId: string; + label: string; + successMsg: string; + errorMsg: string; +} + +export function RemoveMemberButton({ + orgId, + userId, + label, + successMsg, + errorMsg, +}: Props) { + const [isPending, startTransition] = useTransition(); + + return ( + + ); +} diff --git a/lynx/dashboard/ui/src/components/(dashboard)/app/organizations/[id]/projects/[proj_id]/ContainerCard.tsx b/lynx/dashboard/ui/src/components/(dashboard)/app/organizations/[id]/projects/[proj_id]/ContainerCard.tsx new file mode 100644 index 0000000..b414a6d --- /dev/null +++ b/lynx/dashboard/ui/src/components/(dashboard)/app/organizations/[id]/projects/[proj_id]/ContainerCard.tsx @@ -0,0 +1,111 @@ +"use client"; + +import { useTransition } from "react"; +import { toast } from "sonner"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { containerAction } from "@/actions/(dashboard)/app/organizations/[id]/projects/[proj_id]/containers"; + +interface Container { + Names: string[]; + Image: string; + Status: string; + State: string; +} + +interface Props { + orgId: string; + projId: string; + container: Container; + labels: { + start: string; + stop: string; + restart: string; + remove: string; + success: string; + error: string; + }; +} + +function stateVariant(state: string): "default" | "secondary" | "destructive" { + if (state === "running") return "default"; + if (state === "exited" || state === "stopped") return "secondary"; + return "destructive"; +} + +export function ContainerCard({ orgId, projId, container, labels }: Props) { + const [isPending, startTransition] = useTransition(); + const name = container.Names[0]?.replace(/^\//, "") ?? "unknown"; + const isRunning = container.State === "running"; + + function handle(action: "start" | "stop" | "restart" | "remove") { + startTransition(async () => { + const result = await containerAction(orgId, projId, name, action); + if (result.ok) { + toast.success(`${name}: ${labels.success}`); + } else { + toast.error(labels.error, { description: result.error }); + } + }); + } + + return ( +
+
+
+ {name} + + {container.State} + +
+

+ {container.Image} +

+
+
+ {!isRunning && ( + + )} + {isRunning && ( + <> + + + + )} + +
+
+ ); +} diff --git a/lynx/dashboard/ui/src/components/(dashboard)/app/organizations/[id]/projects/[proj_id]/DeployForm.tsx b/lynx/dashboard/ui/src/components/(dashboard)/app/organizations/[id]/projects/[proj_id]/DeployForm.tsx new file mode 100644 index 0000000..d98cb6c --- /dev/null +++ b/lynx/dashboard/ui/src/components/(dashboard)/app/organizations/[id]/projects/[proj_id]/DeployForm.tsx @@ -0,0 +1,131 @@ +"use client"; + +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Field, FieldLabel, FieldError } from "@/components/ui/field"; +import { deployContainerSchema, type DeployContainerInput } from "@/schemas/(dashboard)/app/organizations/[id]/projects/[proj_id]"; +import { deployContainer } from "@/actions/(dashboard)/app/organizations/[id]/projects/[proj_id]/containers"; + +interface Props { + orgId: string; + projId: string; + labels: { + name: string; + image: string; + ports: string; + env: string; + cpus: string; + memoryMb: string; + deploy: string; + success: string; + error: string; + }; +} + +export function DeployForm({ orgId, projId, labels }: Props) { + const { + register, + handleSubmit, + reset, + formState: { errors, isSubmitting }, + } = useForm({ + resolver: zodResolver(deployContainerSchema), + }); + + const onSubmit = (data: DeployContainerInput) => { + const parsedPorts = data.ports + ? data.ports.split(/[\n,]+/).map((s) => s.trim()).filter(Boolean) + : []; + const parsedEnv = data.env + ? data.env.split(/[\n,]+/).map((s) => s.trim()).filter(Boolean) + : []; + + toast.promise( + deployContainer(orgId, projId, { + name: data.name, + image: data.image, + ports: parsedPorts, + env: parsedEnv, + cpus: data.cpus ?? null, + memory_mb: data.memory_mb ?? null, + }).then((r) => { + if (!r.ok) throw new Error(r.error); + reset(); + return r; + }), + { + loading: labels.deploy, + success: labels.success, + error: labels.error, + }, + ); + }; + + return ( +
+
+ + {labels.name} + + + + + {labels.image} + + + +
+ +
+ + {labels.ports} + + + + + {labels.env} + + + +
+ +
+ + {labels.cpus} + + + + + {labels.memoryMb} + + + +
+ +
+ +
+
+ ); +} diff --git a/lynx/dashboard/ui/src/components/(dashboard)/app/organizations/[id]/projects/[proj_id]/HorizontalScaleSection.tsx b/lynx/dashboard/ui/src/components/(dashboard)/app/organizations/[id]/projects/[proj_id]/HorizontalScaleSection.tsx new file mode 100644 index 0000000..63f534c --- /dev/null +++ b/lynx/dashboard/ui/src/components/(dashboard)/app/organizations/[id]/projects/[proj_id]/HorizontalScaleSection.tsx @@ -0,0 +1,257 @@ +"use client"; + +import { useForm, Controller } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useState, useTransition } from "react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Field, FieldLabel, FieldError } from "@/components/ui/field"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Trash2, Plus, Network } from "lucide-react"; +import { addTunnelSchema, type AddTunnelInput } from "@/schemas/(dashboard)/app/organizations/[id]/projects/[proj_id]"; +import { addHorizontalScale, teardownHorizontalScale } from "@/actions/(dashboard)/app/organizations/[id]/projects/[proj_id]/scale"; + +interface Agent { + id: string; + name: string; + wg_ip: string; + status: string; +} + +interface Tunnel { + id: string; + agent_b_id: string; + agent_a_wg_ip: string; + agent_b_wg_ip: string; + replica_count: number; + status: string; +} + +interface Labels { + title: string; + desc: string; + addBtn: string; + dialogTitle: string; + targetAgent: string; + image: string; + replicas: string; + confirm: string; + success: string; + error: string; + teardownSuccess: string; + teardownError: string; + noTunnels: string; + agentB: string; + replicaCount: string; + status: string; +} + +interface Props { + orgId: string; + projId: string; + tunnels: Tunnel[]; + agents: Agent[]; + labels: Labels; +} + +function StatusBadge({ status }: { status: string }) { + const variant = + status === "active" ? "default" : status === "pending" ? "secondary" : "destructive"; + return {status}; +} + +function TeardownButton({ + orgId, + projId, + tunnelId, + labels, +}: { + orgId: string; + projId: string; + tunnelId: string; + labels: { teardownSuccess: string; teardownError: string }; +}) { + const [pending, startTransition] = useTransition(); + return ( + + ); +} + +function AddTunnelDialog({ + orgId, + projId, + agents, + labels, +}: { + orgId: string; + projId: string; + agents: Agent[]; + labels: Labels; +}) { + const [open, setOpen] = useState(false); + const onlineAgents = agents.filter((a) => a.status === "online"); + + const { + register, + handleSubmit, + control, + reset, + formState: { errors, isSubmitting }, + } = useForm({ + resolver: zodResolver(addTunnelSchema), + defaultValues: { replica_count: 1 }, + }); + + const onSubmit = (data: AddTunnelInput) => { + toast.promise( + addHorizontalScale(orgId, projId, data.target_agent_id, data.image, data.replica_count).then( + (r) => { + if (!r.ok) throw new Error(r.error); + setOpen(false); + reset({ replica_count: 1 }); + return r; + }, + ), + { + loading: labels.confirm, + success: labels.success, + error: labels.error, + }, + ); + }; + + return ( + { setOpen(v); if (!v) reset({ replica_count: 1 }); }}> + + + + + + {labels.dialogTitle} + +
+ + {labels.targetAgent} + {onlineAgents.length === 0 ? ( +

{labels.error}

+ ) : ( + ( + + )} + /> + )} + +
+ + {labels.image} + + + + + {labels.replicas} + + + + +
+
+
+ ); +} + +export function HorizontalScaleSection({ orgId, projId, tunnels, agents, labels }: Props) { + return ( +
+
+

+ + {labels.title} +

+ +
+

{labels.desc}

+ {tunnels.length === 0 ? ( +

{labels.noTunnels}

+ ) : ( +
+ {tunnels.map((t) => ( +
+
+ + {t.agent_a_wg_ip} → {t.agent_b_wg_ip} + + + {labels.replicaCount}: {t.replica_count} + +
+
+ + +
+
+ ))} +
+ )} +
+ ); +} diff --git a/lynx/dashboard/ui/src/components/(dashboard)/app/organizations/[id]/projects/[proj_id]/ResourceForm.tsx b/lynx/dashboard/ui/src/components/(dashboard)/app/organizations/[id]/projects/[proj_id]/ResourceForm.tsx new file mode 100644 index 0000000..0e7bf66 --- /dev/null +++ b/lynx/dashboard/ui/src/components/(dashboard)/app/organizations/[id]/projects/[proj_id]/ResourceForm.tsx @@ -0,0 +1,103 @@ +"use client"; + +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Field, FieldLabel, FieldError } from "@/components/ui/field"; +import { resourceFormSchema, type ResourceFormInput } from "@/schemas/(dashboard)/app/organizations/[id]/projects/[proj_id]"; +import { updateContainerResources } from "@/actions/(dashboard)/app/organizations/[id]/projects/[proj_id]"; + +interface Props { + orgId: string; + projId: string; + labels: { + containerName: string; + cpus: string; + memoryMb: string; + apply: string; + success: string; + error: string; + }; +} + +export function ResourceForm({ orgId, projId, labels }: Props) { + const { + register, + handleSubmit, + formState: { errors, isSubmitting }, + } = useForm({ + resolver: zodResolver(resourceFormSchema), + }); + + const onSubmit = (data: ResourceFormInput) => { + toast.promise( + updateContainerResources( + orgId, + projId, + data.container_name, + data.cpus ?? null, + data.memory_mb ?? null, + ).then((r) => { + if (!r.ok) throw new Error(r.error); + return r; + }), + { + loading: labels.apply, + success: labels.success, + error: labels.error, + }, + ); + }; + + return ( +
+ + {labels.containerName} + + + + +
+ + {labels.cpus} + + + + + {labels.memoryMb} + + + +
+ +
+ +
+
+ ); +} diff --git a/lynx/dashboard/ui/src/components/(dashboard)/app/settings/BrandingForm.tsx b/lynx/dashboard/ui/src/components/(dashboard)/app/settings/BrandingForm.tsx new file mode 100644 index 0000000..5003274 --- /dev/null +++ b/lynx/dashboard/ui/src/components/(dashboard)/app/settings/BrandingForm.tsx @@ -0,0 +1,165 @@ +"use client"; + +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useWatch } from "react-hook-form"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Field, FieldLabel, FieldError } from "@/components/ui/field"; +import { brandingSchema, type BrandingInput } from "@/schemas/(dashboard)/app/settings"; +import { updateBranding } from "@/actions/(dashboard)/app/settings"; + +interface Props { + initial: { + company_name: string; + logo_url: string | null; + primary_color: string; + secondary_color: string; + accent_color: string; + }; + labels: { + companyName: string; + logoUrl: string; + primaryColor: string; + secondaryColor: string; + accentColor: string; + save: string; + saved: string; + error: string; + }; +} + +function ColorPreview({ value }: { value: string | undefined }) { + return ( +
+ ); +} + +export function BrandingForm({ initial, labels }: Props) { + const { + register, + handleSubmit, + control, + formState: { errors, isSubmitting }, + } = useForm({ + resolver: zodResolver(brandingSchema), + defaultValues: { + company_name: initial.company_name, + logo_url: initial.logo_url ?? "", + primary_color: initial.primary_color, + secondary_color: initial.secondary_color, + accent_color: initial.accent_color, + }, + }); + + const [primary, secondary, accent] = useWatch({ + control, + name: ["primary_color", "secondary_color", "accent_color"], + }); + + const onSubmit = (data: BrandingInput) => { + toast.promise( + updateBranding({ + company_name: data.company_name || undefined, + logo_url: data.logo_url || null, + primary_color: data.primary_color || undefined, + secondary_color: data.secondary_color || undefined, + accent_color: data.accent_color || undefined, + }).then((r) => { + if (!r.ok) throw new Error(r.error); + return r; + }), + { + loading: labels.save, + success: labels.saved, + error: labels.error, + }, + ); + }; + + return ( +
+ + {labels.companyName} + + + + + + {labels.logoUrl} + + + + +
+ + {labels.primaryColor} +
+ + +
+ +
+ + + {labels.secondaryColor} +
+ + +
+ +
+ + + {labels.accentColor} +
+ + +
+ +
+
+ +
+ +
+
+ ); +} diff --git a/lynx/dashboard/ui/src/components/(dashboard)/app/settings/CertUploadSection.tsx b/lynx/dashboard/ui/src/components/(dashboard)/app/settings/CertUploadSection.tsx new file mode 100644 index 0000000..b009680 --- /dev/null +++ b/lynx/dashboard/ui/src/components/(dashboard)/app/settings/CertUploadSection.tsx @@ -0,0 +1,136 @@ +"use client"; + +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useState } from "react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { Textarea } from "@/components/ui/textarea"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Field, FieldLabel, FieldError } from "@/components/ui/field"; +import { Upload } from "lucide-react"; +import { certUploadSchema, type CertUploadInput } from "@/schemas/(dashboard)/app/settings"; +import { uploadCert } from "@/actions/(dashboard)/app/settings"; + +interface Labels { + title: string; + cloudflareTab: string; + customTab: string; + certPem: string; + certPemPlaceholder: string; + keyPem: string; + keyPemPlaceholder: string; + keyOptional: string; + upload: string; + success: string; + error: string; +} + +interface Props { + labels: Labels; + onSuccess?: () => void; +} + +export function CertUploadSection({ labels, onSuccess }: Props) { + const [tab, setTab] = useState<"cloudflare" | "custom">("cloudflare"); + + const { + register, + handleSubmit, + reset, + formState: { errors, isSubmitting }, + } = useForm({ + resolver: zodResolver(certUploadSchema), + defaultValues: { cert_type: "cloudflare" }, + }); + + const onSubmit = async (data: CertUploadInput) => { + const r = await uploadCert(tab, data.cert_pem, data.key_pem || undefined); + if (r.ok) { + toast.success(labels.success); + reset(); + onSuccess?.(); + } else { + toast.error(labels.error); + } + }; + + return ( +
+
+ + {labels.title} +
+ + { + setTab(v as "cloudflare" | "custom"); + reset({ cert_type: v as "cloudflare" | "custom" }); + }} + > + + + {labels.cloudflareTab} + + + {labels.customTab} + + + +
+ + + + + {labels.certPem} +