Compare commits
61 Commits
98448509f4
...
feature/ng
| Author | SHA1 | Date | |
|---|---|---|---|
| 143a280d26 | |||
| 4376167d74 | |||
| 24d3f46a5c | |||
| 3c2cda88f1 | |||
|
|
c0d243f661 | ||
|
|
f7162f0c17 | ||
|
|
d6a829c529 | ||
|
|
9991b20b28 | ||
|
|
8dbbeb4b24 | ||
|
|
93a36cb101 | ||
|
|
f65cae7f33 | ||
|
|
5f439f6231 | ||
|
|
c52c0b3103 | ||
|
|
153c092abe | ||
|
|
cb4ad27e89 | ||
|
|
9f72cd4bbb | ||
|
|
7173f504ed | ||
|
|
3b7916d66c | ||
|
|
94d5e178d2 | ||
|
|
f428f18f87 | ||
|
|
4252154420 | ||
|
|
984334c232 | ||
|
|
98e07715fa | ||
|
|
7a0111c4c5 | ||
|
|
0575c34fd6 | ||
|
|
1daac01583 | ||
| cd28b5009a | |||
|
|
171e505f22 | ||
|
|
dff828addf | ||
|
|
1481e31182 | ||
|
|
4b80619c97 | ||
|
|
836617fa54 | ||
|
|
e74ddb96ab | ||
|
|
86e9fd42bb | ||
|
|
d9fe053d41 | ||
|
|
1eeca606ec | ||
|
|
6ce8850ddb | ||
|
|
a9a08d2ef8 | ||
|
|
bbde3c1b60 | ||
|
|
e2d364722b | ||
|
|
da0ab29b2a | ||
|
|
60af2c5b80 | ||
| a1996adc45 | |||
|
|
7fde3533d9 | ||
|
|
b5e42f2f30 | ||
|
|
50f17fd69b | ||
|
|
84808832dd | ||
|
|
7fd150ea4a | ||
|
|
e831640540 | ||
|
|
a023cbc082 | ||
| c5c4430b23 | |||
|
|
7a9eeb5f3a | ||
|
|
109d693d59 | ||
|
|
eba30f557e | ||
|
|
d7cbb2a2ce | ||
|
|
f5eb25993b | ||
|
|
9640f03d69 | ||
|
|
3800e38463 | ||
|
|
1a453a7e5c | ||
|
|
2fcdc7d0df | ||
|
|
261e6b1bdb |
39
.devcontainer/devcontainer-lock.json
Normal file
39
.devcontainer/devcontainer-lock.json
Normal file
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"features": {
|
||||
"ghcr.io/devcontainers-extra/features/act": {
|
||||
"version": "1.0.15",
|
||||
"resolved": "ghcr.io/devcontainers-extra/features/act@sha256:db4a2194930d1f7ec62822d4f600dd2fa4aff3c33b98cdb0b578b64ffb10924c",
|
||||
"integrity": "sha256:db4a2194930d1f7ec62822d4f600dd2fa4aff3c33b98cdb0b578b64ffb10924c"
|
||||
},
|
||||
"ghcr.io/devcontainers-extra/features/bun": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "ghcr.io/devcontainers-extra/features/bun@sha256:0624284ecaead9dd4c6654616a7f939cfa4ebcbc60593700a74e35b1767befa5",
|
||||
"integrity": "sha256:0624284ecaead9dd4c6654616a7f939cfa4ebcbc60593700a74e35b1767befa5"
|
||||
},
|
||||
"ghcr.io/devcontainers/features/common-utils:2": {
|
||||
"version": "2.5.9",
|
||||
"resolved": "ghcr.io/devcontainers/features/common-utils@sha256:cb0c4d3c276f157eed17935747e364178d75fee17f55c4e129966f64633deb3a",
|
||||
"integrity": "sha256:cb0c4d3c276f157eed17935747e364178d75fee17f55c4e129966f64633deb3a"
|
||||
},
|
||||
"ghcr.io/devcontainers/features/docker-in-docker:2": {
|
||||
"version": "2.17.0",
|
||||
"resolved": "ghcr.io/devcontainers/features/docker-in-docker@sha256:25b9f05705ffba7dbe503230ac76081419306f8c8bc88e0ce78c4ecd99a0c78c",
|
||||
"integrity": "sha256:25b9f05705ffba7dbe503230ac76081419306f8c8bc88e0ce78c4ecd99a0c78c"
|
||||
},
|
||||
"ghcr.io/devcontainers/features/node:1": {
|
||||
"version": "1.7.1",
|
||||
"resolved": "ghcr.io/devcontainers/features/node@sha256:8c0de46939b61958041700ee89e3493f3b2e4131a06dc46b4d9423427d06e5f6",
|
||||
"integrity": "sha256:8c0de46939b61958041700ee89e3493f3b2e4131a06dc46b4d9423427d06e5f6"
|
||||
},
|
||||
"ghcr.io/devcontainers/features/rust:1": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "ghcr.io/devcontainers/features/rust@sha256:0c55e65f2e3df736e478f26ee4d5ed41bae6b54dac1318c443e31444c8ed283c",
|
||||
"integrity": "sha256:0c55e65f2e3df736e478f26ee4d5ed41bae6b54dac1318c443e31444c8ed283c"
|
||||
},
|
||||
"ghcr.io/guiyomh/features/just:0": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "ghcr.io/guiyomh/features/just@sha256:8311dff976bd153a54a879021353a7e149963e580022b25af49c45cfc5f13bec",
|
||||
"integrity": "sha256:8311dff976bd153a54a879021353a7e149963e580022b25af49c45cfc5f13bec"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -30,7 +30,8 @@
|
||||
"ghcr.io/guiyomh/features/just:0": {},
|
||||
"ghcr.io/devcontainers-extra/features/bun": {
|
||||
"version": "latest"
|
||||
}
|
||||
},
|
||||
"ghcr.io/devcontainers-extra/features/act": {}
|
||||
},
|
||||
|
||||
"customizations": {
|
||||
@@ -42,7 +43,14 @@
|
||||
"esbenp.prettier-vscode",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"ms-azuretools.vscode-docker",
|
||||
"nefrob.vscode-just-syntax"
|
||||
"nefrob.vscode-just-syntax",
|
||||
"zxh404.vscode-proto3",
|
||||
"mhutchie.git-graph",
|
||||
"qwtel.sqlite-viewer",
|
||||
"streetsidesoftware.code-spell-checker",
|
||||
"christian-kohler.npm-intellisense",
|
||||
"christian-kohler.path-intellisense",
|
||||
"redhat.vscode-yaml"
|
||||
],
|
||||
"settings": {
|
||||
"rust-analyzer.cargo.features": "all",
|
||||
|
||||
1
.github/.gitignore
vendored
Normal file
1
.github/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
*.env
|
||||
4
.github/.secrets.env.template
vendored
Normal file
4
.github/.secrets.env.template
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
# This is an example environment variable file for GitHub Actions. You can copy this file to .github/.secrets.env and fill in the values to override the default registry and GitHub token used in the CI workflow. This is useful for testing with a private registry or using a different GitHub account for authentication.
|
||||
OVERRIDE_REGISTRY=<your-registry-url>
|
||||
OVERRIDE_GITHUB_TOKEN=<your-github-token>
|
||||
GITHUB_USERNAME=<your-github-username>
|
||||
70
.github/actions/setup-ci-metadata/action.yaml
vendored
Normal file
70
.github/actions/setup-ci-metadata/action.yaml
vendored
Normal file
@@ -0,0 +1,70 @@
|
||||
name: 'Setup CI metadata'
|
||||
description: 'Composite action to derive the registry and CI image tag for the current repository.'
|
||||
inputs:
|
||||
registry:
|
||||
description: 'Container registry derived from the current GitHub server URL'
|
||||
required: false
|
||||
default: ''
|
||||
repository:
|
||||
description: 'GitHub repository in the format owner/repo'
|
||||
required: false
|
||||
default: ${{ github.repository }}
|
||||
image_tag:
|
||||
description: 'Tag for the CI image'
|
||||
required: false
|
||||
default: 'latest'
|
||||
outputs:
|
||||
registry:
|
||||
description: 'Container registry derived from the current GitHub server URL'
|
||||
value: ${{ steps.setup.outputs.registry }}
|
||||
image_tag:
|
||||
description: 'Fully qualified CI image tag'
|
||||
value: ${{ steps.setup.outputs.image_tag }}
|
||||
latest_tag:
|
||||
description: 'Fully qualified latest CI image tag'
|
||||
value: ${{ steps.setup.outputs.latest_tag }}
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Setup Dynamic Metadata
|
||||
id: setup
|
||||
shell: bash
|
||||
run: |
|
||||
# Extract the domain from server_url, handling both https:// and ssh:// schemes
|
||||
SERVER_URL="${{ github.server_url }}"
|
||||
|
||||
if [[ "$SERVER_URL" =~ ^ssh:// ]]; then
|
||||
# For SSH URLs like ssh://git@host:port/path, extract just the hostname
|
||||
SERVER_DOMAIN=$(echo "$SERVER_URL" | sed -e 's|^ssh://||' -e 's|^[^@]*@||' -e 's|:[0-9]*.*||')
|
||||
else
|
||||
# For HTTPS URLs, extract domain without scheme
|
||||
SERVER_DOMAIN=$(echo "$SERVER_URL" | sed -e 's|^[^/]*//||' -e 's|/.*$||')
|
||||
fi
|
||||
|
||||
echo "Extracted server domain: $SERVER_DOMAIN"
|
||||
|
||||
if [[ -n "${{ inputs.registry }}" ]]; then
|
||||
REGISTRY="${{ inputs.registry }}"
|
||||
elif [[ "$SERVER_DOMAIN" == "github.com" ]]; then
|
||||
REGISTRY="ghcr.io"
|
||||
else
|
||||
REGISTRY="$SERVER_DOMAIN"
|
||||
fi
|
||||
|
||||
# Extract owner/repo from github.repository, handling SSH URLs
|
||||
REPO="${{ inputs.repository }}"
|
||||
if [[ "$REPO" =~ ^ssh:// ]] || [[ "$REPO" =~ ^https:// ]]; then
|
||||
# Extract owner/repo from URLs like ssh://git@host/owner/repo.git or https://host/owner/repo.git
|
||||
REPO=$(echo "$REPO" | sed -e 's|^[^/]*/||' -e 's|\.git$||' | rev | cut -d'/' -f1,2 | rev)
|
||||
fi
|
||||
|
||||
# Docker image names must be lowercase
|
||||
REGISTRY="${REGISTRY,,}"
|
||||
REPO="${REPO,,}"
|
||||
|
||||
IMAGE_TAG="${REGISTRY}/${REPO}/ci:${{ inputs.image_tag }}"
|
||||
LATEST_TAG="${REGISTRY}/${REPO}/ci:latest"
|
||||
|
||||
echo "registry=$REGISTRY" >> "$GITHUB_OUTPUT"
|
||||
echo "image_tag=$IMAGE_TAG" >> "$GITHUB_OUTPUT"
|
||||
echo "latest_tag=$LATEST_TAG" >> "$GITHUB_OUTPUT"
|
||||
74
.github/actions/setup-rust/action.yaml
vendored
Normal file
74
.github/actions/setup-rust/action.yaml
vendored
Normal file
@@ -0,0 +1,74 @@
|
||||
name: 'Setup Rust environment'
|
||||
description: 'Composite action to checkout the repo, restore cargo caches and set up the Rust toolchain. Use this from job steps to keep setup DRY across jobs.'
|
||||
inputs:
|
||||
toolchain:
|
||||
description: 'Rust toolchain to install'
|
||||
required: false
|
||||
default: 'stable'
|
||||
override:
|
||||
description: 'Whether to override the default toolchain'
|
||||
required: false
|
||||
default: 'true'
|
||||
components:
|
||||
description: 'Comma-separated list of additional rust components to install'
|
||||
required: false
|
||||
default: 'clippy, rustfmt'
|
||||
skip_cache:
|
||||
description: 'Whether to skip restoring and uploading caches (useful for testing the workflow without cache interference)'
|
||||
required: false
|
||||
default: 'false'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Cache cargo registry
|
||||
uses: actions/cache@v4
|
||||
if: inputs.skip_cache != 'true'
|
||||
with:
|
||||
path: ~/.cargo/registry
|
||||
key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }}
|
||||
|
||||
- name: Cache cargo index
|
||||
uses: actions/cache@v4
|
||||
if: inputs.skip_cache != 'true'
|
||||
with:
|
||||
path: ~/.cargo/index
|
||||
key: ${{ runner.os }}-cargo-index-${{ hashFiles('**/Cargo.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-cargo-index-${{ hashFiles('**/Cargo.lock') }}
|
||||
|
||||
- name: Sanitize components input
|
||||
shell: bash
|
||||
run: echo "SANITIZED_COMPONENTS=${{ inputs.components }}" | sed -E 's/, ?| /-/g' >> $GITHUB_ENV
|
||||
|
||||
- name: Cache Rust toolchain
|
||||
uses: actions/cache@v3
|
||||
if: inputs.skip_cache != 'true'
|
||||
with:
|
||||
path: ~/.rustup
|
||||
# Key includes the OS and the toolchain version (e.g., 'stable')
|
||||
key: ${{ runner.os }}-rustup-${{ hashFiles('rust-toolchain.toml') }}-v1-${{ inputs.toolchain }}-${{ env.SANITIZED_COMPONENTS }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-rustup-
|
||||
|
||||
- name: Cache cargo build (target)
|
||||
uses: actions/cache@v3
|
||||
if: inputs.skip_cache != 'true'
|
||||
with:
|
||||
path: target
|
||||
key: ${{ runner.os }}-cargo-build-${{ hashFiles('**/Cargo.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-cargo-build-${{ hashFiles('**/Cargo.lock') }}
|
||||
|
||||
- name: Set up rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
toolchain: ${{ inputs.toolchain }}
|
||||
override: ${{ inputs.override }}
|
||||
components: ${{ inputs.components }}
|
||||
31
.github/docker/ci.Dockerfile
vendored
Normal file
31
.github/docker/ci.Dockerfile
vendored
Normal file
@@ -0,0 +1,31 @@
|
||||
FROM node:24-bookworm-slim
|
||||
|
||||
# Install necessary dependencies for building Rust projects and running tests
|
||||
RUN apt-get update && apt-get install -y \
|
||||
curl \
|
||||
git \
|
||||
zstd \
|
||||
build-essential \
|
||||
pkg-config \
|
||||
libssl-dev \
|
||||
gnupg \
|
||||
unzip \
|
||||
tar \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN apt-get update && apt-get install -y \
|
||||
postgresql-client \
|
||||
protobuf-compiler \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# install bun
|
||||
RUN curl -fsSL https://bun.sh/install | bash
|
||||
ENV PATH="/root/.bun/bin:${PATH}"
|
||||
|
||||
# install rust and cargo
|
||||
RUN apt-get update && apt-get install -y curl build-essential
|
||||
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
|
||||
ENV PATH="/root/.cargo/bin:${PATH}"
|
||||
|
||||
# Set the working directory
|
||||
WORKDIR /app
|
||||
54
.github/workflows/build-ci.yaml
vendored
Normal file
54
.github/workflows/build-ci.yaml
vendored
Normal file
@@ -0,0 +1,54 @@
|
||||
name: Build CI Environment
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
image_tag:
|
||||
description: 'Tag for the CI image (e.g., latest)'
|
||||
required: true
|
||||
default: 'latest'
|
||||
|
||||
env:
|
||||
# OVERRIDE_REGISTRY can be set as a secret to override the default registry (e.g., for testing with a private registry). Else '' will be used, which defaults to ghcr.io for github.com and the GitHub server domain for self-hosted GitHub instances.
|
||||
OVERRIDE_REGISTRY: ${{ secrets.OVERRIDE_REGISTRY }}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
concurrency:
|
||||
group: build-ci
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build-ci-image:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup CI metadata
|
||||
id: setup
|
||||
uses: ./.github/actions/setup-ci-metadata
|
||||
with:
|
||||
registry: ${{ env.OVERRIDE_REGISTRY }}
|
||||
image_tag: ${{ github.event.inputs.image_tag }}
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ${{ steps.setup.outputs.registry }}
|
||||
username: ${{ secrets.GITHUB_USERNAME || github.actor }}
|
||||
password: ${{ secrets.OVERRIDE_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push Docker image for CI
|
||||
uses: docker/build-push-action@v3
|
||||
with:
|
||||
context: .
|
||||
file: .github/docker/ci.Dockerfile
|
||||
push: true
|
||||
tags: |
|
||||
${{ steps.setup.outputs.image_tag }}
|
||||
${{ steps.setup.outputs.latest_tag }}
|
||||
200
.github/workflows/test.yaml
vendored
Normal file
200
.github/workflows/test.yaml
vendored
Normal file
@@ -0,0 +1,200 @@
|
||||
# this workflow runs tests on pull request and push events targeting master branch
|
||||
# it also verify the generated code is up to date and valid
|
||||
|
||||
name: Test
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
|
||||
jobs:
|
||||
get-ci-image:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
image_tag: ${{ steps.setup.outputs.image_tag }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup CI metadata
|
||||
id: setup
|
||||
uses: ./.github/actions/setup-ci-metadata
|
||||
with:
|
||||
registry: ${{ secrets.OVERRIDE_REGISTRY }}
|
||||
image_tag: latest
|
||||
|
||||
test-crates:
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- frontend-build
|
||||
- get-ci-image
|
||||
container:
|
||||
image: ${{ needs.get-ci-image.outputs.image_tag }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Rust, checkout and restore caches
|
||||
uses: ./.github/actions/setup-rust
|
||||
|
||||
- name: Restore frontend build cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: apps/nxmesh-frontend/dist
|
||||
key: frontend-build-${{ runner.os }}-run-${{ github.run_id }}
|
||||
restore-keys: |
|
||||
frontend-build-${{ runner.os }}-
|
||||
|
||||
# TODO: uncomment until artifact hanlding fixed in gitea
|
||||
# - name: Download frontend build artifact
|
||||
# uses: actions/download-artifact@v4
|
||||
# with:
|
||||
# name: frontend-dist
|
||||
# path: apps/nxmesh-frontend/dist
|
||||
|
||||
# - name: Copy frontend build to expected location
|
||||
# run: |
|
||||
# # unlink frontend-dist
|
||||
# rm -f apps/nxmesh-master/frontend-dist || true
|
||||
# rm -rf apps/nxmesh-master/frontend-dist || true
|
||||
# cp -r apps/nxmesh-frontend/dist apps/nxmesh-master/frontend-dist
|
||||
|
||||
# ls -la apps/nxmesh-master/frontend-dist
|
||||
|
||||
- name: Create dummy build artifacts
|
||||
run: |
|
||||
rm -f apps/nxmesh-master/frontend-dist || true
|
||||
mkdir -p apps/nxmesh-master/frontend-dist
|
||||
echo "<html><body><h1>Dummy Build</h1></body></html>" > apps/nxmesh-master/frontend-dist/index.html
|
||||
|
||||
- name: Run tests
|
||||
run: cargo test --all-features -- --show-output
|
||||
|
||||
lint-crates:
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- frontend-build
|
||||
- get-ci-image
|
||||
container:
|
||||
image: ${{ needs.get-ci-image.outputs.image_tag }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Rust, checkout and restore caches
|
||||
uses: ./.github/actions/setup-rust
|
||||
with:
|
||||
components: clippy, rustfmt
|
||||
|
||||
- name: Restore frontend build cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: apps/nxmesh-frontend/dist
|
||||
key: frontend-build-${{ runner.os }}-run-${{ github.run_id }}
|
||||
restore-keys: |
|
||||
frontend-build-${{ runner.os }}-
|
||||
|
||||
# TODO: uncomment until artifact hanlding fixed in gitea
|
||||
# - name: Download frontend build artifact
|
||||
# uses: actions/download-artifact@v4
|
||||
# with:
|
||||
# name: frontend-dist
|
||||
# path: apps/nxmesh-frontend/dist
|
||||
|
||||
# - name: Copy frontend build to expected location
|
||||
# run: |
|
||||
# # unlink frontend-dist
|
||||
# rm -f apps/nxmesh-master/frontend-dist || true
|
||||
# rm -rf apps/nxmesh-master/frontend-dist || true
|
||||
# cp -r apps/nxmesh-frontend/dist apps/nxmesh-master/frontend-dist
|
||||
|
||||
- name: Create dummy build artifacts
|
||||
run: |
|
||||
rm -f apps/nxmesh-master/frontend-dist || true
|
||||
mkdir -p apps/nxmesh-master/frontend-dist
|
||||
echo "<html><body><h1>Dummy Build</h1></body></html>" > apps/nxmesh-master/frontend-dist/index.html
|
||||
|
||||
- name: Run clippy
|
||||
run: cargo clippy --all-features
|
||||
|
||||
- name: Check code formatting
|
||||
run: cargo fmt --all -- --check
|
||||
|
||||
lint-frontend:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
name: Install Bun
|
||||
|
||||
- name: Install frontend dependencies
|
||||
run: |
|
||||
cd apps/nxmesh-frontend
|
||||
bun install
|
||||
|
||||
- name: Run frontend linter
|
||||
run: |
|
||||
cd apps/nxmesh-frontend
|
||||
bun run lint
|
||||
|
||||
test-frontend:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
name: Install Bun
|
||||
|
||||
- name: Install frontend dependencies
|
||||
run: |
|
||||
cd apps/nxmesh-frontend
|
||||
bun install
|
||||
|
||||
- name: Run frontend tests
|
||||
run: |
|
||||
cd apps/nxmesh-frontend
|
||||
bun run test
|
||||
|
||||
frontend-build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
name: Install Bun
|
||||
|
||||
- name: Install frontend dependencies
|
||||
run: |
|
||||
cd apps/nxmesh-frontend
|
||||
bun install
|
||||
|
||||
- name: Build frontend
|
||||
run: |
|
||||
cd apps/nxmesh-frontend
|
||||
bun run build
|
||||
|
||||
- name: Cache frontend build
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: apps/nxmesh-frontend/dist
|
||||
key: frontend-build-${{ runner.os }}-run-${{ github.run_id }}
|
||||
# IGNORE restore-keys
|
||||
# TODO: uncomment until artifact hanlding fixed in gitea
|
||||
# - name: Upload frontend build artifact
|
||||
# uses: actions/upload-artifact@v4
|
||||
# with:
|
||||
# name: frontend-dist
|
||||
# path: apps/nxmesh-frontend/dist
|
||||
139
.github/workflows/verify.yaml
vendored
Normal file
139
.github/workflows/verify.yaml
vendored
Normal file
@@ -0,0 +1,139 @@
|
||||
# this workflow verifies the generated code is up to date and valid
|
||||
|
||||
name: Verify
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
|
||||
env:
|
||||
# OVERRIDE_REGISTRY can be set as a secret to override the default registry (e.g., for testing with a private registry). Else '' will be used, which defaults to ghcr.io for github.com and the GitHub server domain for self-hosted GitHub instances.
|
||||
OVERRIDE_REGISTRY: ${{ secrets.OVERRIDE_REGISTRY }}
|
||||
ACTIONS_STEP_DEBUG: true
|
||||
|
||||
jobs:
|
||||
get-ci-image:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
image_tag: ${{ steps.setup.outputs.image_tag }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup CI metadata
|
||||
id: setup
|
||||
uses: ./.github/actions/setup-ci-metadata
|
||||
with:
|
||||
registry: ${{ secrets.OVERRIDE_REGISTRY }}
|
||||
image_tag: latest
|
||||
|
||||
verify-generated-db-entities:
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- get-ci-image
|
||||
container:
|
||||
image: ${{ needs.get-ci-image.outputs.image_tag }}
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16
|
||||
env:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: nxmesh
|
||||
# ! do not set a fixed port to avoid conflicts when running multiple jobs in parallel, use Docker's internal networking instead
|
||||
# ports:
|
||||
# - 5432:5432
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U postgres -d nxmesh"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
|
||||
env:
|
||||
DATABASE_URL: postgres://postgres:postgres@postgres:5432/nxmesh
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Check whether migrations/entities changed
|
||||
id: check_changes
|
||||
shell: bash
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "pull_request" ]; then
|
||||
BASE_SHA=${{ github.event.pull_request.base.sha }}
|
||||
HEAD_SHA=${{ github.event.pull_request.head.sha }}
|
||||
else
|
||||
BASE_SHA=${{ github.event.before }}
|
||||
HEAD_SHA=${{ github.sha }}
|
||||
fi
|
||||
|
||||
if [ -z "$HEAD_SHA" ]; then
|
||||
HEAD_SHA=$(git rev-parse --verify HEAD 2>/dev/null || echo "")
|
||||
fi
|
||||
|
||||
if [ -z "$BASE_SHA" ]; then
|
||||
PREV=$(git rev-parse --verify "${HEAD_SHA}^" 2>/dev/null || true)
|
||||
if [ -n "$PREV" ]; then
|
||||
BASE_SHA=$PREV
|
||||
else
|
||||
BASE_SHA=$HEAD_SHA
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "Comparing $BASE_SHA..$HEAD_SHA"
|
||||
CHANGED_FILES=$(git diff --name-only "$BASE_SHA" "$HEAD_SHA" || true)
|
||||
echo "$CHANGED_FILES"
|
||||
|
||||
echo "$CHANGED_FILES" | grep -E '^(crates/migration/src/|apps/nxmesh-master/src/db/entities/)' >/dev/null 2>&1 \
|
||||
&& echo "changed=true" >> $GITHUB_OUTPUT \
|
||||
|| echo "changed=true" >> $GITHUB_OUTPUT
|
||||
# || echo "changed=false" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Setup Rust, checkout and restore caches
|
||||
if: steps.check_changes.outputs.changed == 'true'
|
||||
uses: ./.github/actions/setup-rust
|
||||
with:
|
||||
skip_cache: ${{ vars.SKIP_CACHE }}
|
||||
|
||||
- name: Install SeaORM CLI
|
||||
if: steps.check_changes.outputs.changed == 'true'
|
||||
run: |
|
||||
cargo install sea-orm-cli@^2.0.0-rc --features "sqlx-postgres runtime-tokio-rustls"
|
||||
|
||||
- name: Apply migrations
|
||||
if: steps.check_changes.outputs.changed == 'true'
|
||||
run: |
|
||||
cd crates && sea-orm-cli migrate up
|
||||
|
||||
- name: Regenerate entities
|
||||
if: steps.check_changes.outputs.changed == 'true'
|
||||
run: |
|
||||
cd crates && sea-orm-cli generate entity \
|
||||
--database-url "$DATABASE_URL" \
|
||||
--output-dir ../apps/nxmesh-master/src/db/entities \
|
||||
--with-serde both \
|
||||
--with-copy-enums \
|
||||
--date-time-crate chrono
|
||||
|
||||
- name: Check for uncommitted changes in entities
|
||||
if: steps.check_changes.outputs.changed == 'true'
|
||||
shell: bash
|
||||
run: |
|
||||
if [[ -n $(git status --porcelain --untracked-files=all | grep 'apps/nxmesh-master/src/db/entities/') ]]; then
|
||||
echo "Generated SeaORM entities are not up to date."
|
||||
echo "Run 'just db-generate' after applying migrations and commit the result."
|
||||
git status --porcelain --untracked-files=all | grep 'apps/nxmesh-master/src/db/entities/'
|
||||
exit 1
|
||||
else
|
||||
echo "Generated SeaORM entities are up to date."
|
||||
fi
|
||||
|
||||
- name: Skip entity generation (no relevant changes)
|
||||
if: steps.check_changes.outputs.changed == 'false'
|
||||
run: echo "No changes in migrations/entities, skipping SeaORM entity verification."
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -68,6 +68,7 @@ web_modules/
|
||||
# dotenv environment variable files
|
||||
.env
|
||||
.env.*
|
||||
*.env
|
||||
!.env.example
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
@@ -160,3 +161,5 @@ target
|
||||
**/mutants.out*/
|
||||
|
||||
.local/
|
||||
|
||||
certs/
|
||||
|
||||
766
Cargo.lock
generated
766
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -37,7 +37,9 @@ tower-http = { version = "0.6.8", features = ["trace", "cors", "fs"] }
|
||||
|
||||
# gRPC
|
||||
tonic = "0.14"
|
||||
tonic-prost = "0.14"
|
||||
prost = "0.14"
|
||||
tonic-async-interceptor = "0.14"
|
||||
|
||||
# Database
|
||||
sea-orm = { version = "2.0.0-rc", features = [
|
||||
@@ -54,9 +56,6 @@ futures = "0.3"
|
||||
toml = "0.9"
|
||||
config = "0.15"
|
||||
|
||||
# HTTP client
|
||||
reqwest = { version = "0.13.2", default-features = false, features = ["json"] }
|
||||
|
||||
# Crypto
|
||||
sha2 = "0.10"
|
||||
hex = "0.4"
|
||||
@@ -75,6 +74,9 @@ uuid = { version = "1", features = ["v4", "serde"] }
|
||||
# Templating
|
||||
handlebars = "6.4"
|
||||
|
||||
# Cert handling
|
||||
zip = { version = "8.2.0" }
|
||||
|
||||
# CLI
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
|
||||
|
||||
@@ -29,13 +29,11 @@ tracing-subscriber.workspace = true
|
||||
# gRPC
|
||||
tonic.workspace = true
|
||||
|
||||
# HTTP
|
||||
reqwest.workspace = true
|
||||
|
||||
# Async
|
||||
async-trait.workspace = true
|
||||
futures.workspace = true
|
||||
tokio-stream.workspace = true
|
||||
tokio-util = "0.7"
|
||||
|
||||
# Config
|
||||
config.workspace = true
|
||||
@@ -54,6 +52,15 @@ uuid.workspace = true
|
||||
# Hostname
|
||||
hostname = "0.4"
|
||||
|
||||
# Certificates
|
||||
zip = { workspace = true }
|
||||
|
||||
# CLI
|
||||
clap = { workspace = true, features = ["derive"] }
|
||||
anyhow = { version = "1.0.102", features = ["backtrace"] }
|
||||
fs4 = { version = "0.13.1", features = ["tokio"] }
|
||||
dashmap = "6.2.1"
|
||||
|
||||
[dev-dependencies]
|
||||
tokio-test.workspace = true
|
||||
mockall.workspace = true
|
||||
|
||||
119
apps/nxmesh-agent/src/cli/import_certs.rs
Normal file
119
apps/nxmesh-agent/src/cli/import_certs.rs
Normal file
@@ -0,0 +1,119 @@
|
||||
use clap::Parser;
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(about = "Import certificates for agent from zip file or separate cert and key files")]
|
||||
pub struct ImportCertsCommand {
|
||||
/// Zip file containing ca.pem cert.pem and key.pem
|
||||
#[arg(value_name = "ZIP_FILE", group = "input_source")]
|
||||
zip: Option<String>,
|
||||
/// Certificate name in zip file, required if using zip input
|
||||
#[arg(
|
||||
long,
|
||||
group = "input_source",
|
||||
requires = "zip",
|
||||
default_value = "cert.pem",
|
||||
value_name = "CERT_NAME"
|
||||
)]
|
||||
cert_name: Option<String>,
|
||||
/// Key name in zip file, required if using zip input
|
||||
#[arg(
|
||||
long,
|
||||
group = "input_source",
|
||||
requires = "zip",
|
||||
default_value = "key.pem",
|
||||
value_name = "KEY_NAME"
|
||||
)]
|
||||
key_name: Option<String>,
|
||||
/// CA certificate name in zip file, required if using zip input
|
||||
#[arg(
|
||||
long,
|
||||
group = "input_source",
|
||||
requires = "zip",
|
||||
default_value = "ca.pem",
|
||||
value_name = "CA_NAME"
|
||||
)]
|
||||
ca_name: Option<String>,
|
||||
|
||||
// Separate cert and key file inputs, required if not using zip input
|
||||
/// Certificate file path
|
||||
#[arg(
|
||||
long,
|
||||
group = "input_source",
|
||||
requires = "key",
|
||||
conflicts_with = "zip",
|
||||
value_name = "CERT_FILE"
|
||||
)]
|
||||
cert: Option<String>,
|
||||
|
||||
/// Key file path
|
||||
#[arg(
|
||||
long,
|
||||
group = "input_source",
|
||||
requires = "cert",
|
||||
conflicts_with = "zip",
|
||||
value_name = "KEY_FILE"
|
||||
)]
|
||||
key: Option<String>,
|
||||
|
||||
/// Master CA certificate file path for verifying master identity, optional if the CA certificate is already trusted by the system
|
||||
/// This is required if the master server uses a self-signed certificate that is not trusted by the system
|
||||
#[arg(
|
||||
long,
|
||||
group = "input_source",
|
||||
conflicts_with = "zip",
|
||||
value_name = "CA_CERT_FILE"
|
||||
)]
|
||||
ca_cert: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_import_certs_with_zip_defaults() {
|
||||
let parsed = ImportCertsCommand::try_parse_from(["import-certs", "bundle.zip"]);
|
||||
assert!(parsed.is_ok());
|
||||
|
||||
let parsed = parsed.ok();
|
||||
assert!(parsed.is_some());
|
||||
let parsed = parsed.unwrap_or_else(|| unreachable!());
|
||||
|
||||
assert_eq!(parsed.zip.as_deref(), Some("bundle.zip"));
|
||||
assert_eq!(parsed.cert_name.as_deref(), Some("cert.pem"));
|
||||
assert_eq!(parsed.key_name.as_deref(), Some("key.pem"));
|
||||
assert_eq!(parsed.ca_name.as_deref(), Some("ca.pem"));
|
||||
assert!(parsed.cert.is_none());
|
||||
assert!(parsed.key.is_none());
|
||||
assert!(parsed.ca_cert.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_import_certs_with_separate_files() {
|
||||
let parsed = ImportCertsCommand::try_parse_from([
|
||||
"import-certs",
|
||||
"--cert",
|
||||
"agent.crt",
|
||||
"--key",
|
||||
"agent.key",
|
||||
"--ca-cert",
|
||||
"ca.crt",
|
||||
]);
|
||||
assert!(parsed.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_conflicting_zip_and_separate_inputs() {
|
||||
let parsed = ImportCertsCommand::try_parse_from([
|
||||
"import-certs",
|
||||
"bundle.zip",
|
||||
"--cert",
|
||||
"agent.crt",
|
||||
"--key",
|
||||
"agent.key",
|
||||
]);
|
||||
|
||||
assert!(parsed.is_err());
|
||||
}
|
||||
}
|
||||
39
apps/nxmesh-agent/src/cli/mod.rs
Normal file
39
apps/nxmesh-agent/src/cli/mod.rs
Normal file
@@ -0,0 +1,39 @@
|
||||
use clap::{Parser, Subcommand};
|
||||
|
||||
pub mod import_certs;
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(version, about, long_about = None)]
|
||||
pub struct Cli {
|
||||
/// Start the agent server
|
||||
#[arg(short, long, group = "mode")]
|
||||
pub serve: bool,
|
||||
|
||||
#[command(subcommand)]
|
||||
pub command: Option<Commands>,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub enum Commands {
|
||||
ImportCerts(import_certs::ImportCertsCommand),
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use clap::Parser;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_serve_flag_without_subcommand() {
|
||||
let parsed = Cli::try_parse_from(["nxmesh-agent", "--serve"]);
|
||||
assert!(parsed.is_ok());
|
||||
|
||||
let parsed = parsed.ok();
|
||||
assert!(parsed.is_some());
|
||||
let parsed = parsed.unwrap_or_else(|| unreachable!());
|
||||
|
||||
assert!(parsed.serve);
|
||||
assert!(parsed.command.is_none());
|
||||
}
|
||||
}
|
||||
1
apps/nxmesh-agent/src/config/mod.rs
Normal file
1
apps/nxmesh-agent/src/config/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub mod settings;
|
||||
166
apps/nxmesh-agent/src/config/settings/auth.rs
Normal file
166
apps/nxmesh-agent/src/config/settings/auth.rs
Normal file
@@ -0,0 +1,166 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::config::settings::{Validate, ValidationError};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum MAuthSettings {
|
||||
Tls(TLSSettings),
|
||||
}
|
||||
|
||||
/// TLS certificate settings
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum TLSSettings {
|
||||
RawPath {
|
||||
ca_path: String,
|
||||
cert_path: String,
|
||||
key_path: String,
|
||||
},
|
||||
ZipPath {
|
||||
cert_zip_path: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl Validate for MAuthSettings {
|
||||
fn validate(&self) -> Result<(), ValidationError> {
|
||||
match self {
|
||||
MAuthSettings::Tls(tls_settings) => tls_settings.validate()?,
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Validate for TLSSettings {
|
||||
fn validate(&self) -> Result<(), ValidationError> {
|
||||
match self {
|
||||
TLSSettings::RawPath {
|
||||
ca_path,
|
||||
cert_path,
|
||||
key_path,
|
||||
} => {
|
||||
if !std::path::Path::new(ca_path).exists() {
|
||||
return Err(format!("CA file not found: {}", ca_path));
|
||||
}
|
||||
if !std::path::Path::new(cert_path).exists() {
|
||||
return Err(format!("Certificate file not found: {}", cert_path));
|
||||
}
|
||||
if !std::path::Path::new(key_path).exists() {
|
||||
return Err(format!("Key file not found: {}", key_path));
|
||||
}
|
||||
}
|
||||
TLSSettings::ZipPath { cert_zip_path } => {
|
||||
if !std::path::Path::new(cert_zip_path).exists() {
|
||||
return Err(format!("Certificate zip file not found: {}", cert_zip_path));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::{
|
||||
fs,
|
||||
os::unix::fs::PermissionsExt,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use tempfile::TempDir;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_esnure_send_and_sync() {
|
||||
fn assert_send_sync<T: Send + Sync>() {}
|
||||
assert_send_sync::<TLSSettings>();
|
||||
}
|
||||
|
||||
fn write_file(path: &Path) {
|
||||
let result = fs::write(path, b"content");
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
fn create_exec_file(path: &Path) {
|
||||
write_file(path);
|
||||
let metadata = fs::metadata(path);
|
||||
assert!(metadata.is_ok());
|
||||
let metadata = metadata.ok();
|
||||
assert!(metadata.is_some());
|
||||
let metadata = metadata.unwrap_or_else(|| unreachable!());
|
||||
|
||||
let mut perms = metadata.permissions();
|
||||
perms.set_mode(0o755);
|
||||
let result = fs::set_permissions(path, perms);
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
fn create_non_exec_file(path: &Path) {
|
||||
write_file(path);
|
||||
let metadata = fs::metadata(path);
|
||||
assert!(metadata.is_ok());
|
||||
let metadata = metadata.ok();
|
||||
assert!(metadata.is_some());
|
||||
let metadata = metadata.unwrap_or_else(|| unreachable!());
|
||||
|
||||
let mut perms = metadata.permissions();
|
||||
perms.set_mode(0o644);
|
||||
let result = fs::set_permissions(path, perms);
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
fn valid_tls_raw_paths(temp_dir: &TempDir) -> (PathBuf, PathBuf, PathBuf) {
|
||||
let ca_path = temp_dir.path().join("ca.pem");
|
||||
let cert_path = temp_dir.path().join("cert.pem");
|
||||
let key_path = temp_dir.path().join("key.pem");
|
||||
|
||||
write_file(&ca_path);
|
||||
write_file(&cert_path);
|
||||
write_file(&key_path);
|
||||
|
||||
(ca_path, cert_path, key_path)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tls_raw_path_validate_succeeds_when_all_files_exist() {
|
||||
let temp_dir = TempDir::new();
|
||||
assert!(temp_dir.is_ok());
|
||||
let temp_dir = temp_dir.ok();
|
||||
assert!(temp_dir.is_some());
|
||||
let temp_dir = temp_dir.unwrap_or_else(|| unreachable!());
|
||||
|
||||
let (ca_path, cert_path, key_path) = valid_tls_raw_paths(&temp_dir);
|
||||
let settings = TLSSettings::RawPath {
|
||||
ca_path: ca_path.to_string_lossy().to_string(),
|
||||
cert_path: cert_path.to_string_lossy().to_string(),
|
||||
key_path: key_path.to_string_lossy().to_string(),
|
||||
};
|
||||
|
||||
assert!(settings.validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tls_raw_path_validate_fails_when_ca_missing() {
|
||||
let settings = TLSSettings::RawPath {
|
||||
ca_path: "/tmp/does-not-exist-ca.pem".into(),
|
||||
cert_path: "/tmp/does-not-exist-cert.pem".into(),
|
||||
key_path: "/tmp/does-not-exist-key.pem".into(),
|
||||
};
|
||||
|
||||
let result = settings.validate();
|
||||
assert!(result.is_err());
|
||||
let msg = result.err().unwrap_or_else(|| unreachable!());
|
||||
assert!(msg.contains("CA file not found"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tls_zip_path_validate_fails_when_zip_missing() {
|
||||
let settings = TLSSettings::ZipPath {
|
||||
cert_zip_path: "/tmp/missing-certs.zip".into(),
|
||||
};
|
||||
|
||||
let result = settings.validate();
|
||||
assert!(result.is_err());
|
||||
let msg = result.err().unwrap_or_else(|| unreachable!());
|
||||
assert!(msg.contains("Certificate zip file not found"));
|
||||
}
|
||||
}
|
||||
34
apps/nxmesh-agent/src/config/settings/cors.rs
Normal file
34
apps/nxmesh-agent/src/config/settings/cors.rs
Normal file
@@ -0,0 +1,34 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::config::settings::{Validate, ValidationError};
|
||||
|
||||
/// CORS settings
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct CorsSettings {
|
||||
#[serde(default)]
|
||||
pub allowed_origins: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub allowed_methods: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub allowed_headers: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub allow_credentials: bool,
|
||||
}
|
||||
|
||||
impl Validate for CorsSettings {
|
||||
fn validate(&self) -> Result<(), ValidationError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_esnure_send_and_sync() {
|
||||
fn assert_send_sync<T: Send + Sync>() {}
|
||||
assert_send_sync::<CorsSettings>();
|
||||
}
|
||||
}
|
||||
56
apps/nxmesh-agent/src/config/settings/grpc.rs
Normal file
56
apps/nxmesh-agent/src/config/settings/grpc.rs
Normal file
@@ -0,0 +1,56 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::super::settings::{Validate, ValidationError};
|
||||
use super::{auth::MAuthSettings, cors::CorsSettings};
|
||||
|
||||
/// gRPC client settings
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct GrpcSettings {
|
||||
pub connection_string: String,
|
||||
pub m_auth: MAuthSettings,
|
||||
#[serde(default)]
|
||||
pub cors: Option<CorsSettings>,
|
||||
}
|
||||
|
||||
impl Validate for GrpcSettings {
|
||||
fn validate(&self) -> Result<(), ValidationError> {
|
||||
if self.connection_string.is_empty() {
|
||||
return Err("gRPC connection string cannot be empty".into());
|
||||
}
|
||||
self.m_auth.validate()?;
|
||||
if let Some(cors) = &self.cors {
|
||||
cors.validate()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
use crate::config::settings::TLSSettings;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_esnure_send_and_sync() {
|
||||
fn assert_send_sync<T: Send + Sync>() {}
|
||||
assert_send_sync::<GrpcSettings>();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grpc_validate_fails_when_connection_string_empty() {
|
||||
let settings = GrpcSettings {
|
||||
connection_string: "".into(),
|
||||
m_auth: MAuthSettings::Tls(TLSSettings::ZipPath {
|
||||
cert_zip_path: "/tmp/does-not-exist.zip".into(),
|
||||
}),
|
||||
cors: None,
|
||||
};
|
||||
|
||||
let result = settings.validate();
|
||||
assert!(result.is_err());
|
||||
let msg = result.err().unwrap_or_else(|| unreachable!());
|
||||
assert!(msg.contains("gRPC connection string cannot be empty"));
|
||||
}
|
||||
}
|
||||
82
apps/nxmesh-agent/src/config/settings/log.rs
Normal file
82
apps/nxmesh-agent/src/config/settings/log.rs
Normal file
@@ -0,0 +1,82 @@
|
||||
use std::str::FromStr;
|
||||
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
use tracing::level_filters::LevelFilter;
|
||||
|
||||
/// Logging settings
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LogSettings {
|
||||
#[serde(
|
||||
deserialize_with = "deserialize_level_filter",
|
||||
serialize_with = "serialize_level_filter"
|
||||
)]
|
||||
pub level: LevelFilter,
|
||||
}
|
||||
|
||||
impl Default for LogSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
level: default_log_level(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn default_log_level() -> LevelFilter {
|
||||
LevelFilter::INFO
|
||||
}
|
||||
|
||||
fn deserialize_level_filter<'de, D>(deserializer: D) -> Result<LevelFilter, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let s = String::deserialize(deserializer)?;
|
||||
LevelFilter::from_str(&s).map_err(serde::de::Error::custom)
|
||||
}
|
||||
|
||||
fn serialize_level_filter<S>(level: &LevelFilter, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: serde::Serializer,
|
||||
{
|
||||
serializer.serialize_str(&level.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_esnure_send_and_sync() {
|
||||
fn assert_send_sync<T: Send + Sync>() {}
|
||||
assert_send_sync::<LogSettings>();
|
||||
}
|
||||
#[test]
|
||||
fn level_filter_round_trip_serialization() {
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct Wrapper {
|
||||
#[serde(
|
||||
deserialize_with = "deserialize_level_filter",
|
||||
serialize_with = "serialize_level_filter"
|
||||
)]
|
||||
level: LevelFilter,
|
||||
}
|
||||
|
||||
let original = Wrapper {
|
||||
level: LevelFilter::DEBUG,
|
||||
};
|
||||
|
||||
let encoded = serde_json::to_string(&original);
|
||||
assert!(encoded.is_ok());
|
||||
let encoded = encoded.ok();
|
||||
assert!(encoded.is_some());
|
||||
let encoded = encoded.unwrap_or_else(|| unreachable!());
|
||||
assert!(encoded.to_lowercase().contains("debug"));
|
||||
|
||||
let decoded = serde_json::from_str::<Wrapper>(&encoded);
|
||||
assert!(decoded.is_ok());
|
||||
let decoded = decoded.ok();
|
||||
assert!(decoded.is_some());
|
||||
let decoded = decoded.unwrap_or_else(|| unreachable!());
|
||||
assert_eq!(decoded.level, LevelFilter::DEBUG);
|
||||
}
|
||||
}
|
||||
76
apps/nxmesh-agent/src/config/settings/mod.rs
Normal file
76
apps/nxmesh-agent/src/config/settings/mod.rs
Normal file
@@ -0,0 +1,76 @@
|
||||
use config::{Config, ConfigError, Environment, File};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
mod auth;
|
||||
mod cors;
|
||||
mod grpc;
|
||||
mod log;
|
||||
mod nginx;
|
||||
|
||||
pub use auth::*;
|
||||
pub use cors::*;
|
||||
pub use grpc::*;
|
||||
pub use log::*;
|
||||
pub use nginx::*;
|
||||
|
||||
pub type ValidationError = String;
|
||||
|
||||
pub trait Validate {
|
||||
fn validate(&self) -> Result<(), ValidationError>;
|
||||
}
|
||||
|
||||
/// Agent settings
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Settings {
|
||||
pub agent_id: String,
|
||||
|
||||
pub grpc: GrpcSettings,
|
||||
#[serde(default)]
|
||||
pub log: LogSettings,
|
||||
#[serde(default)]
|
||||
pub nginx: NginxSettings,
|
||||
}
|
||||
|
||||
impl Validate for Settings {
|
||||
fn validate(&self) -> Result<(), ValidationError> {
|
||||
self.grpc.validate()?;
|
||||
self.nginx.validate()?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Settings {
|
||||
/// Load settings from config files and environment
|
||||
pub fn load() -> Result<Self, ConfigError> {
|
||||
let run_mode = std::env::var("RUN_MODE").unwrap_or_else(|_| "development".into());
|
||||
|
||||
let settings = Config::builder()
|
||||
.add_source(File::with_name("config/default").required(false))
|
||||
.add_source(File::with_name(&format!("config/{}", run_mode)).required(false))
|
||||
.add_source(File::with_name("config/agent/default").required(false))
|
||||
.add_source(File::with_name(&format!("config/agent/{}", run_mode)).required(false))
|
||||
.add_source(Environment::with_prefix("NXMESH").separator("__"))
|
||||
.build()?;
|
||||
|
||||
let mut settings: Self = settings.try_deserialize()?;
|
||||
|
||||
settings.validate().map_err(ConfigError::Message)?;
|
||||
|
||||
settings.nginx.validate().map_err(ConfigError::Message)?;
|
||||
settings.nginx.transform_commands();
|
||||
|
||||
Ok(settings)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_ensure_send_and_sync() {
|
||||
fn assert_send_sync<T: Send + Sync>() {}
|
||||
assert_send_sync::<Settings>();
|
||||
}
|
||||
}
|
||||
280
apps/nxmesh-agent/src/config/settings/nginx.rs
Normal file
280
apps/nxmesh-agent/src/config/settings/nginx.rs
Normal file
@@ -0,0 +1,280 @@
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::config::settings::{Validate, ValidationError};
|
||||
|
||||
const NGINX_BINARY_PATH_TEMPLATE: &str = "{{nginx_binary_path}}";
|
||||
const NGINX_DEFAULT_BINARY: &str = "nginx";
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct NginxSettings {
|
||||
#[serde(default = "default_nginx_config_path")]
|
||||
pub nginx_config_path: String,
|
||||
// #[serde(default = "default_nginx_binary_path")]
|
||||
#[serde(default)]
|
||||
pub nginx_binary_path: Option<String>,
|
||||
// commands
|
||||
#[serde(default = "default_nginx_reload_command")]
|
||||
pub override_nginx_reload_command: Vec<String>,
|
||||
#[serde(default = "default_nginx_test_command")]
|
||||
pub override_nginx_test_command: Vec<String>,
|
||||
// timeouts
|
||||
#[serde(default = "default_nginx_reload_timeout_seconds")]
|
||||
pub nginx_reload_timeout_seconds: u64,
|
||||
#[serde(default = "default_nginx_test_timeout_seconds")]
|
||||
pub nginx_test_timeout_seconds: u64,
|
||||
}
|
||||
|
||||
impl NginxSettings {
|
||||
/// Transforms the reload and test commands by replacing the binary path template with the actual binary path if provided.
|
||||
/// This MUST be called after validation to ensure the binary path is valid and the commands contain the template.
|
||||
pub fn transform_commands(&mut self) {
|
||||
self.override_nginx_reload_command = self.transformed_reload_command();
|
||||
self.override_nginx_test_command = self.transformed_test_command();
|
||||
}
|
||||
|
||||
fn transformed_reload_command(&self) -> Vec<String> {
|
||||
self.override_nginx_reload_command
|
||||
.iter()
|
||||
.map(|cmd| {
|
||||
cmd.replace(
|
||||
NGINX_BINARY_PATH_TEMPLATE,
|
||||
&self
|
||||
.nginx_binary_path
|
||||
.clone()
|
||||
.unwrap_or_else(|| NGINX_DEFAULT_BINARY.into()),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn transformed_test_command(&self) -> Vec<String> {
|
||||
self.override_nginx_test_command
|
||||
.iter()
|
||||
.map(|cmd| {
|
||||
cmd.replace(
|
||||
NGINX_BINARY_PATH_TEMPLATE,
|
||||
&self
|
||||
.nginx_binary_path
|
||||
.clone()
|
||||
.unwrap_or_else(|| NGINX_DEFAULT_BINARY.into()),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl Validate for NginxSettings {
|
||||
fn validate(&self) -> Result<(), ValidationError> {
|
||||
match &self.nginx_binary_path {
|
||||
Some(path) if path.is_empty() => {
|
||||
return Err("Nginx binary path cannot be empty".into());
|
||||
}
|
||||
Some(path) if !std::path::Path::new(path).exists() => {
|
||||
return Err(format!("Nginx binary not found: {}", path));
|
||||
}
|
||||
Some(path)
|
||||
if !std::fs::metadata(path)
|
||||
.map_err(|e| format!("Failed to read nginx binary metadata: {}", e))?
|
||||
.permissions()
|
||||
.mode()
|
||||
& 0o111
|
||||
!= 0 =>
|
||||
{
|
||||
return Err(format!("Nginx binary is not executable: {}", path));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if self.nginx_config_path.is_empty() {
|
||||
return Err("Nginx config path cannot be empty".into());
|
||||
}
|
||||
if !std::path::Path::new(&self.nginx_config_path).exists() {
|
||||
return Err(format!(
|
||||
"Nginx config file not found: {}",
|
||||
self.nginx_config_path
|
||||
));
|
||||
}
|
||||
|
||||
// ensure reload and test commands contain the binary path template
|
||||
if !&self
|
||||
.override_nginx_reload_command
|
||||
.join(" ")
|
||||
.contains(NGINX_BINARY_PATH_TEMPLATE)
|
||||
{
|
||||
return Err(format!(
|
||||
"Nginx reload command must contain the binary path template '{}': {}",
|
||||
NGINX_BINARY_PATH_TEMPLATE,
|
||||
self.override_nginx_reload_command.join(" ")
|
||||
));
|
||||
}
|
||||
if !&self
|
||||
.override_nginx_test_command
|
||||
.join(" ")
|
||||
.contains(NGINX_BINARY_PATH_TEMPLATE)
|
||||
{
|
||||
return Err(format!(
|
||||
"Nginx test command must contain the binary path template '{}': {}",
|
||||
NGINX_BINARY_PATH_TEMPLATE,
|
||||
self.override_nginx_test_command.join(" ")
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn default_nginx_config_path() -> String {
|
||||
"/etc/nginx/nginx.conf".into()
|
||||
}
|
||||
|
||||
fn default_nginx_reload_command() -> Vec<String> {
|
||||
vec![
|
||||
NGINX_BINARY_PATH_TEMPLATE.to_string(),
|
||||
"-s".to_string(),
|
||||
"reload".to_string(),
|
||||
]
|
||||
}
|
||||
|
||||
fn default_nginx_test_command() -> Vec<String> {
|
||||
vec![NGINX_BINARY_PATH_TEMPLATE.to_string(), "-t".to_string()]
|
||||
}
|
||||
|
||||
fn default_nginx_reload_timeout_seconds() -> u64 {
|
||||
30
|
||||
}
|
||||
|
||||
fn default_nginx_test_timeout_seconds() -> u64 {
|
||||
30
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::{fs, os::unix::fs::PermissionsExt, path::Path};
|
||||
|
||||
use tempfile::TempDir;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_esnure_send_and_sync() {
|
||||
fn assert_send_sync<T: Send + Sync>() {}
|
||||
assert_send_sync::<NginxSettings>();
|
||||
}
|
||||
|
||||
fn write_file(path: &Path) {
|
||||
let result = fs::write(path, b"content");
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
fn create_exec_file(path: &Path) {
|
||||
write_file(path);
|
||||
let metadata = fs::metadata(path);
|
||||
assert!(metadata.is_ok());
|
||||
let metadata = metadata.ok();
|
||||
assert!(metadata.is_some());
|
||||
let metadata = metadata.unwrap_or_else(|| unreachable!());
|
||||
|
||||
let mut perms = metadata.permissions();
|
||||
perms.set_mode(0o755);
|
||||
let result = fs::set_permissions(path, perms);
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
fn create_non_exec_file(path: &Path) {
|
||||
write_file(path);
|
||||
let metadata = fs::metadata(path);
|
||||
assert!(metadata.is_ok());
|
||||
let metadata = metadata.ok();
|
||||
assert!(metadata.is_some());
|
||||
let metadata = metadata.unwrap_or_else(|| unreachable!());
|
||||
|
||||
let mut perms = metadata.permissions();
|
||||
perms.set_mode(0o644);
|
||||
let result = fs::set_permissions(path, perms);
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nginx_validate_succeeds_for_valid_paths_and_commands() {
|
||||
let temp_dir = TempDir::new();
|
||||
assert!(temp_dir.is_ok());
|
||||
let temp_dir = temp_dir.ok();
|
||||
assert!(temp_dir.is_some());
|
||||
let temp_dir = temp_dir.unwrap_or_else(|| unreachable!());
|
||||
|
||||
let nginx_binary = temp_dir.path().join("nginx");
|
||||
let nginx_config = temp_dir.path().join("nginx.conf");
|
||||
|
||||
create_exec_file(&nginx_binary);
|
||||
write_file(&nginx_config);
|
||||
|
||||
let nginx = NginxSettings {
|
||||
nginx_config_path: nginx_config.to_string_lossy().to_string(),
|
||||
nginx_binary_path: Some(nginx_binary.to_string_lossy().to_string()),
|
||||
override_nginx_reload_command: default_nginx_reload_command(),
|
||||
override_nginx_test_command: default_nginx_test_command(),
|
||||
nginx_reload_timeout_seconds: 30,
|
||||
nginx_test_timeout_seconds: 30,
|
||||
};
|
||||
|
||||
assert!(nginx.validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nginx_validate_fails_for_non_executable_binary() {
|
||||
let temp_dir = TempDir::new();
|
||||
assert!(temp_dir.is_ok());
|
||||
let temp_dir = temp_dir.ok();
|
||||
assert!(temp_dir.is_some());
|
||||
let temp_dir = temp_dir.unwrap_or_else(|| unreachable!());
|
||||
|
||||
let nginx_binary = temp_dir.path().join("nginx");
|
||||
let nginx_config = temp_dir.path().join("nginx.conf");
|
||||
|
||||
create_non_exec_file(&nginx_binary);
|
||||
write_file(&nginx_config);
|
||||
|
||||
let nginx = NginxSettings {
|
||||
nginx_config_path: nginx_config.to_string_lossy().to_string(),
|
||||
nginx_binary_path: Some(nginx_binary.to_string_lossy().to_string()),
|
||||
override_nginx_reload_command: default_nginx_reload_command(),
|
||||
override_nginx_test_command: default_nginx_test_command(),
|
||||
nginx_reload_timeout_seconds: 30,
|
||||
nginx_test_timeout_seconds: 30,
|
||||
};
|
||||
|
||||
let result = nginx.validate();
|
||||
assert!(result.is_err());
|
||||
let msg = result.err().unwrap_or_else(|| unreachable!());
|
||||
assert!(msg.contains("Nginx binary is not executable"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nginx_validate_fails_when_reload_command_lacks_template() {
|
||||
let temp_dir = TempDir::new();
|
||||
assert!(temp_dir.is_ok());
|
||||
let temp_dir = temp_dir.ok();
|
||||
assert!(temp_dir.is_some());
|
||||
let temp_dir = temp_dir.unwrap_or_else(|| unreachable!());
|
||||
|
||||
let nginx_binary = temp_dir.path().join("nginx");
|
||||
let nginx_config = temp_dir.path().join("nginx.conf");
|
||||
|
||||
create_exec_file(&nginx_binary);
|
||||
write_file(&nginx_config);
|
||||
|
||||
let nginx = NginxSettings {
|
||||
nginx_config_path: nginx_config.to_string_lossy().to_string(),
|
||||
nginx_binary_path: Some(nginx_binary.to_string_lossy().to_string()),
|
||||
override_nginx_reload_command: vec!["nginx".into(), "-s".into(), "reload".into()],
|
||||
override_nginx_test_command: default_nginx_test_command(),
|
||||
nginx_reload_timeout_seconds: 30,
|
||||
nginx_test_timeout_seconds: 30,
|
||||
};
|
||||
|
||||
let result = nginx.validate();
|
||||
assert!(result.is_err());
|
||||
let msg = result.err().unwrap_or_else(|| unreachable!());
|
||||
assert!(msg.contains("Nginx reload command must contain the binary path template"));
|
||||
}
|
||||
}
|
||||
130
apps/nxmesh-agent/src/connector/master/mod.rs
Normal file
130
apps/nxmesh-agent/src/connector/master/mod.rs
Normal file
@@ -0,0 +1,130 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
pub mod ssh;
|
||||
|
||||
pub type AgentClient =
|
||||
nxmesh_proto::agent_service_client::AgentServiceClient<tonic::transport::Channel>;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
pub trait MasterConnectorTrait: Send + Sync {
|
||||
async fn connect(
|
||||
&mut self,
|
||||
settings: &crate::config::settings::Settings,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>>;
|
||||
fn get_client(&self) -> AgentClient;
|
||||
}
|
||||
|
||||
pub struct MasterConnector {
|
||||
connector: Box<dyn MasterConnectorTrait>,
|
||||
}
|
||||
|
||||
impl MasterConnector {
|
||||
pub fn new(connector: Box<dyn MasterConnectorTrait>) -> Self {
|
||||
Self { connector }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MasterConnectorTrait for MasterConnector {
|
||||
async fn connect(
|
||||
&mut self,
|
||||
settings: &crate::config::settings::Settings,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
self.connector.connect(settings).await
|
||||
}
|
||||
|
||||
fn get_client(&self) -> AgentClient {
|
||||
self.connector.get_client()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::{
|
||||
Arc,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
};
|
||||
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use crate::config::settings::{
|
||||
GrpcSettings, LogSettings, MAuthSettings, Settings, TLSSettings,
|
||||
};
|
||||
|
||||
use super::{AgentClient, MasterConnector, MasterConnectorTrait};
|
||||
|
||||
struct FakeConnector {
|
||||
called: Arc<AtomicBool>,
|
||||
fail: bool,
|
||||
client: AgentClient,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MasterConnectorTrait for FakeConnector {
|
||||
async fn connect(
|
||||
&mut self,
|
||||
_settings: &Settings,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
self.called.store(true, Ordering::SeqCst);
|
||||
if self.fail {
|
||||
return Err("connector failed".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get_client(&self) -> AgentClient {
|
||||
self.client.clone()
|
||||
}
|
||||
}
|
||||
|
||||
fn test_settings() -> Settings {
|
||||
Settings {
|
||||
agent_id: "test-agent".to_string(),
|
||||
grpc: GrpcSettings {
|
||||
connection_string: "https://localhost:50051".to_string(),
|
||||
m_auth: MAuthSettings::Tls(TLSSettings::ZipPath {
|
||||
cert_zip_path: "/tmp/certs.zip".to_string(),
|
||||
}),
|
||||
cors: None,
|
||||
},
|
||||
log: LogSettings::default(),
|
||||
nginx: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn test_client() -> AgentClient {
|
||||
let channel =
|
||||
tonic::transport::Channel::from_static("http://127.0.0.1:50051").connect_lazy();
|
||||
AgentClient::new(channel)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn master_connector_delegates_connect_successfully() {
|
||||
let called = Arc::new(AtomicBool::new(false));
|
||||
let fake = FakeConnector {
|
||||
called: called.clone(),
|
||||
fail: false,
|
||||
client: test_client(),
|
||||
};
|
||||
let mut master = MasterConnector::new(Box::new(fake));
|
||||
|
||||
let result = master.connect(&test_settings()).await;
|
||||
assert!(result.is_ok());
|
||||
assert!(called.load(Ordering::SeqCst));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn master_connector_propagates_connect_errors() {
|
||||
let fake = FakeConnector {
|
||||
called: Arc::new(AtomicBool::new(false)),
|
||||
fail: true,
|
||||
client: test_client(),
|
||||
};
|
||||
let mut master = MasterConnector::new(Box::new(fake));
|
||||
|
||||
let result = master.connect(&test_settings()).await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
280
apps/nxmesh-agent/src/connector/master/ssh.rs
Normal file
280
apps/nxmesh-agent/src/connector/master/ssh.rs
Normal file
@@ -0,0 +1,280 @@
|
||||
use std::{fs::File, io::Read};
|
||||
|
||||
use tokio::fs::read;
|
||||
|
||||
use nxmesh_proto::agent_service_client::AgentServiceClient;
|
||||
use tonic::transport::{Certificate, ClientTlsConfig, Identity};
|
||||
use tracing::warn;
|
||||
|
||||
use crate::config::settings::{MAuthSettings, TLSSettings};
|
||||
|
||||
use super::{AgentClient, MasterConnectorTrait};
|
||||
|
||||
pub struct SshMasterConnector {
|
||||
client: AgentClient,
|
||||
}
|
||||
|
||||
impl SshMasterConnector {
|
||||
pub async fn new(
|
||||
settings: crate::config::settings::GrpcSettings,
|
||||
) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let tls_config = Self::generate_tls_config(&settings.m_auth).await?;
|
||||
// Create a gRPC channel
|
||||
let endpoint = tonic::transport::Channel::from_shared(settings.connection_string.clone())
|
||||
.map_err(|e| format!("Failed to create gRPC endpoint: {}", e))?
|
||||
.tls_config(tls_config)
|
||||
.map_err(|e| {
|
||||
format!(
|
||||
"Failed to set TLS config: {}. Ensure TLS settings and certificates are correct.",
|
||||
e
|
||||
)
|
||||
})?
|
||||
.connect_timeout(std::time::Duration::from_secs(5))
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.connect_lazy();
|
||||
|
||||
// Create the gRPC client
|
||||
let client = AgentServiceClient::new(endpoint);
|
||||
Ok(Self { client })
|
||||
}
|
||||
|
||||
async fn generate_tls_config(
|
||||
settings: &MAuthSettings,
|
||||
) -> Result<ClientTlsConfig, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let tls_config = match &settings {
|
||||
MAuthSettings::Tls(tls_settings) => {
|
||||
let (ca, cert, key) = match tls_settings {
|
||||
TLSSettings::RawPath {
|
||||
ca_path,
|
||||
cert_path,
|
||||
key_path,
|
||||
} => {
|
||||
// Read the certificate and key from the specified file paths
|
||||
let ca = read(ca_path).await?;
|
||||
let cert = read(cert_path).await?;
|
||||
let key = read(key_path).await?;
|
||||
(ca, cert, key)
|
||||
}
|
||||
TLSSettings::ZipPath { cert_zip_path } => {
|
||||
// Extract the certificate and key from the zip file
|
||||
Self::extract_certificate(cert_zip_path).await?
|
||||
}
|
||||
};
|
||||
|
||||
// TODO: allow skipping SANs validation if specified in the settings
|
||||
ClientTlsConfig::new()
|
||||
.ca_certificate(Certificate::from_pem(&ca))
|
||||
.identity(Identity::from_pem(&cert, &key))
|
||||
}
|
||||
#[allow(unreachable_patterns)]
|
||||
_ => {
|
||||
return Err("TLS settings are required for SSH connection".into());
|
||||
}
|
||||
};
|
||||
|
||||
Ok(tls_config)
|
||||
}
|
||||
|
||||
async fn extract_certificate(
|
||||
cert_zip_path: &str,
|
||||
) -> Result<(Vec<u8>, Vec<u8>, Vec<u8>), Box<dyn std::error::Error + Send + Sync>> {
|
||||
// unzip the file and extract the cert, ca and key
|
||||
let file = File::open(cert_zip_path)?;
|
||||
let mut archive = zip::ZipArchive::new(file)?;
|
||||
let mut cert = Vec::new();
|
||||
let mut key = Vec::new();
|
||||
let mut ca = Vec::new();
|
||||
|
||||
for i in 0..archive.len() {
|
||||
let mut file = archive.by_index(i)?;
|
||||
let outpath = match file.enclosed_name() {
|
||||
Some(path) => path.to_owned(),
|
||||
None => continue,
|
||||
};
|
||||
let file_name = outpath
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or_default();
|
||||
if file_name != "cert.pem" && file_name != "key.pem" && file_name != "ca.pem" {
|
||||
warn!("Unexpected file in certificate zip: {}", file_name);
|
||||
continue;
|
||||
}
|
||||
if file_name == "cert.pem" {
|
||||
file.read_to_end(&mut cert)?;
|
||||
} else if file_name == "key.pem" {
|
||||
file.read_to_end(&mut key)?;
|
||||
} else if file_name == "ca.pem" {
|
||||
file.read_to_end(&mut ca)?;
|
||||
}
|
||||
}
|
||||
|
||||
if cert.is_empty() || key.is_empty() || ca.is_empty() {
|
||||
return Err("Certificate zip must contain cert.pem, key.pem and ca.pem".into());
|
||||
}
|
||||
|
||||
Ok((ca, cert, key))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MasterConnectorTrait for SshMasterConnector {
|
||||
async fn connect(
|
||||
&mut self,
|
||||
_settings: &crate::config::settings::Settings,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
// ensure connection if required
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get_client(&self) -> AgentClient {
|
||||
self.client.clone()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::expect_used)]
|
||||
mod tests {
|
||||
use std::{
|
||||
fs::{self, File},
|
||||
io::Write,
|
||||
path::Path,
|
||||
};
|
||||
|
||||
use tempfile::TempDir;
|
||||
|
||||
use crate::config::settings::{MAuthSettings, TLSSettings};
|
||||
|
||||
use super::SshMasterConnector;
|
||||
|
||||
const CERT_PEM: &[u8] = b"-----BEGIN CERTIFICATE-----\nAQ==\n-----END CERTIFICATE-----\n";
|
||||
const KEY_PEM: &[u8] = b"-----BEGIN PRIVATE KEY-----\nAQ==\n-----END PRIVATE KEY-----\n";
|
||||
const CA_PEM: &[u8] = b"-----BEGIN CERTIFICATE-----\nAQ==\n-----END CERTIFICATE-----\n";
|
||||
|
||||
fn create_zip_with_entries(
|
||||
dir: &TempDir,
|
||||
file_name: &str,
|
||||
entries: &[(&str, &[u8])],
|
||||
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let zip_path = dir.path().join(file_name);
|
||||
let file = File::create(&zip_path)?;
|
||||
let mut zip = zip::ZipWriter::new(file);
|
||||
let options = zip::write::SimpleFileOptions::default()
|
||||
.compression_method(zip::CompressionMethod::Deflated)
|
||||
.unix_permissions(0o600);
|
||||
|
||||
for (entry_name, contents) in entries {
|
||||
zip.start_file(entry_name, options)?;
|
||||
zip.write_all(contents)?;
|
||||
}
|
||||
|
||||
zip.finish()?;
|
||||
Ok(zip_path.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
fn write_file(
|
||||
path: &Path,
|
||||
contents: &[u8],
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
fs::write(path, contents)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn extract_certificate_succeeds_with_expected_files() {
|
||||
let temp_dir = TempDir::new().expect("failed to create temp dir");
|
||||
let zip_path = create_zip_with_entries(
|
||||
&temp_dir,
|
||||
"certs.zip",
|
||||
&[
|
||||
("cert.pem", CERT_PEM),
|
||||
("key.pem", KEY_PEM),
|
||||
("ca.pem", CA_PEM),
|
||||
("ignored.txt", b"ignored"),
|
||||
],
|
||||
)
|
||||
.expect("failed to create zip");
|
||||
|
||||
let (ca, cert, key) = SshMasterConnector::extract_certificate(&zip_path)
|
||||
.await
|
||||
.expect("expected cert extraction to succeed");
|
||||
|
||||
assert_eq!(ca, CA_PEM);
|
||||
assert_eq!(cert, CERT_PEM);
|
||||
assert_eq!(key, KEY_PEM);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn extract_certificate_fails_when_required_files_are_missing() {
|
||||
let temp_dir = TempDir::new().expect("failed to create temp dir");
|
||||
let zip_path = create_zip_with_entries(
|
||||
&temp_dir,
|
||||
"missing-key.zip",
|
||||
&[("cert.pem", CERT_PEM), ("ca.pem", CA_PEM)],
|
||||
)
|
||||
.expect("failed to create zip");
|
||||
|
||||
let err = SshMasterConnector::extract_certificate(&zip_path)
|
||||
.await
|
||||
.expect_err("expected extraction to fail when key.pem is missing");
|
||||
|
||||
assert!(
|
||||
err.to_string()
|
||||
.contains("Certificate zip must contain cert.pem, key.pem and ca.pem")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn generate_tls_config_succeeds_for_raw_paths() {
|
||||
let temp_dir = TempDir::new().expect("failed to create temp dir");
|
||||
let cert_path = temp_dir.path().join("cert.pem");
|
||||
let key_path = temp_dir.path().join("key.pem");
|
||||
let ca_path = temp_dir.path().join("ca.pem");
|
||||
|
||||
write_file(&cert_path, CERT_PEM).expect("failed to write cert.pem");
|
||||
write_file(&key_path, KEY_PEM).expect("failed to write key.pem");
|
||||
write_file(&ca_path, CA_PEM).expect("failed to write ca.pem");
|
||||
|
||||
let settings = MAuthSettings::Tls(TLSSettings::RawPath {
|
||||
ca_path: ca_path.to_string_lossy().to_string(),
|
||||
cert_path: cert_path.to_string_lossy().to_string(),
|
||||
key_path: key_path.to_string_lossy().to_string(),
|
||||
});
|
||||
|
||||
let result = SshMasterConnector::generate_tls_config(&settings).await;
|
||||
assert!(result.is_ok(), "expected raw path TLS config to succeed");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn generate_tls_config_succeeds_for_zip_path() {
|
||||
let temp_dir = TempDir::new().expect("failed to create temp dir");
|
||||
let zip_path = create_zip_with_entries(
|
||||
&temp_dir,
|
||||
"certs.zip",
|
||||
&[
|
||||
("cert.pem", CERT_PEM),
|
||||
("key.pem", KEY_PEM),
|
||||
("ca.pem", CA_PEM),
|
||||
],
|
||||
)
|
||||
.expect("failed to create zip");
|
||||
|
||||
let settings = MAuthSettings::Tls(TLSSettings::ZipPath {
|
||||
cert_zip_path: zip_path,
|
||||
});
|
||||
|
||||
let result = SshMasterConnector::generate_tls_config(&settings).await;
|
||||
assert!(result.is_ok(), "expected zip path TLS config to succeed");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn generate_tls_config_fails_for_missing_raw_files() {
|
||||
let settings = MAuthSettings::Tls(TLSSettings::RawPath {
|
||||
ca_path: "/tmp/non-existent-ca.pem".to_string(),
|
||||
cert_path: "/tmp/non-existent-cert.pem".to_string(),
|
||||
key_path: "/tmp/non-existent-key.pem".to_string(),
|
||||
});
|
||||
|
||||
let result = SshMasterConnector::generate_tls_config(&settings).await;
|
||||
assert!(result.is_err(), "expected raw path TLS config to fail");
|
||||
}
|
||||
}
|
||||
1
apps/nxmesh-agent/src/connector/mod.rs
Normal file
1
apps/nxmesh-agent/src/connector/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub mod master;
|
||||
@@ -0,0 +1,100 @@
|
||||
#![recursion_limit = "128"]
|
||||
#![forbid(unsafe_code)]
|
||||
#![deny(clippy::unwrap_used, clippy::panic, clippy::expect_used)]
|
||||
|
||||
use std::{process::exit, sync::Arc};
|
||||
|
||||
use tracing::{error, info};
|
||||
use tracing_subscriber::{
|
||||
Layer, filter::LevelFilter, fmt, layer::SubscriberExt, registry::Registry, reload,
|
||||
util::SubscriberInitExt,
|
||||
};
|
||||
|
||||
use crate::service::get_services;
|
||||
|
||||
mod cli;
|
||||
mod config;
|
||||
mod connector;
|
||||
mod service;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
// install a global subscriber for logging
|
||||
let reload_handle = install_tracing_subscriber();
|
||||
// Load configuration settings
|
||||
let settings = match config::settings::Settings::load() {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
error!("Failed to load configuration: {}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
reload_handle
|
||||
.modify(|filter| *filter = Box::new(settings.log.level))
|
||||
.inspect_err(|e| {
|
||||
error!(
|
||||
"Failed to set log level: {}. Continuing with default level.",
|
||||
e
|
||||
)
|
||||
})
|
||||
// ignore errors here since we can still run with the default log level
|
||||
.ok();
|
||||
|
||||
// print the loaded settings for debugging
|
||||
// info!("Loaded settings: {:#?}", settings);
|
||||
|
||||
info!("Starting NxMesh Agent...");
|
||||
let services = get_services(Arc::new(settings))
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("Failed to initialize services: {}", e);
|
||||
e
|
||||
})
|
||||
.unwrap_or_else(|_| {
|
||||
std::process::exit(1);
|
||||
});
|
||||
|
||||
let master_handler = services.master_handler.clone();
|
||||
// spawn the long-running handler so main can wait for shutdown signal
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = master_handler.start_handle_master_message().await {
|
||||
error!("Master message handler exited with error: {:?}", e);
|
||||
}
|
||||
});
|
||||
|
||||
info!("Agent is running. Waiting for shutdown signal.");
|
||||
shutdown_handler(services.master_handler.clone()).await;
|
||||
}
|
||||
|
||||
fn install_tracing_subscriber()
|
||||
-> reload::Handle<Box<dyn tracing_subscriber::layer::Layer<Registry> + Send + Sync>, Registry> {
|
||||
let filter = LevelFilter::INFO;
|
||||
let (filter_layer, reload_handle) =
|
||||
reload::Layer::new(Box::new(fmt::layer().with_filter(filter))
|
||||
as Box<dyn tracing_subscriber::layer::Layer<Registry> + Send + Sync>);
|
||||
|
||||
tracing_subscriber::registry()
|
||||
.with(filter_layer)
|
||||
.with(fmt::Layer::default())
|
||||
.init();
|
||||
|
||||
reload_handle
|
||||
}
|
||||
|
||||
async fn wait_for_shutdown_signal() {
|
||||
#[expect(clippy::expect_used)]
|
||||
tokio::signal::ctrl_c()
|
||||
.await
|
||||
.expect("Failed to listen for ctrl_c");
|
||||
info!("Shutdown signal received, stopping handler.");
|
||||
}
|
||||
|
||||
async fn shutdown_handler(master_handler: Arc<dyn service::master_handler::MasterHandler>) {
|
||||
wait_for_shutdown_signal().await;
|
||||
//
|
||||
let _ = master_handler.stop_handle_master_message().await;
|
||||
//
|
||||
info!("Agent stopped.");
|
||||
exit(0);
|
||||
}
|
||||
|
||||
106
apps/nxmesh-agent/src/service/master_handler/handlers.rs
Normal file
106
apps/nxmesh-agent/src/service/master_handler/handlers.rs
Normal file
@@ -0,0 +1,106 @@
|
||||
use std::sync::{Arc, Weak};
|
||||
|
||||
use nxmesh_proto::{
|
||||
AgentMessage, ConfigUpdate, MasterMessage, command::Command, master_message::Payload,
|
||||
};
|
||||
|
||||
use crate::service::master_handler::{MasterHandlerError, MessageResult};
|
||||
|
||||
#[async_trait::async_trait]
|
||||
pub trait MasterMessageHandler: Send + Sync + 'static {
|
||||
async fn handle_master_message(
|
||||
&self,
|
||||
agent_id: &str,
|
||||
message: MasterMessage,
|
||||
) -> MessageResult<()>;
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
pub trait OnConfigUpdateHandler: Send + Sync + 'static {
|
||||
// Handle the config update message from master, write the config content to files, validate the new config and reload nginx
|
||||
async fn on_config_update(
|
||||
&self,
|
||||
agent_id: &str,
|
||||
timestamp: i64,
|
||||
message_id: &str,
|
||||
config_info: ConfigUpdate,
|
||||
) -> MessageResult<()>;
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
pub trait OnCommandHandler: Send + Sync + 'static {
|
||||
// Handle the command message from master, execute the command and return the result
|
||||
async fn on_command(
|
||||
&self,
|
||||
agent_id: &str,
|
||||
timestamp: i64,
|
||||
message_id: &str,
|
||||
command: Command,
|
||||
) -> MessageResult<()>;
|
||||
}
|
||||
|
||||
pub struct HandlerImpl<OCUH, OCH>
|
||||
where
|
||||
OCUH: OnConfigUpdateHandler + ?Sized,
|
||||
OCH: OnCommandHandler + ?Sized,
|
||||
{
|
||||
on_config_update_handler: Weak<OCUH>,
|
||||
on_command_handler: Weak<OCH>,
|
||||
}
|
||||
|
||||
impl<OCUH, OCH> HandlerImpl<OCUH, OCH>
|
||||
where
|
||||
OCUH: OnConfigUpdateHandler + ?Sized,
|
||||
OCH: OnCommandHandler + ?Sized,
|
||||
{
|
||||
pub fn new(on_config_update_handler: Weak<OCUH>, on_command_handler: Weak<OCH>) -> Self {
|
||||
Self {
|
||||
on_config_update_handler,
|
||||
on_command_handler,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl<OCUH, OCH> MasterMessageHandler for HandlerImpl<OCUH, OCH>
|
||||
where
|
||||
OCUH: OnConfigUpdateHandler + ?Sized,
|
||||
OCH: OnCommandHandler + ?Sized,
|
||||
{
|
||||
async fn handle_master_message(
|
||||
&self,
|
||||
agent_id: &str,
|
||||
message: MasterMessage,
|
||||
) -> MessageResult<()> {
|
||||
match message.payload {
|
||||
Some(Payload::ConfigUpdate(config_info)) => {
|
||||
let on_config_update_handler =
|
||||
self.on_config_update_handler.upgrade().ok_or_else(|| {
|
||||
MasterHandlerError::MessageHandlingError(
|
||||
"Failed to upgrade weak reference to config update handler".to_string(),
|
||||
)
|
||||
})?;
|
||||
on_config_update_handler
|
||||
.on_config_update(
|
||||
agent_id,
|
||||
message.timestamp,
|
||||
&message.message_id,
|
||||
config_info,
|
||||
)
|
||||
.await
|
||||
}
|
||||
Some(_) => {
|
||||
// We should never receive other types of messages from the master, but we should handle it anyway
|
||||
Err(MasterHandlerError::MessageHandlingError(
|
||||
"Received unsupported master message type".to_string(),
|
||||
))
|
||||
}
|
||||
None => {
|
||||
// This should never happen as the master should always send a valid message, but we should handle it anyway
|
||||
return Err(MasterHandlerError::MessageHandlingError(
|
||||
"Received master message with empty payload".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
224
apps/nxmesh-agent/src/service/master_handler/mod.rs
Normal file
224
apps/nxmesh-agent/src/service/master_handler/mod.rs
Normal file
@@ -0,0 +1,224 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use nxmesh_proto::AgentMessage;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio_stream::wrappers::ReceiverStream;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
use crate::{
|
||||
connector::master::{MasterConnector, MasterConnectorTrait},
|
||||
service::master_handler::handlers::MasterMessageHandler,
|
||||
};
|
||||
|
||||
pub mod handlers;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum MasterHandlerError {
|
||||
ConnectionError(String),
|
||||
// TODO: should be protobuf error to transmit the error to master
|
||||
MessageHandlingError(String),
|
||||
RetryLimitExceeded(String),
|
||||
SendMessageError(String),
|
||||
}
|
||||
|
||||
pub type MessageResult<T> = std::result::Result<T, MasterHandlerError>;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
pub trait MasterHandler: Send + Sync + 'static {
|
||||
// Create a new routine to handle incoming messages from the master
|
||||
// This method will auto-reconnect if the connection is lost, so it should run indefinitely until the agent is shut down
|
||||
async fn start_handle_master_message(&self) -> MessageResult<()>;
|
||||
async fn stop_handle_master_message(&self) -> MessageResult<()>;
|
||||
|
||||
// Send a message to the master, response should be handled by the agent message handler registered
|
||||
async fn send_message_to_master(&self, message: AgentMessage) -> MessageResult<()>;
|
||||
}
|
||||
|
||||
struct MessageHandleInfo {
|
||||
tx: mpsc::Sender<AgentMessage>,
|
||||
// used to signal the running handler/connection to stop
|
||||
cancel: CancellationToken,
|
||||
}
|
||||
|
||||
pub struct MasterHandlerImpl<MMH>
|
||||
where
|
||||
MMH: MasterMessageHandler + ?Sized,
|
||||
{
|
||||
agent_id: String,
|
||||
connector: Arc<MasterConnector>,
|
||||
message_handler: Arc<MMH>,
|
||||
message_handle_lock: tokio::sync::RwLock<Option<MessageHandleInfo>>,
|
||||
}
|
||||
|
||||
impl<MMH> MasterHandlerImpl<MMH>
|
||||
where
|
||||
MMH: MasterMessageHandler + ?Sized,
|
||||
{
|
||||
pub fn new(agent_id: &str, connector: Arc<MasterConnector>, message_handler: Arc<MMH>) -> Self {
|
||||
Self {
|
||||
agent_id: agent_id.to_string(),
|
||||
connector,
|
||||
message_handler,
|
||||
message_handle_lock: tokio::sync::RwLock::new(None),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl<MMH> MasterHandler for MasterHandlerImpl<MMH>
|
||||
where
|
||||
MMH: MasterMessageHandler + ?Sized,
|
||||
{
|
||||
async fn start_handle_master_message(&self) -> MessageResult<()> {
|
||||
info!("Starting master message handler...");
|
||||
let mut client = self.connector.get_client();
|
||||
|
||||
// ensure only one caller can start the handler
|
||||
// create the cancel token for the lifetime of this handler invocation
|
||||
let cancel_token = CancellationToken::new();
|
||||
{
|
||||
let mut guard = self.message_handle_lock.write().await;
|
||||
if guard.is_some() {
|
||||
warn!("Master message handler is already running");
|
||||
return Ok(());
|
||||
}
|
||||
// placeholder tx; will be replaced per-connection
|
||||
let (tx, _rx) = mpsc::channel(1);
|
||||
*guard = Some(MessageHandleInfo {
|
||||
tx,
|
||||
cancel: cancel_token.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
'connection_loop: loop {
|
||||
// fresh outbound channel per connection
|
||||
let (tx, rx) = mpsc::channel(32);
|
||||
let outbound_stream = ReceiverStream::new(rx);
|
||||
|
||||
// try to connect
|
||||
let mut stream = match client.stream(outbound_stream).await {
|
||||
Ok(s) => s.into_inner(),
|
||||
Err(e) => {
|
||||
error!(
|
||||
"Failed to connect to master: {}. Retrying in 5 seconds...",
|
||||
e
|
||||
);
|
||||
// update stored sender so any callers see the current tx
|
||||
{
|
||||
let mut guard = self.message_handle_lock.write().await;
|
||||
if let Some(info) = guard.as_mut() {
|
||||
info.tx = tx.clone();
|
||||
}
|
||||
}
|
||||
let conn_token = cancel_token.child_token();
|
||||
tokio::select! {
|
||||
_ = conn_token.cancelled() => break 'connection_loop,
|
||||
_ = tokio::time::sleep(std::time::Duration::from_secs(5)) => continue 'connection_loop,
|
||||
}
|
||||
}
|
||||
};
|
||||
// store current tx so senders can use it
|
||||
{
|
||||
let mut guard = self.message_handle_lock.write().await;
|
||||
if let Some(info) = guard.as_mut() {
|
||||
info.tx = tx.clone();
|
||||
}
|
||||
}
|
||||
// connection-level token to observe stop requests
|
||||
let conn_token = cancel_token.child_token();
|
||||
info!("Connected to master, starting to receive messages...");
|
||||
// process messages inline so we can clear the slot on exit
|
||||
'message_processing: loop {
|
||||
tokio::select! {
|
||||
_ = conn_token.cancelled() => {
|
||||
info!("Stop requested for master handler");
|
||||
break 'connection_loop;
|
||||
}
|
||||
message = stream.message() => {
|
||||
match message {
|
||||
Ok(Some(msg)) => {
|
||||
if let Err(e) = self.message_handler.handle_master_message(&self.agent_id, msg).await {
|
||||
error!("Failed to handle master message: {:?}", e);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
Ok(None) => {
|
||||
warn!("Master closed the connection");
|
||||
break 'message_processing;
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Error receiving message from master: {:?}", e);
|
||||
break 'message_processing;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// connection ended — clear stored info
|
||||
{
|
||||
let mut guard = self.message_handle_lock.write().await;
|
||||
guard.take();
|
||||
}
|
||||
|
||||
// if stop requested, exit
|
||||
if cancel_token.is_cancelled() {
|
||||
break 'connection_loop;
|
||||
}
|
||||
|
||||
// otherwise reconnect after backoff
|
||||
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
||||
}
|
||||
|
||||
// final cleanup
|
||||
let mut guard = self.message_handle_lock.write().await;
|
||||
guard.take();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn stop_handle_master_message(&self) -> MessageResult<()> {
|
||||
// Signal the running handler to stop and wait for it to clear
|
||||
let mut maybe_cancel = None;
|
||||
{
|
||||
let mut guard = self.message_handle_lock.write().await;
|
||||
if let Some(info) = guard.take() {
|
||||
maybe_cancel = Some(info.cancel);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(cancel) = maybe_cancel {
|
||||
cancel.cancel();
|
||||
|
||||
// wait for the handler to clear (with timeout)
|
||||
for _ in 0..50 {
|
||||
if self.message_handle_lock.read().await.is_none() {
|
||||
info!("Master message handler task stopped successfully");
|
||||
return Ok(());
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||
}
|
||||
warn!("Timed out waiting for master message handler to stop");
|
||||
} else {
|
||||
warn!("Master message handler is not running");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn send_message_to_master(&self, message: AgentMessage) -> MessageResult<()> {
|
||||
let guard = self.message_handle_lock.read().await;
|
||||
if let Some(handle_info) = guard.as_ref() {
|
||||
handle_info.tx.send(message).await.map_err(|e| {
|
||||
MasterHandlerError::SendMessageError(format!(
|
||||
"Failed to send message to master: {}",
|
||||
e
|
||||
))
|
||||
})?;
|
||||
} else {
|
||||
return Err(MasterHandlerError::SendMessageError(
|
||||
"Master message handler is not running".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
63
apps/nxmesh-agent/src/service/mod.rs
Normal file
63
apps/nxmesh-agent/src/service/mod.rs
Normal file
@@ -0,0 +1,63 @@
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use crate::{
|
||||
config::settings::Settings,
|
||||
connector::master::{MasterConnector, ssh::SshMasterConnector},
|
||||
service::{
|
||||
master_handler::{MasterHandler, MasterHandlerImpl, handlers::HandlerImpl},
|
||||
nginx_handler::{NginxHandler, NginxHandlerImpl},
|
||||
},
|
||||
};
|
||||
|
||||
pub mod master_handler;
|
||||
pub mod nginx_handler;
|
||||
|
||||
pub struct Services {
|
||||
pub master_handler: Arc<dyn MasterHandler>,
|
||||
pub nginx_handler: Arc<dyn NginxHandler>,
|
||||
}
|
||||
|
||||
pub async fn get_services(settings: Arc<Settings>) -> anyhow::Result<Services> {
|
||||
let master_connector = initialize_master_connector(settings.clone()).await?;
|
||||
let master_connector = Arc::new(master_connector);
|
||||
|
||||
let master_handler_slot = Arc::new(Mutex::new(None));
|
||||
let slot = master_handler_slot.clone();
|
||||
|
||||
#[expect(clippy::expect_used)]
|
||||
let nginx_handler = Arc::new_cyclic(|nginx_handler_weak| {
|
||||
let message_handler = Arc::new(HandlerImpl::new(
|
||||
nginx_handler_weak.clone(),
|
||||
nginx_handler_weak.clone(),
|
||||
));
|
||||
let master_handler = Arc::new(MasterHandlerImpl::new(
|
||||
settings.agent_id.as_str(),
|
||||
master_connector.clone(),
|
||||
message_handler,
|
||||
));
|
||||
*slot.lock().expect("master handler slot lock poisoned") = Some(master_handler.clone());
|
||||
|
||||
NginxHandlerImpl::new(settings.nginx.clone().into(), master_handler)
|
||||
});
|
||||
#[expect(clippy::expect_used)]
|
||||
let master_handler = master_handler_slot
|
||||
.lock()
|
||||
.expect("master handler slot lock poisoned")
|
||||
.clone()
|
||||
.ok_or_else(|| anyhow::anyhow!("Failed to initialize master handler"))?;
|
||||
|
||||
Ok(Services {
|
||||
master_handler,
|
||||
nginx_handler,
|
||||
})
|
||||
}
|
||||
|
||||
async fn initialize_master_connector(settings: Arc<Settings>) -> anyhow::Result<MasterConnector> {
|
||||
let ssh_connector = SshMasterConnector::new(settings.grpc.clone())
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Failed to initialize SSH connector: {}", e))?;
|
||||
|
||||
let master_connector = MasterConnector::new(Box::new(ssh_connector));
|
||||
|
||||
Ok(master_connector)
|
||||
}
|
||||
252
apps/nxmesh-agent/src/service/nginx_handler/command_handler.rs
Normal file
252
apps/nxmesh-agent/src/service/nginx_handler/command_handler.rs
Normal file
@@ -0,0 +1,252 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use thiserror::Error;
|
||||
use tokio::process::Command;
|
||||
use tracing::{debug, warn};
|
||||
|
||||
use crate::{config::settings::NginxSettings, service::master_handler::MasterHandlerError};
|
||||
|
||||
#[cfg(test)]
|
||||
use mockall::predicate::*;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum CommandHandlerError {
|
||||
#[error("Failed to execute command: {0}")]
|
||||
CommandExecutionError(#[from] std::io::Error),
|
||||
#[error("Invalid config path: {0}")]
|
||||
InvalidConfigPath(String),
|
||||
#[error("Invalid output path: {0}")]
|
||||
InvalidOutputPath(String),
|
||||
#[error("Permission denied: {0}")]
|
||||
PermissionDenied(String),
|
||||
#[error("Other error: {0}")]
|
||||
OtherError(String),
|
||||
}
|
||||
|
||||
impl From<CommandHandlerError> for MasterHandlerError {
|
||||
fn from(err: CommandHandlerError) -> Self {
|
||||
MasterHandlerError::MessageHandlingError(err.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
pub type CommandHandlerResult<T> = std::result::Result<T, CommandHandlerError>;
|
||||
type Result<T> = CommandHandlerResult<T>;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
#[cfg_attr(test, mockall::automock)]
|
||||
pub trait CommandHandler: Send + Sync + 'static {
|
||||
// Reload nginx to apply new config. The config_path is an optional parameter that specifies the path to the nginx config file to be used for this reload operation. If not provided, the default config path will be used.
|
||||
async fn reload(&self, config_path: Option<&str>) -> Result<()>;
|
||||
async fn stop(&self) -> Result<()>;
|
||||
async fn validate(&self, config_path: Option<&str>) -> Result<()>;
|
||||
async fn get_version(&self) -> Result<String>;
|
||||
async fn get_status(&self) -> Result<String>;
|
||||
}
|
||||
|
||||
pub struct CommandHandlerImpl {
|
||||
settings: Arc<NginxSettings>,
|
||||
}
|
||||
|
||||
impl CommandHandlerImpl {
|
||||
pub fn new(settings: Arc<NginxSettings>) -> Self {
|
||||
Self { settings }
|
||||
}
|
||||
|
||||
fn get_nginx_command(&self) -> String {
|
||||
// TODO: rename the setting for better clarity, it can be a binary path or a custom command
|
||||
self.settings
|
||||
.nginx_binary_path
|
||||
.clone()
|
||||
.unwrap_or_else(|| "nginx".to_string())
|
||||
}
|
||||
|
||||
fn validate_config_path(config_path: &str) -> Result<()> {
|
||||
if !std::path::Path::new(config_path).exists() {
|
||||
return Err(CommandHandlerError::InvalidConfigPath(format!(
|
||||
"Config file not found at path: {}",
|
||||
config_path
|
||||
)));
|
||||
}
|
||||
if !std::path::Path::new(config_path).is_file() {
|
||||
return Err(CommandHandlerError::InvalidConfigPath(format!(
|
||||
"Config path is not a file: {}",
|
||||
config_path
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn apply_config_path_to_command_vecs<'a>(
|
||||
command: &'a mut Vec<String>,
|
||||
config_path: &str,
|
||||
) -> Result<&'a mut Vec<String>> {
|
||||
// if given a config path, add it to the end of the command arguments to override the default config path used
|
||||
Self::validate_config_path(config_path)?;
|
||||
let parent_dir = match std::path::Path::new(config_path).parent() {
|
||||
Some(dir) => dir,
|
||||
// return root
|
||||
None => std::path::Path::new("/"),
|
||||
};
|
||||
// set prefix path to the parent directory of the config file to ensure nginx can find all related files (e.g. certs, conf.d, etc.)
|
||||
command.push("-p".to_string());
|
||||
command.push(parent_dir.to_string_lossy().to_string());
|
||||
// add the config file path to the command arguments to override the default config path used by nginx
|
||||
command.push("-c".to_string());
|
||||
command.push(config_path.to_string());
|
||||
Ok(command)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl CommandHandler for CommandHandlerImpl {
|
||||
async fn reload(&self, config_path: Option<&str>) -> Result<()> {
|
||||
// TODO: add timeout for the command execution
|
||||
let reload_command_str = self.settings.override_nginx_reload_command.clone();
|
||||
let program = match reload_command_str.first() {
|
||||
Some(cmd) => cmd,
|
||||
None => &self.get_nginx_command(),
|
||||
};
|
||||
|
||||
let mut reload_command_vec = reload_command_str[1..].to_vec();
|
||||
// if given a config path, add it to the end of the command arguments to override the default config path used
|
||||
if let Some(path) = config_path {
|
||||
Self::apply_config_path_to_command_vecs(&mut reload_command_vec, path)?;
|
||||
}
|
||||
|
||||
let output = Command::new(program)
|
||||
.args(&reload_command_vec)
|
||||
.output()
|
||||
.await?;
|
||||
if !output.status.success() {
|
||||
let error_info = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(CommandHandlerError::CommandExecutionError(
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::Other,
|
||||
format!("Failed to reload nginx: {}", error_info.trim()),
|
||||
),
|
||||
));
|
||||
}
|
||||
let success_info = String::from_utf8_lossy(&output.stdout);
|
||||
debug!("Nginx reloaded successfully: {}", success_info.trim());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn stop(&self) -> Result<()> {
|
||||
let output = Command::new(self.get_nginx_command())
|
||||
.arg("-s")
|
||||
.arg("stop")
|
||||
.output()
|
||||
.await?;
|
||||
|
||||
if !output.status.success() {
|
||||
let error_info = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(CommandHandlerError::CommandExecutionError(
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::Other,
|
||||
format!("Failed to stop nginx: {}", error_info.trim()),
|
||||
),
|
||||
));
|
||||
}
|
||||
let success_info = String::from_utf8_lossy(&output.stdout);
|
||||
debug!("Nginx stopped successfully: {}", success_info.trim());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn validate(&self, config_path: Option<&str>) -> Result<()> {
|
||||
// TODO: add timeout for the command execution
|
||||
let validate_command_str = self.settings.override_nginx_test_command.clone();
|
||||
let program = match validate_command_str.first() {
|
||||
Some(cmd) => cmd,
|
||||
None => &self.get_nginx_command(),
|
||||
};
|
||||
let mut validate_args = validate_command_str[1..].to_vec();
|
||||
// if given a config path, add it to the end of the command arguments to override the default config path used
|
||||
if let Some(path) = config_path {
|
||||
Self::apply_config_path_to_command_vecs(&mut validate_args, path)?;
|
||||
}
|
||||
|
||||
let output = Command::new(program).args(&validate_args).output().await?;
|
||||
if !output.status.success() {
|
||||
let error_info = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(CommandHandlerError::CommandExecutionError(
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::Other,
|
||||
format!("Failed to validate nginx config: {}", error_info.trim()),
|
||||
),
|
||||
));
|
||||
}
|
||||
let success_info = String::from_utf8_lossy(&output.stdout);
|
||||
debug!("Nginx config validation succeeded: {}", success_info.trim());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_version(&self) -> Result<String> {
|
||||
let output = Command::new(self.get_nginx_command())
|
||||
.arg("-v")
|
||||
.output()
|
||||
.await?;
|
||||
|
||||
if !output.status.success() {
|
||||
let error_info = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(CommandHandlerError::CommandExecutionError(
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::Other,
|
||||
format!("Failed to get nginx version: {}", error_info.trim()),
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
let version_info = String::from_utf8_lossy(&output.stderr);
|
||||
Ok(version_info.trim().to_string())
|
||||
}
|
||||
|
||||
async fn get_status(&self) -> Result<String> {
|
||||
let output = Command::new(self.get_nginx_command())
|
||||
.arg("-t")
|
||||
.output()
|
||||
.await?;
|
||||
|
||||
if !output.status.success() {
|
||||
let error_info = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(CommandHandlerError::CommandExecutionError(
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::Other,
|
||||
format!("Failed to get nginx status: {}", error_info.trim()),
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
let status_info = String::from_utf8_lossy(&output.stderr);
|
||||
Ok(status_info.trim().to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::expect_used)]
|
||||
mod tests {
|
||||
use tempfile::TempDir;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn apply_config_path_to_command_vecs_appends_prefix_and_config() -> Result<()> {
|
||||
let temp = TempDir::new()?;
|
||||
let cfg_file = temp.path().join("nginx.conf");
|
||||
tokio::fs::write(&cfg_file, b"data").await?;
|
||||
|
||||
let mut args: Vec<String> = vec!["base".to_string()];
|
||||
let result = CommandHandlerImpl::apply_config_path_to_command_vecs(
|
||||
&mut args,
|
||||
&cfg_file.to_string_lossy(),
|
||||
);
|
||||
assert!(result.is_ok());
|
||||
let args = result.expect("Failed to apply config path to command vecs");
|
||||
// expect -p <parent_dir> -c <config>
|
||||
assert!(args.contains(&"-p".to_string()));
|
||||
assert!(args.contains(&"-c".to_string()));
|
||||
assert!(args.contains(&cfg_file.to_string_lossy().to_string()));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
448
apps/nxmesh-agent/src/service/nginx_handler/fs_handler.rs
Normal file
448
apps/nxmesh-agent/src/service/nginx_handler/fs_handler.rs
Normal file
@@ -0,0 +1,448 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use fs4::tokio::AsyncFileExt;
|
||||
use thiserror::Error;
|
||||
use tokio::{io::AsyncWriteExt, process::Command};
|
||||
use tracing::warn;
|
||||
|
||||
use crate::{config::settings::NginxSettings, service::master_handler::MasterHandlerError};
|
||||
|
||||
#[cfg(test)]
|
||||
use mockall::predicate::*;
|
||||
// TODO: custom error type
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum FsHandlerError {
|
||||
#[error("Invalid output path: {0}")]
|
||||
InvalidOutputPath(String),
|
||||
#[error("IO error: {0}")]
|
||||
IoError(#[from] std::io::Error),
|
||||
}
|
||||
|
||||
impl From<FsHandlerError> for MasterHandlerError {
|
||||
fn from(err: FsHandlerError) -> Self {
|
||||
MasterHandlerError::MessageHandlingError(format!("File system handling error: {}", err))
|
||||
}
|
||||
}
|
||||
|
||||
pub type FsResult<T> = std::result::Result<T, FsHandlerError>;
|
||||
type Result<T> = FsResult<T>;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
#[cfg_attr(test, mockall::automock)]
|
||||
pub trait FsHandler: Send + Sync + 'static {
|
||||
fn get_deployment_id(config_id: &str, version: &str) -> String
|
||||
where
|
||||
Self: Sized,
|
||||
{
|
||||
format!("{}-{}", config_id, version)
|
||||
}
|
||||
// Write a new config file for nginx.
|
||||
// The output_path is a relative path to the nginx config directory of the deployment folder. The actual path to the config should not be assumed by the caller, as it can be different in different environments, but will be promised to be relative to the deployment folder for each the corresponding deployment_id. Path traversal is not allowed.
|
||||
async fn write_config(
|
||||
&self,
|
||||
deployment_id: &str,
|
||||
config_content: &str,
|
||||
output_path: &str,
|
||||
) -> Result<String>;
|
||||
// Append a new config content to an existing config file for nginx. This is useful for some use cases where we want to keep the existing config and just add some new config content to it. The output_path is a relative path to the nginx config directory of the deployment folder, which should be the same as the one used in write_config function. Path traversal is not allowed.
|
||||
async fn append_config(
|
||||
&self,
|
||||
deployment_id: &str,
|
||||
config_content: &str,
|
||||
output_path: &str,
|
||||
) -> Result<String>;
|
||||
|
||||
// clean up old config files that are applied to nginx
|
||||
// keep only latest n deployments.
|
||||
async fn cleanup_config(&self, n: usize) -> Result<()>;
|
||||
|
||||
// Persist the root config path of the last successful deployment.
|
||||
// Survives agent restarts so Reload/Test commands work without a new ConfigUpdate.
|
||||
async fn save_last_deployment(&self, root_config_path: &str) -> Result<()>;
|
||||
|
||||
// Load the last persisted root config path, if any.
|
||||
// Returns Ok(None) when no state file exists or it is empty/corrupt.
|
||||
async fn load_last_deployment(&self) -> Result<Option<String>>;
|
||||
}
|
||||
|
||||
pub struct FsHandlerImpl {
|
||||
settings: Arc<NginxSettings>,
|
||||
}
|
||||
|
||||
impl FsHandlerImpl {
|
||||
pub fn new(settings: Arc<NginxSettings>) -> Self {
|
||||
Self { settings }
|
||||
}
|
||||
|
||||
fn validate_config_path(config_path: &str) -> Result<()> {
|
||||
if !std::path::Path::new(config_path).exists() {
|
||||
return Err(FsHandlerError::InvalidOutputPath(format!(
|
||||
"Config file not found at path: {}",
|
||||
config_path
|
||||
)));
|
||||
}
|
||||
if !std::path::Path::new(config_path).is_file() {
|
||||
return Err(FsHandlerError::InvalidOutputPath(format!(
|
||||
"Config path is not a file: {}",
|
||||
config_path
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get_deployment_dir(&self) -> std::path::PathBuf {
|
||||
std::path::Path::new(&self.settings.nginx_config_path).join("deployments")
|
||||
}
|
||||
|
||||
fn get_deployment_dir_path(&self, deployment_id: &str) -> std::path::PathBuf {
|
||||
self.get_deployment_dir().join(deployment_id)
|
||||
}
|
||||
|
||||
fn get_state_file_path(&self) -> std::path::PathBuf {
|
||||
std::path::Path::new(&self.settings.nginx_config_path).join(".last_deployment")
|
||||
}
|
||||
|
||||
async fn get_deployment_config_path(
|
||||
&self,
|
||||
deployment_id: &str,
|
||||
output_path: &str,
|
||||
create_dir_if_not_exists: bool,
|
||||
) -> Result<std::path::PathBuf> {
|
||||
let output_path_obj = std::path::Path::new(output_path);
|
||||
if output_path_obj.is_absolute() {
|
||||
return Err(FsHandlerError::InvalidOutputPath(
|
||||
"Output path must be a relative path".into(),
|
||||
));
|
||||
}
|
||||
if output_path_obj
|
||||
.components()
|
||||
.any(|comp| comp == std::path::Component::ParentDir)
|
||||
{
|
||||
return Err(FsHandlerError::InvalidOutputPath(
|
||||
"Output path must not contain parent directory traversal".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let deployment_config_dir = self.get_deployment_dir_path(deployment_id);
|
||||
let full_path = deployment_config_dir.join(output_path);
|
||||
if create_dir_if_not_exists {
|
||||
if let Some(parent) = full_path.parent() {
|
||||
tokio::fs::create_dir_all(parent).await?;
|
||||
} else {
|
||||
tokio::fs::create_dir_all(&deployment_config_dir).await?;
|
||||
}
|
||||
}
|
||||
Ok(full_path)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl FsHandler for FsHandlerImpl {
|
||||
async fn write_config(
|
||||
&self,
|
||||
deployment_id: &str,
|
||||
config_content: &str,
|
||||
output_path: &str,
|
||||
) -> Result<String> {
|
||||
let full_output_path = self
|
||||
.get_deployment_config_path(deployment_id, output_path, true)
|
||||
.await?;
|
||||
let parent_dir = full_output_path.parent().ok_or_else(|| {
|
||||
FsHandlerError::InvalidOutputPath(format!(
|
||||
"Failed to get parent directory of output path: {:?}",
|
||||
full_output_path
|
||||
))
|
||||
})?;
|
||||
// ensure the parent directory exists before creating the file
|
||||
tokio::fs::create_dir_all(parent_dir).await?;
|
||||
let mut file = tokio::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.open(full_output_path.clone())
|
||||
.await?;
|
||||
// lock the file for writing to prevent concurrent write issue
|
||||
file.lock_exclusive()?;
|
||||
file.write_all(config_content.as_bytes()).await?;
|
||||
file.unlock()?;
|
||||
file.flush().await?;
|
||||
|
||||
Ok(full_output_path.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
async fn append_config(
|
||||
&self,
|
||||
deployment_id: &str,
|
||||
config_content: &str,
|
||||
output_path: &str,
|
||||
) -> Result<String> {
|
||||
let full_output_path = self
|
||||
.get_deployment_config_path(deployment_id, output_path, true)
|
||||
.await?;
|
||||
let mut file = tokio::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(full_output_path.clone())
|
||||
.await?;
|
||||
// lock the file for writing to prevent concurrent write issue
|
||||
file.lock_exclusive()?;
|
||||
file.write_all(config_content.as_bytes()).await?;
|
||||
file.unlock()?;
|
||||
file.flush().await?;
|
||||
|
||||
Ok(full_output_path.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
async fn save_last_deployment(&self, root_config_path: &str) -> Result<()> {
|
||||
let state_path = self.get_state_file_path();
|
||||
let tmp_path = state_path.with_extension("tmp");
|
||||
tokio::fs::write(&tmp_path, format!("{}\n", root_config_path)).await?;
|
||||
tokio::fs::rename(&tmp_path, &state_path).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn load_last_deployment(&self) -> Result<Option<String>> {
|
||||
// primary: try state file
|
||||
let state_path = self.get_state_file_path();
|
||||
if state_path.exists() {
|
||||
let content = tokio::fs::read_to_string(&state_path).await?;
|
||||
let path = content.trim().to_string();
|
||||
if !path.is_empty() {
|
||||
return Ok(Some(path));
|
||||
}
|
||||
}
|
||||
|
||||
// fallback: scan deployments directory for the newest deployment
|
||||
let deployment_dir = self.get_deployment_dir();
|
||||
if !deployment_dir.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
let mut entries = tokio::fs::read_dir(&deployment_dir).await?;
|
||||
let mut candidates: Vec<(std::path::PathBuf, std::time::SystemTime)> = Vec::new();
|
||||
while let Some(entry) = entries.next_entry().await? {
|
||||
if entry.file_type().await.map_or(false, |t| t.is_dir()) {
|
||||
if let Ok(mtime) = entry.metadata().await.and_then(|m| m.modified()) {
|
||||
candidates.push((entry.path(), mtime));
|
||||
}
|
||||
}
|
||||
}
|
||||
// sort descending by mtime (newest first)
|
||||
candidates.sort_by(|a, b| b.1.cmp(&a.1));
|
||||
|
||||
for (dir, _) in &candidates {
|
||||
let mut dir_entries = tokio::fs::read_dir(dir).await?;
|
||||
while let Some(file) = dir_entries.next_entry().await? {
|
||||
if file.file_type().await.map_or(false, |t| t.is_file()) {
|
||||
let name = file.file_name().to_string_lossy().to_string();
|
||||
if name == "nginx.conf" || name.ends_with(".conf") {
|
||||
let path = file.path().to_string_lossy().to_string();
|
||||
return Ok(Some(path));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn cleanup_config(&self, n: usize) -> Result<()> {
|
||||
let deployment_dir = self.get_deployment_dir();
|
||||
// loop through all files in the deployment dir and delete them
|
||||
let mut entries = tokio::fs::read_dir(&deployment_dir).await?;
|
||||
let mut deployment_ids = Vec::new();
|
||||
while let Some(entry) = entries.next_entry().await? {
|
||||
let file_type = entry.file_type().await?;
|
||||
if file_type.is_dir()
|
||||
&& let Some(deployment_id) = entry.file_name().to_str()
|
||||
{
|
||||
deployment_ids.push(deployment_id.to_string());
|
||||
}
|
||||
}
|
||||
// sort the deployment ids by modified time in descending order and keep the latest n deployments, delete the rest
|
||||
deployment_ids.sort_by_key(|id| {
|
||||
let path = self.get_deployment_dir_path(id);
|
||||
std::fs::metadata(path)
|
||||
.and_then(|meta| meta.modified())
|
||||
.unwrap_or(std::time::SystemTime::UNIX_EPOCH)
|
||||
});
|
||||
for deployment_id in deployment_ids.into_iter().skip(n) {
|
||||
let path = self.get_deployment_dir_path(&deployment_id);
|
||||
// ensure path is within the deplyment and nginx directory to prevent accidental deletion of other files
|
||||
if !path.starts_with(&deployment_dir)
|
||||
|| !path.starts_with(&self.settings.nginx_config_path)
|
||||
{
|
||||
warn!(
|
||||
"Skipping deletion of path outside of deployment or nginx config directory: {:?}",
|
||||
path
|
||||
);
|
||||
continue;
|
||||
}
|
||||
tokio::fs::remove_dir_all(path).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::expect_used)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use anyhow::Result;
|
||||
use std::sync::Arc;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[tokio::test]
|
||||
async fn write_and_append_config_roundtrip() -> Result<()> {
|
||||
let temp = TempDir::new()?;
|
||||
let settings = NginxSettings {
|
||||
nginx_config_path: temp.path().to_string_lossy().to_string(),
|
||||
nginx_binary_path: None,
|
||||
override_nginx_reload_command: vec![],
|
||||
override_nginx_test_command: vec![],
|
||||
nginx_reload_timeout_seconds: 1,
|
||||
nginx_test_timeout_seconds: 1,
|
||||
};
|
||||
|
||||
let handler = FsHandlerImpl::new(Arc::new(settings));
|
||||
|
||||
handler
|
||||
.write_config("deployment1", "hello", "conf/nginx.conf")
|
||||
.await?;
|
||||
|
||||
let full_path = temp
|
||||
.path()
|
||||
.join("deployments")
|
||||
.join("deployment1")
|
||||
.join("conf/nginx.conf");
|
||||
|
||||
let content = tokio::fs::read_to_string(&full_path).await?;
|
||||
assert_eq!(content, "hello");
|
||||
|
||||
handler
|
||||
.append_config("deployment1", " world", "conf/nginx.conf")
|
||||
.await?;
|
||||
|
||||
let content = tokio::fs::read_to_string(&full_path).await?;
|
||||
assert_eq!(content, "hello world");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn write_config_rejects_absolute_and_traversal_paths() -> Result<()> {
|
||||
let temp = TempDir::new()?;
|
||||
let settings = NginxSettings {
|
||||
nginx_config_path: temp.path().to_string_lossy().to_string(),
|
||||
nginx_binary_path: None,
|
||||
override_nginx_reload_command: vec![],
|
||||
override_nginx_test_command: vec![],
|
||||
nginx_reload_timeout_seconds: 1,
|
||||
nginx_test_timeout_seconds: 1,
|
||||
};
|
||||
|
||||
let handler = FsHandlerImpl::new(Arc::new(settings));
|
||||
|
||||
let err = handler
|
||||
.write_config("d", "x", "/absolute/path.conf")
|
||||
.await
|
||||
.err();
|
||||
assert!(err.is_some());
|
||||
|
||||
let err = handler.write_config("d", "x", "../escape.conf").await.err();
|
||||
assert!(err.is_some());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn validate_config_path_checks_file_exists_and_is_file() {
|
||||
// missing file
|
||||
let res = FsHandlerImpl::validate_config_path("/this/path/does/not/exist.conf");
|
||||
assert!(res.is_err());
|
||||
|
||||
// create a temp dir and ensure a directory is rejected
|
||||
let temp = TempDir::new().expect("Failed to create temp dir");
|
||||
let dir_path = temp.path();
|
||||
let res = FsHandlerImpl::validate_config_path(dir_path.to_string_lossy().as_ref());
|
||||
assert!(res.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_deployment_config_path_create_flag_behaviour() -> Result<()> {
|
||||
let temp = TempDir::new()?;
|
||||
let settings = NginxSettings {
|
||||
nginx_config_path: temp.path().to_string_lossy().to_string(),
|
||||
nginx_binary_path: None,
|
||||
override_nginx_reload_command: vec![],
|
||||
override_nginx_test_command: vec![],
|
||||
nginx_reload_timeout_seconds: 1,
|
||||
nginx_test_timeout_seconds: 1,
|
||||
};
|
||||
|
||||
let handler = FsHandlerImpl::new(Arc::new(settings));
|
||||
|
||||
// when create_dir_if_not_exists = false, directory shouldn't be created
|
||||
let path = handler
|
||||
.get_deployment_config_path("did", "conf/nginx.conf", false)
|
||||
.await?;
|
||||
assert!(
|
||||
!path
|
||||
.parent()
|
||||
.expect("Failed to get parent directory of deployment config path")
|
||||
.exists()
|
||||
);
|
||||
|
||||
// when create_dir_if_not_exists = true, directory should be created
|
||||
let path = handler
|
||||
.get_deployment_config_path("did", "conf/nginx.conf", true)
|
||||
.await?;
|
||||
assert!(
|
||||
path.parent()
|
||||
.expect("Failed to get parent directory of deployment config path")
|
||||
.exists()
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cleanup_config_deletes_expected_deployments() -> Result<()> {
|
||||
let temp = TempDir::new()?;
|
||||
let settings = NginxSettings {
|
||||
nginx_config_path: temp.path().to_string_lossy().to_string(),
|
||||
nginx_binary_path: None,
|
||||
override_nginx_reload_command: vec![],
|
||||
override_nginx_test_command: vec![],
|
||||
nginx_reload_timeout_seconds: 1,
|
||||
nginx_test_timeout_seconds: 1,
|
||||
};
|
||||
|
||||
let handler = FsHandlerImpl::new(Arc::new(settings));
|
||||
let base = temp.path().join("deployments");
|
||||
|
||||
// create three deployments sequentially so mtimes differ
|
||||
for id in &["d1", "d2", "d3"] {
|
||||
let p = base.join(id);
|
||||
std::fs::create_dir_all(&p)?;
|
||||
std::fs::write(p.join("file"), b"x")?;
|
||||
std::thread::sleep(std::time::Duration::from_millis(500));
|
||||
}
|
||||
|
||||
// call cleanup keeping 1; current implementation keeps the oldest n, so expect only d1 remains
|
||||
handler.cleanup_config(1).await?;
|
||||
|
||||
let mut exists = vec![];
|
||||
for id in &["d1", "d2", "d3"] {
|
||||
exists.push((id.to_string(), base.join(id).exists()));
|
||||
}
|
||||
|
||||
// d1 should remain, others removed (matches current implementation behavior)
|
||||
assert!(exists.iter().find(|(id, e)| id == "d1" && *e).is_some());
|
||||
assert!(exists.iter().find(|(id, e)| id == "d2" && !*e).is_some());
|
||||
assert!(exists.iter().find(|(id, e)| id == "d3" && !*e).is_some());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
185
apps/nxmesh-agent/src/service/nginx_handler/message_handler.rs
Normal file
185
apps/nxmesh-agent/src/service/nginx_handler/message_handler.rs
Normal file
@@ -0,0 +1,185 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use dashmap::DashMap;
|
||||
use nxmesh_proto::{
|
||||
ConfigUpdate, ConfigUpdateResult,
|
||||
agent_message::Payload::ConfigUpdateResult as ConfigUpdateResultPayload, command::Command,
|
||||
command_result,
|
||||
};
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::{
|
||||
config::settings::NginxSettings,
|
||||
service::{
|
||||
master_handler::{
|
||||
MasterHandler, MessageResult,
|
||||
handlers::{OnCommandHandler, OnConfigUpdateHandler},
|
||||
},
|
||||
nginx_handler::{command_handler::CommandHandler, fs_handler::FsHandler},
|
||||
},
|
||||
};
|
||||
|
||||
const DEFAULT_CONFIG_PATH: &str = "nginx.conf";
|
||||
const DEFAULT_NGINX_CONFIG_CONTENT: &str = r#"
|
||||
events {}
|
||||
"#;
|
||||
|
||||
pub trait NginxMasterMessageHandler: Send + Sync + 'static
|
||||
//
|
||||
+ OnConfigUpdateHandler
|
||||
+ OnCommandHandler
|
||||
{}
|
||||
|
||||
pub struct NginxMasterMessageHandlerImpl {
|
||||
settings: Arc<NginxSettings>,
|
||||
command_handler: Arc<dyn CommandHandler>,
|
||||
fs_handler: Arc<dyn FsHandler>,
|
||||
master_handler: Arc<dyn MasterHandler>,
|
||||
//
|
||||
// dash_map for for storing the on-going config updates, with the key as deployment_id, and the value as a tuple of (version_id, timestamp). On-going update must lock the deployment_id, and the new update with newer timestamp will wait until the lock is released. This is to ensure the config updates are applied in order.
|
||||
// When the current timestamp is older than the timestamp in the map, the current update must be rejected, and the master should be informed to resend the update with the latest timestamp.
|
||||
ongoing_updates: DashMap<String, (String, i64)>,
|
||||
}
|
||||
|
||||
impl NginxMasterMessageHandlerImpl {
|
||||
pub fn new(
|
||||
settings: Arc<NginxSettings>,
|
||||
command_handler: Arc<dyn CommandHandler>,
|
||||
fs_handler: Arc<dyn FsHandler>,
|
||||
master_handler: Arc<dyn MasterHandler>,
|
||||
) -> Self {
|
||||
Self {
|
||||
settings,
|
||||
command_handler,
|
||||
fs_handler,
|
||||
master_handler,
|
||||
ongoing_updates: DashMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl NginxMasterMessageHandler for NginxMasterMessageHandlerImpl {}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl OnConfigUpdateHandler for NginxMasterMessageHandlerImpl {
|
||||
async fn on_config_update(
|
||||
&self,
|
||||
agent_id: &str,
|
||||
timestamp: i64,
|
||||
message_id: &str,
|
||||
config_info: ConfigUpdate,
|
||||
) -> MessageResult<()> {
|
||||
// TODO: handle concurrency, expect only the latest version with latest timestamp is applied
|
||||
// when a newer config update comes in, and the older config update is still being processed. The new config will wait until the old config is applied.
|
||||
let deployment_id = format!("{}-{}", config_info.config_id, config_info.version);
|
||||
// write the configs
|
||||
let root_config_path = match config_info.root_config {
|
||||
Some(config_content) => {
|
||||
self.fs_handler
|
||||
.write_config(
|
||||
&deployment_id,
|
||||
&config_content.content,
|
||||
&config_content.path,
|
||||
)
|
||||
.await?
|
||||
}
|
||||
None => {
|
||||
// If the config content is not provided, write a default config to ensure the deployment folder is created and can be used for later updates.
|
||||
warn!(
|
||||
"Config content is not provided for config update, writing a default minimal config for deployment_id: {}",
|
||||
deployment_id
|
||||
);
|
||||
self.fs_handler
|
||||
.write_config(
|
||||
&deployment_id,
|
||||
DEFAULT_NGINX_CONFIG_CONTENT,
|
||||
DEFAULT_CONFIG_PATH,
|
||||
)
|
||||
.await?
|
||||
}
|
||||
};
|
||||
//
|
||||
for config in config_info.configs {
|
||||
self.fs_handler
|
||||
.write_config(&deployment_id, &config.content, &config.path)
|
||||
.await?;
|
||||
}
|
||||
// apply reload on the root config
|
||||
self.command_handler.reload(Some(&root_config_path)).await?;
|
||||
// persist deployment path so Reload/Test commands survive agent restarts
|
||||
self.fs_handler.save_last_deployment(&root_config_path).await?;
|
||||
info!("Persisted last deployment path: {}", root_config_path);
|
||||
// Reply the master to confirm the config update is successful
|
||||
self.master_handler
|
||||
.send_message_to_master(nxmesh_proto::AgentMessage {
|
||||
agent_id: agent_id.to_string(),
|
||||
timestamp,
|
||||
message_id: message_id.to_string(),
|
||||
payload: Some(ConfigUpdateResultPayload(ConfigUpdateResult {
|
||||
success: true,
|
||||
error_message: None,
|
||||
config_id: config_info.config_id,
|
||||
version: config_info.version,
|
||||
})),
|
||||
})
|
||||
.await?;
|
||||
//
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl OnCommandHandler for NginxMasterMessageHandlerImpl {
|
||||
async fn on_command(
|
||||
&self,
|
||||
agent_id: &str,
|
||||
timestamp: i64,
|
||||
message_id: &str,
|
||||
command: Command,
|
||||
) -> MessageResult<()> {
|
||||
// execute the command
|
||||
let mut agent_message = nxmesh_proto::AgentMessage {
|
||||
agent_id: agent_id.to_string(),
|
||||
timestamp,
|
||||
message_id: message_id.to_string(),
|
||||
payload: None,
|
||||
};
|
||||
// load the last known deployment path for use with Reload/Test commands
|
||||
let last_config_path = self.fs_handler.load_last_deployment().await?;
|
||||
|
||||
let result: command_result::Result = match command {
|
||||
Command::Reload(_) => {
|
||||
let result = self
|
||||
.command_handler
|
||||
.reload(last_config_path.as_deref())
|
||||
.await;
|
||||
command_result::Result::ReloadResult(nxmesh_proto::ReloadResult {
|
||||
success: result.is_ok(),
|
||||
error_message: result.err().map(|e| e.to_string()).unwrap_or_default(),
|
||||
})
|
||||
}
|
||||
Command::Test(_) => {
|
||||
let result = self
|
||||
.command_handler
|
||||
.validate(last_config_path.as_deref())
|
||||
.await;
|
||||
command_result::Result::TestResult(nxmesh_proto::TestResult {
|
||||
success: result.is_ok(),
|
||||
error_message: result.err().map(|e| e.to_string()).unwrap_or_default(),
|
||||
})
|
||||
}
|
||||
};
|
||||
// Reply the master to confirm the command execution is successful, and return the command output
|
||||
agent_message.payload = Some(nxmesh_proto::agent_message::Payload::CommandResult(
|
||||
nxmesh_proto::CommandResult {
|
||||
result: Some(result),
|
||||
},
|
||||
));
|
||||
|
||||
self.master_handler
|
||||
.send_message_to_master(agent_message)
|
||||
.await?;
|
||||
//
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
161
apps/nxmesh-agent/src/service/nginx_handler/mod.rs
Normal file
161
apps/nxmesh-agent/src/service/nginx_handler/mod.rs
Normal file
@@ -0,0 +1,161 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use nxmesh_proto::{ConfigUpdate, command::Command};
|
||||
|
||||
use crate::{
|
||||
config::settings::NginxSettings,
|
||||
service::{
|
||||
master_handler::{
|
||||
MasterHandler, MessageResult,
|
||||
handlers::{OnCommandHandler, OnConfigUpdateHandler},
|
||||
},
|
||||
nginx_handler::{
|
||||
command_handler::{CommandHandler, CommandHandlerImpl, CommandHandlerResult},
|
||||
fs_handler::{FsHandler, FsHandlerImpl, FsResult},
|
||||
message_handler::{NginxMasterMessageHandler, NginxMasterMessageHandlerImpl},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
mod command_handler;
|
||||
mod fs_handler;
|
||||
mod message_handler;
|
||||
|
||||
#[cfg(test)]
|
||||
use mockall::predicate::*;
|
||||
// TODO: custom error type
|
||||
|
||||
#[async_trait::async_trait]
|
||||
#[cfg_attr(test, mockall::automock)]
|
||||
pub trait NginxHandler: Send + Sync + 'static {
|
||||
// Reload nginx to apply new config. The config_path is an optional parameter that specifies the path to the nginx config file to be used for this reload operation. If not provided, the default config path will be used.
|
||||
async fn reload(&self, config_path: Option<&str>) -> CommandHandlerResult<()>;
|
||||
async fn stop(&self) -> CommandHandlerResult<()>;
|
||||
async fn validate(&self, config_path: Option<&str>) -> CommandHandlerResult<()>;
|
||||
async fn get_version(&self) -> CommandHandlerResult<String>;
|
||||
async fn get_status(&self) -> CommandHandlerResult<String>;
|
||||
// Write a new config file for nginx.
|
||||
// The output_path is a relative path to the nginx config directory of the deployment folder. The actual path to the config should not be assumed by the caller, as it can be different in different environments, but will be promised to be relative to the deployment folder for each the corresponding deployment_id. Path traversal is not allowed.
|
||||
async fn write_config(
|
||||
&self,
|
||||
deployment_id: &str,
|
||||
config_content: &str,
|
||||
output_path: &str,
|
||||
) -> FsResult<String>;
|
||||
// Append a new config content to an existing config file for nginx. This is useful for some use cases where we want to keep the existing config and just add some new config content to it. The output_path is a relative path to the nginx config directory of the deployment folder, which should be the same as the one used in write_config function. Path traversal is not allowed.
|
||||
async fn append_config(
|
||||
&self,
|
||||
deployment_id: &str,
|
||||
config_content: &str,
|
||||
output_path: &str,
|
||||
) -> FsResult<String>;
|
||||
|
||||
// clean up old config files that are applied to nginx
|
||||
// keep only latest n deployments.
|
||||
async fn cleanup_config(&self, n: usize) -> FsResult<()>;
|
||||
}
|
||||
|
||||
pub struct NginxHandlerImpl {
|
||||
settings: Arc<NginxSettings>,
|
||||
command_handler: Arc<dyn CommandHandler>,
|
||||
fs_handler: Arc<dyn FsHandler>,
|
||||
nginx_master_message_handler: Arc<dyn NginxMasterMessageHandler>,
|
||||
}
|
||||
|
||||
impl NginxHandlerImpl {
|
||||
pub fn new(settings: Arc<NginxSettings>, master_handler: Arc<dyn MasterHandler>) -> Self {
|
||||
let command_handler: Arc<dyn CommandHandler> =
|
||||
Arc::new(CommandHandlerImpl::new(settings.clone()));
|
||||
let fs_handler: Arc<dyn FsHandler> = Arc::new(FsHandlerImpl::new(settings.clone()));
|
||||
Self {
|
||||
settings: settings.clone(),
|
||||
command_handler: command_handler.clone(),
|
||||
fs_handler: fs_handler.clone(),
|
||||
nginx_master_message_handler: Arc::new(NginxMasterMessageHandlerImpl::new(
|
||||
settings.clone(),
|
||||
command_handler.clone(),
|
||||
fs_handler.clone(),
|
||||
master_handler,
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl NginxHandler for NginxHandlerImpl {
|
||||
async fn reload(&self, config_path: Option<&str>) -> CommandHandlerResult<()> {
|
||||
self.command_handler.reload(config_path).await
|
||||
}
|
||||
|
||||
async fn stop(&self) -> CommandHandlerResult<()> {
|
||||
self.command_handler.stop().await
|
||||
}
|
||||
|
||||
async fn validate(&self, config_path: Option<&str>) -> CommandHandlerResult<()> {
|
||||
self.command_handler.validate(config_path).await
|
||||
}
|
||||
|
||||
async fn get_version(&self) -> CommandHandlerResult<String> {
|
||||
self.command_handler.get_version().await
|
||||
}
|
||||
|
||||
async fn get_status(&self) -> CommandHandlerResult<String> {
|
||||
self.command_handler.get_status().await
|
||||
}
|
||||
|
||||
async fn write_config(
|
||||
&self,
|
||||
deployment_id: &str,
|
||||
config_content: &str,
|
||||
output_path: &str,
|
||||
) -> FsResult<String> {
|
||||
self.fs_handler
|
||||
.write_config(deployment_id, config_content, output_path)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn append_config(
|
||||
&self,
|
||||
deployment_id: &str,
|
||||
config_content: &str,
|
||||
output_path: &str,
|
||||
) -> FsResult<String> {
|
||||
self.fs_handler
|
||||
.append_config(deployment_id, config_content, output_path)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn cleanup_config(&self, n: usize) -> FsResult<()> {
|
||||
self.fs_handler.cleanup_config(n).await
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl OnConfigUpdateHandler for NginxHandlerImpl {
|
||||
async fn on_config_update(
|
||||
&self,
|
||||
agent_id: &str,
|
||||
timestamp: i64,
|
||||
message_id: &str,
|
||||
config_info: ConfigUpdate,
|
||||
) -> MessageResult<()> {
|
||||
self.nginx_master_message_handler
|
||||
.on_config_update(agent_id, timestamp, message_id, config_info)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl OnCommandHandler for NginxHandlerImpl {
|
||||
async fn on_command(
|
||||
&self,
|
||||
agent_id: &str,
|
||||
timestamp: i64,
|
||||
message_id: &str,
|
||||
command: Command,
|
||||
) -> MessageResult<()> {
|
||||
self.nginx_master_message_handler
|
||||
.on_command(agent_id, timestamp, message_id, command)
|
||||
.await
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,8 @@
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"test": "echo \"No test specified\" && exit 0"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^19.2.0",
|
||||
@@ -27,4 +28,4 @@
|
||||
"typescript-eslint": "^8.48.0",
|
||||
"vite": "^7.3.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -37,8 +37,7 @@ tower.workspace = true
|
||||
tower-http = { workspace = true, features = ["fs", "cors"] }
|
||||
|
||||
# OpenAPI
|
||||
utoipa = { version = "4", features = ["axum_extras"] }
|
||||
utoipa-swagger-ui = { version = "6", features = ["axum"] }
|
||||
utoipa = { version = "5.4", features = ["axum_extras"] }
|
||||
|
||||
# gRPC
|
||||
tonic.workspace = true
|
||||
@@ -79,7 +78,19 @@ handlebars.workspace = true
|
||||
|
||||
# Random generation
|
||||
rand = "0.10"
|
||||
clap = { workspace = true, features = ["derive"] }
|
||||
rcgen = { version = "0.14.7", features = ["x509-parser"] }
|
||||
time = "0.3"
|
||||
|
||||
# Cert handling
|
||||
zip = { workspace = true }
|
||||
rust-embed = { version = "8.11.0", features = [] }
|
||||
mime_guess = "2.0.5"
|
||||
axum-test = "20.0.0"
|
||||
|
||||
[dev-dependencies]
|
||||
tokio-test.workspace = true
|
||||
mockall.workspace = true
|
||||
|
||||
[features]
|
||||
dev-tools = ["axum/macros"]
|
||||
|
||||
1
apps/nxmesh-master/frontend-dist
Symbolic link
1
apps/nxmesh-master/frontend-dist
Symbolic link
@@ -0,0 +1 @@
|
||||
../nxmesh-frontend/dist/
|
||||
3
apps/nxmesh-master/src/bin/gen-openapi.rs
Normal file
3
apps/nxmesh-master/src/bin/gen-openapi.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
// TODO:
|
||||
}
|
||||
52
apps/nxmesh-master/src/cli/gen_agent_certs.rs
Normal file
52
apps/nxmesh-master/src/cli/gen_agent_certs.rs
Normal file
@@ -0,0 +1,52 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use tracing::{error, info};
|
||||
|
||||
use crate::{config::settings::Settings, db, service};
|
||||
|
||||
pub async fn gen_agent_certs(
|
||||
settings: &Settings,
|
||||
output: String,
|
||||
agent_id: String,
|
||||
zip: bool,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
info!("Generating certificates to output directory: {}", output);
|
||||
use service::certificate::CertificateService;
|
||||
//
|
||||
let cert_service = service::certificate::CertificateServiceImpl::new(
|
||||
#[expect(clippy::expect_used)]
|
||||
db::establish_connection(&settings.database.url)
|
||||
.await
|
||||
.expect("Failed to connect to database"),
|
||||
output.clone(),
|
||||
Arc::new(settings.clone()),
|
||||
);
|
||||
|
||||
let output = cert_service
|
||||
.generate_agent_certs(&agent_id, &output)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("Failed to generate agent certificates: {}", e);
|
||||
std::process::exit(1);
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
info!(
|
||||
"Successfully generated agent certificates at: cert path: {}, key path: {}, ca cert path: {}",
|
||||
output.cert_path, output.key_path, output.ca_cert_path
|
||||
);
|
||||
if zip {
|
||||
// Implementation for zipping certificates
|
||||
info!("Zipping generated certificates...");
|
||||
if let Err(e) = cert_service
|
||||
.zip_certificates(&output.cert_path, &output.key_path, &output.ca_cert_path)
|
||||
.await
|
||||
{
|
||||
error!("Failed to zip certificates: {}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
info!("Successfully zipped certificates.");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
31
apps/nxmesh-master/src/cli/gen_certs.rs
Normal file
31
apps/nxmesh-master/src/cli/gen_certs.rs
Normal file
@@ -0,0 +1,31 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use tracing::{error, info};
|
||||
|
||||
use crate::{config::settings::Settings, db, service};
|
||||
|
||||
pub async fn gen_certs(
|
||||
settings: &Settings,
|
||||
output: String,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
info!("Generating CA certificate to output directory: {}", output);
|
||||
use service::certificate::CertificateService;
|
||||
let cert_service = service::certificate::CertificateServiceImpl::new(
|
||||
#[expect(clippy::expect_used)]
|
||||
db::establish_connection(&settings.database.url)
|
||||
.await
|
||||
.expect("Failed to connect to database"),
|
||||
output.to_string(),
|
||||
Arc::new(settings.clone()),
|
||||
);
|
||||
cert_service
|
||||
.generate_ca_cert()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("Failed to generate CA certificate: {}", e);
|
||||
std::process::exit(1);
|
||||
})
|
||||
.unwrap();
|
||||
info!("Successfully generated CA certificate at: {}", output);
|
||||
Ok(())
|
||||
}
|
||||
133
apps/nxmesh-master/src/cli/mod.rs
Normal file
133
apps/nxmesh-master/src/cli/mod.rs
Normal file
@@ -0,0 +1,133 @@
|
||||
mod gen_agent_certs;
|
||||
mod gen_certs;
|
||||
|
||||
use clap::{Parser, Subcommand};
|
||||
|
||||
use crate::{
|
||||
cli::{gen_agent_certs::gen_agent_certs, gen_certs::gen_certs},
|
||||
config::settings::Settings,
|
||||
};
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(version, about, long_about = None)]
|
||||
pub struct Cli {
|
||||
/// Start the master server
|
||||
#[arg(short, long, group = "mode")]
|
||||
pub serve: bool,
|
||||
|
||||
/// generate CA for key signing if not exist
|
||||
/// If the CA already exists, generating CA will be skipped and the existing CA will be used
|
||||
/// If the CA does not exist, a new CA will be generated and saved to the default location (./certs/ca.crt and ./certs/ca.key)
|
||||
/// The generated CA will be used for signing agent certificates
|
||||
/// If not specified, the server will check if the CA already exists and use it if available, otherwise exit with an error
|
||||
#[arg(long)]
|
||||
pub generate_ca: bool,
|
||||
|
||||
#[command(subcommand)]
|
||||
pub command: Option<Commands>,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub enum Commands {
|
||||
GenCerts {
|
||||
/// Output directory for generated certificates
|
||||
#[arg(short, long, default_value = "./certs")]
|
||||
output: String,
|
||||
},
|
||||
/// Generate certificates for agent
|
||||
#[command(about = "Generate certificates for agent")]
|
||||
GenAgentCerts {
|
||||
/// Output directory for generated certificates
|
||||
#[arg(short, long, default_value = "./certs")]
|
||||
output: String,
|
||||
#[arg(long, default_value = "agent-id-placeholder")]
|
||||
agent_id: String,
|
||||
#[arg(short, long, default_value = "false")]
|
||||
zip: bool,
|
||||
},
|
||||
}
|
||||
|
||||
pub async fn handle_sub_command(
|
||||
settings: &Settings,
|
||||
command: Commands,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
// run as a CLI tool for other commands
|
||||
match command {
|
||||
Commands::GenCerts { output } => Ok(gen_certs(settings, output).await?),
|
||||
Commands::GenAgentCerts {
|
||||
output,
|
||||
agent_id,
|
||||
zip,
|
||||
} => Ok(gen_agent_certs(settings, output, agent_id, zip).await?),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use clap::Parser;
|
||||
|
||||
use super::{Cli, Commands};
|
||||
|
||||
#[test]
|
||||
fn parses_serve_mode() {
|
||||
let parsed = Cli::try_parse_from(["nxmesh-master", "--serve"]);
|
||||
assert!(parsed.is_ok());
|
||||
let parsed = parsed.unwrap_or_else(|_| unreachable!());
|
||||
|
||||
assert!(parsed.serve);
|
||||
assert!(!parsed.generate_ca);
|
||||
assert!(parsed.command.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_generate_ca_flag() {
|
||||
let parsed = Cli::try_parse_from(["nxmesh-master", "--generate-ca", "--serve"]);
|
||||
assert!(parsed.is_ok());
|
||||
let parsed = parsed.unwrap_or_else(|_| unreachable!());
|
||||
|
||||
assert!(parsed.generate_ca);
|
||||
assert!(parsed.serve);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_gen_certs_with_default_output() {
|
||||
let parsed = Cli::try_parse_from(["nxmesh-master", "gen-certs"]);
|
||||
assert!(parsed.is_ok());
|
||||
let parsed = parsed.unwrap_or_else(|_| unreachable!());
|
||||
|
||||
match parsed.command {
|
||||
Some(Commands::GenCerts { output }) => {
|
||||
assert_eq!(output, "./certs");
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_gen_agent_certs_with_custom_values() {
|
||||
let parsed = Cli::try_parse_from([
|
||||
"nxmesh-master",
|
||||
"gen-agent-certs",
|
||||
"--output",
|
||||
"./out",
|
||||
"--agent-id",
|
||||
"agent-123",
|
||||
"--zip",
|
||||
]);
|
||||
assert!(parsed.is_ok());
|
||||
let parsed = parsed.unwrap_or_else(|_| unreachable!());
|
||||
|
||||
match parsed.command {
|
||||
Some(Commands::GenAgentCerts {
|
||||
output,
|
||||
agent_id,
|
||||
zip,
|
||||
}) => {
|
||||
assert_eq!(output, "./out");
|
||||
assert_eq!(agent_id, "agent-123");
|
||||
assert!(zip);
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
}
|
||||
1
apps/nxmesh-master/src/config/mod.rs
Normal file
1
apps/nxmesh-master/src/config/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub mod settings;
|
||||
58
apps/nxmesh-master/src/config/settings/auth.rs
Normal file
58
apps/nxmesh-master/src/config/settings/auth.rs
Normal file
@@ -0,0 +1,58 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::config::settings::{Validate, ValidationError};
|
||||
|
||||
/// Authentication settings
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AuthSettings {
|
||||
pub jwt_secret: String,
|
||||
#[serde(default = "default_jwt_expiration_hours")]
|
||||
pub jwt_expiration_hours: u64,
|
||||
}
|
||||
|
||||
impl Validate for AuthSettings {
|
||||
fn validate(&self) -> Result<(), ValidationError> {
|
||||
if self.jwt_secret.is_empty() {
|
||||
return Err("JWT secret cannot be empty".into());
|
||||
}
|
||||
if self.jwt_expiration_hours == 0 {
|
||||
return Err("JWT expiration hours must be greater than 0".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn default_jwt_expiration_hours() -> u64 {
|
||||
24
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::{
|
||||
fs,
|
||||
net::{IpAddr, Ipv4Addr},
|
||||
path::PathBuf,
|
||||
time::{SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_esnure_send_and_sync() {
|
||||
fn assert_send_sync<T: Send + Sync>() {}
|
||||
assert_send_sync::<AuthSettings>();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auth_validate_fails_for_empty_secret() {
|
||||
let auth = AuthSettings {
|
||||
jwt_secret: "".to_string(),
|
||||
jwt_expiration_hours: 24,
|
||||
};
|
||||
|
||||
let result = auth.validate();
|
||||
assert!(result.is_err());
|
||||
let msg = result.err().unwrap_or_default();
|
||||
assert!(msg.contains("JWT secret cannot be empty"));
|
||||
}
|
||||
}
|
||||
276
apps/nxmesh-master/src/config/settings/cert.rs
Normal file
276
apps/nxmesh-master/src/config/settings/cert.rs
Normal file
@@ -0,0 +1,276 @@
|
||||
use std::net::IpAddr;
|
||||
|
||||
use rcgen::string::Ia5String;
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
|
||||
use crate::config::settings::{Validate, ValidationError};
|
||||
|
||||
/// TLS certificate settings
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct CertificateSettings {
|
||||
#[serde(default = "default_cert_folder")]
|
||||
pub cert_dir: String,
|
||||
#[serde(
|
||||
default,
|
||||
serialize_with = "serialize_ia5string_vec",
|
||||
deserialize_with = "deserialize_ia5string_vec"
|
||||
)]
|
||||
pub san_dns: Vec<Ia5String>,
|
||||
#[serde(default)]
|
||||
pub san_ip: Vec<IpAddr>,
|
||||
#[serde(default)]
|
||||
pub cert_path: Option<String>,
|
||||
#[serde(default)]
|
||||
pub key_path: Option<String>,
|
||||
}
|
||||
|
||||
impl CertificateSettings {
|
||||
pub fn cert_path(&self) -> Option<String> {
|
||||
self.cert_path
|
||||
.as_ref()
|
||||
.map(|p| format!("{}/{}", self.cert_dir, p))
|
||||
}
|
||||
|
||||
pub fn key_path(&self) -> Option<String> {
|
||||
self.key_path
|
||||
.as_ref()
|
||||
.map(|p| format!("{}/{}", self.cert_dir, p))
|
||||
}
|
||||
}
|
||||
|
||||
impl Validate for CertificateSettings {
|
||||
fn validate(&self) -> Result<(), ValidationError> {
|
||||
let base_path = std::path::Path::new(&self.cert_dir);
|
||||
if !base_path.exists() {
|
||||
// create the cert directory if it doesn't exist
|
||||
std::fs::create_dir_all(base_path).map_err(|e| {
|
||||
format!(
|
||||
"Failed to create certificate directory {:?}: {}",
|
||||
base_path, e
|
||||
)
|
||||
})?;
|
||||
}
|
||||
let cert_path = self.cert_path.as_ref().map(|p| base_path.join(p));
|
||||
let key_path = self.key_path.as_ref().map(|p| base_path.join(p));
|
||||
if (cert_path.is_some() && key_path.is_none())
|
||||
|| (cert_path.is_none() && key_path.is_some())
|
||||
{
|
||||
return Err("Both certificate and key paths must be provided for TLS".into());
|
||||
}
|
||||
if let (Some(cert_path), Some(key_path)) = (&cert_path, &key_path) {
|
||||
if !std::path::Path::new(cert_path).exists() {
|
||||
return Err(format!("Certificate file not found: {:?}", cert_path));
|
||||
}
|
||||
if !std::path::Path::new(key_path).exists() {
|
||||
return Err(format!("Key file not found: {:?}", key_path));
|
||||
}
|
||||
}
|
||||
|
||||
// validate for SAN entries - must be valid DNS names or IP addresses
|
||||
for dns in &self.san_dns {
|
||||
if dns.to_string().is_empty() {
|
||||
return Err("SAN DNS entries cannot be empty".into());
|
||||
}
|
||||
}
|
||||
for ip in &self.san_ip {
|
||||
if ip.is_unspecified() {
|
||||
return Err("SAN IP entries cannot be unspecified".into());
|
||||
}
|
||||
}
|
||||
// require at least one SAN entry for the generated certificate
|
||||
if self.san_dns.is_empty() && self.san_ip.is_empty() {
|
||||
return Err(
|
||||
"At least one SAN entry (DNS or IP) must be provided for the certificate".into(),
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn default_cert_folder() -> String {
|
||||
"./certs".into()
|
||||
}
|
||||
|
||||
fn deserialize_ia5string_vec<'de, D>(deserializer: D) -> Result<Vec<Ia5String>, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let vec = Vec::<String>::deserialize(deserializer)?;
|
||||
vec.into_iter()
|
||||
.map(|s| Ia5String::try_from(s).map_err(serde::de::Error::custom))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn serialize_ia5string_vec<S>(vec: &Vec<Ia5String>, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: serde::Serializer,
|
||||
{
|
||||
let string_vec: Vec<String> = vec.iter().map(|ia5| ia5.to_string()).collect();
|
||||
string_vec.serialize(serializer)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::{
|
||||
fs,
|
||||
net::{IpAddr, Ipv4Addr},
|
||||
path::PathBuf,
|
||||
time::{SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_esnure_send_and_sync() {
|
||||
fn assert_send_sync<T: Send + Sync>() {}
|
||||
assert_send_sync::<CertificateSettings>();
|
||||
}
|
||||
|
||||
fn make_temp_dir(prefix: &str) -> PathBuf {
|
||||
let ts = SystemTime::now().duration_since(UNIX_EPOCH);
|
||||
assert!(ts.is_ok());
|
||||
let ts = ts.unwrap_or_default();
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"{}_{}_{}",
|
||||
prefix,
|
||||
std::process::id(),
|
||||
ts.as_nanos()
|
||||
));
|
||||
let created = fs::create_dir_all(&path);
|
||||
assert!(created.is_ok());
|
||||
path
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn certificate_paths_include_cert_dir() {
|
||||
let cert = CertificateSettings {
|
||||
cert_dir: "./certs".to_string(),
|
||||
san_dns: Vec::new(),
|
||||
san_ip: Vec::new(),
|
||||
cert_path: Some("server.crt".to_string()),
|
||||
key_path: Some("server.key".to_string()),
|
||||
};
|
||||
|
||||
assert_eq!(cert.cert_path(), Some("./certs/server.crt".to_string()));
|
||||
assert_eq!(cert.key_path(), Some("./certs/server.key".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn certificate_validate_creates_directory_when_missing() {
|
||||
let cert_dir = make_temp_dir("nxmesh-master-cert-create").join("nested");
|
||||
let san = Ia5String::try_from("localhost".to_string());
|
||||
assert!(san.is_ok());
|
||||
let san = san.unwrap_or_else(|_| unreachable!());
|
||||
let cert = CertificateSettings {
|
||||
cert_dir: cert_dir.to_string_lossy().to_string(),
|
||||
san_dns: vec![san],
|
||||
san_ip: Vec::new(),
|
||||
cert_path: None,
|
||||
key_path: None,
|
||||
};
|
||||
|
||||
let result = cert.validate();
|
||||
assert!(result.is_ok());
|
||||
assert!(cert_dir.exists());
|
||||
|
||||
let _ = fs::remove_dir_all(cert_dir.parent().unwrap_or(&cert_dir));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn certificate_validate_fails_when_only_cert_path_is_set() {
|
||||
let cert_dir = make_temp_dir("nxmesh-master-cert-partial");
|
||||
let san = Ia5String::try_from("localhost".to_string());
|
||||
assert!(san.is_ok());
|
||||
let san = san.unwrap_or_else(|_| unreachable!());
|
||||
let cert = CertificateSettings {
|
||||
cert_dir: cert_dir.to_string_lossy().to_string(),
|
||||
san_dns: vec![san],
|
||||
san_ip: Vec::new(),
|
||||
cert_path: Some("server.crt".to_string()),
|
||||
key_path: None,
|
||||
};
|
||||
|
||||
let result = cert.validate();
|
||||
assert!(result.is_err());
|
||||
let msg = result.err().unwrap_or_default();
|
||||
assert!(msg.contains("Both certificate and key paths must be provided"));
|
||||
|
||||
let _ = fs::remove_dir_all(&cert_dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn certificate_validate_fails_with_unspecified_ip() {
|
||||
let cert_dir = make_temp_dir("nxmesh-master-cert-unspecified-ip");
|
||||
let cert = CertificateSettings {
|
||||
cert_dir: cert_dir.to_string_lossy().to_string(),
|
||||
san_dns: Vec::new(),
|
||||
san_ip: vec![IpAddr::V4(Ipv4Addr::UNSPECIFIED)],
|
||||
cert_path: None,
|
||||
key_path: None,
|
||||
};
|
||||
|
||||
let result = cert.validate();
|
||||
assert!(result.is_err());
|
||||
let msg = result.err().unwrap_or_default();
|
||||
assert!(msg.contains("SAN IP entries cannot be unspecified"));
|
||||
|
||||
let _ = fs::remove_dir_all(&cert_dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn certificate_validate_fails_without_any_san_entries() {
|
||||
let cert_dir = make_temp_dir("nxmesh-master-cert-no-san");
|
||||
let cert = CertificateSettings {
|
||||
cert_dir: cert_dir.to_string_lossy().to_string(),
|
||||
san_dns: Vec::new(),
|
||||
san_ip: Vec::new(),
|
||||
cert_path: None,
|
||||
key_path: None,
|
||||
};
|
||||
|
||||
let result = cert.validate();
|
||||
assert!(result.is_err());
|
||||
let msg = result.err().unwrap_or_default();
|
||||
assert!(msg.contains("At least one SAN entry"));
|
||||
|
||||
let _ = fs::remove_dir_all(&cert_dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ia5string_vec_round_trip_serialization() {
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct Wrapper {
|
||||
#[serde(
|
||||
deserialize_with = "deserialize_ia5string_vec",
|
||||
serialize_with = "serialize_ia5string_vec"
|
||||
)]
|
||||
san_dns: Vec<Ia5String>,
|
||||
}
|
||||
|
||||
let first = Ia5String::try_from("localhost".to_string());
|
||||
assert!(first.is_ok());
|
||||
let second = Ia5String::try_from("example.com".to_string());
|
||||
assert!(second.is_ok());
|
||||
|
||||
let first = first.unwrap_or_else(|_| unreachable!());
|
||||
let second = second.unwrap_or_else(|_| unreachable!());
|
||||
|
||||
let data = Wrapper {
|
||||
san_dns: vec![first, second],
|
||||
};
|
||||
|
||||
let encoded = serde_json::to_string(&data);
|
||||
assert!(encoded.is_ok());
|
||||
let encoded = encoded.unwrap_or_default();
|
||||
assert!(encoded.contains("localhost"));
|
||||
assert!(encoded.contains("example.com"));
|
||||
|
||||
let decoded: Result<Wrapper, _> = serde_json::from_str(&encoded);
|
||||
assert!(decoded.is_ok());
|
||||
let decoded = decoded.unwrap_or(Wrapper {
|
||||
san_dns: Vec::new(),
|
||||
});
|
||||
assert_eq!(decoded.san_dns.len(), 2);
|
||||
}
|
||||
}
|
||||
34
apps/nxmesh-master/src/config/settings/cors.rs
Normal file
34
apps/nxmesh-master/src/config/settings/cors.rs
Normal file
@@ -0,0 +1,34 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::config::settings::{Validate, ValidationError};
|
||||
|
||||
/// CORS settings
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct CorsSettings {
|
||||
#[serde(default)]
|
||||
pub allowed_origins: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub allowed_methods: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub allowed_headers: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub allow_credentials: bool,
|
||||
}
|
||||
|
||||
impl Validate for CorsSettings {
|
||||
fn validate(&self) -> Result<(), ValidationError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_esnure_send_and_sync() {
|
||||
fn assert_send_sync<T: Send + Sync>() {}
|
||||
assert_send_sync::<CorsSettings>();
|
||||
}
|
||||
}
|
||||
48
apps/nxmesh-master/src/config/settings/database.rs
Normal file
48
apps/nxmesh-master/src/config/settings/database.rs
Normal file
@@ -0,0 +1,48 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::config::settings::{Validate, ValidationError};
|
||||
|
||||
/// Database connection settings
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DatabaseSettings {
|
||||
pub url: String,
|
||||
pub max_connections: Option<u32>,
|
||||
}
|
||||
|
||||
impl Validate for DatabaseSettings {
|
||||
fn validate(&self) -> Result<(), ValidationError> {
|
||||
if self.url.is_empty() {
|
||||
return Err("Database URL cannot be empty".into());
|
||||
}
|
||||
if let Some(max_connections) = self.max_connections
|
||||
&& max_connections == 0
|
||||
{
|
||||
return Err("Max database connections must be greater than 0".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_esnure_send_and_sync() {
|
||||
fn assert_send_sync<T: Send + Sync>() {}
|
||||
assert_send_sync::<DatabaseSettings>();
|
||||
}
|
||||
#[test]
|
||||
fn database_validate_fails_for_zero_max_connections() {
|
||||
let db = DatabaseSettings {
|
||||
url: "postgres://localhost/db".to_string(),
|
||||
max_connections: Some(0),
|
||||
};
|
||||
|
||||
let result = db.validate();
|
||||
assert!(result.is_err());
|
||||
let msg = result.err().unwrap_or_default();
|
||||
assert!(msg.contains("Max database connections must be greater than 0"));
|
||||
}
|
||||
}
|
||||
53
apps/nxmesh-master/src/config/settings/grpc.rs
Normal file
53
apps/nxmesh-master/src/config/settings/grpc.rs
Normal file
@@ -0,0 +1,53 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::config::settings::{
|
||||
Validate, ValidationError, cert::CertificateSettings, cors::CorsSettings,
|
||||
};
|
||||
|
||||
/// gRPC server settings
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct GrpcSettings {
|
||||
#[serde(default = "default_grpc_bind_address")]
|
||||
pub bind_address: String,
|
||||
#[serde(default = "default_grpc_port")]
|
||||
pub port: u16,
|
||||
#[serde(default)]
|
||||
pub certificate: CertificateSettings,
|
||||
#[serde(default)]
|
||||
pub cors: Option<CorsSettings>,
|
||||
}
|
||||
|
||||
impl Validate for GrpcSettings {
|
||||
fn validate(&self) -> Result<(), ValidationError> {
|
||||
if self.bind_address.is_empty() {
|
||||
return Err("gRPC bind address cannot be empty".into());
|
||||
}
|
||||
if self.port == 0 {
|
||||
return Err("gRPC port must be greater than 0".into());
|
||||
}
|
||||
self.certificate.validate()?;
|
||||
if let Some(cors) = &self.cors {
|
||||
cors.validate()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn default_grpc_bind_address() -> String {
|
||||
"0.0.0.0".into()
|
||||
}
|
||||
|
||||
fn default_grpc_port() -> u16 {
|
||||
50051
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_esnure_send_and_sync() {
|
||||
fn assert_send_sync<T: Send + Sync>() {}
|
||||
assert_send_sync::<GrpcSettings>();
|
||||
}
|
||||
}
|
||||
81
apps/nxmesh-master/src/config/settings/log.rs
Normal file
81
apps/nxmesh-master/src/config/settings/log.rs
Normal file
@@ -0,0 +1,81 @@
|
||||
use std::str::FromStr;
|
||||
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
use tracing::level_filters::LevelFilter;
|
||||
|
||||
/// Logging settings
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LogSettings {
|
||||
#[serde(
|
||||
deserialize_with = "deserialize_level_filter",
|
||||
serialize_with = "serialize_level_filter"
|
||||
)]
|
||||
pub level: LevelFilter,
|
||||
}
|
||||
|
||||
impl Default for LogSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
level: default_log_level(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn default_log_level() -> LevelFilter {
|
||||
LevelFilter::INFO
|
||||
}
|
||||
|
||||
fn deserialize_level_filter<'de, D>(deserializer: D) -> Result<LevelFilter, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let s = String::deserialize(deserializer)?;
|
||||
LevelFilter::from_str(&s).map_err(serde::de::Error::custom)
|
||||
}
|
||||
|
||||
fn serialize_level_filter<S>(level: &LevelFilter, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: serde::Serializer,
|
||||
{
|
||||
serializer.serialize_str(&level.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_esnure_send_and_sync() {
|
||||
fn assert_send_sync<T: Send + Sync>() {}
|
||||
assert_send_sync::<LogSettings>();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn level_filter_round_trip_serialization() {
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct Wrapper {
|
||||
#[serde(
|
||||
deserialize_with = "deserialize_level_filter",
|
||||
serialize_with = "serialize_level_filter"
|
||||
)]
|
||||
level: LevelFilter,
|
||||
}
|
||||
|
||||
let data = Wrapper {
|
||||
level: LevelFilter::DEBUG,
|
||||
};
|
||||
|
||||
let encoded = serde_json::to_string(&data);
|
||||
assert!(encoded.is_ok());
|
||||
let encoded = encoded.unwrap_or_default();
|
||||
assert!(encoded.to_lowercase().contains("debug"));
|
||||
|
||||
let decoded: Result<Wrapper, _> = serde_json::from_str(&encoded);
|
||||
assert!(decoded.is_ok());
|
||||
let decoded = decoded.unwrap_or(Wrapper {
|
||||
level: LevelFilter::ERROR,
|
||||
});
|
||||
assert_eq!(decoded.level, LevelFilter::DEBUG);
|
||||
}
|
||||
}
|
||||
75
apps/nxmesh-master/src/config/settings/mod.rs
Normal file
75
apps/nxmesh-master/src/config/settings/mod.rs
Normal file
@@ -0,0 +1,75 @@
|
||||
use config::{Config, ConfigError, Environment, File};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub type ValidationError = String;
|
||||
|
||||
pub mod auth;
|
||||
pub mod cert;
|
||||
pub mod cors;
|
||||
pub mod database;
|
||||
pub mod grpc;
|
||||
pub mod log;
|
||||
pub mod server;
|
||||
|
||||
use auth::AuthSettings;
|
||||
use database::DatabaseSettings;
|
||||
use grpc::GrpcSettings;
|
||||
use log::LogSettings;
|
||||
use server::ServerSettings;
|
||||
|
||||
pub trait Validate {
|
||||
fn validate(&self) -> Result<(), ValidationError>;
|
||||
}
|
||||
|
||||
/// Master server settings
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Settings {
|
||||
pub server: ServerSettings,
|
||||
pub database: DatabaseSettings,
|
||||
pub grpc: GrpcSettings,
|
||||
pub auth: AuthSettings,
|
||||
#[serde(default)]
|
||||
pub log: LogSettings,
|
||||
}
|
||||
|
||||
impl Validate for Settings {
|
||||
fn validate(&self) -> Result<(), ValidationError> {
|
||||
self.server.validate()?;
|
||||
self.grpc.validate()?;
|
||||
self.database.validate()?;
|
||||
self.auth.validate()?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Settings {
|
||||
/// Load settings from config files and environment
|
||||
pub fn load() -> Result<Self, ConfigError> {
|
||||
let run_mode = std::env::var("RUN_MODE").unwrap_or_else(|_| "development".into());
|
||||
|
||||
let settings = Config::builder()
|
||||
.add_source(File::with_name("config/default").required(false))
|
||||
.add_source(File::with_name(&format!("config/{}", run_mode)).required(false))
|
||||
.add_source(File::with_name("config/master/default").required(false))
|
||||
.add_source(File::with_name(&format!("config/master/{}", run_mode)).required(false))
|
||||
.add_source(Environment::with_prefix("NXMESH").separator("__"))
|
||||
.build()?;
|
||||
|
||||
let settings: Self = settings.try_deserialize()?;
|
||||
|
||||
settings.validate().map_err(ConfigError::Message)?;
|
||||
|
||||
Ok(settings)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_esnure_send_and_sync() {
|
||||
fn assert_send_sync<T: Send + Sync>() {}
|
||||
assert_send_sync::<Settings>();
|
||||
}
|
||||
}
|
||||
103
apps/nxmesh-master/src/config/settings/server.rs
Normal file
103
apps/nxmesh-master/src/config/settings/server.rs
Normal file
@@ -0,0 +1,103 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::config::settings::{
|
||||
Validate, ValidationError, cert::CertificateSettings, cors::CorsSettings,
|
||||
};
|
||||
|
||||
/// HTTP server settings
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ServerSettings {
|
||||
#[serde(default = "default_server_bind_address")]
|
||||
pub bind_address: String,
|
||||
#[serde(default = "default_server_port")]
|
||||
pub port: u16,
|
||||
#[serde(default)]
|
||||
pub certificate: CertificateSettings,
|
||||
#[serde(default)]
|
||||
pub cors: Option<CorsSettings>,
|
||||
}
|
||||
|
||||
impl Validate for ServerSettings {
|
||||
fn validate(&self) -> Result<(), ValidationError> {
|
||||
if self.bind_address.is_empty() {
|
||||
return Err("Server bind address cannot be empty".into());
|
||||
}
|
||||
if self.port == 0 {
|
||||
return Err("Server port must be greater than 0".into());
|
||||
}
|
||||
self.certificate.validate()?;
|
||||
if let Some(cors) = &self.cors {
|
||||
cors.validate()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn default_server_bind_address() -> String {
|
||||
"0.0.0.0".into()
|
||||
}
|
||||
|
||||
fn default_server_port() -> u16 {
|
||||
8080
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::{
|
||||
fs,
|
||||
path::PathBuf,
|
||||
time::{SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
use rcgen::string::Ia5String;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_esnure_send_and_sync() {
|
||||
fn assert_send_sync<T: Send + Sync>() {}
|
||||
assert_send_sync::<ServerSettings>();
|
||||
}
|
||||
|
||||
fn make_temp_dir(prefix: &str) -> PathBuf {
|
||||
let ts = SystemTime::now().duration_since(UNIX_EPOCH);
|
||||
assert!(ts.is_ok());
|
||||
let ts = ts.unwrap_or_default();
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"{}_{}_{}",
|
||||
prefix,
|
||||
std::process::id(),
|
||||
ts.as_nanos()
|
||||
));
|
||||
let created = fs::create_dir_all(&path);
|
||||
assert!(created.is_ok());
|
||||
path
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_validate_fails_for_zero_port() {
|
||||
let cert_dir = make_temp_dir("nxmesh-master-server-validate");
|
||||
let san = Ia5String::try_from("localhost".to_string());
|
||||
assert!(san.is_ok());
|
||||
let san = san.unwrap_or_else(|_| unreachable!());
|
||||
let server = ServerSettings {
|
||||
bind_address: "0.0.0.0".to_string(),
|
||||
port: 0,
|
||||
certificate: CertificateSettings {
|
||||
cert_dir: cert_dir.to_string_lossy().to_string(),
|
||||
san_dns: vec![san],
|
||||
san_ip: Vec::new(),
|
||||
cert_path: None,
|
||||
key_path: None,
|
||||
},
|
||||
cors: None,
|
||||
};
|
||||
|
||||
let result = server.validate();
|
||||
assert!(result.is_err());
|
||||
let msg = result.err().unwrap_or_default();
|
||||
assert!(msg.contains("Server port must be greater than 0"));
|
||||
|
||||
let _ = fs::remove_dir_all(&cert_dir);
|
||||
}
|
||||
}
|
||||
40
apps/nxmesh-master/src/connector/agent/mod.rs
Normal file
40
apps/nxmesh-master/src/connector/agent/mod.rs
Normal file
@@ -0,0 +1,40 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use sea_orm::DatabaseConnection;
|
||||
use tonic::transport::Server;
|
||||
|
||||
pub mod ssh;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
pub trait AgentConnectorTrait: Send + Sync {
|
||||
async fn start_server(
|
||||
&mut self,
|
||||
settings: &crate::config::settings::Settings,
|
||||
cert_service: Arc<dyn crate::service::certificate::CertificateService>,
|
||||
connection: DatabaseConnection,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>>;
|
||||
}
|
||||
|
||||
pub struct AgentConnector {
|
||||
connector: Box<dyn AgentConnectorTrait>,
|
||||
}
|
||||
|
||||
impl AgentConnector {
|
||||
pub fn new(connector: Box<dyn AgentConnectorTrait>) -> Self {
|
||||
Self { connector }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl AgentConnectorTrait for AgentConnector {
|
||||
async fn start_server(
|
||||
&mut self,
|
||||
settings: &crate::config::settings::Settings,
|
||||
cert_service: Arc<dyn crate::service::certificate::CertificateService>,
|
||||
connection: DatabaseConnection,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
self.connector
|
||||
.start_server(settings, cert_service, connection)
|
||||
.await
|
||||
}
|
||||
}
|
||||
118
apps/nxmesh-master/src/connector/agent/ssh.rs
Normal file
118
apps/nxmesh-master/src/connector/agent/ssh.rs
Normal file
@@ -0,0 +1,118 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use nxmesh_proto::{
|
||||
agent_service_server::AgentServiceServer,
|
||||
auth::ssh_auth::{CertificateValidationProvider, create_ssh_auth_interceptor},
|
||||
};
|
||||
use sea_orm::{ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter};
|
||||
use tonic::transport::Server;
|
||||
use tracing::info;
|
||||
|
||||
use crate::{db::entities::public_key_revocations, service::agent::AgentServerService};
|
||||
|
||||
use super::AgentConnectorTrait;
|
||||
|
||||
const MAX_CERTS_TO_CHECK: usize = 50;
|
||||
|
||||
pub struct SshAgentConnector {
|
||||
// router: Router<Stack<AsyncInterceptorLayer<SshAuthInterceptor>, Identity>>,
|
||||
settings: Arc<crate::config::settings::Settings>,
|
||||
}
|
||||
|
||||
impl SshAgentConnector {
|
||||
pub fn new(
|
||||
settings: impl Into<Arc<crate::config::settings::Settings>>,
|
||||
) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
|
||||
Ok(Self {
|
||||
settings: settings.into(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn get_tls_config(
|
||||
cert_service: Arc<dyn crate::service::certificate::CertificateService>,
|
||||
) -> Result<tonic::transport::ServerTlsConfig, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let (san_ips, san_dns) =
|
||||
cert_service.get_sans(crate::service::certificate::ConnectionType::GRPC);
|
||||
let (cert_pem, key_pem) = cert_service
|
||||
.generate_pub_cert_pair(san_ips, san_dns)
|
||||
.await?;
|
||||
let (ca_cert_path, _) = cert_service.get_ca_cert().await?;
|
||||
let ca_cert_pem = std::fs::read_to_string(&ca_cert_path)?;
|
||||
|
||||
let tls_config = tonic::transport::ServerTlsConfig::new()
|
||||
.identity(tonic::transport::Identity::from_pem(cert_pem, key_pem))
|
||||
.client_ca_root(tonic::transport::Certificate::from_pem(ca_cert_pem));
|
||||
Ok(tls_config)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl AgentConnectorTrait for SshAgentConnector {
|
||||
async fn start_server(
|
||||
&mut self,
|
||||
settings: &crate::config::settings::Settings,
|
||||
cert_service: Arc<dyn crate::service::certificate::CertificateService>,
|
||||
connection: DatabaseConnection,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let addr = settings.grpc.bind_address.clone().parse()?;
|
||||
let port = settings.grpc.port;
|
||||
let addr = std::net::SocketAddr::new(addr, port);
|
||||
|
||||
// Create the gRPC server
|
||||
let cert_validation_provider = Arc::new(CertificateValidationProviderImpl::new(connection));
|
||||
let ssh_interceptor = create_ssh_auth_interceptor(cert_validation_provider);
|
||||
let agent_server_service = AgentServiceServer::new(AgentServerService::default());
|
||||
|
||||
let tls_config = Self::get_tls_config(cert_service.clone()).await?;
|
||||
|
||||
let router = Server::builder()
|
||||
.tls_config(tls_config)?
|
||||
.layer(ssh_interceptor)
|
||||
.add_service(agent_server_service);
|
||||
|
||||
info!("SSH Agent gRPC server is listening on {}", addr);
|
||||
router
|
||||
.serve(addr)
|
||||
.await
|
||||
.inspect(|_| info!("SSH Agent gRPC server stopped gracefully."))
|
||||
.inspect_err(|e| {
|
||||
tracing::error!("SSH Agent gRPC server failed: {}", e);
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
struct CertificateValidationProviderImpl {
|
||||
connection: DatabaseConnection,
|
||||
}
|
||||
|
||||
impl CertificateValidationProviderImpl {
|
||||
pub fn new(connection: DatabaseConnection) -> Self {
|
||||
CertificateValidationProviderImpl { connection }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl CertificateValidationProvider for CertificateValidationProviderImpl {
|
||||
async fn is_authorized(
|
||||
&self,
|
||||
certs: &Arc<Vec<tonic::transport::CertificateDer<'_>>>,
|
||||
) -> Result<bool, tonic::Status> {
|
||||
// check if the certificate's public key matches any agent's public key in the database
|
||||
let found = public_key_revocations::Entity::find()
|
||||
.filter(public_key_revocations::Column::PublicKeyHash.is_in(
|
||||
certs.iter().take(MAX_CERTS_TO_CHECK).map(|cert| {
|
||||
use sha2::{Digest, Sha256};
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(cert.as_ref());
|
||||
hex::encode(hasher.finalize())
|
||||
}),
|
||||
))
|
||||
.one(&self.connection)
|
||||
.await
|
||||
.map_err(|e| tonic::Status::internal(format!("Database query failed: {}", e)))?
|
||||
.is_some();
|
||||
|
||||
Ok(!found)
|
||||
}
|
||||
}
|
||||
1
apps/nxmesh-master/src/connector/mod.rs
Normal file
1
apps/nxmesh-master/src/connector/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub mod agent;
|
||||
27
apps/nxmesh-master/src/db/entities/agents.rs
Normal file
27
apps/nxmesh-master/src/db/entities/agents.rs
Normal file
@@ -0,0 +1,27 @@
|
||||
//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0
|
||||
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "agents")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
pub ip_address: Option<String>,
|
||||
pub version: Option<String>,
|
||||
pub state: String,
|
||||
pub deployment_mode: Option<String>,
|
||||
pub last_seen_at: Option<DateTimeWithTimeZone>,
|
||||
pub capabilities: Option<Json>,
|
||||
pub public_key_hash: Option<String>,
|
||||
pub labels: Option<Json>,
|
||||
pub created_at: DateTimeWithTimeZone,
|
||||
pub updated_at: DateTimeWithTimeZone,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
6
apps/nxmesh-master/src/db/entities/mod.rs
Normal file
6
apps/nxmesh-master/src/db/entities/mod.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0
|
||||
|
||||
pub mod prelude;
|
||||
|
||||
pub mod agents;
|
||||
pub mod public_key_revocations;
|
||||
4
apps/nxmesh-master/src/db/entities/prelude.rs
Normal file
4
apps/nxmesh-master/src/db/entities/prelude.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0
|
||||
|
||||
pub use super::agents::Entity as Agents;
|
||||
pub use super::public_key_revocations::Entity as PublicKeyRevocations;
|
||||
18
apps/nxmesh-master/src/db/entities/public_key_revocations.rs
Normal file
18
apps/nxmesh-master/src/db/entities/public_key_revocations.rs
Normal file
@@ -0,0 +1,18 @@
|
||||
//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0
|
||||
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "public_key_revocations")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub id: Uuid,
|
||||
pub public_key_hash: String,
|
||||
pub created_at: DateTimeWithTimeZone,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
24
apps/nxmesh-master/src/db/mod.rs
Normal file
24
apps/nxmesh-master/src/db/mod.rs
Normal file
@@ -0,0 +1,24 @@
|
||||
use sea_orm::{Database, DatabaseConnection};
|
||||
|
||||
pub mod entities;
|
||||
|
||||
pub(crate) async fn establish_connection(
|
||||
url: &str,
|
||||
) -> Result<DatabaseConnection, Box<dyn std::error::Error + Send + Sync>> {
|
||||
Database::connect(url)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to connect to database: {}", e).into())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::establish_connection;
|
||||
|
||||
#[tokio::test]
|
||||
async fn establish_connection_fails_for_invalid_url_scheme() {
|
||||
let result = establish_connection("invalid://not-a-db").await;
|
||||
assert!(result.is_err());
|
||||
let msg = result.err().map(|e| e.to_string()).unwrap_or_default();
|
||||
assert!(msg.contains("Failed to connect to database"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
#![forbid(unsafe_code, unused_must_use)]
|
||||
#![deny(clippy::unwrap_used, clippy::panic, clippy::expect_used)]
|
||||
|
||||
use clap::{CommandFactory, Parser};
|
||||
use tracing::{error, info};
|
||||
use tracing_subscriber::{
|
||||
Layer, filter::LevelFilter, fmt, layer::SubscriberExt, registry::Registry, reload,
|
||||
util::SubscriberInitExt,
|
||||
};
|
||||
|
||||
use crate::cli::{Cli, handle_sub_command};
|
||||
|
||||
mod cli;
|
||||
mod config;
|
||||
mod connector;
|
||||
mod db;
|
||||
mod routes;
|
||||
mod service;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
// install a global subscriber for logging
|
||||
let reload_handle = install_tracing_subscriber();
|
||||
// Load configuration settings
|
||||
let settings = match config::settings::Settings::load() {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
error!("Failed to load configuration: {}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
reload_handle
|
||||
.modify(|filter| *filter = Box::new(settings.log.level))
|
||||
.inspect_err(|e| {
|
||||
error!(
|
||||
"Failed to set log level: {}. Continuing with default level.",
|
||||
e
|
||||
)
|
||||
})
|
||||
// ignore errors here since we can still run with the default log level
|
||||
.ok();
|
||||
|
||||
// print the loaded settings for debugging
|
||||
// info!("Loaded settings: {:#?}", settings);
|
||||
let cli = Cli::parse();
|
||||
|
||||
if cli.serve {
|
||||
info!("Starting master server...");
|
||||
if let Err(e) = service::start_master_server(settings, cli).await {
|
||||
error!("Failed to start master server: {}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
} else if let Some(command) = cli.command {
|
||||
handle_sub_command(&settings, command)
|
||||
.await
|
||||
.unwrap_or_else(|e| {
|
||||
error!("Error handling command: {}", e);
|
||||
std::process::exit(1);
|
||||
});
|
||||
} else {
|
||||
error!("No mode specified.");
|
||||
// display help message
|
||||
#[allow(clippy::expect_used)]
|
||||
Cli::command()
|
||||
.print_help()
|
||||
.expect("Failed to print help message");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
fn install_tracing_subscriber()
|
||||
-> reload::Handle<Box<dyn tracing_subscriber::layer::Layer<Registry> + Send + Sync>, Registry> {
|
||||
let filter = LevelFilter::INFO;
|
||||
let (filter_layer, reload_handle) =
|
||||
reload::Layer::new(Box::new(fmt::layer().with_filter(filter))
|
||||
as Box<dyn tracing_subscriber::layer::Layer<Registry> + Send + Sync>);
|
||||
|
||||
tracing_subscriber::registry()
|
||||
.with(filter_layer)
|
||||
.with(fmt::Layer::default())
|
||||
.init();
|
||||
|
||||
reload_handle
|
||||
}
|
||||
|
||||
167
apps/nxmesh-master/src/routes/frontend/mod.rs
Normal file
167
apps/nxmesh-master/src/routes/frontend/mod.rs
Normal file
@@ -0,0 +1,167 @@
|
||||
use axum::{Router, response::IntoResponse};
|
||||
use tracing::error;
|
||||
|
||||
// In development, build the frontend from the source directory, the soft link will handle the path resolution
|
||||
// In deployment, pre-build the frontend and replace the frontend-dist folder with the built assets, the rust-embed will handle the embedding and path resolution
|
||||
#[derive(rust_embed::Embed)]
|
||||
#[folder = "./frontend-dist/"]
|
||||
struct FrontendAssets;
|
||||
|
||||
const INDEX_HTML: &str = "index.html";
|
||||
|
||||
pub async fn get_router() -> Router {
|
||||
Router::new()
|
||||
.route(
|
||||
"/",
|
||||
axum::routing::get(get_fallback_handler)
|
||||
.head(get_fallback_handler)
|
||||
.options(get_fallback_handler),
|
||||
)
|
||||
.route(
|
||||
"/{*path}",
|
||||
axum::routing::get(get_file_handler)
|
||||
.head(get_file_handler)
|
||||
.options(get_file_handler),
|
||||
)
|
||||
//
|
||||
.fallback(get_fallback_handler().await)
|
||||
}
|
||||
|
||||
pub async fn get_fallback_handler() -> Result<axum::response::Html<Vec<u8>>, axum::http::StatusCode>
|
||||
{
|
||||
let index_html = get_index_html();
|
||||
match index_html {
|
||||
Some(html) => Ok(axum::response::Html(html)),
|
||||
None => Err(axum::http::StatusCode::NOT_FOUND),
|
||||
}
|
||||
}
|
||||
|
||||
fn get_index_html() -> Option<Vec<u8>> {
|
||||
// Try root index.html first, then fall back to client/index.html when assets
|
||||
// are packaged under the `client/` subfolder.
|
||||
FrontendAssets::get(INDEX_HTML)
|
||||
.or_else(|| FrontendAssets::get(&format!("client/{}", INDEX_HTML)))
|
||||
.map(|asset| asset.data.as_ref().to_owned())
|
||||
}
|
||||
|
||||
async fn get_file_handler(
|
||||
axum::extract::Path(path): axum::extract::Path<String>,
|
||||
) -> Result<axum::response::Response, axum::http::StatusCode> {
|
||||
let file_path = if path.is_empty() {
|
||||
INDEX_HTML.to_string()
|
||||
} else {
|
||||
path
|
||||
};
|
||||
|
||||
// Try direct lookup first, then fallback to the `client/` subfolder.
|
||||
match FrontendAssets::get(&file_path)
|
||||
.or_else(|| FrontendAssets::get(&format!("client/{}", file_path)))
|
||||
{
|
||||
Some(asset) => {
|
||||
let content_type = mime_guess::from_path(&file_path).first_or_octet_stream();
|
||||
let response = axum::response::Response::builder()
|
||||
.header(axum::http::header::CONTENT_TYPE, content_type.as_ref())
|
||||
.body(asset.data.into_owned().into())
|
||||
.map_err(|e| {
|
||||
error!("Failed to build response for {}: {}", file_path, e);
|
||||
axum::http::StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
Ok(response)
|
||||
}
|
||||
// return index.html for any file not found to support client-side routing in the frontend
|
||||
None => get_fallback_handler()
|
||||
.await
|
||||
.map(|html| html.into_response()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::expect_used)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_asset() {
|
||||
// list all embedded assets for debugging
|
||||
let assets = FrontendAssets::iter().collect::<Vec<_>>();
|
||||
println!("Embedded assets: {:?}", assets);
|
||||
assert!(
|
||||
!assets.is_empty(),
|
||||
"Expected to find embedded assets, but found none"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_index_html() {
|
||||
let index_html = get_index_html();
|
||||
assert!(
|
||||
index_html.is_some(),
|
||||
"Expected to find index.html in embedded assets"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_file_handler_existing_file() {
|
||||
let response = get_file_handler(axum::extract::Path("index.html".to_string())).await;
|
||||
assert!(
|
||||
response.is_ok(),
|
||||
"Expected to successfully retrieve index.html"
|
||||
);
|
||||
let response = response.expect("Expected response to be Ok");
|
||||
assert_eq!(response.status(), axum::http::StatusCode::OK);
|
||||
assert!(
|
||||
response
|
||||
.headers()
|
||||
.get(axum::http::header::CONTENT_TYPE)
|
||||
.map(|ct| ct.to_str().unwrap_or(""))
|
||||
.expect("Content-Type header should be present")
|
||||
.starts_with("text/html")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_file_handler_nonexistent_file() {
|
||||
let response = get_file_handler(axum::extract::Path("nonexistent.txt".to_string())).await;
|
||||
assert!(
|
||||
response.is_ok(),
|
||||
"Expected to fallback to index.html for nonexistent file"
|
||||
);
|
||||
let response = response.expect("Expected response to be Ok");
|
||||
assert_eq!(response.status(), axum::http::StatusCode::OK);
|
||||
assert!(
|
||||
response
|
||||
.headers()
|
||||
.get(axum::http::header::CONTENT_TYPE)
|
||||
.map(|ct| ct.to_str().unwrap_or(""))
|
||||
.expect("Content-Type header should be present")
|
||||
.starts_with("text/html")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod axum_tests {
|
||||
use super::*;
|
||||
use axum_test::TestServer;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_should_return_index_html_for_root_path() {
|
||||
let router = get_router().await;
|
||||
let server = TestServer::new(router);
|
||||
let response = server.get("/").await;
|
||||
assert_eq!(response.status_code(), 200);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_should_return_index_html_for_nonexistent_path() {
|
||||
let router = get_router().await;
|
||||
let server = TestServer::new(router);
|
||||
let fallback_response = server.get("/nonexistent").await;
|
||||
assert_eq!(fallback_response.status_code(), 200);
|
||||
|
||||
let index_response = server.get("/").await;
|
||||
assert_eq!(index_response.status_code(), 200);
|
||||
|
||||
assert_eq!(fallback_response.text(), index_response.text());
|
||||
}
|
||||
}
|
||||
36
apps/nxmesh-master/src/routes/mod.rs
Normal file
36
apps/nxmesh-master/src/routes/mod.rs
Normal file
@@ -0,0 +1,36 @@
|
||||
use axum::Router;
|
||||
|
||||
mod frontend;
|
||||
|
||||
pub async fn get_root_router() -> Router {
|
||||
Router::new()
|
||||
.merge(frontend::get_router().await)
|
||||
.fallback(frontend::get_fallback_handler().await)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use axum_test::TestServer;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_should_return_index_html_for_root_path() {
|
||||
let router = get_root_router().await;
|
||||
let server = TestServer::new(router);
|
||||
let response = server.get("/").await;
|
||||
assert_eq!(response.status_code(), 200);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_should_return_index_html_for_nonexistent_path() {
|
||||
let router = get_root_router().await;
|
||||
let server = TestServer::new(router);
|
||||
let fallback_response = server.get("/nonexistent").await;
|
||||
assert_eq!(fallback_response.status_code(), 200);
|
||||
|
||||
let index_response = server.get("/").await;
|
||||
assert_eq!(index_response.status_code(), 200);
|
||||
|
||||
assert_eq!(fallback_response.text(), index_response.text());
|
||||
}
|
||||
}
|
||||
35
apps/nxmesh-master/src/service/agent/mod.rs
Normal file
35
apps/nxmesh-master/src/service/agent/mod.rs
Normal file
@@ -0,0 +1,35 @@
|
||||
use nxmesh_proto::{AgentMessage, MasterMessage, agent_service_server::AgentService};
|
||||
|
||||
pub mod repo;
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct AgentServerService {}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl AgentService for AgentServerService {
|
||||
#[doc = " Server streaming response type for the Stream method."]
|
||||
type StreamStream = tonic::codec::Streaming<MasterMessage>;
|
||||
|
||||
#[doc = " Stream establishes a persistent connection for real-time communication"]
|
||||
#[allow(
|
||||
mismatched_lifetime_syntaxes,
|
||||
clippy::type_complexity,
|
||||
clippy::type_repetition_in_bounds
|
||||
)]
|
||||
async fn stream(
|
||||
&self,
|
||||
request: tonic::Request<tonic::Streaming<AgentMessage>>,
|
||||
) -> Result<tonic::Response<Self::StreamStream>, tonic::Status> {
|
||||
todo!()
|
||||
}
|
||||
|
||||
async fn connection_test(
|
||||
&self,
|
||||
_request: tonic::Request<nxmesh_proto::TestRequest>,
|
||||
) -> Result<tonic::Response<nxmesh_proto::TestResponse>, tonic::Status> {
|
||||
Ok(tonic::Response::new(nxmesh_proto::TestResponse {
|
||||
success: true,
|
||||
error_message: String::new(),
|
||||
}))
|
||||
}
|
||||
}
|
||||
738
apps/nxmesh-master/src/service/certificate/mod.rs
Normal file
738
apps/nxmesh-master/src/service/certificate/mod.rs
Normal file
@@ -0,0 +1,738 @@
|
||||
use std::{io::Write, os::unix::fs::PermissionsExt, path::Path, sync::Arc};
|
||||
|
||||
use rcgen::{
|
||||
BasicConstraints, CertificateParams, DnType, ExtendedKeyUsagePurpose, IsCa, Issuer, KeyPair,
|
||||
KeyUsagePurpose, SanType, string::Ia5String,
|
||||
};
|
||||
use sea_orm::DatabaseConnection;
|
||||
use time::{Duration, OffsetDateTime};
|
||||
use tracing::debug;
|
||||
|
||||
// TODO: cert rotation, revocation, and CRL support
|
||||
|
||||
pub enum ConnectionType {
|
||||
GRPC,
|
||||
HTTP,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
pub trait CertificateService: Sync + Send {
|
||||
/// Get the CA certificate path, if the CA certificate does not exist, return an error
|
||||
async fn get_ca_cert(
|
||||
&self,
|
||||
) -> Result<(String, String), Box<dyn std::error::Error + Send + Sync>>;
|
||||
/// Generate an in memory public and private key pair, sign it with the CA certificate and key, and return the signed public and private key as PEM string, if the CA certificate does not exist, return an error, if the CA certificate already exists, return an error
|
||||
async fn generate_pub_cert_pair(
|
||||
&self,
|
||||
san_ips: Vec<std::net::IpAddr>,
|
||||
san_dns: Vec<Ia5String>,
|
||||
) -> Result<(String, String), Box<dyn std::error::Error + Send + Sync>>;
|
||||
/// Generate a new CA certificate and save it to the specified path, if the CA certificate already exists, return an error
|
||||
async fn generate_ca_cert(
|
||||
&self,
|
||||
) -> Result<CertPathInfo, Box<dyn std::error::Error + Send + Sync>>;
|
||||
/// Generate certificates for agent and save them to the specified output directory, the output directory should be created if it does not exist
|
||||
async fn generate_agent_certs(
|
||||
&self,
|
||||
agent_id: &str,
|
||||
output_dir: &str,
|
||||
) -> Result<AgentCertPathInfo, Box<dyn std::error::Error + Send + Sync>>;
|
||||
/// Zip the generated agent certificates, the input should be the cert path and key path, the output should be a zip file containing the cert and key
|
||||
async fn zip_certificates(
|
||||
&self,
|
||||
cert_path: &str,
|
||||
key_path: &str,
|
||||
ca_cert_path: &str,
|
||||
) -> Result<String, Box<dyn std::error::Error + Send + Sync>>;
|
||||
/// Get the sans to be included in the generated certificates, this is used to support IP-based connections to the agent, the SANs will be included in both the CA certificate and the agent certificates, if the SANs are not specified, some common local addresses will be included by default
|
||||
fn get_sans(&self, connection_type: ConnectionType) -> (Vec<std::net::IpAddr>, Vec<Ia5String>);
|
||||
}
|
||||
|
||||
pub struct CertificateServiceImpl {
|
||||
db: DatabaseConnection,
|
||||
/// The path to the CA certificate, the CA certificate and private key will be saved to this path when generating a new CA certificate
|
||||
cert_folder_path: String,
|
||||
settings: Arc<crate::config::settings::Settings>,
|
||||
}
|
||||
|
||||
impl CertificateServiceImpl {
|
||||
pub fn new(
|
||||
db: DatabaseConnection,
|
||||
cert_folder_path: String,
|
||||
settings: Arc<crate::config::settings::Settings>,
|
||||
) -> Self {
|
||||
Self {
|
||||
db,
|
||||
cert_folder_path,
|
||||
settings,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CertPathInfo {
|
||||
pub private_key: String,
|
||||
pub cert_pem: String,
|
||||
pub public_key: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AgentCertPathInfo {
|
||||
pub cert_path: String,
|
||||
pub key_path: String,
|
||||
pub ca_cert_path: String,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl CertificateService for CertificateServiceImpl {
|
||||
async fn get_ca_cert(
|
||||
&self,
|
||||
) -> Result<(String, String), Box<dyn std::error::Error + Send + Sync>> {
|
||||
if Path::new(&self.cert_folder_path).exists() {
|
||||
let cert_path = Path::new(&self.cert_folder_path).join("ca.crt");
|
||||
let key_path = Path::new(&self.cert_folder_path).join("ca.key");
|
||||
if cert_path.exists() && key_path.exists() {
|
||||
Ok((
|
||||
cert_path.to_string_lossy().to_string(),
|
||||
key_path.to_string_lossy().to_string(),
|
||||
))
|
||||
} else {
|
||||
Err(Box::new(std::io::Error::new(
|
||||
std::io::ErrorKind::NotFound,
|
||||
"CA certificate or key not found",
|
||||
)))
|
||||
}
|
||||
} else {
|
||||
Err(Box::new(std::io::Error::new(
|
||||
std::io::ErrorKind::NotFound,
|
||||
"CA certificate folder not found",
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
async fn generate_pub_cert_pair(
|
||||
&self,
|
||||
san_ips: Vec<std::net::IpAddr>,
|
||||
san_dns: Vec<Ia5String>,
|
||||
) -> Result<(String, String), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let (ca_cert_path, ca_key_path) = self.get_ca_cert().await?;
|
||||
let ca_cert_pem = std::fs::read_to_string(ca_cert_path)?;
|
||||
let ca_key_pem = std::fs::read_to_string(ca_key_path)?;
|
||||
|
||||
let ca_key = KeyPair::from_pem(&ca_key_pem)?;
|
||||
let issuer = Issuer::from_ca_cert_pem(&ca_cert_pem, ca_key)?;
|
||||
|
||||
// TODO: require input to set the SANs for the generated certificate, for now we will include some common local addresses to support IP-based connections to the agent, but in the future we should allow users to specify the SANs for the generated certificates
|
||||
// Include SANs for common local addresses to support IP-based connections
|
||||
let subject_alt_names: Vec<SanType> = [
|
||||
san_ips
|
||||
.into_iter()
|
||||
.map(SanType::IpAddress)
|
||||
.collect::<Vec<SanType>>(),
|
||||
san_dns
|
||||
.into_iter()
|
||||
.map(|dns| SanType::DnsName(dns))
|
||||
.collect::<Vec<SanType>>(),
|
||||
]
|
||||
.concat();
|
||||
|
||||
let mut params = CertificateParams::default();
|
||||
params.subject_alt_names = subject_alt_names;
|
||||
params.is_ca = IsCa::NoCa;
|
||||
params.key_usages.push(KeyUsagePurpose::DigitalSignature);
|
||||
params
|
||||
.extended_key_usages
|
||||
.push(ExtendedKeyUsagePurpose::ServerAuth);
|
||||
params
|
||||
.extended_key_usages
|
||||
.push(ExtendedKeyUsagePurpose::ClientAuth);
|
||||
params.serial_number = Some(rand::random::<u64>().into()); // Unique serial
|
||||
|
||||
let (not_before, not_after) = validity_period();
|
||||
params.not_before = not_before;
|
||||
params.not_after = not_after;
|
||||
|
||||
let key_pair = KeyPair::generate_for(&rcgen::PKCS_ED25519)?;
|
||||
let cert = params.signed_by(&key_pair, &issuer)?;
|
||||
Ok((cert.pem(), key_pair.serialize_pem()))
|
||||
}
|
||||
|
||||
async fn generate_ca_cert(
|
||||
&self,
|
||||
) -> Result<CertPathInfo, Box<dyn std::error::Error + Send + Sync>> {
|
||||
// check if the CA certificate already exists in the folder
|
||||
let cert_folder_path = Path::new(&self.cert_folder_path);
|
||||
let cert_path = cert_folder_path.join("ca.crt");
|
||||
let key_path = cert_folder_path.join("ca.key");
|
||||
let pub_path = cert_folder_path.join("ca.pub");
|
||||
if !cert_folder_path.exists() {
|
||||
std::fs::create_dir_all(cert_folder_path)?;
|
||||
}
|
||||
if cert_path.exists() || key_path.exists() || pub_path.exists() {
|
||||
return Err(Box::new(std::io::Error::new(
|
||||
std::io::ErrorKind::AlreadyExists,
|
||||
"CA certificate already exists",
|
||||
)));
|
||||
}
|
||||
|
||||
let kp = KeyPair::generate_for(&rcgen::PKCS_ED25519)?;
|
||||
let mut params = CertificateParams::new(Vec::default())?;
|
||||
params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);
|
||||
params
|
||||
.distinguished_name
|
||||
.push(DnType::OrganizationName, "MasterCA");
|
||||
params.key_usages.push(KeyUsagePurpose::DigitalSignature);
|
||||
params.key_usages.push(KeyUsagePurpose::KeyCertSign);
|
||||
params.key_usages.push(KeyUsagePurpose::CrlSign);
|
||||
|
||||
let (not_before, not_after) = validity_period();
|
||||
params.not_before = not_before;
|
||||
params.not_after = not_after;
|
||||
|
||||
let ca_cert = params.self_signed(&kp)?;
|
||||
let cert_pem = ca_cert.pem();
|
||||
let private_key = kp.serialize_pem();
|
||||
let public_key = kp.public_key_pem();
|
||||
|
||||
// save the CA certificate and private key to the specified path
|
||||
|
||||
std::fs::write(&cert_path, cert_pem.as_bytes())?;
|
||||
std::fs::set_permissions(cert_path, std::fs::Permissions::from_mode(0o600))?;
|
||||
|
||||
std::fs::write(&key_path, private_key.as_bytes())?;
|
||||
std::fs::set_permissions(key_path, std::fs::Permissions::from_mode(0o600))?;
|
||||
|
||||
std::fs::write(&pub_path, public_key.as_bytes())?;
|
||||
std::fs::set_permissions(pub_path, std::fs::Permissions::from_mode(0o600))?;
|
||||
|
||||
Ok(CertPathInfo {
|
||||
private_key,
|
||||
cert_pem,
|
||||
public_key,
|
||||
})
|
||||
}
|
||||
|
||||
async fn generate_agent_certs(
|
||||
&self,
|
||||
agent_id: &str,
|
||||
output_dir: &str,
|
||||
) -> Result<AgentCertPathInfo, Box<dyn std::error::Error + Send + Sync>> {
|
||||
debug!(
|
||||
"Generating agent certificates for agent_id: {}, output_dir: {}",
|
||||
agent_id, output_dir
|
||||
);
|
||||
let output_path_dir = Path::new(output_dir).join(agent_id);
|
||||
let cert_path = output_path_dir.join("cert.pem");
|
||||
let key_path = output_path_dir.join("key.pem");
|
||||
|
||||
// validate output parent directory exists
|
||||
if !std::path::Path::new(output_dir).exists() {
|
||||
// TODO: custom error type
|
||||
return Err(Box::new(std::io::Error::new(
|
||||
std::io::ErrorKind::NotFound,
|
||||
"Output parent directory does not exist",
|
||||
)));
|
||||
}
|
||||
|
||||
// create output directory if it does not exist
|
||||
if !output_path_dir.exists() {
|
||||
std::fs::create_dir_all(&output_path_dir)?;
|
||||
}
|
||||
|
||||
// Check if CA certificate exists
|
||||
let (ca_cert_path, ca_key_path) = self.get_ca_cert().await?;
|
||||
|
||||
// Read CA certificate and key from disk
|
||||
debug!("Reading CA certificate from path: {:?}", ca_cert_path);
|
||||
let ca_cert_pem = std::fs::read_to_string(ca_cert_path.clone())?;
|
||||
let ca_key_pem = std::fs::read_to_string(ca_key_path)?;
|
||||
|
||||
// Parse CA key and create issuer
|
||||
debug!("Parsing CA key and creating issuer");
|
||||
let ca_key = KeyPair::from_pem(&ca_key_pem)?;
|
||||
let issuer = Issuer::from_ca_cert_pem(&ca_cert_pem, ca_key)?;
|
||||
|
||||
// Generate agent keypair
|
||||
let agent_keypair = KeyPair::generate_for(&rcgen::PKCS_ED25519)?;
|
||||
|
||||
// Params for agent leaf cert
|
||||
let mut params = CertificateParams::new(vec![agent_id.to_string()])?;
|
||||
params
|
||||
.distinguished_name
|
||||
.push(DnType::CommonName, agent_id.to_string());
|
||||
params.use_authority_key_identifier_extension = true;
|
||||
params.key_usages.push(KeyUsagePurpose::DigitalSignature);
|
||||
params
|
||||
.extended_key_usages
|
||||
.push(ExtendedKeyUsagePurpose::ServerAuth);
|
||||
params
|
||||
.extended_key_usages
|
||||
.push(ExtendedKeyUsagePurpose::ClientAuth);
|
||||
params.serial_number = Some(rand::random::<u64>().into()); // Unique serial
|
||||
|
||||
let (not_before, not_after) = validity_period();
|
||||
params.not_before = not_before;
|
||||
params.not_after = not_after;
|
||||
|
||||
// Sign with CA
|
||||
let agent_cert = params.signed_by(&agent_keypair, &issuer)?;
|
||||
let agent_cert_pem = agent_cert.pem();
|
||||
let agent_key_pem = agent_keypair.serialize_pem();
|
||||
|
||||
// Save agent certificate and private key to output directory
|
||||
|
||||
debug!(
|
||||
"Saving agent certificate and key to output directory: {:?}",
|
||||
output_path_dir
|
||||
);
|
||||
std::fs::write(&cert_path, agent_cert_pem.as_bytes())?;
|
||||
std::fs::set_permissions(&cert_path, std::fs::Permissions::from_mode(0o600))?;
|
||||
|
||||
std::fs::write(&key_path, agent_key_pem.as_bytes())?;
|
||||
std::fs::set_permissions(&key_path, std::fs::Permissions::from_mode(0o600))?;
|
||||
|
||||
Ok(AgentCertPathInfo {
|
||||
cert_path: cert_path.to_string_lossy().to_string(),
|
||||
key_path: key_path.to_string_lossy().to_string(),
|
||||
ca_cert_path: ca_cert_path.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn zip_certificates(
|
||||
&self,
|
||||
cert_path: &str,
|
||||
key_path: &str,
|
||||
ca_cert_path: &str,
|
||||
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let zip_path = format!("{}.zip", cert_path.trim_end_matches(".pem"));
|
||||
let file = std::fs::File::create(&zip_path)?;
|
||||
let mut zip = zip::ZipWriter::new(file);
|
||||
|
||||
let options = zip::write::SimpleFileOptions::default()
|
||||
.compression_method(zip::CompressionMethod::Deflated)
|
||||
.unix_permissions(0o600);
|
||||
|
||||
zip.start_file("cert.pem", options)?;
|
||||
let cert_data = std::fs::read(cert_path)?;
|
||||
zip.write_all(&cert_data)?;
|
||||
|
||||
zip.start_file("key.pem", options)?;
|
||||
let key_data = std::fs::read(key_path)?;
|
||||
zip.write_all(&key_data)?;
|
||||
|
||||
zip.start_file("ca.pem", options)?;
|
||||
let ca_cert_data = std::fs::read(ca_cert_path)?;
|
||||
zip.write_all(&ca_cert_data)?;
|
||||
|
||||
zip.finish()?;
|
||||
Ok(zip_path)
|
||||
}
|
||||
|
||||
fn get_sans(&self, connection_type: ConnectionType) -> (Vec<std::net::IpAddr>, Vec<Ia5String>) {
|
||||
let cert_settings = match connection_type {
|
||||
ConnectionType::GRPC => &self.settings.grpc.certificate,
|
||||
ConnectionType::HTTP => &self.settings.server.certificate,
|
||||
};
|
||||
|
||||
(cert_settings.san_ip.clone(), cert_settings.san_dns.clone())
|
||||
}
|
||||
}
|
||||
|
||||
fn validity_period() -> (OffsetDateTime, OffsetDateTime) {
|
||||
let year = Duration::new(365 * 86400, 0);
|
||||
let not_before = OffsetDateTime::now_utc();
|
||||
let not_after = match not_before.checked_add(year) {
|
||||
Some(v) => v,
|
||||
None => not_before,
|
||||
};
|
||||
(not_before, not_after)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::{
|
||||
fs,
|
||||
net::{IpAddr, Ipv4Addr},
|
||||
os::unix::fs::PermissionsExt,
|
||||
path::{Path, PathBuf},
|
||||
sync::Arc,
|
||||
time::{SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
use rcgen::string::Ia5String;
|
||||
use sea_orm::DatabaseConnection;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::config::settings::Settings;
|
||||
|
||||
use super::{CertificateService, CertificateServiceImpl, ConnectionType, validity_period};
|
||||
|
||||
fn unique_temp_dir(prefix: &str) -> PathBuf {
|
||||
let now = SystemTime::now().duration_since(UNIX_EPOCH);
|
||||
assert!(now.is_ok());
|
||||
let now = now.unwrap_or_default();
|
||||
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"{}_{}_{}",
|
||||
prefix,
|
||||
std::process::id(),
|
||||
now.as_nanos()
|
||||
));
|
||||
let created = fs::create_dir_all(&dir);
|
||||
assert!(created.is_ok());
|
||||
dir
|
||||
}
|
||||
|
||||
fn mock_db_connection() -> DatabaseConnection {
|
||||
DatabaseConnection::default()
|
||||
}
|
||||
|
||||
fn parse_ia5(input: &str) -> Ia5String {
|
||||
let value = Ia5String::try_from(input.to_string());
|
||||
assert!(value.is_ok());
|
||||
value.unwrap_or_else(|_| unreachable!())
|
||||
}
|
||||
|
||||
fn test_settings(grpc_dns: &str, http_dns: &str) -> Arc<Settings> {
|
||||
let parsed = serde_json::from_value::<Settings>(json!({
|
||||
"server": {
|
||||
"bind_address": "127.0.0.1",
|
||||
"port": 8080,
|
||||
"certificate": {
|
||||
"cert_dir": "./certs",
|
||||
"san_dns": [http_dns],
|
||||
"san_ip": ["127.0.0.2"]
|
||||
},
|
||||
"cors": null
|
||||
},
|
||||
"database": {
|
||||
"url": "postgres://user:pass@localhost/db",
|
||||
"max_connections": 5
|
||||
},
|
||||
"grpc": {
|
||||
"bind_address": "127.0.0.1",
|
||||
"port": 50051,
|
||||
"certificate": {
|
||||
"cert_dir": "./certs",
|
||||
"san_dns": [grpc_dns],
|
||||
"san_ip": ["127.0.0.1"]
|
||||
},
|
||||
"cors": null
|
||||
},
|
||||
"auth": {
|
||||
"jwt_secret": "secret",
|
||||
"jwt_expiration_hours": 24
|
||||
},
|
||||
"log": {
|
||||
"level": "INFO"
|
||||
}
|
||||
}));
|
||||
|
||||
assert!(parsed.is_ok());
|
||||
Arc::new(parsed.unwrap_or_else(|_| unreachable!()))
|
||||
}
|
||||
|
||||
fn new_service(cert_dir: &str) -> CertificateServiceImpl {
|
||||
CertificateServiceImpl::new(
|
||||
mock_db_connection(),
|
||||
cert_dir.to_string(),
|
||||
test_settings("grpc.local", "http.local"),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validity_period_is_forward_and_about_one_year() {
|
||||
let (not_before, not_after) = validity_period();
|
||||
assert!(not_after > not_before);
|
||||
|
||||
let diff = not_after - not_before;
|
||||
assert!(diff.whole_days() >= 364);
|
||||
assert!(diff.whole_days() <= 366);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_ca_cert_fails_when_folder_missing() {
|
||||
let dir = unique_temp_dir("nxmesh-master-ca-missing");
|
||||
let removed = fs::remove_dir_all(&dir);
|
||||
assert!(removed.is_ok());
|
||||
|
||||
let service = new_service(&dir.to_string_lossy());
|
||||
let result = service.get_ca_cert().await;
|
||||
assert!(result.is_err());
|
||||
|
||||
let msg = result.err().map(|e| e.to_string()).unwrap_or_default();
|
||||
assert!(msg.contains("CA certificate folder not found"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn generate_ca_cert_creates_files_and_is_retrievable() {
|
||||
let dir = unique_temp_dir("nxmesh-master-generate-ca");
|
||||
let service = new_service(&dir.to_string_lossy());
|
||||
|
||||
let generated = service.generate_ca_cert().await;
|
||||
assert!(generated.is_ok());
|
||||
let generated = generated.unwrap_or_else(|_| unreachable!());
|
||||
assert!(!generated.cert_pem.is_empty());
|
||||
assert!(!generated.private_key.is_empty());
|
||||
assert!(!generated.public_key.is_empty());
|
||||
|
||||
let ca_cert = dir.join("ca.crt");
|
||||
let ca_key = dir.join("ca.key");
|
||||
let ca_pub = dir.join("ca.pub");
|
||||
|
||||
assert!(ca_cert.exists());
|
||||
assert!(ca_key.exists());
|
||||
assert!(ca_pub.exists());
|
||||
|
||||
let cert_meta = fs::metadata(&ca_cert);
|
||||
assert!(cert_meta.is_ok());
|
||||
let cert_meta = cert_meta.unwrap_or_else(|_| unreachable!());
|
||||
assert_eq!(cert_meta.permissions().mode() & 0o777, 0o600);
|
||||
|
||||
let key_meta = fs::metadata(&ca_key);
|
||||
assert!(key_meta.is_ok());
|
||||
let key_meta = key_meta.unwrap_or_else(|_| unreachable!());
|
||||
assert_eq!(key_meta.permissions().mode() & 0o777, 0o600);
|
||||
|
||||
let pub_meta = fs::metadata(&ca_pub);
|
||||
assert!(pub_meta.is_ok());
|
||||
let pub_meta = pub_meta.unwrap_or_else(|_| unreachable!());
|
||||
assert_eq!(pub_meta.permissions().mode() & 0o777, 0o600);
|
||||
|
||||
let retrieved = service.get_ca_cert().await;
|
||||
assert!(retrieved.is_ok());
|
||||
let (cert_path, key_path) = retrieved.unwrap_or_else(|_| unreachable!());
|
||||
assert_eq!(cert_path, ca_cert.to_string_lossy());
|
||||
assert_eq!(key_path, ca_key.to_string_lossy());
|
||||
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_ca_cert_fails_when_folder_exists_but_files_missing() {
|
||||
let dir = unique_temp_dir("nxmesh-master-ca-partial");
|
||||
let service = new_service(&dir.to_string_lossy());
|
||||
|
||||
let ca_cert_path = dir.join("ca.crt");
|
||||
let write_result = fs::write(&ca_cert_path, "dummy cert");
|
||||
assert!(write_result.is_ok());
|
||||
|
||||
let result = service.get_ca_cert().await;
|
||||
assert!(result.is_err());
|
||||
let msg = result.err().map(|e| e.to_string()).unwrap_or_default();
|
||||
assert!(msg.contains("CA certificate or key not found"));
|
||||
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn generate_ca_cert_fails_when_ca_exists() {
|
||||
let dir = unique_temp_dir("nxmesh-master-ca-exists");
|
||||
let service = new_service(&dir.to_string_lossy());
|
||||
|
||||
let first = service.generate_ca_cert().await;
|
||||
assert!(first.is_ok());
|
||||
|
||||
let second = service.generate_ca_cert().await;
|
||||
assert!(second.is_err());
|
||||
let msg = second.err().map(|e| e.to_string()).unwrap_or_default();
|
||||
assert!(msg.contains("CA certificate already exists"));
|
||||
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn generate_pub_cert_pair_requires_ca_cert() {
|
||||
let dir = unique_temp_dir("nxmesh-master-pub-cert-missing-ca");
|
||||
let service = new_service(&dir.to_string_lossy());
|
||||
|
||||
let result = service
|
||||
.generate_pub_cert_pair(
|
||||
vec![IpAddr::V4(Ipv4Addr::LOCALHOST)],
|
||||
vec![parse_ia5("localhost")],
|
||||
)
|
||||
.await;
|
||||
assert!(result.is_err());
|
||||
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn generate_pub_cert_pair_succeeds_after_ca_generation() {
|
||||
let dir = unique_temp_dir("nxmesh-master-pub-cert-ok");
|
||||
let service = new_service(&dir.to_string_lossy());
|
||||
|
||||
let ca = service.generate_ca_cert().await;
|
||||
assert!(ca.is_ok());
|
||||
|
||||
let cert_pair = service
|
||||
.generate_pub_cert_pair(
|
||||
vec![IpAddr::V4(Ipv4Addr::LOCALHOST)],
|
||||
vec![parse_ia5("localhost")],
|
||||
)
|
||||
.await;
|
||||
assert!(cert_pair.is_ok());
|
||||
let (cert, key) = cert_pair.unwrap_or_else(|_| unreachable!());
|
||||
|
||||
assert!(cert.contains("BEGIN CERTIFICATE"));
|
||||
assert!(key.contains("BEGIN PRIVATE KEY"));
|
||||
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn generate_agent_certs_fails_when_output_parent_missing() {
|
||||
let ca_dir = unique_temp_dir("nxmesh-master-agent-certs-ca");
|
||||
let missing_output = unique_temp_dir("nxmesh-master-agent-certs-missing");
|
||||
let removed = fs::remove_dir_all(&missing_output);
|
||||
assert!(removed.is_ok());
|
||||
|
||||
let service = new_service(&ca_dir.to_string_lossy());
|
||||
let ca = service.generate_ca_cert().await;
|
||||
assert!(ca.is_ok());
|
||||
|
||||
let result = service
|
||||
.generate_agent_certs("agent-id", &missing_output.to_string_lossy())
|
||||
.await;
|
||||
assert!(result.is_err());
|
||||
let msg = result.err().map(|e| e.to_string()).unwrap_or_default();
|
||||
assert!(msg.contains("Output parent directory does not exist"));
|
||||
|
||||
let _ = fs::remove_dir_all(&ca_dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn generate_agent_certs_fails_when_ca_is_missing() {
|
||||
let ca_dir = unique_temp_dir("nxmesh-master-agent-certs-no-ca");
|
||||
let output_parent = unique_temp_dir("nxmesh-master-agent-certs-no-ca-out");
|
||||
let service = new_service(&ca_dir.to_string_lossy());
|
||||
|
||||
let result = service
|
||||
.generate_agent_certs("agent-1", &output_parent.to_string_lossy())
|
||||
.await;
|
||||
assert!(result.is_err());
|
||||
let msg = result.err().map(|e| e.to_string()).unwrap_or_default();
|
||||
assert!(
|
||||
msg.contains("CA certificate")
|
||||
|| msg.contains("CA certificate folder not found")
|
||||
|| msg.contains("CA certificate or key not found")
|
||||
);
|
||||
|
||||
let _ = fs::remove_dir_all(&ca_dir);
|
||||
let _ = fs::remove_dir_all(&output_parent);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn generate_agent_certs_and_zip_certificates_succeeds() {
|
||||
let ca_dir = unique_temp_dir("nxmesh-master-agent-certs-zip-ca");
|
||||
let output_parent = unique_temp_dir("nxmesh-master-agent-certs-zip-out");
|
||||
let service = new_service(&ca_dir.to_string_lossy());
|
||||
|
||||
let ca = service.generate_ca_cert().await;
|
||||
assert!(ca.is_ok());
|
||||
|
||||
let generated = service
|
||||
.generate_agent_certs("agent-42", &output_parent.to_string_lossy())
|
||||
.await;
|
||||
assert!(generated.is_ok());
|
||||
let generated = generated.unwrap_or_else(|_| unreachable!());
|
||||
|
||||
assert!(Path::new(&generated.cert_path).exists());
|
||||
assert!(Path::new(&generated.key_path).exists());
|
||||
assert!(Path::new(&generated.ca_cert_path).exists());
|
||||
|
||||
assert!(generated.cert_path.ends_with("agent-42/cert.pem"));
|
||||
assert!(generated.key_path.ends_with("agent-42/key.pem"));
|
||||
|
||||
let cert_meta = fs::metadata(&generated.cert_path);
|
||||
assert!(cert_meta.is_ok());
|
||||
let cert_meta = cert_meta.unwrap_or_else(|_| unreachable!());
|
||||
assert_eq!(cert_meta.permissions().mode() & 0o777, 0o600);
|
||||
|
||||
let key_meta = fs::metadata(&generated.key_path);
|
||||
assert!(key_meta.is_ok());
|
||||
let key_meta = key_meta.unwrap_or_else(|_| unreachable!());
|
||||
assert_eq!(key_meta.permissions().mode() & 0o777, 0o600);
|
||||
|
||||
let zip = service
|
||||
.zip_certificates(
|
||||
&generated.cert_path,
|
||||
&generated.key_path,
|
||||
&generated.ca_cert_path,
|
||||
)
|
||||
.await;
|
||||
assert!(zip.is_ok());
|
||||
let zip = zip.unwrap_or_else(|_| unreachable!());
|
||||
assert!(Path::new(&zip).exists());
|
||||
|
||||
let zip_file = std::fs::File::open(&zip);
|
||||
assert!(zip_file.is_ok());
|
||||
let zip_file = zip_file.unwrap_or_else(|_| unreachable!());
|
||||
let archive = zip::ZipArchive::new(zip_file);
|
||||
assert!(archive.is_ok());
|
||||
let mut archive = archive.unwrap_or_else(|_| unreachable!());
|
||||
|
||||
assert!(archive.by_name("cert.pem").is_ok());
|
||||
assert!(archive.by_name("key.pem").is_ok());
|
||||
assert!(archive.by_name("ca.pem").is_ok());
|
||||
|
||||
assert!(zip.ends_with("cert.zip"));
|
||||
|
||||
let _ = fs::remove_dir_all(&ca_dir);
|
||||
let _ = fs::remove_dir_all(&output_parent);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn zip_certificates_fails_when_input_files_are_missing() {
|
||||
let cert_dir = unique_temp_dir("nxmesh-master-zip-missing-input");
|
||||
let service = new_service(&cert_dir.to_string_lossy());
|
||||
|
||||
let missing_cert = cert_dir.join("missing-cert.pem");
|
||||
let missing_key = cert_dir.join("missing-key.pem");
|
||||
let missing_ca = cert_dir.join("missing-ca.pem");
|
||||
|
||||
let result = service
|
||||
.zip_certificates(
|
||||
&missing_cert.to_string_lossy(),
|
||||
&missing_key.to_string_lossy(),
|
||||
&missing_ca.to_string_lossy(),
|
||||
)
|
||||
.await;
|
||||
assert!(result.is_err());
|
||||
|
||||
let _ = fs::remove_dir_all(&cert_dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_sans_returns_values_for_each_connection_type() {
|
||||
let dir = unique_temp_dir("nxmesh-master-get-sans");
|
||||
let service = CertificateServiceImpl::new(
|
||||
mock_db_connection(),
|
||||
dir.to_string_lossy().to_string(),
|
||||
test_settings("grpc.example.test", "http.example.test"),
|
||||
);
|
||||
|
||||
let (grpc_ips, grpc_dns) = service.get_sans(ConnectionType::GRPC);
|
||||
assert_eq!(grpc_ips.len(), 1);
|
||||
assert_eq!(grpc_ips[0], IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)));
|
||||
assert_eq!(grpc_dns.len(), 1);
|
||||
assert_eq!(grpc_dns[0].to_string(), "grpc.example.test");
|
||||
|
||||
let (http_ips, http_dns) = service.get_sans(ConnectionType::HTTP);
|
||||
assert_eq!(http_ips.len(), 1);
|
||||
assert_eq!(http_ips[0], IpAddr::V4(Ipv4Addr::new(127, 0, 0, 2)));
|
||||
assert_eq!(http_dns.len(), 1);
|
||||
assert_eq!(http_dns[0].to_string(), "http.example.test");
|
||||
|
||||
let mut grpc_ips_mut = grpc_ips.clone();
|
||||
grpc_ips_mut.clear();
|
||||
let mut grpc_dns_mut = grpc_dns.clone();
|
||||
grpc_dns_mut.clear();
|
||||
|
||||
let (grpc_ips_again, grpc_dns_again) = service.get_sans(ConnectionType::GRPC);
|
||||
assert_eq!(grpc_ips_again.len(), 1);
|
||||
assert_eq!(grpc_dns_again.len(), 1);
|
||||
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
65
apps/nxmesh-master/src/service/mod.rs
Normal file
65
apps/nxmesh-master/src/service/mod.rs
Normal file
@@ -0,0 +1,65 @@
|
||||
use std::{net::ToSocketAddrs, sync::Arc};
|
||||
|
||||
use tracing::info;
|
||||
|
||||
use crate::{connector::agent::AgentConnectorTrait, service::certificate::CertificateService};
|
||||
|
||||
pub mod agent;
|
||||
pub mod certificate;
|
||||
|
||||
pub async fn start_master_server(
|
||||
settings: crate::config::settings::Settings,
|
||||
cli: crate::cli::Cli,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
// Initialize database connection
|
||||
let db_connection = crate::db::establish_connection(&settings.database.url).await?;
|
||||
|
||||
// Initialize certificate service with default cert folder path
|
||||
let cert_service = Arc::new(crate::service::certificate::CertificateServiceImpl::new(
|
||||
db_connection.clone(),
|
||||
settings.grpc.certificate.cert_dir.clone(),
|
||||
Arc::new(settings.clone()),
|
||||
));
|
||||
|
||||
// if generate_ca is set, generate a new certificate and exit
|
||||
if cli.generate_ca {
|
||||
// TODO: check the error type and return a more specific error message
|
||||
cert_service.generate_ca_cert().await.ok();
|
||||
println!("Certificate generated and stored successfully.");
|
||||
}
|
||||
|
||||
let ssh_connector = crate::connector::agent::ssh::SshAgentConnector::new(settings.clone())?;
|
||||
let cert_service_for_agent = cert_service.clone();
|
||||
let settings_for_agent = settings.clone();
|
||||
let connection_for_agent = db_connection.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let mut connector = ssh_connector;
|
||||
tracing::info!("Starting agent server...");
|
||||
if let Err(e) = connector
|
||||
.start_server(
|
||||
&settings_for_agent,
|
||||
cert_service_for_agent,
|
||||
connection_for_agent,
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::error!("Agent server failed: {}", e);
|
||||
} else {
|
||||
tracing::info!("Agent server stopped.");
|
||||
}
|
||||
});
|
||||
|
||||
let axum_router = crate::routes::get_root_router().await;
|
||||
|
||||
// Start the HTTP server
|
||||
let addr = format!("{}:{}", settings.server.bind_address, settings.server.port)
|
||||
.to_socket_addrs()?
|
||||
.next()
|
||||
.ok_or("Invalid bind address")?;
|
||||
let listener = tokio::net::TcpListener::bind(addr).await?;
|
||||
info!("Web/API server is listening on {}", addr);
|
||||
axum::serve(listener, axum_router).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
1
config/.gitignore
vendored
Normal file
1
config/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
*.pem
|
||||
25
config/agent/development.toml
Normal file
25
config/agent/development.toml
Normal file
@@ -0,0 +1,25 @@
|
||||
agent_id = "agent-id-01"
|
||||
|
||||
[grpc]
|
||||
connection_string = "https://127.0.0.1:8443"
|
||||
|
||||
[grpc.m_auth.Tls.ZipPath]
|
||||
# cert_zip_path = "./.local/agent-tls.zip"
|
||||
cert_zip_path = "./certs/agent-id-placeholder/cert.zip"
|
||||
|
||||
[grpc.cors]
|
||||
allowed_origins = ["*"]
|
||||
allowed_methods = ["GET", "POST"]
|
||||
allowed_headers = ["*"]
|
||||
allow_credentials = true
|
||||
|
||||
[log]
|
||||
level = "INFO"
|
||||
|
||||
[nginx]
|
||||
nginx_config_path = "./.devcontainer/nginx/nginx.conf"
|
||||
# nginx_binary_path = "/usr/sbin/nginx"
|
||||
# override_nginx_reload_command = ["/usr/sbin/nginx", "-s", "reload"]
|
||||
# override_nginx_test_command = ["/usr/sbin/nginx", "-t"]
|
||||
nginx_reload_timeout_seconds = 30
|
||||
nginx_test_timeout_seconds = 30
|
||||
31
config/master/development.toml
Normal file
31
config/master/development.toml
Normal file
@@ -0,0 +1,31 @@
|
||||
[server]
|
||||
bind_address = "0.0.0.0"
|
||||
port = 8080
|
||||
[server.certificate]
|
||||
san_dns = ["localhost"]
|
||||
san_ip = ["127.0.0.1"]
|
||||
|
||||
[log]
|
||||
level = "debug"
|
||||
|
||||
[database]
|
||||
url = "postgres://postgres:postgres@postgres:5432/nxmesh"
|
||||
max_connections = 10
|
||||
|
||||
[grpc]
|
||||
bind_address = "0.0.0.0"
|
||||
port = 8443
|
||||
[grpc.certificate]
|
||||
san_dns = ["localhost"]
|
||||
san_ip = ["127.0.0.1"]
|
||||
|
||||
[auth]
|
||||
jwt_secret = "development-secret-do-not-use-in-production"
|
||||
jwt_expiration_hours = 24
|
||||
|
||||
[agent]
|
||||
name = "development-agent"
|
||||
data_dir = "./agent-runtime-data"
|
||||
|
||||
[master]
|
||||
url = "http://localhost:8080"
|
||||
@@ -14,3 +14,4 @@ async-std = { version = "1", features = ["attributes", "tokio1"] }
|
||||
[dependencies.sea-orm-migration]
|
||||
version = "2.0.0-rc"
|
||||
features = ["runtime-tokio-rustls", "sqlx-postgres"]
|
||||
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
pub use sea_orm_migration::prelude::*;
|
||||
|
||||
mod m20260301_000001_create_agents;
|
||||
mod m20260301_000002_create_public_key_revokaction;
|
||||
|
||||
pub struct Migrator;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigratorTrait for Migrator {
|
||||
fn migrations() -> Vec<Box<dyn MigrationTrait>> {
|
||||
vec![]
|
||||
vec![
|
||||
Box::new(m20260301_000001_create_agents::Migration),
|
||||
Box::new(m20260301_000002_create_public_key_revokaction::Migration),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
61
crates/migration/src/m20260301_000001_create_agents.rs
Normal file
61
crates/migration/src/m20260301_000001_create_agents.rs
Normal file
@@ -0,0 +1,61 @@
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.create_table(
|
||||
Table::create()
|
||||
.table(Agents::Table)
|
||||
.if_not_exists()
|
||||
.col(ColumnDef::new(Agents::Id).uuid().not_null().primary_key())
|
||||
.col(ColumnDef::new(Agents::Name).string().not_null())
|
||||
.col(ColumnDef::new(Agents::IpAddress).string())
|
||||
.col(ColumnDef::new(Agents::Version).string())
|
||||
.col(ColumnDef::new(Agents::State).string().not_null())
|
||||
.col(ColumnDef::new(Agents::DeploymentMode).string())
|
||||
.col(ColumnDef::new(Agents::LastSeenAt).timestamp_with_time_zone())
|
||||
.col(ColumnDef::new(Agents::Capabilities).json())
|
||||
.col(ColumnDef::new(Agents::PublicKeyHash).string())
|
||||
.col(ColumnDef::new(Agents::Labels).json())
|
||||
.col(
|
||||
ColumnDef::new(Agents::CreatedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.not_null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(Agents::UpdatedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.not_null(),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.drop_table(Table::drop().table(Agents::Table).to_owned())
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum Agents {
|
||||
Table,
|
||||
Id,
|
||||
Name,
|
||||
IpAddress,
|
||||
Version,
|
||||
State,
|
||||
DeploymentMode,
|
||||
LastSeenAt,
|
||||
Capabilities,
|
||||
PublicKeyHash,
|
||||
Labels,
|
||||
CreatedAt,
|
||||
UpdatedAt,
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
let table = manager
|
||||
.create_table(
|
||||
Table::create()
|
||||
.table(PublicKeyRevocations::Table)
|
||||
.if_not_exists()
|
||||
.col(
|
||||
ColumnDef::new(PublicKeyRevocations::Id)
|
||||
.uuid()
|
||||
.not_null()
|
||||
.primary_key(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(PublicKeyRevocations::PublicKeyHash)
|
||||
.string()
|
||||
.not_null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(PublicKeyRevocations::CreatedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.not_null(),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
// add index on PublicKeyHash for faster lookups
|
||||
manager
|
||||
.create_index(
|
||||
Index::create()
|
||||
.name("idx_public_key_hash")
|
||||
.table(PublicKeyRevocations::Table)
|
||||
.col(PublicKeyRevocations::PublicKeyHash)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
Ok(table)
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.drop_table(Table::drop().table(PublicKeyRevocations::Table).to_owned())
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum PublicKeyRevocations {
|
||||
Table,
|
||||
Id,
|
||||
PublicKeyHash,
|
||||
CreatedAt,
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
|
||||
@@ -10,6 +10,21 @@ rust-version.workspace = true
|
||||
[dependencies]
|
||||
tonic.workspace = true
|
||||
prost.workspace = true
|
||||
tonic-prost.workspace = true
|
||||
tonic-async-interceptor = { workspace = true, optional = true }
|
||||
|
||||
# allow user to specify tonic server or client
|
||||
[features]
|
||||
default = ["server", "client"]
|
||||
server = [
|
||||
"tonic/server",
|
||||
"tonic/tls-native-roots",
|
||||
"dep:tonic-async-interceptor",
|
||||
]
|
||||
client = []
|
||||
|
||||
[build-dependencies]
|
||||
tonic-prost-build.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tokio-test.workspace = true
|
||||
|
||||
@@ -4,6 +4,7 @@ fn main() -> Result<()> {
|
||||
tonic_prost_build::configure()
|
||||
.build_server(true)
|
||||
.build_client(true)
|
||||
.protoc_arg("--experimental_allow_proto3_optional")
|
||||
.compile_protos(&["proto/agent.proto"], &["proto"])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -3,296 +3,119 @@ package nxmesh.agent.v1;
|
||||
|
||||
option go_package = "github.com/nxmesh/api/agent/v1";
|
||||
|
||||
// For all file paths in this proto, we use forward slashes ("/") as the separator, even on Windows. This is because gRPC and protobuf are designed to be cross-platform and forward slashes are universally accepted as path separators in URLs and many programming languages. Using forward slashes ensures consistency and avoids issues with escaping backslashes on different platforms.
|
||||
// All file paths MUST be relative paths from other config files, e.g. "site.conf", "private/example.com.conf". Absolute paths or path traversal above the config directory should be rejected by the agent for security reasons. The config files must live within the generated config directory, e.g. "/etc/nginx/conf-<timestamp>/site.conf". This allows the agent to manage the lifecycle of config files, e.g. cleanup old configs after successful apply.
|
||||
|
||||
// AgentService defines the bidirectional communication between master and agents
|
||||
service AgentService {
|
||||
// Stream establishes a persistent connection for real-time communication
|
||||
rpc Stream(stream AgentMessage) returns (stream MasterMessage);
|
||||
|
||||
// ReportHealth sends a health report to the master
|
||||
rpc ReportHealth(HealthReport) returns (Ack);
|
||||
rpc ConnectionTest(TestRequest) returns (TestResponse);
|
||||
}
|
||||
|
||||
// ReportMetrics sends metrics batch to the master
|
||||
rpc ReportMetrics(MetricsBatch) returns (Ack);
|
||||
message TestRequest {
|
||||
// no fields needed for test request
|
||||
}
|
||||
|
||||
message TestResponse {
|
||||
bool success = 1;
|
||||
string error_message = 2; // if success is false, this field should contain the error message
|
||||
}
|
||||
|
||||
// Messages sent from master to agent
|
||||
message MasterMessage {
|
||||
int64 timestamp = 1;
|
||||
string message_id = 2;
|
||||
oneof payload {
|
||||
// requests
|
||||
ConfigUpdate config_update = 3;
|
||||
Command command = 4;
|
||||
}
|
||||
}
|
||||
|
||||
// Messages sent from agent to master
|
||||
message AgentMessage {
|
||||
string agent_id = 1;
|
||||
int64 timestamp = 2;
|
||||
string message_id = 3;
|
||||
oneof payload {
|
||||
RegistrationRequest registration = 3;
|
||||
HealthReport health = 4;
|
||||
ConfigStatus config_status = 5;
|
||||
MetricsBatch metrics = 6;
|
||||
LogBatch logs = 7;
|
||||
Event event = 8;
|
||||
// responses
|
||||
ConfigUpdateResult config_update_result = 6;
|
||||
CommandResult command_result = 7;
|
||||
}
|
||||
}
|
||||
|
||||
// Messages sent from master to agent
|
||||
message MasterMessage {
|
||||
int64 timestamp = 1;
|
||||
oneof payload {
|
||||
RegistrationResponse registration_response = 2;
|
||||
ConfigUpdate config_update = 3;
|
||||
Command command = 4;
|
||||
Ack ack = 5;
|
||||
Error error = 6;
|
||||
}
|
||||
}
|
||||
//
|
||||
//
|
||||
//
|
||||
|
||||
// Registration
|
||||
message RegistrationRequest {
|
||||
string token = 1;
|
||||
string hostname = 2;
|
||||
string ip_address = 3;
|
||||
string version = 4;
|
||||
repeated string capabilities = 5;
|
||||
map<string, string> labels = 6;
|
||||
DeploymentMode deployment_mode = 7;
|
||||
}
|
||||
|
||||
message RegistrationResponse {
|
||||
string agent_id = 1;
|
||||
bool success = 2;
|
||||
string error_message = 3;
|
||||
int64 heartbeat_interval_seconds = 4;
|
||||
}
|
||||
|
||||
enum DeploymentMode {
|
||||
DEPLOYMENT_MODE_UNSPECIFIED = 0;
|
||||
DOCKER_SIDECAR = 1;
|
||||
KUBERNETES_SIDECAR = 2;
|
||||
STANDALONE = 3;
|
||||
}
|
||||
|
||||
// Health Reporting
|
||||
message HealthReport {
|
||||
NginxStatus nginx = 1;
|
||||
SystemMetrics system = 2;
|
||||
string config_checksum = 3;
|
||||
int64 config_version = 4;
|
||||
repeated Alert alerts = 5;
|
||||
}
|
||||
|
||||
message NginxStatus {
|
||||
bool is_running = 1;
|
||||
uint32 pid = 2;
|
||||
uint64 uptime_seconds = 3;
|
||||
uint32 active_connections = 4;
|
||||
uint64 total_requests = 5;
|
||||
float requests_per_second = 6;
|
||||
}
|
||||
|
||||
message SystemMetrics {
|
||||
float cpu_percent = 1;
|
||||
uint64 memory_used_bytes = 2;
|
||||
uint64 memory_total_bytes = 3;
|
||||
uint64 disk_used_bytes = 4;
|
||||
uint64 disk_total_bytes = 5;
|
||||
float load_average_1m = 6;
|
||||
}
|
||||
|
||||
message Alert {
|
||||
string id = 1;
|
||||
string severity = 2; // info, warning, error, critical
|
||||
string message = 3;
|
||||
int64 timestamp = 4;
|
||||
}
|
||||
|
||||
// Configuration
|
||||
// ConfigUpdate represents a request from master to agent to update the configuration
|
||||
message ConfigUpdate {
|
||||
string config_id = 1;
|
||||
int64 version = 2;
|
||||
repeated VirtualHost virtual_hosts = 3;
|
||||
repeated Upstream upstreams = 4;
|
||||
map<string, Certificate> certificates = 5;
|
||||
GlobalSettings global_settings = 6;
|
||||
string config_id = 1; // unique identifier for this config update
|
||||
string version = 2;
|
||||
// The root config is the main nginx.conf file, this file will be used as the entry point for nginx configuration. The content of this file should include references to other config files if needed. The agent will write this root config to the nginx config directory and use it to reload nginx.
|
||||
ConfigContent root_config = 3;
|
||||
// The other config files that are referenced by the root config, e.g. "site.conf", "private/example.com.conf". If the root config does not reference any other config files, this field can be left empty. The agent will write these config files to the nginx config directory and ensure they are included in the root config.
|
||||
repeated ConfigContent configs = 4;
|
||||
}
|
||||
|
||||
message VirtualHost {
|
||||
string id = 1;
|
||||
string name = 2;
|
||||
string server_name = 3;
|
||||
uint32 listen_port = 4;
|
||||
bool ssl_enabled = 5;
|
||||
string ssl_certificate_id = 6;
|
||||
bool http2_enabled = 7;
|
||||
bool http3_enabled = 8;
|
||||
repeated Location locations = 9;
|
||||
map<string, string> custom_directives = 10;
|
||||
}
|
||||
|
||||
message Location {
|
||||
message ConfigContent {
|
||||
// relative path from other config files, e.g. "site.conf", "private/example.com.conf"
|
||||
string path = 1;
|
||||
string proxy_pass = 2;
|
||||
string upstream_id = 3;
|
||||
string root = 4;
|
||||
string index = 5;
|
||||
repeated Header custom_headers = 6;
|
||||
repeated RewriteRule rewrite_rules = 7;
|
||||
map<string, string> custom_directives = 8;
|
||||
string content = 2;
|
||||
}
|
||||
|
||||
message Header {
|
||||
string name = 1;
|
||||
string value = 2;
|
||||
bool always = 3;
|
||||
message ConfigUpdateResult {
|
||||
string config_id = 1; // should match the config_id in ConfigUpdate
|
||||
string version = 2;
|
||||
bool success = 3;
|
||||
optional ConfigUpdateError error_message = 4; // if success is false, this field should contain the error message
|
||||
}
|
||||
|
||||
message RewriteRule {
|
||||
string pattern = 1;
|
||||
string replacement = 2;
|
||||
string flag = 3;
|
||||
enum ConfigUpdateError {
|
||||
UNKNOWN = 0;
|
||||
INVALID_CONFIG = 1; // the config content is invalid, e.g. syntax error
|
||||
WRITE_FAILED = 2; // failed to write the config file to disk
|
||||
RELOAD_FAILED = 3; // failed to reload nginx with the new config
|
||||
}
|
||||
|
||||
message Upstream {
|
||||
string id = 1;
|
||||
string name = 2;
|
||||
LoadBalanceAlgorithm algorithm = 3;
|
||||
repeated UpstreamServer servers = 4;
|
||||
HealthCheckConfig health_check = 5;
|
||||
uint32 keepalive_connections = 6;
|
||||
}
|
||||
//
|
||||
//
|
||||
//
|
||||
|
||||
enum LoadBalanceAlgorithm {
|
||||
LOAD_BALANCE_ALGORITHM_UNSPECIFIED = 0;
|
||||
ROUND_ROBIN = 1;
|
||||
LEAST_CONNECTIONS = 2;
|
||||
IP_HASH = 3;
|
||||
WEIGHTED_ROUND_ROBIN = 4;
|
||||
}
|
||||
// TODO: allow setting the default fallback and the corresponding default nginx root config when nginx reload fails to re-use old config, "use default config", "stop nginx".
|
||||
|
||||
message UpstreamServer {
|
||||
string address = 1;
|
||||
uint32 weight = 2;
|
||||
bool backup = 3;
|
||||
bool down = 4;
|
||||
uint32 max_fails = 5;
|
||||
uint32 fail_timeout_seconds = 6;
|
||||
}
|
||||
|
||||
message HealthCheckConfig {
|
||||
bool enabled = 1;
|
||||
string path = 2;
|
||||
uint32 interval_seconds = 3;
|
||||
uint32 timeout_seconds = 4;
|
||||
uint32 healthy_threshold = 5;
|
||||
uint32 unhealthy_threshold = 6;
|
||||
}
|
||||
|
||||
message Certificate {
|
||||
string id = 1;
|
||||
string domain = 2;
|
||||
string certificate_pem = 3;
|
||||
string private_key_pem = 4;
|
||||
int64 expires_at = 5;
|
||||
}
|
||||
|
||||
message GlobalSettings {
|
||||
map<string, string> nginx_directives = 1;
|
||||
map<string, string> env_vars = 2;
|
||||
}
|
||||
|
||||
message ConfigStatus {
|
||||
string config_id = 1;
|
||||
int64 version = 2;
|
||||
ConfigApplyStatus status = 3;
|
||||
string error_message = 4;
|
||||
int64 applied_at = 5;
|
||||
}
|
||||
|
||||
enum ConfigApplyStatus {
|
||||
CONFIG_APPLY_STATUS_UNSPECIFIED = 0;
|
||||
PENDING = 1;
|
||||
VALIDATING = 2;
|
||||
APPLYING = 3;
|
||||
SUCCESS = 4;
|
||||
FAILED = 5;
|
||||
ROLLED_BACK = 6;
|
||||
}
|
||||
|
||||
// Metrics
|
||||
message MetricsBatch {
|
||||
int64 timestamp = 1;
|
||||
repeated Metric metrics = 2;
|
||||
}
|
||||
|
||||
message Metric {
|
||||
string name = 1;
|
||||
double value = 2;
|
||||
int64 timestamp = 3;
|
||||
map<string, string> labels = 4;
|
||||
MetricType type = 5;
|
||||
}
|
||||
|
||||
enum MetricType {
|
||||
METRIC_TYPE_UNSPECIFIED = 0;
|
||||
GAUGE = 1;
|
||||
COUNTER = 2;
|
||||
HISTOGRAM = 3;
|
||||
}
|
||||
|
||||
// Logs
|
||||
message LogBatch {
|
||||
repeated LogEntry entries = 1;
|
||||
}
|
||||
|
||||
message LogEntry {
|
||||
int64 timestamp = 1;
|
||||
string level = 2;
|
||||
string message = 3;
|
||||
map<string, string> fields = 4;
|
||||
}
|
||||
|
||||
// Commands
|
||||
// Command represents a request from master to agent to execute a command, e.g. "reload", "test"
|
||||
message Command {
|
||||
string command_id = 1;
|
||||
oneof command {
|
||||
ReloadCommand reload = 2;
|
||||
RestartCommand restart = 3;
|
||||
StopCommand stop = 4;
|
||||
GetStatusCommand get_status = 5;
|
||||
ValidateConfigCommand validate_config = 6;
|
||||
ReloadCommand reload = 1;
|
||||
TestCommand test = 2;
|
||||
}
|
||||
}
|
||||
|
||||
message ReloadCommand {
|
||||
bool graceful = 1;
|
||||
// no additional fields needed for reload command
|
||||
}
|
||||
|
||||
message RestartCommand {
|
||||
bool force = 1;
|
||||
message TestCommand {
|
||||
// no additional fields needed for test command
|
||||
}
|
||||
|
||||
message StopCommand {
|
||||
bool graceful = 1;
|
||||
uint32 timeout_seconds = 2;
|
||||
message CommandResult {
|
||||
oneof result {
|
||||
ReloadResult reload_result = 1;
|
||||
TestResult test_result = 2;
|
||||
}
|
||||
}
|
||||
|
||||
message GetStatusCommand {}
|
||||
|
||||
message ValidateConfigCommand {
|
||||
string config_content = 1;
|
||||
message ReloadResult {
|
||||
bool success = 1;
|
||||
string error_message = 2; // if success is false, this field should contain the error message
|
||||
}
|
||||
|
||||
// Events
|
||||
message Event {
|
||||
string event_id = 1;
|
||||
string event_type = 2;
|
||||
int64 timestamp = 3;
|
||||
map<string, string> data = 4;
|
||||
message TestResult {
|
||||
bool success = 1;
|
||||
string error_message = 2; // if success is false, this field should contain the error message
|
||||
}
|
||||
|
||||
// Common messages
|
||||
message Ack {
|
||||
string message_id = 1;
|
||||
bool success = 2;
|
||||
string error_message = 3;
|
||||
}
|
||||
|
||||
message Error {
|
||||
string code = 1;
|
||||
string message = 2;
|
||||
map<string, string> details = 3;
|
||||
}
|
||||
|
||||
1
crates/nxmesh-proto/src/auth/mod.rs
Normal file
1
crates/nxmesh-proto/src/auth/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub mod ssh_auth;
|
||||
199
crates/nxmesh-proto/src/auth/ssh_auth.rs
Normal file
199
crates/nxmesh-proto/src/auth/ssh_auth.rs
Normal file
@@ -0,0 +1,199 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use tonic::{Request, Status, async_trait, transport::CertificateDer};
|
||||
use tonic_async_interceptor::{AsyncInterceptor, AsyncInterceptorLayer, async_interceptor};
|
||||
|
||||
pub fn create_ssh_auth_interceptor(
|
||||
certificate_provider: Arc<dyn CertificateValidationProvider>,
|
||||
) -> AsyncInterceptorLayer<SshAuthInterceptor> {
|
||||
async_interceptor(SshAuthInterceptor::new(certificate_provider))
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct SshAuthInterceptor {
|
||||
certificate_provider: Arc<dyn CertificateValidationProvider>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait CertificateValidationProvider: Send + Sync {
|
||||
async fn is_authorized(&self, certs: &Arc<Vec<CertificateDer<'_>>>) -> Result<bool, Status>;
|
||||
}
|
||||
|
||||
impl AsyncInterceptor for SshAuthInterceptor {
|
||||
type Future =
|
||||
std::pin::Pin<Box<dyn std::future::Future<Output = Result<Request<()>, Status>> + Send>>;
|
||||
fn call(&mut self, req: Request<()>) -> Self::Future {
|
||||
let this = self.clone();
|
||||
Box::pin(async move { this.authenticate(req).await })
|
||||
}
|
||||
}
|
||||
|
||||
impl SshAuthInterceptor {
|
||||
pub fn new(certificate_provider: Arc<dyn CertificateValidationProvider>) -> Self {
|
||||
SshAuthInterceptor {
|
||||
certificate_provider,
|
||||
}
|
||||
}
|
||||
|
||||
async fn authenticate(&self, req: Request<()>) -> Result<Request<()>, Status> {
|
||||
let certs = req.peer_certs().ok_or(Status::unauthenticated("No cert"))?;
|
||||
|
||||
self.validate_certs(&certs).await?;
|
||||
Ok(req)
|
||||
}
|
||||
|
||||
async fn validate_certs(&self, certs: &Arc<Vec<CertificateDer<'_>>>) -> Result<(), Status> {
|
||||
let is_authorized = self.certificate_provider.is_authorized(certs).await?;
|
||||
|
||||
if is_authorized {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(Status::permission_denied("Blocked"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::{
|
||||
Arc, Mutex,
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
};
|
||||
|
||||
use tonic::{Request, Status, transport::CertificateDer};
|
||||
use tonic_async_interceptor::AsyncInterceptor;
|
||||
|
||||
use super::{CertificateValidationProvider, SshAuthInterceptor, create_ssh_auth_interceptor};
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum ProviderMode {
|
||||
Allow,
|
||||
Deny,
|
||||
Error,
|
||||
}
|
||||
|
||||
struct TestCertificateProvider {
|
||||
mode: ProviderMode,
|
||||
calls: Arc<AtomicUsize>,
|
||||
cert_count_seen: Arc<Mutex<Option<usize>>>,
|
||||
}
|
||||
|
||||
#[tonic::async_trait]
|
||||
impl CertificateValidationProvider for TestCertificateProvider {
|
||||
async fn is_authorized(
|
||||
&self,
|
||||
certs: &Arc<Vec<CertificateDer<'_>>>,
|
||||
) -> Result<bool, Status> {
|
||||
self.calls.fetch_add(1, Ordering::SeqCst);
|
||||
|
||||
let lock = self.cert_count_seen.lock();
|
||||
assert!(lock.is_ok());
|
||||
let mut lock = lock.unwrap_or_else(|_| unreachable!());
|
||||
*lock = Some(certs.len());
|
||||
|
||||
match self.mode {
|
||||
ProviderMode::Allow => Ok(true),
|
||||
ProviderMode::Deny => Ok(false),
|
||||
ProviderMode::Error => Err(Status::internal("provider failed")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn build_provider(
|
||||
mode: ProviderMode,
|
||||
) -> (
|
||||
Arc<TestCertificateProvider>,
|
||||
Arc<AtomicUsize>,
|
||||
Arc<Mutex<Option<usize>>>,
|
||||
) {
|
||||
let calls = Arc::new(AtomicUsize::new(0));
|
||||
let cert_count_seen = Arc::new(Mutex::new(None));
|
||||
let provider = Arc::new(TestCertificateProvider {
|
||||
mode,
|
||||
calls: calls.clone(),
|
||||
cert_count_seen: cert_count_seen.clone(),
|
||||
});
|
||||
(provider, calls, cert_count_seen)
|
||||
}
|
||||
|
||||
fn sample_certs() -> Arc<Vec<CertificateDer<'static>>> {
|
||||
Arc::new(vec![
|
||||
CertificateDer::from(vec![1, 2, 3]),
|
||||
CertificateDer::from(vec![4, 5, 6]),
|
||||
])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_ssh_auth_interceptor_builds_layer() {
|
||||
let (provider, _, _) = build_provider(ProviderMode::Allow);
|
||||
let _ = create_ssh_auth_interceptor(provider);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authenticate_fails_when_no_peer_certificates_exist() {
|
||||
let (provider, calls, _) = build_provider(ProviderMode::Allow);
|
||||
let interceptor = SshAuthInterceptor::new(provider);
|
||||
|
||||
let result = tokio_test::block_on(interceptor.authenticate(Request::new(())));
|
||||
assert!(result.is_err());
|
||||
let err = result.err().unwrap_or_else(|| unreachable!());
|
||||
assert_eq!(err.code(), tonic::Code::Unauthenticated);
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_certs_succeeds_when_provider_allows() {
|
||||
let (provider, calls, cert_count_seen) = build_provider(ProviderMode::Allow);
|
||||
let interceptor = SshAuthInterceptor::new(provider);
|
||||
let certs = sample_certs();
|
||||
|
||||
let result = tokio_test::block_on(interceptor.validate_certs(&certs));
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 1);
|
||||
|
||||
let seen = cert_count_seen.lock();
|
||||
assert!(seen.is_ok());
|
||||
let seen = seen.unwrap_or_else(|_| unreachable!());
|
||||
assert_eq!(*seen, Some(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_certs_returns_permission_denied_when_provider_denies() {
|
||||
let (provider, calls, _) = build_provider(ProviderMode::Deny);
|
||||
let interceptor = SshAuthInterceptor::new(provider);
|
||||
let certs = sample_certs();
|
||||
|
||||
let result = tokio_test::block_on(interceptor.validate_certs(&certs));
|
||||
assert!(result.is_err());
|
||||
let err = result.err().unwrap_or_else(|| unreachable!());
|
||||
assert_eq!(err.code(), tonic::Code::PermissionDenied);
|
||||
assert_eq!(err.message(), "Blocked");
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_certs_propagates_provider_errors() {
|
||||
let (provider, calls, _) = build_provider(ProviderMode::Error);
|
||||
let interceptor = SshAuthInterceptor::new(provider);
|
||||
let certs = sample_certs();
|
||||
|
||||
let result = tokio_test::block_on(interceptor.validate_certs(&certs));
|
||||
assert!(result.is_err());
|
||||
let err = result.err().unwrap_or_else(|| unreachable!());
|
||||
assert_eq!(err.code(), tonic::Code::Internal);
|
||||
assert_eq!(err.message(), "provider failed");
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn async_interceptor_call_delegates_to_authenticate() {
|
||||
let (provider, calls, _) = build_provider(ProviderMode::Allow);
|
||||
let mut interceptor = SshAuthInterceptor::new(provider);
|
||||
|
||||
let result = tokio_test::block_on(interceptor.call(Request::new(())));
|
||||
assert!(result.is_err());
|
||||
let err = result.err().unwrap_or_else(|| unreachable!());
|
||||
assert_eq!(err.code(), tonic::Code::Unauthenticated);
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 0);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,125 @@
|
||||
//! NxMesh Protocol Buffers
|
||||
//!
|
||||
//! This crate contains the gRPC protocol definitions for master-agent communication.
|
||||
#![forbid(clippy::unwrap_used, clippy::panic, unsafe_code)]
|
||||
#![deny(clippy::expect_used)]
|
||||
|
||||
pub mod agent {
|
||||
tonic::include_proto!("nxmesh.agent.v1");
|
||||
}
|
||||
|
||||
pub use agent::*;
|
||||
pub mod auth;
|
||||
#[allow(ambiguous_glob_reexports)]
|
||||
pub use tonic_async_interceptor::*;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use prost::Message;
|
||||
|
||||
use crate::agent::{
|
||||
AgentMessage, ConfigApplyStatus, ConfigStatus, DeploymentMode, Error, MasterMessage,
|
||||
MetricType, RegistrationRequest, agent_message, master_message,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn agent_message_round_trip_with_registration_payload() {
|
||||
let msg = AgentMessage {
|
||||
agent_id: "agent-1".to_string(),
|
||||
timestamp: 123,
|
||||
payload: Some(agent_message::Payload::Registration(RegistrationRequest {
|
||||
hostname: "node-1".to_string(),
|
||||
ip_address: "127.0.0.1".to_string(),
|
||||
version: "1.0.0".to_string(),
|
||||
capabilities: vec!["reload".to_string(), "metrics".to_string()],
|
||||
labels: std::collections::HashMap::from([
|
||||
("region".to_string(), "dev".to_string()),
|
||||
("tier".to_string(), "edge".to_string()),
|
||||
]),
|
||||
deployment_mode: DeploymentMode::Standalone as i32,
|
||||
})),
|
||||
};
|
||||
|
||||
let encoded = msg.encode_to_vec();
|
||||
let decoded = AgentMessage::decode(encoded.as_slice());
|
||||
assert!(decoded.is_ok());
|
||||
let decoded = decoded.unwrap_or_else(|_| unreachable!());
|
||||
|
||||
assert_eq!(decoded.agent_id, "agent-1");
|
||||
assert_eq!(decoded.timestamp, 123);
|
||||
|
||||
match decoded.payload {
|
||||
Some(agent_message::Payload::Registration(payload)) => {
|
||||
assert_eq!(payload.hostname, "node-1");
|
||||
assert_eq!(payload.ip_address, "127.0.0.1");
|
||||
assert_eq!(payload.version, "1.0.0");
|
||||
assert_eq!(payload.capabilities.len(), 2);
|
||||
assert_eq!(payload.labels.get("region"), Some(&"dev".to_string()));
|
||||
assert_eq!(payload.deployment_mode, DeploymentMode::Standalone as i32);
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn master_message_round_trip_with_error_payload() {
|
||||
let msg = MasterMessage {
|
||||
timestamp: 999,
|
||||
payload: Some(master_message::Payload::Error(Error {
|
||||
code: "E_CONFIG_INVALID".to_string(),
|
||||
message: "invalid config".to_string(),
|
||||
details: std::collections::HashMap::from([
|
||||
("file".to_string(), "site.conf".to_string()),
|
||||
("line".to_string(), "42".to_string()),
|
||||
]),
|
||||
})),
|
||||
};
|
||||
|
||||
let encoded = msg.encode_to_vec();
|
||||
let decoded = MasterMessage::decode(encoded.as_slice());
|
||||
assert!(decoded.is_ok());
|
||||
let decoded = decoded.unwrap_or_else(|_| unreachable!());
|
||||
|
||||
assert_eq!(decoded.timestamp, 999);
|
||||
match decoded.payload {
|
||||
Some(master_message::Payload::Error(err)) => {
|
||||
assert_eq!(err.code, "E_CONFIG_INVALID");
|
||||
assert_eq!(err.message, "invalid config");
|
||||
assert_eq!(err.details.get("line"), Some(&"42".to_string()));
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enum_integer_mappings_are_stable() {
|
||||
assert_eq!(DeploymentMode::Unspecified as i32, 0);
|
||||
assert_eq!(DeploymentMode::DockerSidecar as i32, 1);
|
||||
assert_eq!(DeploymentMode::KubernetesSidecar as i32, 2);
|
||||
assert_eq!(DeploymentMode::Standalone as i32, 3);
|
||||
|
||||
assert_eq!(ConfigApplyStatus::Unspecified as i32, 0);
|
||||
assert_eq!(ConfigApplyStatus::Pending as i32, 1);
|
||||
assert_eq!(ConfigApplyStatus::Validating as i32, 2);
|
||||
assert_eq!(ConfigApplyStatus::Applying as i32, 3);
|
||||
assert_eq!(ConfigApplyStatus::Success as i32, 4);
|
||||
assert_eq!(ConfigApplyStatus::Failed as i32, 5);
|
||||
assert_eq!(ConfigApplyStatus::RolledBack as i32, 6);
|
||||
|
||||
assert_eq!(MetricType::Unspecified as i32, 0);
|
||||
assert_eq!(MetricType::Gauge as i32, 1);
|
||||
assert_eq!(MetricType::Counter as i32, 2);
|
||||
assert_eq!(MetricType::Histogram as i32, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_status_defaults_are_proto3_zero_values() {
|
||||
let status = ConfigStatus::default();
|
||||
|
||||
assert_eq!(status.config_id, "");
|
||||
assert_eq!(status.version, 0);
|
||||
assert_eq!(status.status, ConfigApplyStatus::Unspecified as i32);
|
||||
assert_eq!(status.error_message, "");
|
||||
assert_eq!(status.applied_at, 0);
|
||||
}
|
||||
}
|
||||
|
||||
24
justfile
24
justfile
@@ -25,7 +25,6 @@ setup-rust-tools:
|
||||
cargo install sea-orm-cli@^2.0.0-rc --features "sqlx-postgres runtime-tokio-rustls"
|
||||
cargo install cargo-watch
|
||||
|
||||
|
||||
# Setup frontend dependencies
|
||||
setup-frontend:
|
||||
@echo "📦 Installing frontend dependencies..."
|
||||
@@ -35,6 +34,12 @@ setup-frontend:
|
||||
# Development Commands
|
||||
# =============================================================================
|
||||
|
||||
# act
|
||||
act *ARGS:
|
||||
# run act with custom secret-file
|
||||
@echo "🎬 Running act with custom secrets file..."
|
||||
act --env-file .github/.env --secret-file .github/.secrets.env --var-file .github/.var.env --network host --artifact-server-path ./.act/.artifacts {{ ARGS }}
|
||||
|
||||
# Start all services for development
|
||||
dev:
|
||||
@echo "🚀 Starting all development services..."
|
||||
@@ -43,9 +48,13 @@ dev:
|
||||
wait
|
||||
|
||||
# Start Rust backend with hot reload
|
||||
dev-backend:
|
||||
dev-master *ARGS:
|
||||
@echo "🔧 Starting Rust backend..."
|
||||
cargo watch -x run
|
||||
cargo watch -w apps/nxmesh-master -x 'run --bin nxmesh-master -- {{ ARGS }}'
|
||||
|
||||
dev-agent *ARGS:
|
||||
@echo "🔧 Starting Rust agent..."
|
||||
cargo watch -w apps/nxmesh-agent -x 'run --bin nxmesh-agent -- {{ ARGS }}'
|
||||
|
||||
# Start Vite frontend development server
|
||||
dev-frontend:
|
||||
@@ -85,7 +94,7 @@ build-frontend:
|
||||
# =============================================================================
|
||||
|
||||
db *ARGS:
|
||||
cd crates && sea-orm-cli {{ARGS}}
|
||||
cd crates && sea-orm-cli {{ ARGS }}
|
||||
|
||||
# Setup database
|
||||
db-setup:
|
||||
@@ -113,7 +122,7 @@ db-new-migration name:
|
||||
# Reset database (drop and recreate)
|
||||
db-reset:
|
||||
@echo "⚠️ Resetting database..."
|
||||
cd crates && sea-orm-cli database reset
|
||||
cd crates && sea-orm-cli migrate reset
|
||||
|
||||
# Connect to database with psql
|
||||
db-console:
|
||||
@@ -201,6 +210,11 @@ docker-run:
|
||||
@echo "🐳 Running Docker container..."
|
||||
docker run -p 8080:8080 --env-file .env nxmesh:latest
|
||||
|
||||
# Build Docker image for CI
|
||||
docker-build-ci REGISTRY="ghcr.io/nxmesh":
|
||||
@echo "🐳 Building Docker image for CI..."
|
||||
docker build -t {{ REGISTRY }}/ci:latest -f ./.github/docker/ci.Dockerfile .
|
||||
|
||||
# =============================================================================
|
||||
# Nginx Commands (Shared PID Namespace + Docker Fallback)
|
||||
# =============================================================================
|
||||
|
||||
Reference in New Issue
Block a user