feat: basic
This commit is contained in:
46
.dockerignore
Normal file
46
.dockerignore
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
# Git
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
|
||||||
|
# Build artifacts
|
||||||
|
target/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# Documentation
|
||||||
|
docs/
|
||||||
|
|
||||||
|
# Development files
|
||||||
|
*.md
|
||||||
|
!README.md
|
||||||
|
Makefile
|
||||||
|
|
||||||
|
# Test files
|
||||||
|
tests/
|
||||||
|
*_test.rs
|
||||||
|
*.test.ts
|
||||||
|
*.spec.ts
|
||||||
|
|
||||||
|
# CI/CD
|
||||||
|
.github/
|
||||||
|
|
||||||
|
# Local configuration
|
||||||
|
config/
|
||||||
|
!config/galvanize.example.toml
|
||||||
|
!config/users.example.toml
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
|
||||||
|
# Node modules (handled separately in webui build)
|
||||||
|
webui/node_modules/
|
||||||
|
|
||||||
|
# OS files
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
49
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
49
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
---
|
||||||
|
name: Bug Report
|
||||||
|
about: Create a report to help us improve
|
||||||
|
title: "[BUG] "
|
||||||
|
labels: bug
|
||||||
|
assignees: ''
|
||||||
|
---
|
||||||
|
|
||||||
|
## Describe the Bug
|
||||||
|
|
||||||
|
A clear and concise description of what the bug is.
|
||||||
|
|
||||||
|
## To Reproduce
|
||||||
|
|
||||||
|
Steps to reproduce the behavior:
|
||||||
|
|
||||||
|
1. Go to '...'
|
||||||
|
2. Click on '...'
|
||||||
|
3. See error
|
||||||
|
|
||||||
|
## Expected Behavior
|
||||||
|
|
||||||
|
A clear and concise description of what you expected to happen.
|
||||||
|
|
||||||
|
## Actual Behavior
|
||||||
|
|
||||||
|
What actually happened.
|
||||||
|
|
||||||
|
## Screenshots
|
||||||
|
|
||||||
|
If applicable, add screenshots to help explain your problem.
|
||||||
|
|
||||||
|
## Environment
|
||||||
|
|
||||||
|
- **OS**: [e.g., Ubuntu 22.04, macOS 14, Windows 11]
|
||||||
|
- **Galvanize Version**: [e.g., 0.1.0]
|
||||||
|
- **Rust Version**: [e.g., 1.75.0]
|
||||||
|
- **Installation Method**: [e.g., binary, Docker, source]
|
||||||
|
|
||||||
|
## Logs
|
||||||
|
|
||||||
|
```
|
||||||
|
Paste relevant log output here
|
||||||
|
```
|
||||||
|
|
||||||
|
## Additional Context
|
||||||
|
|
||||||
|
Add any other context about the problem here.
|
||||||
|
|
||||||
29
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
29
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
---
|
||||||
|
name: Feature Request
|
||||||
|
about: Suggest an idea for this project
|
||||||
|
title: "[FEATURE] "
|
||||||
|
labels: enhancement
|
||||||
|
assignees: ''
|
||||||
|
---
|
||||||
|
|
||||||
|
## Is your feature request related to a problem?
|
||||||
|
|
||||||
|
A clear and concise description of what the problem is.
|
||||||
|
Ex. I'm always frustrated when [...]
|
||||||
|
|
||||||
|
## Describe the solution you'd like
|
||||||
|
|
||||||
|
A clear and concise description of what you want to happen.
|
||||||
|
|
||||||
|
## Describe alternatives you've considered
|
||||||
|
|
||||||
|
A clear and concise description of any alternative solutions or features you've considered.
|
||||||
|
|
||||||
|
## Use Case
|
||||||
|
|
||||||
|
Describe the use case for this feature. How would it be used?
|
||||||
|
|
||||||
|
## Additional Context
|
||||||
|
|
||||||
|
Add any other context or screenshots about the feature request here.
|
||||||
|
|
||||||
33
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
33
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
## Description
|
||||||
|
|
||||||
|
Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context.
|
||||||
|
|
||||||
|
Fixes # (issue)
|
||||||
|
|
||||||
|
## Type of Change
|
||||||
|
|
||||||
|
Please delete options that are not relevant.
|
||||||
|
|
||||||
|
- [ ] Bug fix (non-breaking change which fixes an issue)
|
||||||
|
- [ ] New feature (non-breaking change which adds functionality)
|
||||||
|
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
|
||||||
|
- [ ] Documentation update
|
||||||
|
|
||||||
|
## How Has This Been Tested?
|
||||||
|
|
||||||
|
Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce.
|
||||||
|
|
||||||
|
- [ ] Test A
|
||||||
|
- [ ] Test B
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
|
||||||
|
- [ ] My code follows the style guidelines of this project
|
||||||
|
- [ ] I have performed a self-review of my own code
|
||||||
|
- [ ] I have commented my code, particularly in hard-to-understand areas
|
||||||
|
- [ ] I have made corresponding changes to the documentation
|
||||||
|
- [ ] My changes generate no new warnings
|
||||||
|
- [ ] I have added tests that prove my fix is effective or that my feature works
|
||||||
|
- [ ] New and existing unit tests pass locally with my changes
|
||||||
|
- [ ] Any dependent changes have been merged and published in downstream modules
|
||||||
|
|
||||||
112
.github/workflows/ci.yml
vendored
Normal file
112
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main, develop]
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
env:
|
||||||
|
CARGO_TERM_COLOR: always
|
||||||
|
RUST_BACKTRACE: 1
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
# ==========================================================================
|
||||||
|
# Rust Checks
|
||||||
|
# ==========================================================================
|
||||||
|
rust-check:
|
||||||
|
name: Rust Check
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install Rust toolchain
|
||||||
|
uses: dtolnay/rust-action@stable
|
||||||
|
with:
|
||||||
|
components: rustfmt, clippy
|
||||||
|
|
||||||
|
- name: Cache cargo registry
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cargo/registry
|
||||||
|
~/.cargo/git
|
||||||
|
target
|
||||||
|
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-cargo-
|
||||||
|
|
||||||
|
- name: Check formatting
|
||||||
|
run: cargo fmt --all -- --check
|
||||||
|
|
||||||
|
- name: Run clippy
|
||||||
|
run: cargo clippy --all-targets --all-features -- -D warnings
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: cargo test --all-features --verbose
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# Web UI Checks
|
||||||
|
# ==========================================================================
|
||||||
|
webui-check:
|
||||||
|
name: Web UI Check
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: webui
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "20"
|
||||||
|
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: latest
|
||||||
|
|
||||||
|
- name: Get pnpm store directory
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
- name: Cache pnpm dependencies
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: ${{ env.STORE_PATH }}
|
||||||
|
key: ${{ runner.os }}-pnpm-${{ hashFiles('webui/pnpm-lock.yaml') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-pnpm-
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Lint
|
||||||
|
run: pnpm lint
|
||||||
|
|
||||||
|
- name: Type check
|
||||||
|
run: pnpm typecheck
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: pnpm build
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# Security Audit
|
||||||
|
# ==========================================================================
|
||||||
|
security-audit:
|
||||||
|
name: Security Audit
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install Rust toolchain
|
||||||
|
uses: dtolnay/rust-action@stable
|
||||||
|
|
||||||
|
- name: Install cargo-audit
|
||||||
|
run: cargo install cargo-audit
|
||||||
|
|
||||||
|
- name: Run security audit
|
||||||
|
run: cargo audit
|
||||||
|
|
||||||
209
.github/workflows/release.yml
vendored
Normal file
209
.github/workflows/release.yml
vendored
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
name: Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- "v*"
|
||||||
|
|
||||||
|
env:
|
||||||
|
CARGO_TERM_COLOR: always
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
packages: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
# ==========================================================================
|
||||||
|
# Build Web UI
|
||||||
|
# ==========================================================================
|
||||||
|
build-webui:
|
||||||
|
name: Build Web UI
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "20"
|
||||||
|
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: latest
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
working-directory: webui
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
working-directory: webui
|
||||||
|
run: pnpm build
|
||||||
|
|
||||||
|
- name: Upload Web UI artifacts
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: webui-dist
|
||||||
|
path: webui/dist
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# Build Binaries
|
||||||
|
# ==========================================================================
|
||||||
|
build-binaries:
|
||||||
|
name: Build (${{ matrix.target }})
|
||||||
|
needs: build-webui
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- target: x86_64-unknown-linux-gnu
|
||||||
|
os: ubuntu-latest
|
||||||
|
name: galvanize-linux-x86_64
|
||||||
|
- target: aarch64-unknown-linux-gnu
|
||||||
|
os: ubuntu-latest
|
||||||
|
name: galvanize-linux-aarch64
|
||||||
|
- target: x86_64-apple-darwin
|
||||||
|
os: macos-latest
|
||||||
|
name: galvanize-darwin-x86_64
|
||||||
|
- target: aarch64-apple-darwin
|
||||||
|
os: macos-latest
|
||||||
|
name: galvanize-darwin-aarch64
|
||||||
|
- target: x86_64-pc-windows-msvc
|
||||||
|
os: windows-latest
|
||||||
|
name: galvanize-windows-x86_64.exe
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Download Web UI artifacts
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: webui-dist
|
||||||
|
path: webui/dist
|
||||||
|
|
||||||
|
- name: Install Rust toolchain
|
||||||
|
uses: dtolnay/rust-action@stable
|
||||||
|
with:
|
||||||
|
targets: ${{ matrix.target }}
|
||||||
|
|
||||||
|
- name: Install cross-compilation tools
|
||||||
|
if: matrix.target == 'aarch64-unknown-linux-gnu'
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y gcc-aarch64-linux-gnu
|
||||||
|
|
||||||
|
- name: Build binary
|
||||||
|
run: cargo build --release --target ${{ matrix.target }} --features webui
|
||||||
|
env:
|
||||||
|
CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER: aarch64-linux-gnu-gcc
|
||||||
|
|
||||||
|
- name: Prepare binary (Unix)
|
||||||
|
if: runner.os != 'Windows'
|
||||||
|
run: |
|
||||||
|
cp target/${{ matrix.target }}/release/galvanize ${{ matrix.name }}
|
||||||
|
chmod +x ${{ matrix.name }}
|
||||||
|
|
||||||
|
- name: Prepare binary (Windows)
|
||||||
|
if: runner.os == 'Windows'
|
||||||
|
run: cp target/${{ matrix.target }}/release/galvanize.exe ${{ matrix.name }}
|
||||||
|
|
||||||
|
- name: Upload binary
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: ${{ matrix.name }}
|
||||||
|
path: ${{ matrix.name }}
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# Build Docker Image
|
||||||
|
# ==========================================================================
|
||||||
|
build-docker:
|
||||||
|
name: Build Docker Image
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v3
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Login to Docker Hub
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Login to GitHub Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Extract version from tag
|
||||||
|
id: version
|
||||||
|
run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Build and push
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
aitiome/galvanize:latest
|
||||||
|
aitiome/galvanize:${{ steps.version.outputs.VERSION }}
|
||||||
|
ghcr.io/aitiome/galvanize:latest
|
||||||
|
ghcr.io/aitiome/galvanize:${{ steps.version.outputs.VERSION }}
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# Create GitHub Release
|
||||||
|
# ==========================================================================
|
||||||
|
create-release:
|
||||||
|
name: Create Release
|
||||||
|
needs: [build-binaries, build-docker]
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Download all artifacts
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
path: artifacts
|
||||||
|
|
||||||
|
- name: Extract version from tag
|
||||||
|
id: version
|
||||||
|
run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Create checksums
|
||||||
|
run: |
|
||||||
|
cd artifacts
|
||||||
|
for dir in galvanize-*; do
|
||||||
|
if [ -d "$dir" ]; then
|
||||||
|
cd "$dir"
|
||||||
|
sha256sum * > ../checksums-$dir.txt
|
||||||
|
cd ..
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
cat checksums-*.txt > SHA256SUMS.txt
|
||||||
|
rm checksums-*.txt
|
||||||
|
|
||||||
|
- name: Create Release
|
||||||
|
uses: softprops/action-gh-release@v2
|
||||||
|
with:
|
||||||
|
name: Galvanize v${{ steps.version.outputs.VERSION }}
|
||||||
|
draft: false
|
||||||
|
prerelease: ${{ contains(github.ref, 'alpha') || contains(github.ref, 'beta') || contains(github.ref, 'rc') }}
|
||||||
|
generate_release_notes: true
|
||||||
|
files: |
|
||||||
|
artifacts/galvanize-linux-x86_64/*
|
||||||
|
artifacts/galvanize-linux-aarch64/*
|
||||||
|
artifacts/galvanize-darwin-x86_64/*
|
||||||
|
artifacts/galvanize-darwin-aarch64/*
|
||||||
|
artifacts/galvanize-windows-x86_64.exe/*
|
||||||
|
artifacts/SHA256SUMS.txt
|
||||||
|
|
||||||
89
.gitignore
vendored
Normal file
89
.gitignore
vendored
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
# Generated by Cargo
|
||||||
|
# will have compiled files and executables
|
||||||
|
debug/
|
||||||
|
target/
|
||||||
|
|
||||||
|
# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries
|
||||||
|
# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html
|
||||||
|
# Cargo.lock
|
||||||
|
|
||||||
|
# These are backup files generated by rustfmt
|
||||||
|
**/*.rs.bk
|
||||||
|
|
||||||
|
# MSVC Windows builds
|
||||||
|
*.exp
|
||||||
|
*.lib
|
||||||
|
*.pdb
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# macOS
|
||||||
|
.DS_Store
|
||||||
|
.AppleDouble
|
||||||
|
.LSOverride
|
||||||
|
._*
|
||||||
|
|
||||||
|
# Windows
|
||||||
|
Thumbs.db
|
||||||
|
ehthumbs.db
|
||||||
|
Desktop.ini
|
||||||
|
|
||||||
|
# Linux
|
||||||
|
*~
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs/
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
|
||||||
|
# Runtime data
|
||||||
|
pids/
|
||||||
|
*.pid
|
||||||
|
*.seed
|
||||||
|
*.pid.lock
|
||||||
|
|
||||||
|
# Coverage
|
||||||
|
coverage/
|
||||||
|
*.lcov
|
||||||
|
tarpaulin-report.html
|
||||||
|
tarpaulin-report.json
|
||||||
|
|
||||||
|
# Web UI
|
||||||
|
webui/node_modules/
|
||||||
|
webui/dist/
|
||||||
|
webui/.turbo/
|
||||||
|
|
||||||
|
# Build artifacts
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
|
||||||
|
# Config (local development)
|
||||||
|
config/
|
||||||
|
!config/galvanize.example.toml
|
||||||
|
!config/devices/.gitkeep
|
||||||
|
|
||||||
|
# Temporary files
|
||||||
|
tmp/
|
||||||
|
temp/
|
||||||
|
*.tmp
|
||||||
|
|
||||||
|
# Documentation build
|
||||||
|
docs/book/
|
||||||
|
|
||||||
|
# Test artifacts
|
||||||
|
test-results/
|
||||||
|
playwright-report/
|
||||||
|
|
||||||
60
CHANGELOG.md
Normal file
60
CHANGELOG.md
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||||
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Initial project setup
|
||||||
|
- HTTP REST API for Wake-on-LAN operations
|
||||||
|
- MQTT support for device wake commands
|
||||||
|
- Basic username/password authentication
|
||||||
|
- OIDC provider integration
|
||||||
|
- JSON-based device configuration persistence
|
||||||
|
- Configuration file hot-reload with file system watching
|
||||||
|
- React + shadcn/ui Web UI dashboard
|
||||||
|
- Docker image and docker-compose setup
|
||||||
|
- Multi-platform binary releases (Linux, macOS, Windows)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- N/A
|
||||||
|
|
||||||
|
### Deprecated
|
||||||
|
|
||||||
|
- N/A
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
|
||||||
|
- N/A
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- N/A
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- N/A
|
||||||
|
|
||||||
|
## [0.1.0] - YYYY-MM-DD
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Initial release of Galvanize
|
||||||
|
- Core Wake-on-LAN functionality
|
||||||
|
- HTTP API with RESTful endpoints
|
||||||
|
- MQTT protocol support
|
||||||
|
- Basic and OIDC authentication
|
||||||
|
- Web UI for device management
|
||||||
|
- Configuration persistence and hot-reload
|
||||||
|
- Docker support with multi-arch images
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
[Unreleased]: https://github.com/aitiome/galvanize/compare/v0.1.0...HEAD
|
||||||
|
[0.1.0]: https://github.com/aitiome/galvanize/releases/tag/v0.1.0
|
||||||
|
|
||||||
252
CONTRIBUTING.md
Normal file
252
CONTRIBUTING.md
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
# Contributing to Galvanize
|
||||||
|
|
||||||
|
First off, thank you for considering contributing to Galvanize! It's people like you that make Galvanize such a great tool.
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
- [Code of Conduct](#code-of-conduct)
|
||||||
|
- [Getting Started](#getting-started)
|
||||||
|
- [Development Setup](#development-setup)
|
||||||
|
- [How to Contribute](#how-to-contribute)
|
||||||
|
- [Pull Request Process](#pull-request-process)
|
||||||
|
- [Coding Standards](#coding-standards)
|
||||||
|
- [Commit Messages](#commit-messages)
|
||||||
|
|
||||||
|
## Code of Conduct
|
||||||
|
|
||||||
|
This project and everyone participating in it is governed by our commitment to providing a welcoming and inclusive environment. By participating, you are expected to uphold this commitment. Please report unacceptable behavior to [contact@aitiome.org](mailto:contact@aitiome.org).
|
||||||
|
|
||||||
|
### Our Standards
|
||||||
|
|
||||||
|
- Using welcoming and inclusive language
|
||||||
|
- Being respectful of differing viewpoints and experiences
|
||||||
|
- Gracefully accepting constructive criticism
|
||||||
|
- Focusing on what is best for the community
|
||||||
|
- Showing empathy towards other community members
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- **Rust 1.75+** - Install via [rustup](https://rustup.rs/)
|
||||||
|
- **Node.js 20+** - For Web UI development
|
||||||
|
- **pnpm** - Package manager for Web UI
|
||||||
|
- **Docker** (optional) - For container builds
|
||||||
|
|
||||||
|
### Development Setup
|
||||||
|
|
||||||
|
1. **Clone the repository:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/aitiome/galvanize.git
|
||||||
|
cd galvanize
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Setup Rust environment:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install Rust (if not already installed)
|
||||||
|
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
|
||||||
|
|
||||||
|
# Install additional components
|
||||||
|
rustup component add rustfmt clippy
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Setup Web UI environment:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd webui
|
||||||
|
pnpm install
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Build the project:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build server only
|
||||||
|
cargo build
|
||||||
|
|
||||||
|
# Build with Web UI
|
||||||
|
cargo build --features webui
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Run tests:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Rust tests
|
||||||
|
cargo test
|
||||||
|
|
||||||
|
# Web UI tests
|
||||||
|
cd webui && pnpm test
|
||||||
|
```
|
||||||
|
|
||||||
|
## How to Contribute
|
||||||
|
|
||||||
|
### Reporting Bugs
|
||||||
|
|
||||||
|
Before creating bug reports, please check existing issues to avoid duplicates.
|
||||||
|
|
||||||
|
When creating a bug report, please include:
|
||||||
|
|
||||||
|
- **Clear title** describing the issue
|
||||||
|
- **Steps to reproduce** the behavior
|
||||||
|
- **Expected behavior** - what you expected to happen
|
||||||
|
- **Actual behavior** - what actually happened
|
||||||
|
- **Environment details:**
|
||||||
|
- OS and version
|
||||||
|
- Rust version (`rustc --version`)
|
||||||
|
- Galvanize version
|
||||||
|
- **Screenshots** (if applicable)
|
||||||
|
- **Logs** (if applicable)
|
||||||
|
|
||||||
|
### Suggesting Features
|
||||||
|
|
||||||
|
Feature requests are welcome! Please provide:
|
||||||
|
|
||||||
|
- **Clear title** describing the feature
|
||||||
|
- **Detailed description** of the proposed functionality
|
||||||
|
- **Use case** - why is this feature needed?
|
||||||
|
- **Possible implementation** (optional)
|
||||||
|
|
||||||
|
### Your First Code Contribution
|
||||||
|
|
||||||
|
Looking for something to work on? Check out issues labeled:
|
||||||
|
|
||||||
|
- `good first issue` - Good for newcomers
|
||||||
|
- `help wanted` - Extra attention needed
|
||||||
|
- `documentation` - Documentation improvements
|
||||||
|
|
||||||
|
## Pull Request Process
|
||||||
|
|
||||||
|
1. **Fork the repository** and create your branch from `main`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git checkout -b feature/my-new-feature
|
||||||
|
# or
|
||||||
|
git checkout -b fix/bug-description
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Make your changes** following our coding standards.
|
||||||
|
|
||||||
|
3. **Write tests** for your changes.
|
||||||
|
|
||||||
|
4. **Ensure all tests pass:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo test
|
||||||
|
cargo clippy -- -D warnings
|
||||||
|
cargo fmt -- --check
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Update documentation** if needed.
|
||||||
|
|
||||||
|
6. **Commit your changes** using conventional commit messages.
|
||||||
|
|
||||||
|
7. **Push to your fork** and submit a Pull Request.
|
||||||
|
|
||||||
|
8. **Describe your changes** in the PR description:
|
||||||
|
- What does this PR do?
|
||||||
|
- How has it been tested?
|
||||||
|
- Are there any breaking changes?
|
||||||
|
|
||||||
|
### PR Review Process
|
||||||
|
|
||||||
|
- PRs require at least one approving review
|
||||||
|
- CI checks must pass
|
||||||
|
- PRs should be up to date with `main` before merging
|
||||||
|
|
||||||
|
## Coding Standards
|
||||||
|
|
||||||
|
### Rust
|
||||||
|
|
||||||
|
- Follow the [Rust API Guidelines](https://rust-lang.github.io/api-guidelines/)
|
||||||
|
- Use `rustfmt` for formatting
|
||||||
|
- Address all `clippy` warnings
|
||||||
|
- Write documentation for public APIs
|
||||||
|
- Include unit tests for new functionality
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// Good: Documented public function with error handling
|
||||||
|
/// Sends a Wake-on-LAN magic packet to the specified MAC address.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `mac` - The MAC address of the target device
|
||||||
|
/// * `broadcast` - Optional broadcast address (defaults to 255.255.255.255)
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns an error if the magic packet cannot be sent.
|
||||||
|
pub fn wake_device(mac: &MacAddress, broadcast: Option<IpAddr>) -> Result<(), WolError> {
|
||||||
|
// Implementation
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### TypeScript/React (Web UI)
|
||||||
|
|
||||||
|
- Use TypeScript strict mode
|
||||||
|
- Follow ESLint and Prettier configuration
|
||||||
|
- Use functional components with hooks
|
||||||
|
- Keep components small and focused
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Good: Typed component with proper props interface
|
||||||
|
interface DeviceCardProps {
|
||||||
|
device: Device;
|
||||||
|
onWake: (id: string) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DeviceCard({ device, onWake }: DeviceCardProps) {
|
||||||
|
// Implementation
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Commit Messages
|
||||||
|
|
||||||
|
We use [Conventional Commits](https://www.conventionalcommits.org/) format:
|
||||||
|
|
||||||
|
```
|
||||||
|
<type>(<scope>): <subject>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Types
|
||||||
|
|
||||||
|
- `feat` - New feature
|
||||||
|
- `fix` - Bug fix
|
||||||
|
- `docs` - Documentation only
|
||||||
|
- `style` - Code style (formatting, etc.)
|
||||||
|
- `refactor` - Code refactoring
|
||||||
|
- `perf` - Performance improvement
|
||||||
|
- `test` - Adding tests
|
||||||
|
- `chore` - Maintenance tasks
|
||||||
|
- `ci` - CI/CD changes
|
||||||
|
|
||||||
|
### Examples
|
||||||
|
|
||||||
|
```
|
||||||
|
feat(api): add device groups endpoint
|
||||||
|
|
||||||
|
Add support for organizing devices into groups.
|
||||||
|
Groups can be used to wake multiple devices at once.
|
||||||
|
|
||||||
|
Closes #123
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
fix(wol): handle network interface binding on Linux
|
||||||
|
|
||||||
|
Previously, the WoL packet was not being sent on the correct
|
||||||
|
network interface when multiple interfaces were available.
|
||||||
|
|
||||||
|
Fixes #456
|
||||||
|
```
|
||||||
|
|
||||||
|
## Questions?
|
||||||
|
|
||||||
|
Feel free to open an issue for any questions or reach out to the maintainers.
|
||||||
|
|
||||||
|
Thank you for contributing! 🎉
|
||||||
|
|
||||||
4301
Cargo.lock
generated
Normal file
4301
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
96
Cargo.toml
Normal file
96
Cargo.toml
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
[package]
|
||||||
|
name = "galvanize"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
authors = ["aitiome <contact@aitiome.org>"]
|
||||||
|
description = "A modern, Rust-based Wake-on-LAN control plane with HTTP/MQTT APIs, pluggable auth, and an optional React + shadcn Web UI."
|
||||||
|
license = "MIT OR Apache-2.0"
|
||||||
|
repository = "https://github.com/aitiome/galvanize"
|
||||||
|
homepage = "https://github.com/aitiome/galvanize"
|
||||||
|
documentation = "https://github.com/aitiome/galvanize#readme"
|
||||||
|
readme = "README.md"
|
||||||
|
keywords = ["wake-on-lan", "wol", "network", "mqtt", "rest-api"]
|
||||||
|
categories = ["command-line-utilities", "network-programming"]
|
||||||
|
rust-version = "1.85"
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = ["webui"]
|
||||||
|
webui = []
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "galvanize"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
# Async runtime
|
||||||
|
tokio = { version = "1.40", features = ["full"] }
|
||||||
|
|
||||||
|
# Web framework
|
||||||
|
axum = { version = "0.8", features = ["macros", "ws"] }
|
||||||
|
tower = { version = "0.5", features = ["util"] }
|
||||||
|
tower-http = { version = "0.6", features = [
|
||||||
|
"cors",
|
||||||
|
"fs",
|
||||||
|
"trace",
|
||||||
|
"compression-gzip",
|
||||||
|
] }
|
||||||
|
|
||||||
|
# Serialization
|
||||||
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
serde_json = "1.0"
|
||||||
|
toml = "0.9"
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
config = "0.15"
|
||||||
|
|
||||||
|
# CLI
|
||||||
|
clap = { version = "4.5", features = ["derive", "env"] }
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
tracing = "0.1"
|
||||||
|
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
|
||||||
|
|
||||||
|
# Authentication
|
||||||
|
argon2 = "0.5"
|
||||||
|
jsonwebtoken = { version = "10", features = ["rust_crypto"] }
|
||||||
|
openidconnect = "4"
|
||||||
|
|
||||||
|
# MQTT
|
||||||
|
rumqttc = "0.25"
|
||||||
|
|
||||||
|
# Wake-on-LAN
|
||||||
|
wake-on-lan = "0.2"
|
||||||
|
|
||||||
|
# File watching
|
||||||
|
notify = "8.2"
|
||||||
|
|
||||||
|
# Utilities
|
||||||
|
snafu = { version = "0.8", features = ["futures"] }
|
||||||
|
anyhow = "1.0"
|
||||||
|
uuid = { version = "1.19", features = ["v4", "serde"] }
|
||||||
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
|
async-trait = "0.1"
|
||||||
|
once_cell = "1.20"
|
||||||
|
regex = "1.11"
|
||||||
|
rand = "0.9"
|
||||||
|
base64 = "0.22"
|
||||||
|
|
||||||
|
# Embedded Web UI assets (when webui feature is enabled)
|
||||||
|
rust-embed = { version = "8.5", features = ["compression"] }
|
||||||
|
mime_guess = "2.0"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tokio-test = "0.4"
|
||||||
|
reqwest = { version = "0.12", features = ["json"] }
|
||||||
|
mockall = "0.14"
|
||||||
|
tempfile = "3.12"
|
||||||
|
pretty_assertions = "1.4"
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
lto = true
|
||||||
|
codegen-units = 1
|
||||||
|
panic = "abort"
|
||||||
|
strip = true
|
||||||
|
|
||||||
|
[profile.dev]
|
||||||
|
debug = true
|
||||||
96
Dockerfile
Normal file
96
Dockerfile
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Stage 1: Build Web UI
|
||||||
|
# =============================================================================
|
||||||
|
FROM node:20-alpine AS webui-builder
|
||||||
|
|
||||||
|
WORKDIR /app/webui
|
||||||
|
|
||||||
|
# Install pnpm
|
||||||
|
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||||
|
|
||||||
|
# Copy package files
|
||||||
|
COPY webui/package.json webui/pnpm-lock.yaml ./
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
# Copy source files
|
||||||
|
COPY webui/ ./
|
||||||
|
|
||||||
|
# Build production bundle
|
||||||
|
RUN pnpm build
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Stage 2: Build Rust Server
|
||||||
|
# =============================================================================
|
||||||
|
FROM rust:1.75-alpine AS server-builder
|
||||||
|
|
||||||
|
# Install build dependencies
|
||||||
|
RUN apk add --no-cache musl-dev openssl-dev openssl-libs-static pkgconf
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Create a new empty shell project for caching dependencies
|
||||||
|
RUN cargo new --bin galvanize
|
||||||
|
WORKDIR /app/galvanize
|
||||||
|
|
||||||
|
# Copy manifests
|
||||||
|
COPY Cargo.toml Cargo.lock ./
|
||||||
|
|
||||||
|
# Build and cache dependencies
|
||||||
|
RUN cargo build --release && rm src/*.rs
|
||||||
|
|
||||||
|
# Copy source code
|
||||||
|
COPY src/ ./src/
|
||||||
|
|
||||||
|
# Copy Web UI build artifacts
|
||||||
|
COPY --from=webui-builder /app/webui/dist ./webui/dist
|
||||||
|
|
||||||
|
# Build the actual binary
|
||||||
|
RUN touch src/main.rs && cargo build --release --features webui
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Stage 3: Runtime Image
|
||||||
|
# =============================================================================
|
||||||
|
FROM alpine:3.20 AS runtime
|
||||||
|
|
||||||
|
# Install runtime dependencies
|
||||||
|
RUN apk add --no-cache ca-certificates tzdata
|
||||||
|
|
||||||
|
# Create non-root user
|
||||||
|
RUN addgroup -g 1000 galvanize && \
|
||||||
|
adduser -u 1000 -G galvanize -s /bin/sh -D galvanize
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy binary from builder
|
||||||
|
COPY --from=server-builder /app/galvanize/target/release/galvanize /usr/local/bin/galvanize
|
||||||
|
|
||||||
|
# Create config directory
|
||||||
|
RUN mkdir -p /app/config/devices && \
|
||||||
|
chown -R galvanize:galvanize /app
|
||||||
|
|
||||||
|
# Copy example config
|
||||||
|
COPY config/galvanize.example.toml /app/config/galvanize.toml
|
||||||
|
|
||||||
|
# Switch to non-root user
|
||||||
|
USER galvanize
|
||||||
|
|
||||||
|
# Expose port
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||||
|
CMD wget --no-verbose --tries=1 --spider http://localhost:8080/api/v1/health || exit 1
|
||||||
|
|
||||||
|
# Set environment variables
|
||||||
|
ENV GALVANIZE_HOST=0.0.0.0
|
||||||
|
ENV GALVANIZE_PORT=8080
|
||||||
|
ENV GALVANIZE_CONFIG_PATH=/app/config
|
||||||
|
|
||||||
|
# Run the binary
|
||||||
|
ENTRYPOINT ["galvanize"]
|
||||||
|
CMD ["serve", "--config", "/app/config"]
|
||||||
|
|
||||||
202
LICENSE-APACHE
Normal file
202
LICENSE-APACHE
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
Apache License
|
||||||
|
Version 2.0, January 2004
|
||||||
|
http://www.apache.org/licenses/
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||||
|
|
||||||
|
1. Definitions.
|
||||||
|
|
||||||
|
"License" shall mean the terms and conditions for use, reproduction,
|
||||||
|
and distribution as defined by Sections 1 through 9 of this document.
|
||||||
|
|
||||||
|
"Licensor" shall mean the copyright owner or entity authorized by
|
||||||
|
the copyright owner that is granting the License.
|
||||||
|
|
||||||
|
"Legal Entity" shall mean the union of the acting entity and all
|
||||||
|
other entities that control, are controlled by, or are under common
|
||||||
|
control with that entity. For the purposes of this definition,
|
||||||
|
"control" means (i) the power, direct or indirect, to cause the
|
||||||
|
direction or management of such entity, whether by contract or
|
||||||
|
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||||
|
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||||
|
|
||||||
|
"You" (or "Your") shall mean an individual or Legal Entity
|
||||||
|
exercising permissions granted by this License.
|
||||||
|
|
||||||
|
"Source" form shall mean the preferred form for making modifications,
|
||||||
|
including but not limited to software source code, documentation
|
||||||
|
source, and configuration files.
|
||||||
|
|
||||||
|
"Object" form shall mean any form resulting from mechanical
|
||||||
|
transformation or translation of a Source form, including but
|
||||||
|
not limited to compiled object code, generated documentation,
|
||||||
|
and conversions to other media types.
|
||||||
|
|
||||||
|
"Work" shall mean the work of authorship, whether in Source or
|
||||||
|
Object form, made available under the License, as indicated by a
|
||||||
|
copyright notice that is included in or attached to the work
|
||||||
|
(an example is provided in the Appendix below).
|
||||||
|
|
||||||
|
"Derivative Works" shall mean any work, whether in Source or Object
|
||||||
|
form, that is based on (or derived from) the Work and for which the
|
||||||
|
editorial revisions, annotations, elaborations, or other modifications
|
||||||
|
represent, as a whole, an original work of authorship. For the purposes
|
||||||
|
of this License, Derivative Works shall not include works that remain
|
||||||
|
separable from, or merely link (or bind by name) to the interfaces of,
|
||||||
|
the Work and Derivative Works thereof.
|
||||||
|
|
||||||
|
"Contribution" shall mean any work of authorship, including
|
||||||
|
the original version of the Work and any modifications or additions
|
||||||
|
to that Work or Derivative Works thereof, that is intentionally
|
||||||
|
submitted to the Licensor for inclusion in the Work by the copyright owner
|
||||||
|
or by an individual or Legal Entity authorized to submit on behalf of
|
||||||
|
the copyright owner. For the purposes of this definition, "submitted"
|
||||||
|
means any form of electronic, verbal, or written communication sent
|
||||||
|
to the Licensor or its representatives, including but not limited to
|
||||||
|
communication on electronic mailing lists, source code control systems,
|
||||||
|
and issue tracking systems that are managed by, or on behalf of, the
|
||||||
|
Licensor for the purpose of discussing and improving the Work, but
|
||||||
|
excluding communication that is conspicuously marked or otherwise
|
||||||
|
designated in writing by the copyright owner as "Not a Contribution."
|
||||||
|
|
||||||
|
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||||
|
on behalf of whom a Contribution has been received by Licensor and
|
||||||
|
subsequently incorporated within the Work.
|
||||||
|
|
||||||
|
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
copyright license to reproduce, prepare Derivative Works of,
|
||||||
|
publicly display, publicly perform, sublicense, and distribute the
|
||||||
|
Work and such Derivative Works in Source or Object form.
|
||||||
|
|
||||||
|
3. Grant of Patent License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
(except as stated in this section) patent license to make, have made,
|
||||||
|
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||||
|
where such license applies only to those patent claims licensable
|
||||||
|
by such Contributor that are necessarily infringed by their
|
||||||
|
Contribution(s) alone or by combination of their Contribution(s)
|
||||||
|
with the Work to which such Contribution(s) was submitted. If You
|
||||||
|
institute patent litigation against any entity (including a
|
||||||
|
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||||
|
or a Contribution incorporated within the Work constitutes direct
|
||||||
|
or contributory patent infringement, then any patent licenses
|
||||||
|
granted to You under this License for that Work shall terminate
|
||||||
|
as of the date such litigation is filed.
|
||||||
|
|
||||||
|
4. Redistribution. You may reproduce and distribute copies of the
|
||||||
|
Work or Derivative Works thereof in any medium, with or without
|
||||||
|
modifications, and in Source or Object form, provided that You
|
||||||
|
meet the following conditions:
|
||||||
|
|
||||||
|
(a) You must give any other recipients of the Work or
|
||||||
|
Derivative Works a copy of this License; and
|
||||||
|
|
||||||
|
(b) You must cause any modified files to carry prominent notices
|
||||||
|
stating that You changed the files; and
|
||||||
|
|
||||||
|
(c) You must retain, in the Source form of any Derivative Works
|
||||||
|
that You distribute, all copyright, patent, trademark, and
|
||||||
|
attribution notices from the Source form of the Work,
|
||||||
|
excluding those notices that do not pertain to any part of
|
||||||
|
the Derivative Works; and
|
||||||
|
|
||||||
|
(d) If the Work includes a "NOTICE" text file as part of its
|
||||||
|
distribution, then any Derivative Works that You distribute must
|
||||||
|
include a readable copy of the attribution notices contained
|
||||||
|
within such NOTICE file, excluding those notices that do not
|
||||||
|
pertain to any part of the Derivative Works, in at least one
|
||||||
|
of the following places: within a NOTICE text file distributed
|
||||||
|
as part of the Derivative Works; within the Source form or
|
||||||
|
documentation, if provided along with the Derivative Works; or,
|
||||||
|
within a display generated by the Derivative Works, if and
|
||||||
|
wherever such third-party notices normally appear. The contents
|
||||||
|
of the NOTICE file are for informational purposes only and
|
||||||
|
do not modify the License. You may add Your own attribution
|
||||||
|
notices within Derivative Works that You distribute, alongside
|
||||||
|
or as an addendum to the NOTICE text from the Work, provided
|
||||||
|
that such additional attribution notices cannot be construed
|
||||||
|
as modifying the License.
|
||||||
|
|
||||||
|
You may add Your own copyright statement to Your modifications and
|
||||||
|
may provide additional or different license terms and conditions
|
||||||
|
for use, reproduction, or distribution of Your modifications, or
|
||||||
|
for any such Derivative Works as a whole, provided Your use,
|
||||||
|
reproduction, and distribution of the Work otherwise complies with
|
||||||
|
the conditions stated in this License.
|
||||||
|
|
||||||
|
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||||
|
any Contribution intentionally submitted for inclusion in the Work
|
||||||
|
by You to the Licensor shall be under the terms and conditions of
|
||||||
|
this License, without any additional terms or conditions.
|
||||||
|
Notwithstanding the above, nothing herein shall supersede or modify
|
||||||
|
the terms of any separate license agreement you may have executed
|
||||||
|
with Licensor regarding such Contributions.
|
||||||
|
|
||||||
|
6. Trademarks. This License does not grant permission to use the trade
|
||||||
|
names, trademarks, service marks, or product names of the Licensor,
|
||||||
|
except as required for reasonable and customary use in describing the
|
||||||
|
origin of the Work and reproducing the content of the NOTICE file.
|
||||||
|
|
||||||
|
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||||
|
agreed to in writing, Licensor provides the Work (and each
|
||||||
|
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||||
|
implied, including, without limitation, any warranties or conditions
|
||||||
|
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||||
|
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||||
|
appropriateness of using or redistributing the Work and assume any
|
||||||
|
risks associated with Your exercise of permissions under this License.
|
||||||
|
|
||||||
|
8. Limitation of Liability. In no event and under no legal theory,
|
||||||
|
whether in tort (including negligence), contract, or otherwise,
|
||||||
|
unless required by applicable law (such as deliberate and grossly
|
||||||
|
negligent acts) or agreed to in writing, shall any Contributor be
|
||||||
|
liable to You for damages, including any direct, indirect, special,
|
||||||
|
incidental, or consequential damages of any character arising as a
|
||||||
|
result of this License or out of the use or inability to use the
|
||||||
|
Work (including but not limited to damages for loss of goodwill,
|
||||||
|
work stoppage, computer failure or malfunction, or any and all
|
||||||
|
other commercial damages or losses), even if such Contributor
|
||||||
|
has been advised of the possibility of such damages.
|
||||||
|
|
||||||
|
9. Accepting Warranty or Additional Liability. While redistributing
|
||||||
|
the Work or Derivative Works thereof, You may choose to offer,
|
||||||
|
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||||
|
or other liability obligations and/or rights consistent with this
|
||||||
|
License. However, in accepting such obligations, You may act only
|
||||||
|
on Your own behalf and on Your sole responsibility, not on behalf
|
||||||
|
of any other Contributor, and only if You agree to indemnify,
|
||||||
|
defend, and hold each Contributor harmless for any liability
|
||||||
|
incurred by, or claims asserted against, such Contributor by reason
|
||||||
|
of your accepting any such warranty or additional liability.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
APPENDIX: How to apply the Apache License to your work.
|
||||||
|
|
||||||
|
To apply the Apache License to your work, attach the following
|
||||||
|
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||||
|
replaced with your own identifying information. (Don't include
|
||||||
|
the brackets!) The text should be enclosed in the appropriate
|
||||||
|
comment syntax for the file format. We also recommend that a
|
||||||
|
file or class name and description of purpose be included on the
|
||||||
|
same "printed page" as the copyright notice for easier
|
||||||
|
identification within third-party archives.
|
||||||
|
|
||||||
|
Copyright 2025 aitiome
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
|
||||||
22
LICENSE-MIT
Normal file
22
LICENSE-MIT
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2025 aitiome
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
|
|
||||||
396
README.md
396
README.md
@@ -1 +1,395 @@
|
|||||||
# galvanize
|
# Galvanize
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="docs/assets/logo.svg" alt="Galvanize Logo" width="200" />
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<strong>A modern, Rust-based Wake-on-LAN control plane with HTTP/MQTT APIs, pluggable auth, and an optional React + shadcn Web UI.</strong>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://github.com/aitiome/galvanize/actions"><img src="https://github.com/aitiome/galvanize/workflows/CI/badge.svg" alt="CI Status" /></a>
|
||||||
|
<a href="https://github.com/aitiome/galvanize/releases"><img src="https://img.shields.io/github/v/release/aitiome/galvanize" alt="Release" /></a>
|
||||||
|
<a href="https://hub.docker.com/r/aitiome/galvanize"><img src="https://img.shields.io/docker/pulls/aitiome/galvanize" alt="Docker Pulls" /></a>
|
||||||
|
<a href="LICENSE-MIT"><img src="https://img.shields.io/badge/license-MIT%2FApache--2.0-blue" alt="License" /></a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✨ Features
|
||||||
|
|
||||||
|
- **🚀 Multi-Protocol Support** — Listen on HTTP REST API and/or MQTT for maximum flexibility
|
||||||
|
- **🔐 Pluggable Authentication** — Simple username/password auth or external OIDC provider integration
|
||||||
|
- **💾 Persistent Configuration** — WoL device configurations stored as JSON with hot-reload support
|
||||||
|
- **🎨 Modern Web UI** — Optional React + shadcn/ui dashboard (can be disabled with `--webui=false`)
|
||||||
|
- **📦 Easy Deployment** — Pre-built binaries, Docker images, and docker-compose ready
|
||||||
|
- **🦀 Built with Rust** — Fast, safe, and reliable
|
||||||
|
|
||||||
|
## 📋 Table of Contents
|
||||||
|
|
||||||
|
- [Installation](#-installation)
|
||||||
|
- [Quick Start](#-quick-start)
|
||||||
|
- [Configuration](#-configuration)
|
||||||
|
- [API Reference](#-api-reference)
|
||||||
|
- [Authentication](#-authentication)
|
||||||
|
- [Web UI](#-web-ui)
|
||||||
|
- [Docker](#-docker)
|
||||||
|
- [Development](#-development)
|
||||||
|
- [Related Projects](#-related-projects)
|
||||||
|
- [Contributing](#-contributing)
|
||||||
|
- [License](#-license)
|
||||||
|
|
||||||
|
## 📦 Installation
|
||||||
|
|
||||||
|
### Pre-built Binaries
|
||||||
|
|
||||||
|
Download the latest release for your platform from the [Releases](https://github.com/aitiome/galvanize/releases) page.
|
||||||
|
|
||||||
|
| Platform | Architecture | Download |
|
||||||
|
|----------|--------------|----------|
|
||||||
|
| Linux | x86_64 | `galvanize-linux-x86_64` |
|
||||||
|
| Linux | aarch64 | `galvanize-linux-aarch64` |
|
||||||
|
| macOS | x86_64 | `galvanize-darwin-x86_64` |
|
||||||
|
| macOS | aarch64 (Apple Silicon) | `galvanize-darwin-aarch64` |
|
||||||
|
| Windows | x86_64 | `galvanize-windows-x86_64.exe` |
|
||||||
|
|
||||||
|
### Docker
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker pull aitiome/galvanize:latest
|
||||||
|
|
||||||
|
# Run with default settings
|
||||||
|
docker run -d \
|
||||||
|
--name galvanize \
|
||||||
|
--network host \
|
||||||
|
-v ./config:/app/config \
|
||||||
|
aitiome/galvanize:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
### Build from Source
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Clone the repository
|
||||||
|
git clone https://github.com/aitiome/galvanize.git
|
||||||
|
cd galvanize
|
||||||
|
|
||||||
|
# Build release binary
|
||||||
|
cargo build --release
|
||||||
|
|
||||||
|
# Build with Web UI (requires Node.js)
|
||||||
|
cd webui && pnpm install && pnpm build && cd ..
|
||||||
|
cargo build --release --features webui
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🚀 Quick Start
|
||||||
|
|
||||||
|
1. **Start the server:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
galvanize serve --config ./config
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Add a device via API:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:8080/api/v1/devices \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "Authorization: Bearer <token>" \
|
||||||
|
-d '{
|
||||||
|
"name": "my-server",
|
||||||
|
"mac": "AA:BB:CC:DD:EE:FF",
|
||||||
|
"broadcast": "192.168.1.255",
|
||||||
|
"port": 9
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Wake a device:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:8080/api/v1/devices/my-server/wake \
|
||||||
|
-H "Authorization: Bearer <token>"
|
||||||
|
```
|
||||||
|
|
||||||
|
## ⚙️ Configuration
|
||||||
|
|
||||||
|
Galvanize uses a configuration directory structure:
|
||||||
|
|
||||||
|
```
|
||||||
|
config/
|
||||||
|
├── galvanize.toml # Main configuration file
|
||||||
|
├── devices/ # Device configurations (JSON)
|
||||||
|
│ ├── my-server.json
|
||||||
|
│ └── nas.json
|
||||||
|
└── users.toml # Local user credentials (optional)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Main Configuration (`galvanize.toml`)
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[server]
|
||||||
|
host = "0.0.0.0"
|
||||||
|
port = 8080
|
||||||
|
webui = true
|
||||||
|
|
||||||
|
[mqtt]
|
||||||
|
enabled = false
|
||||||
|
broker = "mqtt://localhost:1883"
|
||||||
|
topic_prefix = "galvanize"
|
||||||
|
client_id = "galvanize-server"
|
||||||
|
|
||||||
|
[auth]
|
||||||
|
# Authentication mode: "none", "basic", "oidc", or "both"
|
||||||
|
mode = "basic"
|
||||||
|
|
||||||
|
[auth.basic]
|
||||||
|
# Path to users file or inline users
|
||||||
|
users_file = "users.toml"
|
||||||
|
|
||||||
|
[auth.oidc]
|
||||||
|
enabled = false
|
||||||
|
issuer = "https://auth.example.com"
|
||||||
|
client_id = "galvanize"
|
||||||
|
client_secret = "${OIDC_CLIENT_SECRET}"
|
||||||
|
redirect_uri = "http://localhost:8080/auth/callback"
|
||||||
|
|
||||||
|
[storage]
|
||||||
|
# Directory for device configurations
|
||||||
|
path = "./config/devices"
|
||||||
|
# Watch for file changes and auto-reload
|
||||||
|
watch = true
|
||||||
|
```
|
||||||
|
|
||||||
|
### Device Configuration
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "my-server",
|
||||||
|
"name": "My Server",
|
||||||
|
"mac": "AA:BB:CC:DD:EE:FF",
|
||||||
|
"broadcast": "192.168.1.255",
|
||||||
|
"port": 9,
|
||||||
|
"interface": null,
|
||||||
|
"tags": ["servers", "homelab"],
|
||||||
|
"metadata": {
|
||||||
|
"location": "Rack 1",
|
||||||
|
"os": "Ubuntu 22.04"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📡 API Reference
|
||||||
|
|
||||||
|
### HTTP Endpoints
|
||||||
|
|
||||||
|
| Method | Endpoint | Description |
|
||||||
|
|--------|----------|-------------|
|
||||||
|
| `GET` | `/api/v1/devices` | List all devices |
|
||||||
|
| `GET` | `/api/v1/devices/:id` | Get device by ID |
|
||||||
|
| `POST` | `/api/v1/devices` | Create a new device |
|
||||||
|
| `PUT` | `/api/v1/devices/:id` | Update a device |
|
||||||
|
| `DELETE` | `/api/v1/devices/:id` | Delete a device |
|
||||||
|
| `POST` | `/api/v1/devices/:id/wake` | Send WoL magic packet |
|
||||||
|
| `POST` | `/api/v1/wake` | Wake device by MAC address (body) |
|
||||||
|
| `GET` | `/api/v1/health` | Health check endpoint |
|
||||||
|
|
||||||
|
### MQTT Topics
|
||||||
|
|
||||||
|
When MQTT is enabled, Galvanize subscribes to:
|
||||||
|
|
||||||
|
| Topic | Payload | Description |
|
||||||
|
|-------|---------|-------------|
|
||||||
|
| `galvanize/wake/:device_id` | `{}` | Wake device by ID |
|
||||||
|
| `galvanize/wake` | `{"mac": "AA:BB:..."}` | Wake by MAC address |
|
||||||
|
|
||||||
|
And publishes to:
|
||||||
|
|
||||||
|
| Topic | Description |
|
||||||
|
|-------|-------------|
|
||||||
|
| `galvanize/status` | Server status updates |
|
||||||
|
| `galvanize/events` | Wake events and results |
|
||||||
|
|
||||||
|
## 🔐 Authentication
|
||||||
|
|
||||||
|
### Basic Authentication
|
||||||
|
|
||||||
|
Create a `users.toml` file:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[[users]]
|
||||||
|
username = "admin"
|
||||||
|
# Generate with: galvanize hash-password
|
||||||
|
password_hash = "$argon2id$v=19$m=19456,t=2,p=1$..."
|
||||||
|
roles = ["admin"]
|
||||||
|
|
||||||
|
[[users]]
|
||||||
|
username = "user"
|
||||||
|
password_hash = "$argon2id$v=19$m=19456,t=2,p=1$..."
|
||||||
|
roles = ["user"]
|
||||||
|
```
|
||||||
|
|
||||||
|
### OIDC Authentication
|
||||||
|
|
||||||
|
Configure your OIDC provider in `galvanize.toml`:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[auth.oidc]
|
||||||
|
enabled = true
|
||||||
|
issuer = "https://auth.example.com"
|
||||||
|
client_id = "galvanize"
|
||||||
|
client_secret = "${OIDC_CLIENT_SECRET}"
|
||||||
|
redirect_uri = "http://localhost:8080/auth/callback"
|
||||||
|
scopes = ["openid", "profile", "email"]
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🎨 Web UI
|
||||||
|
|
||||||
|
The Web UI provides a modern dashboard for managing your Wake-on-LAN devices.
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="docs/assets/webui-screenshot.png" alt="Web UI Screenshot" width="800" />
|
||||||
|
</p>
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- 📱 Responsive design for desktop and mobile
|
||||||
|
- 🌙 Light/Dark mode support
|
||||||
|
- 🔍 Search and filter devices
|
||||||
|
- 📊 Device status monitoring
|
||||||
|
- ⚡ One-click wake functionality
|
||||||
|
|
||||||
|
### Disable Web UI
|
||||||
|
|
||||||
|
To run in API-only mode:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
galvanize serve --webui=false
|
||||||
|
```
|
||||||
|
|
||||||
|
Or in configuration:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[server]
|
||||||
|
webui = false
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🐳 Docker
|
||||||
|
|
||||||
|
### Docker Compose
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
version: "3.8"
|
||||||
|
|
||||||
|
services:
|
||||||
|
galvanize:
|
||||||
|
image: aitiome/galvanize:latest
|
||||||
|
container_name: galvanize
|
||||||
|
network_mode: host # Required for WoL broadcast
|
||||||
|
volumes:
|
||||||
|
- ./config:/app/config
|
||||||
|
environment:
|
||||||
|
- GALVANIZE_AUTH_MODE=basic
|
||||||
|
- OIDC_CLIENT_SECRET=${OIDC_CLIENT_SECRET}
|
||||||
|
restart: unless-stopped
|
||||||
|
```
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
| Variable | Description | Default |
|
||||||
|
|----------|-------------|---------|
|
||||||
|
| `GALVANIZE_HOST` | Server bind address | `0.0.0.0` |
|
||||||
|
| `GALVANIZE_PORT` | Server port | `8080` |
|
||||||
|
| `GALVANIZE_WEBUI` | Enable Web UI | `true` |
|
||||||
|
| `GALVANIZE_AUTH_MODE` | Auth mode | `none` |
|
||||||
|
| `GALVANIZE_CONFIG_PATH` | Config directory | `/app/config` |
|
||||||
|
| `OIDC_CLIENT_SECRET` | OIDC client secret | - |
|
||||||
|
|
||||||
|
## 🛠️ Development
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- Rust 1.75+
|
||||||
|
- Node.js 20+ (for Web UI)
|
||||||
|
- pnpm (for Web UI)
|
||||||
|
|
||||||
|
### Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Clone repository
|
||||||
|
git clone https://github.com/aitiome/galvanize.git
|
||||||
|
cd galvanize
|
||||||
|
|
||||||
|
# Install Rust dependencies
|
||||||
|
cargo build
|
||||||
|
|
||||||
|
# Install Web UI dependencies
|
||||||
|
cd webui
|
||||||
|
pnpm install
|
||||||
|
pnpm dev # Start dev server on :5173
|
||||||
|
```
|
||||||
|
|
||||||
|
### Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
galvanize/
|
||||||
|
├── src/ # Rust server source
|
||||||
|
│ ├── main.rs
|
||||||
|
│ ├── api/ # HTTP API handlers
|
||||||
|
│ ├── mqtt/ # MQTT handlers
|
||||||
|
│ ├── auth/ # Authentication modules
|
||||||
|
│ ├── wol/ # Wake-on-LAN implementation
|
||||||
|
│ └── storage/ # Configuration persistence
|
||||||
|
├── webui/ # React Web UI
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── components/ # shadcn/ui components
|
||||||
|
│ │ ├── pages/
|
||||||
|
│ │ └── lib/
|
||||||
|
│ └── package.json
|
||||||
|
├── config/ # Example configurations
|
||||||
|
├── docs/ # Documentation
|
||||||
|
└── Cargo.toml
|
||||||
|
```
|
||||||
|
|
||||||
|
### Running Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run all tests
|
||||||
|
cargo test
|
||||||
|
|
||||||
|
# Run with coverage
|
||||||
|
cargo tarpaulin
|
||||||
|
|
||||||
|
# Web UI tests
|
||||||
|
cd webui && pnpm test
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔗 Related Projects
|
||||||
|
|
||||||
|
Other projects from the [aitiome](https://github.com/aitiome) organization:
|
||||||
|
|
||||||
|
- **[outposts](https://github.com/aitiome/outposts)** — Distributed monitoring agents
|
||||||
|
- **[securitydept](https://github.com/aitiome/securitydept)** — Security policy management
|
||||||
|
|
||||||
|
## 🤝 Contributing
|
||||||
|
|
||||||
|
Contributions are welcome! Please read our [Contributing Guide](CONTRIBUTING.md) for details on:
|
||||||
|
|
||||||
|
- Code of Conduct
|
||||||
|
- Development workflow
|
||||||
|
- Pull request process
|
||||||
|
- Coding standards
|
||||||
|
|
||||||
|
## 📄 License
|
||||||
|
|
||||||
|
Galvanize is dual-licensed under:
|
||||||
|
|
||||||
|
- [MIT License](LICENSE-MIT)
|
||||||
|
- [Apache License 2.0](LICENSE-APACHE)
|
||||||
|
|
||||||
|
You may choose either license at your option.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
Made with ❤️ by <a href="https://github.com/aitiome">aitiome</a>
|
||||||
|
</p>
|
||||||
|
|||||||
56
docker-compose.yml
Normal file
56
docker-compose.yml
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
version: "3.8"
|
||||||
|
|
||||||
|
services:
|
||||||
|
galvanize:
|
||||||
|
image: aitiome/galvanize:latest
|
||||||
|
container_name: galvanize
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
# Network mode must be "host" for Wake-on-LAN broadcast packets
|
||||||
|
# to reach devices on the local network
|
||||||
|
network_mode: host
|
||||||
|
volumes:
|
||||||
|
# Persistent configuration storage
|
||||||
|
- ./config:/app/config
|
||||||
|
environment:
|
||||||
|
# Server configuration
|
||||||
|
- GALVANIZE_HOST=0.0.0.0
|
||||||
|
- GALVANIZE_PORT=8080
|
||||||
|
- GALVANIZE_WEBUI=true
|
||||||
|
# Authentication
|
||||||
|
- GALVANIZE_AUTH_MODE=basic
|
||||||
|
# OIDC settings (uncomment to enable)
|
||||||
|
# - GALVANIZE_AUTH_OIDC_ENABLED=true
|
||||||
|
# - GALVANIZE_AUTH_OIDC_ISSUER=https://auth.example.com
|
||||||
|
# - GALVANIZE_AUTH_OIDC_CLIENT_ID=galvanize
|
||||||
|
# - OIDC_CLIENT_SECRET=${OIDC_CLIENT_SECRET}
|
||||||
|
# MQTT settings (uncomment to enable)
|
||||||
|
# - GALVANIZE_MQTT_ENABLED=true
|
||||||
|
# - GALVANIZE_MQTT_BROKER=mqtt://mosquitto:1883
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/api/v1/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 10s
|
||||||
|
|
||||||
|
# Optional: MQTT Broker
|
||||||
|
# Uncomment this section if you want to use MQTT
|
||||||
|
# mosquitto:
|
||||||
|
# image: eclipse-mosquitto:2
|
||||||
|
# container_name: galvanize-mqtt
|
||||||
|
# ports:
|
||||||
|
# - "1883:1883"
|
||||||
|
# - "9001:9001"
|
||||||
|
# volumes:
|
||||||
|
# - ./mosquitto/config:/mosquitto/config
|
||||||
|
# - ./mosquitto/data:/mosquitto/data
|
||||||
|
# - ./mosquitto/log:/mosquitto/log
|
||||||
|
# restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
config:
|
||||||
|
driver: local
|
||||||
|
|
||||||
3
docs/assets/.gitkeep
Normal file
3
docs/assets/.gitkeep
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# Documentation assets directory
|
||||||
|
# Place logo.svg and screenshots here
|
||||||
|
|
||||||
4
mise.toml
Normal file
4
mise.toml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
[tools]
|
||||||
|
node = "24"
|
||||||
|
pnpm = "10.25.0"
|
||||||
|
rust = "nightly"
|
||||||
3
rust-toolchain.toml
Normal file
3
rust-toolchain.toml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
[toolchain]
|
||||||
|
channel = "nightly"
|
||||||
|
components = ["rustfmt", "clippy"]
|
||||||
7
rustfmt.toml
Normal file
7
rustfmt.toml
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
# Rust formatting configuration
|
||||||
|
# https://rust-lang.github.io/rustfmt/
|
||||||
|
|
||||||
|
edition = "2024"
|
||||||
|
max_width = 100
|
||||||
|
tab_spaces = 4
|
||||||
|
use_small_heuristics = "Default"
|
||||||
88
src/api/auth_handlers.rs
Normal file
88
src/api/auth_handlers.rs
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
//! Authentication API handlers
|
||||||
|
//!
|
||||||
|
//! This module provides the HTTP handlers for authentication.
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
extract::{Query, State},
|
||||||
|
http::StatusCode,
|
||||||
|
response::Redirect,
|
||||||
|
Json,
|
||||||
|
};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use crate::auth::{
|
||||||
|
BasicAuthManager, JwtManager, LoginRequest, OidcCallback, OidcManager, TokenResponse,
|
||||||
|
};
|
||||||
|
use crate::error::{AppError, Result};
|
||||||
|
|
||||||
|
/// Authentication handlers state
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct AuthHandlersState {
|
||||||
|
pub jwt_manager: Arc<JwtManager>,
|
||||||
|
pub basic_auth: Arc<BasicAuthManager>,
|
||||||
|
pub oidc_manager: Option<Arc<OidcManager>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Login with username and password
|
||||||
|
pub async fn login(
|
||||||
|
State(state): State<AuthHandlersState>,
|
||||||
|
Json(request): Json<LoginRequest>,
|
||||||
|
) -> Result<Json<TokenResponse>> {
|
||||||
|
let user = state
|
||||||
|
.basic_auth
|
||||||
|
.verify(&request.username, &request.password)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let token = state
|
||||||
|
.jwt_manager
|
||||||
|
.generate_token(&user.username, user.roles)?;
|
||||||
|
|
||||||
|
Ok(Json(TokenResponse {
|
||||||
|
access_token: token,
|
||||||
|
token_type: "Bearer".to_string(),
|
||||||
|
expires_in: 86400, // TODO: Get from config
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Initiate OIDC login flow
|
||||||
|
pub async fn oidc_login(State(state): State<AuthHandlersState>) -> Result<Redirect> {
|
||||||
|
let oidc = state
|
||||||
|
.oidc_manager
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| AppError::OidcError {
|
||||||
|
message: "OIDC not configured".to_string(),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let (auth_url, _state) = oidc.authorize_url().await?;
|
||||||
|
|
||||||
|
Ok(Redirect::temporary(&auth_url))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle OIDC callback
|
||||||
|
pub async fn oidc_callback(
|
||||||
|
State(state): State<AuthHandlersState>,
|
||||||
|
Query(callback): Query<OidcCallback>,
|
||||||
|
) -> Result<Json<TokenResponse>> {
|
||||||
|
let oidc = state
|
||||||
|
.oidc_manager
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| AppError::OidcError {
|
||||||
|
message: "OIDC not configured".to_string(),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let (username, roles) = oidc.exchange_code(&callback.code, &callback.state).await?;
|
||||||
|
|
||||||
|
let token = state.jwt_manager.generate_token(&username, roles)?;
|
||||||
|
|
||||||
|
Ok(Json(TokenResponse {
|
||||||
|
access_token: token,
|
||||||
|
token_type: "Bearer".to_string(),
|
||||||
|
expires_in: 86400,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Password hash helper (for CLI)
|
||||||
|
pub async fn hash_password_handler(Json(password): Json<String>) -> Result<(StatusCode, String)> {
|
||||||
|
let hash = BasicAuthManager::hash_password(&password)?;
|
||||||
|
Ok((StatusCode::OK, hash))
|
||||||
|
}
|
||||||
242
src/api/devices.rs
Normal file
242
src/api/devices.rs
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
//! Device management API handlers
|
||||||
|
//!
|
||||||
|
//! This module provides the HTTP handlers for device CRUD operations and Wake-on-LAN.
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
extract::{Path, State},
|
||||||
|
http::StatusCode,
|
||||||
|
Json,
|
||||||
|
};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use crate::auth::middleware::CurrentUser;
|
||||||
|
use crate::error::{AppError, Result};
|
||||||
|
use crate::storage::DeviceStorage;
|
||||||
|
use crate::types::{
|
||||||
|
CreateDeviceRequest, Device, ListResponse, UpdateDeviceRequest, WakeRequest, WakeResponse,
|
||||||
|
};
|
||||||
|
use crate::wol::WolSender;
|
||||||
|
|
||||||
|
/// Shared application state
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct AppState {
|
||||||
|
pub storage: Arc<DeviceStorage>,
|
||||||
|
pub wol_sender: Arc<WolSender>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List all devices
|
||||||
|
pub async fn list_devices(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
CurrentUser(_user): CurrentUser,
|
||||||
|
) -> Json<ListResponse<Device>> {
|
||||||
|
let devices = state.storage.list().await;
|
||||||
|
let total = devices.len();
|
||||||
|
|
||||||
|
Json(ListResponse {
|
||||||
|
items: devices,
|
||||||
|
total,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a device by ID
|
||||||
|
pub async fn get_device(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
CurrentUser(_user): CurrentUser,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
) -> Result<Json<Device>> {
|
||||||
|
let device = state
|
||||||
|
.storage
|
||||||
|
.get(&id)
|
||||||
|
.await
|
||||||
|
.ok_or_else(|| AppError::DeviceNotFound { id })?;
|
||||||
|
|
||||||
|
Ok(Json(device))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a new device
|
||||||
|
pub async fn create_device(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
CurrentUser(user): CurrentUser,
|
||||||
|
Json(request): Json<CreateDeviceRequest>,
|
||||||
|
) -> Result<(StatusCode, Json<Device>)> {
|
||||||
|
// Check permission
|
||||||
|
if !user.can_modify() {
|
||||||
|
return Err(AppError::AuthorizationFailed {
|
||||||
|
message: "Admin role required to create devices".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let device = state.storage.create(request).await?;
|
||||||
|
|
||||||
|
Ok((StatusCode::CREATED, Json(device)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update a device
|
||||||
|
pub async fn update_device(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
CurrentUser(user): CurrentUser,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
Json(request): Json<UpdateDeviceRequest>,
|
||||||
|
) -> Result<Json<Device>> {
|
||||||
|
// Check permission
|
||||||
|
if !user.can_modify() {
|
||||||
|
return Err(AppError::AuthorizationFailed {
|
||||||
|
message: "Admin role required to update devices".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let device = state.storage.update(&id, request).await?;
|
||||||
|
|
||||||
|
Ok(Json(device))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete a device
|
||||||
|
pub async fn delete_device(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
CurrentUser(user): CurrentUser,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
) -> Result<StatusCode> {
|
||||||
|
// Check permission
|
||||||
|
if !user.can_modify() {
|
||||||
|
return Err(AppError::AuthorizationFailed {
|
||||||
|
message: "Admin role required to delete devices".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
state.storage.delete(&id).await?;
|
||||||
|
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wake a device by ID
|
||||||
|
pub async fn wake_device(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
CurrentUser(user): CurrentUser,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
) -> Result<Json<WakeResponse>> {
|
||||||
|
// Check permission
|
||||||
|
if !user.can_wake() {
|
||||||
|
return Err(AppError::AuthorizationFailed {
|
||||||
|
message: "Wake permission required".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let device = state
|
||||||
|
.storage
|
||||||
|
.get(&id)
|
||||||
|
.await
|
||||||
|
.ok_or_else(|| AppError::DeviceNotFound { id: id.clone() })?;
|
||||||
|
|
||||||
|
if !device.enabled {
|
||||||
|
return Err(AppError::ValidationError {
|
||||||
|
message: format!("Device {} is disabled", device.id),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
state
|
||||||
|
.wol_sender
|
||||||
|
.wake(&device.mac, Some(&device.broadcast), Some(device.port))
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Json(WakeResponse {
|
||||||
|
success: true,
|
||||||
|
message: format!("Wake-on-LAN packet sent to {}", device.name),
|
||||||
|
device_id: Some(device.id),
|
||||||
|
mac: device.mac.to_string(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wake a device by MAC address
|
||||||
|
pub async fn wake_by_mac(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
CurrentUser(user): CurrentUser,
|
||||||
|
Json(request): Json<WakeRequest>,
|
||||||
|
) -> Result<Json<WakeResponse>> {
|
||||||
|
// Check permission
|
||||||
|
if !user.can_wake() {
|
||||||
|
return Err(AppError::AuthorizationFailed {
|
||||||
|
message: "Wake permission required".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
state
|
||||||
|
.wol_sender
|
||||||
|
.wake_by_mac_str(&request.mac, request.broadcast.as_deref(), request.port)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Json(WakeResponse {
|
||||||
|
success: true,
|
||||||
|
message: format!("Wake-on-LAN packet sent to {}", request.mac),
|
||||||
|
device_id: None,
|
||||||
|
mac: request.mac,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get devices by tag
|
||||||
|
pub async fn get_devices_by_tag(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
CurrentUser(_user): CurrentUser,
|
||||||
|
Path(tag): Path<String>,
|
||||||
|
) -> Json<ListResponse<Device>> {
|
||||||
|
let devices = state.storage.get_by_tag(&tag).await;
|
||||||
|
let total = devices.len();
|
||||||
|
|
||||||
|
Json(ListResponse {
|
||||||
|
items: devices,
|
||||||
|
total,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wake all devices with a specific tag
|
||||||
|
pub async fn wake_devices_by_tag(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
CurrentUser(user): CurrentUser,
|
||||||
|
Path(tag): Path<String>,
|
||||||
|
) -> Result<Json<Vec<WakeResponse>>> {
|
||||||
|
// Check permission
|
||||||
|
if !user.can_wake() {
|
||||||
|
return Err(AppError::AuthorizationFailed {
|
||||||
|
message: "Wake permission required".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let devices = state.storage.get_by_tag(&tag).await;
|
||||||
|
let mut responses = Vec::new();
|
||||||
|
|
||||||
|
for device in devices {
|
||||||
|
if !device.enabled {
|
||||||
|
responses.push(WakeResponse {
|
||||||
|
success: false,
|
||||||
|
message: format!("Device {} is disabled", device.name),
|
||||||
|
device_id: Some(device.id),
|
||||||
|
mac: device.mac.to_string(),
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
match state
|
||||||
|
.wol_sender
|
||||||
|
.wake(&device.mac, Some(&device.broadcast), Some(device.port))
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(_) => {
|
||||||
|
responses.push(WakeResponse {
|
||||||
|
success: true,
|
||||||
|
message: format!("Wake-on-LAN packet sent to {}", device.name),
|
||||||
|
device_id: Some(device.id),
|
||||||
|
mac: device.mac.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
responses.push(WakeResponse {
|
||||||
|
success: false,
|
||||||
|
message: format!("Failed to wake {}: {}", device.name, e),
|
||||||
|
device_id: Some(device.id),
|
||||||
|
mac: device.mac.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Json(responses))
|
||||||
|
}
|
||||||
19
src/api/health.rs
Normal file
19
src/api/health.rs
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
//! Health check endpoint
|
||||||
|
//!
|
||||||
|
//! This module provides the health check API endpoint.
|
||||||
|
|
||||||
|
use axum::Json;
|
||||||
|
|
||||||
|
use crate::types::HealthResponse;
|
||||||
|
|
||||||
|
/// Health check handler
|
||||||
|
pub async fn health_check() -> Json<HealthResponse> {
|
||||||
|
static START_TIME: std::sync::OnceLock<std::time::Instant> = std::sync::OnceLock::new();
|
||||||
|
let start = START_TIME.get_or_init(std::time::Instant::now);
|
||||||
|
|
||||||
|
Json(HealthResponse {
|
||||||
|
status: "ok".to_string(),
|
||||||
|
version: env!("CARGO_PKG_VERSION").to_string(),
|
||||||
|
uptime_seconds: start.elapsed().as_secs(),
|
||||||
|
})
|
||||||
|
}
|
||||||
13
src/api/mod.rs
Normal file
13
src/api/mod.rs
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
//! HTTP API module for Galvanize
|
||||||
|
//!
|
||||||
|
//! This module contains the HTTP REST API handlers for device management
|
||||||
|
//! and Wake-on-LAN operations.
|
||||||
|
|
||||||
|
pub mod auth_handlers;
|
||||||
|
pub mod devices;
|
||||||
|
pub mod health;
|
||||||
|
pub mod routes;
|
||||||
|
|
||||||
|
pub use devices::AppState;
|
||||||
|
pub use routes::{create_router, create_router_with_webui};
|
||||||
|
|
||||||
105
src/api/routes.rs
Normal file
105
src/api/routes.rs
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
//! API routes configuration
|
||||||
|
//!
|
||||||
|
//! This module configures all HTTP routes for the API.
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
middleware,
|
||||||
|
routing::{get, post},
|
||||||
|
Router,
|
||||||
|
};
|
||||||
|
use tower_http::{
|
||||||
|
compression::CompressionLayer,
|
||||||
|
cors::{Any, CorsLayer},
|
||||||
|
trace::TraceLayer,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::auth::middleware::{auth_middleware, AuthState};
|
||||||
|
|
||||||
|
use super::auth_handlers::{
|
||||||
|
hash_password_handler, login, oidc_callback, oidc_login, AuthHandlersState,
|
||||||
|
};
|
||||||
|
use super::devices::{
|
||||||
|
create_device, delete_device, get_device, get_devices_by_tag, list_devices, update_device,
|
||||||
|
wake_by_mac, wake_device, wake_devices_by_tag, AppState,
|
||||||
|
};
|
||||||
|
use super::health::health_check;
|
||||||
|
|
||||||
|
/// Create the API router
|
||||||
|
pub fn create_router(
|
||||||
|
app_state: AppState,
|
||||||
|
auth_state: AuthState,
|
||||||
|
auth_handlers_state: AuthHandlersState,
|
||||||
|
) -> Router {
|
||||||
|
// Public routes (no authentication required)
|
||||||
|
let public_routes = Router::new()
|
||||||
|
.route("/health", get(health_check))
|
||||||
|
.route("/auth/login", post(login))
|
||||||
|
.route("/auth/oidc/login", get(oidc_login))
|
||||||
|
.route("/auth/oidc/callback", get(oidc_callback))
|
||||||
|
.with_state(auth_handlers_state.clone());
|
||||||
|
|
||||||
|
// Protected device routes
|
||||||
|
let device_routes = Router::new()
|
||||||
|
.route("/", get(list_devices).post(create_device))
|
||||||
|
.route("/:id", get(get_device).put(update_device).delete(delete_device))
|
||||||
|
.route("/:id/wake", post(wake_device))
|
||||||
|
.with_state(app_state.clone());
|
||||||
|
|
||||||
|
// Protected wake routes
|
||||||
|
let wake_routes = Router::new()
|
||||||
|
.route("/", post(wake_by_mac))
|
||||||
|
.with_state(app_state.clone());
|
||||||
|
|
||||||
|
// Protected tag routes
|
||||||
|
let tag_routes = Router::new()
|
||||||
|
.route("/:tag/devices", get(get_devices_by_tag))
|
||||||
|
.route("/:tag/wake", post(wake_devices_by_tag))
|
||||||
|
.with_state(app_state.clone());
|
||||||
|
|
||||||
|
// Admin routes
|
||||||
|
let admin_routes = Router::new()
|
||||||
|
.route("/hash-password", post(hash_password_handler));
|
||||||
|
|
||||||
|
// Combine protected routes with authentication middleware
|
||||||
|
let protected_routes = Router::new()
|
||||||
|
.nest("/devices", device_routes)
|
||||||
|
.nest("/wake", wake_routes)
|
||||||
|
.nest("/tags", tag_routes)
|
||||||
|
.nest("/admin", admin_routes)
|
||||||
|
.layer(middleware::from_fn_with_state(
|
||||||
|
auth_state.clone(),
|
||||||
|
auth_middleware,
|
||||||
|
));
|
||||||
|
|
||||||
|
// Combine all API routes
|
||||||
|
let api_routes = Router::new()
|
||||||
|
.merge(public_routes)
|
||||||
|
.nest("/v1", protected_routes);
|
||||||
|
|
||||||
|
// Build final router with middleware
|
||||||
|
Router::new()
|
||||||
|
.nest("/api", api_routes)
|
||||||
|
.layer(CompressionLayer::new())
|
||||||
|
.layer(
|
||||||
|
CorsLayer::new()
|
||||||
|
.allow_origin(Any)
|
||||||
|
.allow_methods(Any)
|
||||||
|
.allow_headers(Any),
|
||||||
|
)
|
||||||
|
.layer(TraceLayer::new_for_http())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create router with optional WebUI static file serving
|
||||||
|
///
|
||||||
|
/// When the webui feature is enabled and the dist folder exists,
|
||||||
|
/// static files will be served. Otherwise, falls back to API-only mode.
|
||||||
|
pub fn create_router_with_webui(
|
||||||
|
app_state: AppState,
|
||||||
|
auth_state: AuthState,
|
||||||
|
auth_handlers_state: AuthHandlersState,
|
||||||
|
) -> Router {
|
||||||
|
// For now, just return the API router
|
||||||
|
// WebUI static serving will be added when the webui is built
|
||||||
|
create_router(app_state, auth_state, auth_handlers_state)
|
||||||
|
}
|
||||||
|
|
||||||
149
src/auth/basic.rs
Normal file
149
src/auth/basic.rs
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
//! Basic authentication
|
||||||
|
//!
|
||||||
|
//! This module handles username/password authentication with Argon2 password hashing.
|
||||||
|
|
||||||
|
use argon2::{
|
||||||
|
password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
|
||||||
|
Argon2,
|
||||||
|
};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::sync::RwLock;
|
||||||
|
|
||||||
|
use crate::config::models::UserConfig;
|
||||||
|
use crate::error::{AppError, Result};
|
||||||
|
use crate::types::User;
|
||||||
|
|
||||||
|
/// Basic authentication manager
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct BasicAuthManager {
|
||||||
|
users: Arc<RwLock<HashMap<String, User>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BasicAuthManager {
|
||||||
|
/// Create a new basic auth manager
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
users: Arc::new(RwLock::new(HashMap::new())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load users from configuration
|
||||||
|
pub async fn load_users(&self, user_configs: Vec<UserConfig>) {
|
||||||
|
let mut users = self.users.write().await;
|
||||||
|
users.clear();
|
||||||
|
|
||||||
|
for config in user_configs {
|
||||||
|
let user = User {
|
||||||
|
username: config.username.clone(),
|
||||||
|
password_hash: config.password_hash,
|
||||||
|
roles: config.roles,
|
||||||
|
};
|
||||||
|
users.insert(config.username, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::info!(count = users.len(), "Loaded users for basic authentication");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verify username and password
|
||||||
|
pub async fn verify(&self, username: &str, password: &str) -> Result<User> {
|
||||||
|
let users = self.users.read().await;
|
||||||
|
|
||||||
|
let user = users
|
||||||
|
.get(username)
|
||||||
|
.ok_or_else(|| AppError::AuthenticationFailed {
|
||||||
|
message: "Invalid credentials".to_string(),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// Verify password
|
||||||
|
let parsed_hash =
|
||||||
|
PasswordHash::new(&user.password_hash).map_err(|_| AppError::AuthenticationFailed {
|
||||||
|
message: "Invalid password hash".to_string(),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Argon2::default()
|
||||||
|
.verify_password(password.as_bytes(), &parsed_hash)
|
||||||
|
.map_err(|_| AppError::AuthenticationFailed {
|
||||||
|
message: "Invalid credentials".to_string(),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(user.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hash a password using Argon2
|
||||||
|
pub fn hash_password(password: &str) -> Result<String> {
|
||||||
|
let salt = SaltString::generate(&mut OsRng);
|
||||||
|
let argon2 = Argon2::default();
|
||||||
|
|
||||||
|
let hash = argon2
|
||||||
|
.hash_password(password.as_bytes(), &salt)
|
||||||
|
.map_err(|e| AppError::InternalError {
|
||||||
|
message: format!("Failed to hash password: {}", e),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(hash.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a user by username
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub async fn get_user(&self, username: &str) -> Option<User> {
|
||||||
|
self.users.read().await.get(username).cloned()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add or update a user
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub async fn add_user(&self, user: User) {
|
||||||
|
self.users.write().await.insert(user.username.clone(), user);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove a user
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub async fn remove_user(&self, username: &str) -> Option<User> {
|
||||||
|
self.users.write().await.remove(username)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for BasicAuthManager {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Login request
|
||||||
|
#[derive(Debug, Clone, serde::Deserialize)]
|
||||||
|
pub struct LoginRequest {
|
||||||
|
pub username: String,
|
||||||
|
pub password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_hash_password() {
|
||||||
|
let hash = BasicAuthManager::hash_password("testpassword").unwrap();
|
||||||
|
assert!(hash.starts_with("$argon2"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_verify_password() {
|
||||||
|
let manager = BasicAuthManager::new();
|
||||||
|
|
||||||
|
let hash = BasicAuthManager::hash_password("testpassword").unwrap();
|
||||||
|
|
||||||
|
let config = UserConfig {
|
||||||
|
username: "testuser".to_string(),
|
||||||
|
password_hash: hash,
|
||||||
|
roles: vec!["admin".to_string()],
|
||||||
|
};
|
||||||
|
|
||||||
|
manager.load_users(vec![config]).await;
|
||||||
|
|
||||||
|
let user = manager.verify("testuser", "testpassword").await.unwrap();
|
||||||
|
assert_eq!(user.username, "testuser");
|
||||||
|
|
||||||
|
let result = manager.verify("testuser", "wrongpassword").await;
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
3
src/auth/constants.rs
Normal file
3
src/auth/constants.rs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
pub const ADMIN_ROLE: &str = "admin";
|
||||||
|
pub const USER_ROLE: &str = "user";
|
||||||
|
pub const ANONYMOUS_USERNAME: &str = "anonymous";
|
||||||
139
src/auth/jwt.rs
Normal file
139
src/auth/jwt.rs
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
//! JWT token management
|
||||||
|
//!
|
||||||
|
//! This module handles JWT token creation and validation.
|
||||||
|
|
||||||
|
use chrono::{Duration, Utc};
|
||||||
|
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, TokenData, Validation};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::config::models::JwtConfig;
|
||||||
|
use crate::error::{AppError, Result};
|
||||||
|
|
||||||
|
/// JWT claims
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Claims {
|
||||||
|
/// Subject (username)
|
||||||
|
pub sub: String,
|
||||||
|
|
||||||
|
/// Expiration time (as UTC timestamp)
|
||||||
|
pub exp: i64,
|
||||||
|
|
||||||
|
/// Issued at (as UTC timestamp)
|
||||||
|
pub iat: i64,
|
||||||
|
|
||||||
|
/// User roles
|
||||||
|
pub roles: Vec<String>,
|
||||||
|
|
||||||
|
/// Token type (access or refresh)
|
||||||
|
#[serde(default)]
|
||||||
|
pub token_type: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// JWT manager
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct JwtManager {
|
||||||
|
encoding_key: EncodingKey,
|
||||||
|
decoding_key: DecodingKey,
|
||||||
|
expires_in: Duration,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JwtManager {
|
||||||
|
/// Create a new JWT manager from configuration
|
||||||
|
pub fn new(config: &JwtConfig) -> Self {
|
||||||
|
Self {
|
||||||
|
encoding_key: EncodingKey::from_secret(config.secret.as_bytes()),
|
||||||
|
decoding_key: DecodingKey::from_secret(config.secret.as_bytes()),
|
||||||
|
expires_in: Duration::seconds(config.expires_in as i64),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate an access token for a user
|
||||||
|
pub fn generate_token(&self, username: &str, roles: Vec<String>) -> Result<String> {
|
||||||
|
let now = Utc::now();
|
||||||
|
let exp = now + self.expires_in;
|
||||||
|
|
||||||
|
let claims = Claims {
|
||||||
|
sub: username.to_string(),
|
||||||
|
exp: exp.timestamp(),
|
||||||
|
iat: now.timestamp(),
|
||||||
|
roles,
|
||||||
|
token_type: "access".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
encode(&Header::default(), &claims, &self.encoding_key).map_err(|e| {
|
||||||
|
AppError::InternalError {
|
||||||
|
message: format!("Failed to generate token: {}", e),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate and decode a token
|
||||||
|
pub fn validate_token(&self, token: &str) -> Result<Claims> {
|
||||||
|
let token_data: TokenData<Claims> =
|
||||||
|
decode(token, &self.decoding_key, &Validation::default()).map_err(|e| {
|
||||||
|
match e.kind() {
|
||||||
|
jsonwebtoken::errors::ErrorKind::ExpiredSignature => AppError::TokenExpired,
|
||||||
|
_ => AppError::InvalidToken {
|
||||||
|
message: e.to_string(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(token_data.claims)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract token from Authorization header
|
||||||
|
pub fn extract_token(auth_header: &str) -> Option<&str> {
|
||||||
|
auth_header.strip_prefix("Bearer ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Token response
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct TokenResponse {
|
||||||
|
pub access_token: String,
|
||||||
|
pub token_type: String,
|
||||||
|
pub expires_in: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn create_test_manager() -> JwtManager {
|
||||||
|
let config = JwtConfig {
|
||||||
|
secret: "test-secret-key-for-testing-only".to_string(),
|
||||||
|
expires_in: 3600,
|
||||||
|
};
|
||||||
|
JwtManager::new(&config)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_generate_and_validate_token() {
|
||||||
|
let manager = create_test_manager();
|
||||||
|
|
||||||
|
let token = manager
|
||||||
|
.generate_token("testuser", vec!["admin".to_string()])
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let claims = manager.validate_token(&token).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(claims.sub, "testuser");
|
||||||
|
assert_eq!(claims.roles, vec!["admin".to_string()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_invalid_token() {
|
||||||
|
let manager = create_test_manager();
|
||||||
|
|
||||||
|
let result = manager.validate_token("invalid-token");
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_token() {
|
||||||
|
assert_eq!(JwtManager::extract_token("Bearer abc123"), Some("abc123"));
|
||||||
|
assert_eq!(JwtManager::extract_token("abc123"), None);
|
||||||
|
assert_eq!(JwtManager::extract_token("Basic abc123"), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
222
src/auth/middleware.rs
Normal file
222
src/auth/middleware.rs
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
//! Authentication middleware
|
||||||
|
//!
|
||||||
|
//! This module provides Axum middleware for authentication.
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
extract::{Request, State},
|
||||||
|
http::{header::AUTHORIZATION, StatusCode},
|
||||||
|
middleware::Next,
|
||||||
|
response::{IntoResponse, Response},
|
||||||
|
Json,
|
||||||
|
};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use crate::config::models::AuthMode;
|
||||||
|
use crate::error::ErrorResponse;
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
constants::{ADMIN_ROLE, ANONYMOUS_USERNAME, USER_ROLE},
|
||||||
|
jwt::{Claims, JwtManager},
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Authentication state shared with handlers
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct AuthState {
|
||||||
|
pub jwt_manager: Arc<JwtManager>,
|
||||||
|
pub auth_mode: AuthMode,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Authenticated user extracted from request
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct AuthenticatedUser {
|
||||||
|
#[allow(unused)]
|
||||||
|
pub username: String,
|
||||||
|
pub roles: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AuthenticatedUser {
|
||||||
|
/// Check if user has admin role
|
||||||
|
pub fn is_admin(&self) -> bool {
|
||||||
|
self.roles.iter().any(|r| r == ADMIN_ROLE)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if user can wake devices
|
||||||
|
pub fn can_wake(&self) -> bool {
|
||||||
|
self.roles.iter().any(|r| r == ADMIN_ROLE || r == USER_ROLE)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if user can modify devices
|
||||||
|
pub fn can_modify(&self) -> bool {
|
||||||
|
self.is_admin()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Authentication layer configuration
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct AuthLayer {
|
||||||
|
pub state: AuthState,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AuthLayer {
|
||||||
|
pub fn new(jwt_manager: Arc<JwtManager>, auth_mode: AuthMode) -> Self {
|
||||||
|
Self {
|
||||||
|
state: AuthState {
|
||||||
|
jwt_manager,
|
||||||
|
auth_mode,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Authentication middleware function
|
||||||
|
pub async fn auth_middleware(
|
||||||
|
State(auth_state): State<AuthState>,
|
||||||
|
mut request: Request,
|
||||||
|
next: Next,
|
||||||
|
) -> Response {
|
||||||
|
// Skip authentication if mode is None
|
||||||
|
if auth_state.auth_mode == AuthMode::None {
|
||||||
|
// Set a default user for unauthenticated mode
|
||||||
|
request.extensions_mut().insert(AuthenticatedUser {
|
||||||
|
username: ANONYMOUS_USERNAME.to_string(),
|
||||||
|
roles: vec![ADMIN_ROLE.to_string()],
|
||||||
|
});
|
||||||
|
return next.run(request).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract Authorization header
|
||||||
|
let auth_header = request
|
||||||
|
.headers()
|
||||||
|
.get(AUTHORIZATION)
|
||||||
|
.and_then(|h| h.to_str().ok());
|
||||||
|
|
||||||
|
let auth_header = match auth_header {
|
||||||
|
Some(h) => h,
|
||||||
|
None => {
|
||||||
|
return (
|
||||||
|
StatusCode::UNAUTHORIZED,
|
||||||
|
Json(ErrorResponse {
|
||||||
|
error: "unauthorized".to_string(),
|
||||||
|
message: "Missing Authorization header".to_string(),
|
||||||
|
details: None,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Extract and validate token
|
||||||
|
let token = match JwtManager::extract_token(auth_header) {
|
||||||
|
Some(t) => t,
|
||||||
|
None => {
|
||||||
|
return (
|
||||||
|
StatusCode::UNAUTHORIZED,
|
||||||
|
Json(ErrorResponse {
|
||||||
|
error: "unauthorized".to_string(),
|
||||||
|
message: "Invalid Authorization header format".to_string(),
|
||||||
|
details: None,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Validate token
|
||||||
|
let claims: Claims = match auth_state.jwt_manager.validate_token(token) {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => {
|
||||||
|
return (
|
||||||
|
StatusCode::UNAUTHORIZED,
|
||||||
|
Json(ErrorResponse {
|
||||||
|
error: "unauthorized".to_string(),
|
||||||
|
message: e.to_string(),
|
||||||
|
details: None,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Insert authenticated user into request extensions
|
||||||
|
request.extensions_mut().insert(AuthenticatedUser {
|
||||||
|
username: claims.sub,
|
||||||
|
roles: claims.roles,
|
||||||
|
});
|
||||||
|
|
||||||
|
next.run(request).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extractor for authenticated user
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct CurrentUser(pub AuthenticatedUser);
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl<S> axum::extract::FromRequestParts<S> for CurrentUser
|
||||||
|
where
|
||||||
|
S: Send + Sync,
|
||||||
|
{
|
||||||
|
type Rejection = (StatusCode, Json<ErrorResponse>);
|
||||||
|
|
||||||
|
async fn from_request_parts(
|
||||||
|
parts: &mut axum::http::request::Parts,
|
||||||
|
_state: &S,
|
||||||
|
) -> Result<Self, Self::Rejection> {
|
||||||
|
parts
|
||||||
|
.extensions
|
||||||
|
.get::<AuthenticatedUser>()
|
||||||
|
.cloned()
|
||||||
|
.map(CurrentUser)
|
||||||
|
.ok_or_else(|| {
|
||||||
|
(
|
||||||
|
StatusCode::UNAUTHORIZED,
|
||||||
|
Json(ErrorResponse {
|
||||||
|
error: "unauthorized".to_string(),
|
||||||
|
message: "Not authenticated".to_string(),
|
||||||
|
details: None,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Require admin role middleware
|
||||||
|
pub async fn require_admin(
|
||||||
|
CurrentUser(user): CurrentUser,
|
||||||
|
request: Request,
|
||||||
|
next: Next,
|
||||||
|
) -> Response {
|
||||||
|
if !user.is_admin() {
|
||||||
|
return (
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
Json(ErrorResponse {
|
||||||
|
error: "forbidden".to_string(),
|
||||||
|
message: "Admin role required".to_string(),
|
||||||
|
details: None,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
next.run(request).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Require wake permission middleware
|
||||||
|
pub async fn require_wake_permission(
|
||||||
|
CurrentUser(user): CurrentUser,
|
||||||
|
request: Request,
|
||||||
|
next: Next,
|
||||||
|
) -> Response {
|
||||||
|
if !user.can_wake() {
|
||||||
|
return (
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
Json(ErrorResponse {
|
||||||
|
error: "forbidden".to_string(),
|
||||||
|
message: "Wake permission required".to_string(),
|
||||||
|
details: None,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
next.run(request).await
|
||||||
|
}
|
||||||
18
src/auth/mod.rs
Normal file
18
src/auth/mod.rs
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
//! Authentication module for Galvanize
|
||||||
|
//!
|
||||||
|
//! This module provides pluggable authentication support including:
|
||||||
|
//! - Basic username/password authentication with Argon2 password hashing
|
||||||
|
//! - OpenID Connect (OIDC) provider integration
|
||||||
|
//! - JWT token management
|
||||||
|
|
||||||
|
pub mod basic;
|
||||||
|
pub mod constants;
|
||||||
|
pub mod jwt;
|
||||||
|
pub mod middleware;
|
||||||
|
pub mod oidc;
|
||||||
|
|
||||||
|
pub use basic::{BasicAuthManager, LoginRequest};
|
||||||
|
pub use constants::{ADMIN_ROLE, USER_ROLE, ANONYMOUS_USERNAME};
|
||||||
|
pub use jwt::{Claims, JwtManager, TokenResponse};
|
||||||
|
pub use middleware::AuthLayer;
|
||||||
|
pub use oidc::{OidcCallback, OidcManager};
|
||||||
172
src/auth/oidc.rs
Normal file
172
src/auth/oidc.rs
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
//! OIDC authentication
|
||||||
|
//!
|
||||||
|
//! This module handles OpenID Connect provider integration.
|
||||||
|
|
||||||
|
use openidconnect::{
|
||||||
|
AuthenticationFlow, AuthorizationCode, ClientId, ClientSecret, CsrfToken, IssuerUrl, Nonce,
|
||||||
|
RedirectUrl, Scope, TokenResponse,
|
||||||
|
core::{CoreClient, CoreProviderMetadata, CoreResponseType},
|
||||||
|
reqwest::Client,
|
||||||
|
};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::sync::RwLock;
|
||||||
|
|
||||||
|
use crate::config::models::OidcConfig;
|
||||||
|
use crate::error::{AppError, Result};
|
||||||
|
|
||||||
|
/// OIDC authentication state
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct OidcState {
|
||||||
|
pub csrf_token: String,
|
||||||
|
pub nonce: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// OIDC authentication manager
|
||||||
|
pub struct OidcManager {
|
||||||
|
client: Option<CoreClient>,
|
||||||
|
config: OidcConfig,
|
||||||
|
states: Arc<RwLock<std::collections::HashMap<String, OidcState>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OidcManager {
|
||||||
|
/// Create a new OIDC manager
|
||||||
|
pub async fn new(config: OidcConfig) -> Result<Self> {
|
||||||
|
let client = if config.enabled && !config.issuer.is_empty() {
|
||||||
|
Some(Self::create_client(&config).await?)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
client,
|
||||||
|
config,
|
||||||
|
states: Arc::new(RwLock::new(std::collections::HashMap::new())),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create OIDC client from configuration
|
||||||
|
async fn create_client(config: &OidcConfig) -> Result<CoreClient> {
|
||||||
|
let issuer_url =
|
||||||
|
IssuerUrl::new(config.issuer.clone()).map_err(|e| AppError::OidcError {
|
||||||
|
message: format!("Invalid issuer URL: {}", e),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// Discover provider metadata
|
||||||
|
let provider_metadata = CoreProviderMetadata::discover_async(issuer_url, async_http_client)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::OidcError {
|
||||||
|
message: format!("Failed to discover provider: {}", e),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let client_id = ClientId::new(config.client_id.clone());
|
||||||
|
let client_secret = if config.client_secret.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(ClientSecret::new(config.client_secret.clone()))
|
||||||
|
};
|
||||||
|
|
||||||
|
let redirect_url =
|
||||||
|
RedirectUrl::new(config.redirect_uri.clone()).map_err(|e| AppError::OidcError {
|
||||||
|
message: format!("Invalid redirect URI: {}", e),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let client =
|
||||||
|
CoreClient::from_provider_metadata(provider_metadata, client_id, client_secret)
|
||||||
|
.set_redirect_uri(redirect_url);
|
||||||
|
|
||||||
|
Ok(client)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if OIDC is enabled
|
||||||
|
pub fn is_enabled(&self) -> bool {
|
||||||
|
self.client.is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate authorization URL
|
||||||
|
pub async fn authorize_url(&self) -> Result<(String, OidcState)> {
|
||||||
|
let client = self.client.as_ref().ok_or_else(|| AppError::OidcError {
|
||||||
|
message: "OIDC not configured".to_string(),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let mut auth_request = client.authorize_url(
|
||||||
|
AuthenticationFlow::<CoreResponseType>::AuthorizationCode,
|
||||||
|
CsrfToken::new_random,
|
||||||
|
Nonce::new_random,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add configured scopes
|
||||||
|
for scope in &self.config.scopes {
|
||||||
|
auth_request = auth_request.add_scope(Scope::new(scope.clone()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let (auth_url, csrf_token, nonce) = auth_request.url();
|
||||||
|
|
||||||
|
let state = OidcState {
|
||||||
|
csrf_token: csrf_token.secret().clone(),
|
||||||
|
nonce: nonce.secret().clone(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Store state for verification
|
||||||
|
self.states
|
||||||
|
.write()
|
||||||
|
.await
|
||||||
|
.insert(csrf_token.secret().clone(), state.clone());
|
||||||
|
|
||||||
|
Ok((auth_url.to_string(), state))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Exchange authorization code for tokens
|
||||||
|
pub async fn exchange_code(&self, code: &str, state: &str) -> Result<(String, Vec<String>)> {
|
||||||
|
let client = self.client.as_ref().ok_or_else(|| AppError::OidcError {
|
||||||
|
message: "OIDC not configured".to_string(),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// Verify state
|
||||||
|
let stored_state =
|
||||||
|
self.states
|
||||||
|
.write()
|
||||||
|
.await
|
||||||
|
.remove(state)
|
||||||
|
.ok_or_else(|| AppError::OidcError {
|
||||||
|
message: "Invalid state parameter".to_string(),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// Exchange code for tokens
|
||||||
|
let token_response = client
|
||||||
|
.exchange_code(AuthorizationCode::new(code.to_string()))
|
||||||
|
.request_async(Client::new)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::OidcError {
|
||||||
|
message: format!("Failed to exchange code: {}", e),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// Get ID token
|
||||||
|
let id_token = token_response
|
||||||
|
.id_token()
|
||||||
|
.ok_or_else(|| AppError::OidcError {
|
||||||
|
message: "No ID token in response".to_string(),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// Verify and decode ID token
|
||||||
|
let claims = id_token
|
||||||
|
.claims(&client.id_token_verifier(), &Nonce::new(stored_state.nonce))
|
||||||
|
.map_err(|e| AppError::OidcError {
|
||||||
|
message: format!("Failed to verify ID token: {}", e),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// Extract username from subject claim
|
||||||
|
let username = claims.subject().to_string();
|
||||||
|
|
||||||
|
// Default to user role
|
||||||
|
let roles = vec!["user".to_string()];
|
||||||
|
|
||||||
|
Ok((username, roles))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// OIDC callback parameters
|
||||||
|
#[derive(Debug, Clone, serde::Deserialize)]
|
||||||
|
pub struct OidcCallback {
|
||||||
|
pub code: String,
|
||||||
|
pub state: String,
|
||||||
|
}
|
||||||
168
src/error.rs
Normal file
168
src/error.rs
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
//! Error types for Galvanize
|
||||||
|
//!
|
||||||
|
//! This module defines the error types used throughout the application.
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
http::StatusCode,
|
||||||
|
response::{IntoResponse, Response},
|
||||||
|
Json,
|
||||||
|
};
|
||||||
|
use serde::Serialize;
|
||||||
|
use snafu::prelude::*;
|
||||||
|
|
||||||
|
/// Application error type
|
||||||
|
#[derive(Debug, Snafu)]
|
||||||
|
#[snafu(visibility(pub))]
|
||||||
|
pub enum AppError {
|
||||||
|
#[snafu(display("Device not found: {id}"))]
|
||||||
|
DeviceNotFound { id: String },
|
||||||
|
|
||||||
|
#[snafu(display("Device already exists: {id}"))]
|
||||||
|
DeviceAlreadyExists { id: String },
|
||||||
|
|
||||||
|
#[snafu(display("Invalid MAC address: {mac}"))]
|
||||||
|
InvalidMacAddress { mac: String },
|
||||||
|
|
||||||
|
#[snafu(display("Invalid configuration: {message}"))]
|
||||||
|
InvalidConfig { message: String },
|
||||||
|
|
||||||
|
#[snafu(display("Authentication failed: {message}"))]
|
||||||
|
AuthenticationFailed { message: String },
|
||||||
|
|
||||||
|
#[snafu(display("Authorization failed: {message}"))]
|
||||||
|
AuthorizationFailed { message: String },
|
||||||
|
|
||||||
|
#[snafu(display("Token expired"))]
|
||||||
|
TokenExpired,
|
||||||
|
|
||||||
|
#[snafu(display("Invalid token: {message}"))]
|
||||||
|
InvalidToken { message: String },
|
||||||
|
|
||||||
|
#[snafu(display("OIDC error: {message}"))]
|
||||||
|
OidcError { message: String },
|
||||||
|
|
||||||
|
#[snafu(display("Network error: {message}"))]
|
||||||
|
NetworkError { message: String },
|
||||||
|
|
||||||
|
#[snafu(display("Wake-on-LAN failed: {message}"))]
|
||||||
|
WolFailed { message: String },
|
||||||
|
|
||||||
|
#[snafu(display("Storage error: {message}"))]
|
||||||
|
StorageError { message: String },
|
||||||
|
|
||||||
|
#[snafu(display("MQTT error: {message}"))]
|
||||||
|
MqttError { message: String },
|
||||||
|
|
||||||
|
#[snafu(display("Validation error: {message}"))]
|
||||||
|
ValidationError { message: String },
|
||||||
|
|
||||||
|
#[snafu(display("Internal error: {message}"))]
|
||||||
|
InternalError { message: String },
|
||||||
|
|
||||||
|
#[snafu(display("IO error: {}", source))]
|
||||||
|
IoError { source: std::io::Error },
|
||||||
|
|
||||||
|
#[snafu(display("JSON error: {}", source))]
|
||||||
|
SerdeJson { source: serde_json::Error },
|
||||||
|
|
||||||
|
#[snafu(display("TOML error: {}", source))]
|
||||||
|
TomlDe { source: toml::de::Error },
|
||||||
|
|
||||||
|
#[snafu(display("Error: {}", source))]
|
||||||
|
Anyhow { source: anyhow::Error },
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Error response body
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct ErrorResponse {
|
||||||
|
pub error: String,
|
||||||
|
pub message: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub details: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IntoResponse for AppError {
|
||||||
|
fn into_response(self) -> Response {
|
||||||
|
let (status, error_type, message) = match &self {
|
||||||
|
AppError::DeviceNotFound { .. } => {
|
||||||
|
(StatusCode::NOT_FOUND, "not_found", self.to_string())
|
||||||
|
}
|
||||||
|
AppError::DeviceAlreadyExists { .. } => {
|
||||||
|
(StatusCode::CONFLICT, "conflict", self.to_string())
|
||||||
|
}
|
||||||
|
AppError::InvalidMacAddress { .. } => {
|
||||||
|
(StatusCode::BAD_REQUEST, "invalid_mac", self.to_string())
|
||||||
|
}
|
||||||
|
AppError::InvalidConfig { .. } => {
|
||||||
|
(StatusCode::BAD_REQUEST, "invalid_config", self.to_string())
|
||||||
|
}
|
||||||
|
AppError::AuthenticationFailed { .. } => {
|
||||||
|
(StatusCode::UNAUTHORIZED, "auth_failed", self.to_string())
|
||||||
|
}
|
||||||
|
AppError::AuthorizationFailed { .. } => {
|
||||||
|
(StatusCode::FORBIDDEN, "forbidden", self.to_string())
|
||||||
|
}
|
||||||
|
AppError::TokenExpired => (StatusCode::UNAUTHORIZED, "token_expired", self.to_string()),
|
||||||
|
AppError::InvalidToken { .. } => {
|
||||||
|
(StatusCode::UNAUTHORIZED, "invalid_token", self.to_string())
|
||||||
|
}
|
||||||
|
AppError::OidcError { .. } => (
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"oidc_error",
|
||||||
|
self.to_string(),
|
||||||
|
),
|
||||||
|
AppError::NetworkError { .. } => (
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"network_error",
|
||||||
|
self.to_string(),
|
||||||
|
),
|
||||||
|
AppError::WolFailed { .. } => (
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"wol_failed",
|
||||||
|
self.to_string(),
|
||||||
|
),
|
||||||
|
AppError::StorageError { .. } => (
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"storage_error",
|
||||||
|
self.to_string(),
|
||||||
|
),
|
||||||
|
AppError::MqttError { .. } => (
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"mqtt_error",
|
||||||
|
self.to_string(),
|
||||||
|
),
|
||||||
|
AppError::ValidationError { .. } => (
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"validation_error",
|
||||||
|
self.to_string(),
|
||||||
|
),
|
||||||
|
AppError::InternalError { .. } => (
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"internal_error",
|
||||||
|
self.to_string(),
|
||||||
|
),
|
||||||
|
AppError::IoError { .. } => (
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"io_error",
|
||||||
|
self.to_string(),
|
||||||
|
),
|
||||||
|
AppError::SerdeJson { .. } => (StatusCode::BAD_REQUEST, "json_error", self.to_string()),
|
||||||
|
AppError::TomlDe { .. } => (StatusCode::BAD_REQUEST, "toml_error", self.to_string()),
|
||||||
|
AppError::Anyhow { .. } => (
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"internal_error",
|
||||||
|
self.to_string(),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
let body = Json(ErrorResponse {
|
||||||
|
error: error_type.to_string(),
|
||||||
|
message,
|
||||||
|
details: None,
|
||||||
|
});
|
||||||
|
|
||||||
|
(status, body).into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type Result<T> = std::result::Result<T, AppError>;
|
||||||
43
src/lib.rs
Normal file
43
src/lib.rs
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
//! Galvanize - A modern Wake-on-LAN control plane
|
||||||
|
//!
|
||||||
|
//! This crate provides a complete solution for managing Wake-on-LAN devices
|
||||||
|
//! through HTTP REST and MQTT APIs with pluggable authentication.
|
||||||
|
//!
|
||||||
|
//! ## Features
|
||||||
|
//!
|
||||||
|
//! - **Multi-Protocol Support**: HTTP REST API and MQTT
|
||||||
|
//! - **Pluggable Authentication**: Basic auth or OIDC
|
||||||
|
//! - **Persistent Configuration**: JSON-based device storage with hot-reload
|
||||||
|
//! - **Optional Web UI**: React + shadcn/ui dashboard
|
||||||
|
//!
|
||||||
|
//! ## Example
|
||||||
|
//!
|
||||||
|
//! ```rust,ignore
|
||||||
|
//! use galvanize::{config, storage, wol};
|
||||||
|
//!
|
||||||
|
//! // Load configuration
|
||||||
|
//! let config = config::load_config(Path::new("./config"))?;
|
||||||
|
//!
|
||||||
|
//! // Initialize storage
|
||||||
|
//! let storage = storage::DeviceStorage::new(config.storage.path);
|
||||||
|
//! storage.init().await?;
|
||||||
|
//!
|
||||||
|
//! // Create WoL sender
|
||||||
|
//! let sender = wol::WolSender::new(config.wol);
|
||||||
|
//!
|
||||||
|
//! // Wake a device
|
||||||
|
//! sender.wake_by_mac_str("AA:BB:CC:DD:EE:FF", None, None).await?;
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
pub mod api;
|
||||||
|
pub mod auth;
|
||||||
|
pub mod config;
|
||||||
|
pub mod error;
|
||||||
|
pub mod mqtt;
|
||||||
|
pub mod storage;
|
||||||
|
pub mod types;
|
||||||
|
pub mod wol;
|
||||||
|
|
||||||
|
pub use error::{AppError, Result};
|
||||||
|
pub use types::{Device, MacAddress};
|
||||||
|
|
||||||
292
src/main.rs
Normal file
292
src/main.rs
Normal file
@@ -0,0 +1,292 @@
|
|||||||
|
//! Galvanize - A modern Wake-on-LAN control plane
|
||||||
|
//!
|
||||||
|
//! Galvanize provides HTTP REST and MQTT APIs for managing Wake-on-LAN devices,
|
||||||
|
//! with pluggable authentication (basic or OIDC) and an optional React Web UI.
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use clap::{Parser, Subcommand};
|
||||||
|
use tokio::net::TcpListener;
|
||||||
|
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||||
|
|
||||||
|
mod api;
|
||||||
|
mod auth;
|
||||||
|
mod config;
|
||||||
|
mod error;
|
||||||
|
mod mqtt;
|
||||||
|
mod storage;
|
||||||
|
mod types;
|
||||||
|
mod wol;
|
||||||
|
|
||||||
|
use api::{auth_handlers::AuthHandlersState, devices::AppState, create_router_with_webui};
|
||||||
|
use auth::{middleware::AuthState, BasicAuthManager, JwtManager, OidcManager};
|
||||||
|
use config::loader;
|
||||||
|
use storage::{DeviceStorage, FileWatcher};
|
||||||
|
use wol::WolSender;
|
||||||
|
|
||||||
|
/// Galvanize - Wake-on-LAN Control Plane
|
||||||
|
#[derive(Parser)]
|
||||||
|
#[command(name = "galvanize")]
|
||||||
|
#[command(author, version, about, long_about = None)]
|
||||||
|
struct Cli {
|
||||||
|
#[command(subcommand)]
|
||||||
|
command: Commands,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand)]
|
||||||
|
enum Commands {
|
||||||
|
/// Start the Galvanize server
|
||||||
|
Serve {
|
||||||
|
/// Path to configuration directory
|
||||||
|
#[arg(short, long, default_value = "./config")]
|
||||||
|
config: PathBuf,
|
||||||
|
|
||||||
|
/// Bind address
|
||||||
|
#[arg(long, env = "GALVANIZE_HOST")]
|
||||||
|
host: Option<String>,
|
||||||
|
|
||||||
|
/// Port to listen on
|
||||||
|
#[arg(short, long, env = "GALVANIZE_PORT")]
|
||||||
|
port: Option<u16>,
|
||||||
|
|
||||||
|
/// Enable or disable Web UI
|
||||||
|
#[arg(long, env = "GALVANIZE_WEBUI")]
|
||||||
|
webui: Option<bool>,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Generate a password hash for user configuration
|
||||||
|
HashPassword {
|
||||||
|
/// Password to hash (omit to read from stdin)
|
||||||
|
password: Option<String>,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Validate configuration files
|
||||||
|
Validate {
|
||||||
|
/// Path to configuration directory
|
||||||
|
#[arg(short, long, default_value = "./config")]
|
||||||
|
config: PathBuf,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Send a Wake-on-LAN packet directly
|
||||||
|
Wake {
|
||||||
|
/// MAC address of the device to wake
|
||||||
|
mac: String,
|
||||||
|
|
||||||
|
/// Broadcast address
|
||||||
|
#[arg(short, long, default_value = "255.255.255.255")]
|
||||||
|
broadcast: String,
|
||||||
|
|
||||||
|
/// Port to send the magic packet
|
||||||
|
#[arg(short, long, default_value = "9")]
|
||||||
|
port: u16,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> anyhow::Result<()> {
|
||||||
|
// Initialize logging
|
||||||
|
tracing_subscriber::registry()
|
||||||
|
.with(
|
||||||
|
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||||
|
.unwrap_or_else(|_| "galvanize=info,tower_http=debug".into()),
|
||||||
|
)
|
||||||
|
.with(tracing_subscriber::fmt::layer())
|
||||||
|
.init();
|
||||||
|
|
||||||
|
let cli = Cli::parse();
|
||||||
|
|
||||||
|
match cli.command {
|
||||||
|
Commands::Serve {
|
||||||
|
config: config_path,
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
webui,
|
||||||
|
} => {
|
||||||
|
run_server(config_path, host, port, webui).await?;
|
||||||
|
}
|
||||||
|
Commands::HashPassword { password } => {
|
||||||
|
hash_password_command(password)?;
|
||||||
|
}
|
||||||
|
Commands::Validate { config } => {
|
||||||
|
validate_config_command(config)?;
|
||||||
|
}
|
||||||
|
Commands::Wake {
|
||||||
|
mac,
|
||||||
|
broadcast,
|
||||||
|
port,
|
||||||
|
} => {
|
||||||
|
wake_command(&mac, &broadcast, port).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run the Galvanize server
|
||||||
|
async fn run_server(
|
||||||
|
config_path: PathBuf,
|
||||||
|
host_override: Option<String>,
|
||||||
|
port_override: Option<u16>,
|
||||||
|
webui_override: Option<bool>,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
tracing::info!("Starting Galvanize server...");
|
||||||
|
tracing::info!("Config directory: {}", config_path.display());
|
||||||
|
|
||||||
|
// Load configuration
|
||||||
|
let config = loader::load_config(&config_path)?;
|
||||||
|
|
||||||
|
// Apply overrides
|
||||||
|
let host = host_override.unwrap_or(config.server.host.clone());
|
||||||
|
let port = port_override.unwrap_or(config.server.port);
|
||||||
|
let webui_enabled = webui_override.unwrap_or(config.server.webui);
|
||||||
|
|
||||||
|
tracing::info!("Server will listen on {}:{}", host, port);
|
||||||
|
tracing::info!("Web UI enabled: {}", webui_enabled);
|
||||||
|
|
||||||
|
// Initialize storage
|
||||||
|
let storage = Arc::new(DeviceStorage::new(config.storage.path.clone()));
|
||||||
|
storage.init().await?;
|
||||||
|
|
||||||
|
// Start file watcher if enabled
|
||||||
|
let _watcher = if config.storage.watch {
|
||||||
|
Some(FileWatcher::new(
|
||||||
|
config.storage.path.clone(),
|
||||||
|
storage.clone(),
|
||||||
|
config.storage.debounce_ms,
|
||||||
|
)?)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initialize WoL sender
|
||||||
|
let wol_sender = Arc::new(WolSender::new(config.wol.clone()));
|
||||||
|
|
||||||
|
// Initialize authentication
|
||||||
|
let jwt_manager = Arc::new(JwtManager::new(&config.auth.jwt));
|
||||||
|
let basic_auth = Arc::new(BasicAuthManager::new());
|
||||||
|
|
||||||
|
// Load users for basic auth
|
||||||
|
if let Some(ref users_file) = config.auth.basic.users_file {
|
||||||
|
let users = loader::load_users(&config_path, users_file)?;
|
||||||
|
basic_auth.load_users(users).await;
|
||||||
|
}
|
||||||
|
if !config.auth.basic.users.is_empty() {
|
||||||
|
basic_auth.load_users(config.auth.basic.users.clone()).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize OIDC if enabled
|
||||||
|
let oidc_manager = if config.auth.oidc.enabled {
|
||||||
|
Some(Arc::new(OidcManager::new(config.auth.oidc.clone()).await?))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create application state
|
||||||
|
let app_state = AppState {
|
||||||
|
storage: storage.clone(),
|
||||||
|
wol_sender: wol_sender.clone(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let auth_state = AuthState {
|
||||||
|
jwt_manager: jwt_manager.clone(),
|
||||||
|
auth_mode: config.auth.mode,
|
||||||
|
};
|
||||||
|
|
||||||
|
let auth_handlers_state = AuthHandlersState {
|
||||||
|
jwt_manager: jwt_manager.clone(),
|
||||||
|
basic_auth: basic_auth.clone(),
|
||||||
|
oidc_manager,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create router
|
||||||
|
let router = if webui_enabled {
|
||||||
|
create_router_with_webui(app_state, auth_state, auth_handlers_state)
|
||||||
|
} else {
|
||||||
|
api::create_router(app_state, auth_state, auth_handlers_state)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Start MQTT client if enabled
|
||||||
|
if config.mqtt.enabled {
|
||||||
|
let mqtt_config = config.mqtt.clone();
|
||||||
|
let mqtt_storage = storage.clone();
|
||||||
|
let mqtt_wol_sender = wol_sender.clone();
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
if let Err(e) = mqtt::start_mqtt_client(mqtt_config, mqtt_storage, mqtt_wol_sender).await {
|
||||||
|
tracing::error!(error = %e, "MQTT client error");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start HTTP server
|
||||||
|
let addr = format!("{}:{}", host, port);
|
||||||
|
let listener = TcpListener::bind(&addr).await?;
|
||||||
|
|
||||||
|
tracing::info!("🚀 Galvanize server running at http://{}", addr);
|
||||||
|
|
||||||
|
axum::serve(listener, router).await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hash a password using Argon2
|
||||||
|
fn hash_password_command(password: Option<String>) -> anyhow::Result<()> {
|
||||||
|
let password = if let Some(p) = password {
|
||||||
|
p
|
||||||
|
} else {
|
||||||
|
// Read from stdin
|
||||||
|
println!("Enter password to hash:");
|
||||||
|
let mut input = String::new();
|
||||||
|
std::io::stdin().read_line(&mut input)?;
|
||||||
|
input.trim().to_string()
|
||||||
|
};
|
||||||
|
|
||||||
|
let hash = BasicAuthManager::hash_password(&password)?;
|
||||||
|
println!("\nPassword hash:");
|
||||||
|
println!("{}", hash);
|
||||||
|
println!("\nAdd this to your users.toml file.");
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate configuration files
|
||||||
|
fn validate_config_command(config_path: PathBuf) -> anyhow::Result<()> {
|
||||||
|
tracing::info!("Validating configuration in: {}", config_path.display());
|
||||||
|
|
||||||
|
match loader::load_config(&config_path) {
|
||||||
|
Ok(config) => {
|
||||||
|
println!("✅ Configuration is valid!");
|
||||||
|
println!("\nConfiguration summary:");
|
||||||
|
println!(" Server: {}:{}", config.server.host, config.server.port);
|
||||||
|
println!(" Web UI: {}", config.server.webui);
|
||||||
|
println!(" Auth mode: {:?}", config.auth.mode);
|
||||||
|
println!(" MQTT enabled: {}", config.mqtt.enabled);
|
||||||
|
println!(" Storage path: {}", config.storage.path.display());
|
||||||
|
println!(" File watching: {}", config.storage.watch);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
println!("❌ Configuration error: {}", e);
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send a Wake-on-LAN packet directly
|
||||||
|
async fn wake_command(mac: &str, broadcast: &str, port: u16) -> anyhow::Result<()> {
|
||||||
|
let sender = WolSender::default();
|
||||||
|
|
||||||
|
println!("Sending Wake-on-LAN packet...");
|
||||||
|
println!(" MAC: {}", mac);
|
||||||
|
println!(" Broadcast: {}", broadcast);
|
||||||
|
println!(" Port: {}", port);
|
||||||
|
|
||||||
|
sender
|
||||||
|
.wake_by_mac_str(mac, Some(broadcast), Some(port))
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
println!("✅ Magic packet sent successfully!");
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
141
src/mqtt/client.rs
Normal file
141
src/mqtt/client.rs
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
//! MQTT client implementation
|
||||||
|
//!
|
||||||
|
//! This module provides the MQTT client for receiving Wake-on-LAN commands.
|
||||||
|
|
||||||
|
use rumqttc::{AsyncClient, Event, MqttOptions, Packet, QoS};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use crate::config::models::MqttConfig;
|
||||||
|
use crate::error::{AppError, Result};
|
||||||
|
use crate::storage::DeviceStorage;
|
||||||
|
use crate::wol::WolSender;
|
||||||
|
|
||||||
|
use super::handlers::MqttHandler;
|
||||||
|
|
||||||
|
/// Start the MQTT client in a separate task
|
||||||
|
pub async fn start_mqtt_client(
|
||||||
|
config: MqttConfig,
|
||||||
|
storage: Arc<DeviceStorage>,
|
||||||
|
wol_sender: Arc<WolSender>,
|
||||||
|
) -> Result<()> {
|
||||||
|
// Parse broker URL
|
||||||
|
let (host, port) = parse_broker_url(&config.broker)?;
|
||||||
|
|
||||||
|
let mut mqtt_options = MqttOptions::new(&config.client_id, host, port);
|
||||||
|
mqtt_options.set_keep_alive(Duration::from_secs(30));
|
||||||
|
|
||||||
|
if let (Some(username), Some(password)) = (&config.username, &config.password) {
|
||||||
|
mqtt_options.set_credentials(username, password);
|
||||||
|
}
|
||||||
|
|
||||||
|
let (client, mut event_loop) = AsyncClient::new(mqtt_options, 100);
|
||||||
|
|
||||||
|
let handler = Arc::new(MqttHandler::new(
|
||||||
|
config.topic_prefix.clone(),
|
||||||
|
storage,
|
||||||
|
wol_sender,
|
||||||
|
));
|
||||||
|
|
||||||
|
// Subscribe to topics
|
||||||
|
let topics = vec![
|
||||||
|
format!("{}/wake/+", config.topic_prefix),
|
||||||
|
format!("{}/wake", config.topic_prefix),
|
||||||
|
];
|
||||||
|
|
||||||
|
for topic in &topics {
|
||||||
|
client
|
||||||
|
.subscribe(topic, QoS::AtLeastOnce)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::MqttError {
|
||||||
|
message: format!("Failed to subscribe to {}: {}", topic, e),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
tracing::debug!(topic = %topic, "Subscribed to MQTT topic");
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
broker = %config.broker,
|
||||||
|
client_id = %config.client_id,
|
||||||
|
"MQTT client connected"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Publish online status
|
||||||
|
let status_topic = format!("{}/status", config.topic_prefix);
|
||||||
|
let status_payload = serde_json::json!({
|
||||||
|
"status": "online",
|
||||||
|
"client_id": config.client_id,
|
||||||
|
"timestamp": chrono::Utc::now().to_rfc3339(),
|
||||||
|
});
|
||||||
|
|
||||||
|
let _ = client
|
||||||
|
.publish(&status_topic, QoS::AtLeastOnce, true, status_payload.to_string())
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// Process events in a loop
|
||||||
|
loop {
|
||||||
|
match event_loop.poll().await {
|
||||||
|
Ok(notification) => {
|
||||||
|
if let Event::Incoming(Packet::Publish(publish)) = notification {
|
||||||
|
let topic = publish.topic.clone();
|
||||||
|
let payload = publish.payload.to_vec();
|
||||||
|
|
||||||
|
let handler = handler.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
if let Err(e) = handler.handle_message(&topic, &payload).await {
|
||||||
|
tracing::error!(
|
||||||
|
topic = %topic,
|
||||||
|
error = %e,
|
||||||
|
"Failed to handle MQTT message"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!(error = %e, "MQTT connection error");
|
||||||
|
// Wait before reconnecting
|
||||||
|
tokio::time::sleep(Duration::from_secs(5)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse MQTT broker URL
|
||||||
|
fn parse_broker_url(url: &str) -> Result<(String, u16)> {
|
||||||
|
// Remove mqtt:// or mqtts:// prefix
|
||||||
|
let url = url
|
||||||
|
.strip_prefix("mqtt://")
|
||||||
|
.or_else(|| url.strip_prefix("mqtts://"))
|
||||||
|
.unwrap_or(url);
|
||||||
|
|
||||||
|
// Split host and port
|
||||||
|
let parts: Vec<&str> = url.split(':').collect();
|
||||||
|
let host = parts.first().unwrap_or(&"localhost").to_string();
|
||||||
|
let port = parts
|
||||||
|
.get(1)
|
||||||
|
.and_then(|p| p.parse().ok())
|
||||||
|
.unwrap_or(1883);
|
||||||
|
|
||||||
|
Ok((host, port))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_broker_url() {
|
||||||
|
let (host, port) = parse_broker_url("mqtt://localhost:1883").unwrap();
|
||||||
|
assert_eq!(host, "localhost");
|
||||||
|
assert_eq!(port, 1883);
|
||||||
|
|
||||||
|
let (host, port) = parse_broker_url("broker.example.com:8883").unwrap();
|
||||||
|
assert_eq!(host, "broker.example.com");
|
||||||
|
assert_eq!(port, 8883);
|
||||||
|
|
||||||
|
let (host, port) = parse_broker_url("localhost").unwrap();
|
||||||
|
assert_eq!(host, "localhost");
|
||||||
|
assert_eq!(port, 1883);
|
||||||
|
}
|
||||||
|
}
|
||||||
157
src/mqtt/handlers.rs
Normal file
157
src/mqtt/handlers.rs
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
//! MQTT message handlers
|
||||||
|
//!
|
||||||
|
//! This module handles incoming MQTT messages.
|
||||||
|
|
||||||
|
use serde::Deserialize;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use crate::error::{AppError, Result};
|
||||||
|
use crate::storage::DeviceStorage;
|
||||||
|
use crate::types::MacAddress;
|
||||||
|
use crate::wol::WolSender;
|
||||||
|
|
||||||
|
/// MQTT message handler
|
||||||
|
pub struct MqttHandler {
|
||||||
|
#[allow(dead_code)]
|
||||||
|
topic_prefix: String,
|
||||||
|
storage: Arc<DeviceStorage>,
|
||||||
|
wol_sender: Arc<WolSender>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MqttHandler {
|
||||||
|
/// Create a new MQTT handler
|
||||||
|
pub fn new(
|
||||||
|
topic_prefix: String,
|
||||||
|
storage: Arc<DeviceStorage>,
|
||||||
|
wol_sender: Arc<WolSender>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
topic_prefix,
|
||||||
|
storage,
|
||||||
|
wol_sender,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle an incoming MQTT message
|
||||||
|
pub async fn handle_message(&self, topic: &str, payload: &[u8]) -> Result<()> {
|
||||||
|
tracing::debug!(topic = %topic, "Received MQTT message");
|
||||||
|
|
||||||
|
// Parse topic
|
||||||
|
let topic_parts: Vec<&str> = topic.split('/').collect();
|
||||||
|
|
||||||
|
if topic_parts.len() < 2 {
|
||||||
|
return Err(AppError::MqttError {
|
||||||
|
message: "Invalid topic format".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this is a wake command
|
||||||
|
if topic_parts.get(1) == Some(&"wake") {
|
||||||
|
if let Some(device_id) = topic_parts.get(2) {
|
||||||
|
// Wake by device ID
|
||||||
|
self.wake_by_id(device_id).await?;
|
||||||
|
} else {
|
||||||
|
// Wake by MAC address from payload
|
||||||
|
self.wake_by_payload(payload).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wake a device by ID
|
||||||
|
async fn wake_by_id(&self, device_id: &str) -> Result<()> {
|
||||||
|
let device = self
|
||||||
|
.storage
|
||||||
|
.get(device_id)
|
||||||
|
.await
|
||||||
|
.ok_or_else(|| AppError::DeviceNotFound {
|
||||||
|
id: device_id.to_string(),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if !device.enabled {
|
||||||
|
tracing::warn!(device_id = %device_id, "Device is disabled, skipping wake");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
self.wol_sender
|
||||||
|
.wake(&device.mac, Some(&device.broadcast), Some(device.port))
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
device_id = %device_id,
|
||||||
|
mac = %device.mac,
|
||||||
|
"Woke device via MQTT"
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wake a device by MAC address from payload
|
||||||
|
async fn wake_by_payload(&self, payload: &[u8]) -> Result<()> {
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct WakePayload {
|
||||||
|
mac: String,
|
||||||
|
broadcast: Option<String>,
|
||||||
|
port: Option<u16>,
|
||||||
|
}
|
||||||
|
|
||||||
|
let payload: WakePayload = serde_json::from_slice(payload)
|
||||||
|
.map_err(|e| AppError::MqttError {
|
||||||
|
message: format!("Invalid payload: {}", e),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let mac = MacAddress::parse(&payload.mac)
|
||||||
|
.map_err(|e| AppError::InvalidMacAddress {
|
||||||
|
mac: format!("{}: {}", payload.mac, e),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
self.wol_sender
|
||||||
|
.wake(&mac, payload.broadcast.as_deref(), payload.port)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
mac = %mac,
|
||||||
|
"Woke device via MQTT (by MAC)"
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// MQTT event publisher
|
||||||
|
pub struct MqttPublisher {
|
||||||
|
client: rumqttc::AsyncClient,
|
||||||
|
topic_prefix: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MqttPublisher {
|
||||||
|
/// Create a new MQTT publisher
|
||||||
|
pub fn new(client: rumqttc::AsyncClient, topic_prefix: String) -> Self {
|
||||||
|
Self {
|
||||||
|
client,
|
||||||
|
topic_prefix,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Publish a wake event
|
||||||
|
pub async fn publish_wake_event(&self, device_id: &str, mac: &str, success: bool) -> Result<()> {
|
||||||
|
let topic = format!("{}/events/wake", self.topic_prefix);
|
||||||
|
let payload = serde_json::json!({
|
||||||
|
"device_id": device_id,
|
||||||
|
"mac": mac,
|
||||||
|
"success": success,
|
||||||
|
"timestamp": chrono::Utc::now().to_rfc3339(),
|
||||||
|
});
|
||||||
|
|
||||||
|
self.client
|
||||||
|
.publish(&topic, rumqttc::QoS::AtLeastOnce, false, payload.to_string())
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::MqttError {
|
||||||
|
message: format!("Failed to publish wake event: {}", e),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
10
src/mqtt/mod.rs
Normal file
10
src/mqtt/mod.rs
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
//! MQTT module for Galvanize
|
||||||
|
//!
|
||||||
|
//! This module provides MQTT protocol support for receiving Wake-on-LAN
|
||||||
|
//! commands and publishing status events.
|
||||||
|
|
||||||
|
pub mod client;
|
||||||
|
pub mod handlers;
|
||||||
|
|
||||||
|
pub use client::start_mqtt_client;
|
||||||
|
|
||||||
408
src/storage/devices.rs
Normal file
408
src/storage/devices.rs
Normal file
@@ -0,0 +1,408 @@
|
|||||||
|
//! Device storage implementation
|
||||||
|
//!
|
||||||
|
//! This module handles persistent storage of device configurations as JSON files.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use chrono::Utc;
|
||||||
|
use tokio::sync::RwLock;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::error::{AppError, Result};
|
||||||
|
use crate::types::{CreateDeviceRequest, Device, MacAddress, UpdateDeviceRequest};
|
||||||
|
|
||||||
|
/// Device storage manager
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct DeviceStorage {
|
||||||
|
/// Path to device storage directory
|
||||||
|
path: PathBuf,
|
||||||
|
/// In-memory cache of devices
|
||||||
|
devices: Arc<RwLock<HashMap<String, Device>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DeviceStorage {
|
||||||
|
/// Create a new device storage
|
||||||
|
pub fn new(path: PathBuf) -> Self {
|
||||||
|
Self {
|
||||||
|
path,
|
||||||
|
devices: Arc::new(RwLock::new(HashMap::new())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Initialize storage - load all devices from disk
|
||||||
|
pub async fn init(&self) -> Result<()> {
|
||||||
|
// Ensure storage directory exists
|
||||||
|
if !self.path.exists() {
|
||||||
|
fs::create_dir_all(&self.path).map_err(|e| AppError::IoError { source: e })?;
|
||||||
|
tracing::info!(path = %self.path.display(), "Created device storage directory");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load all device files
|
||||||
|
self.reload_all().await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reload all devices from disk
|
||||||
|
pub async fn reload_all(&self) -> Result<()> {
|
||||||
|
let mut devices = HashMap::new();
|
||||||
|
|
||||||
|
if self.path.exists() {
|
||||||
|
for entry in fs::read_dir(&self.path).map_err(|e| AppError::IoError { source: e })? {
|
||||||
|
let entry = entry.map_err(|e| AppError::IoError { source: e })?;
|
||||||
|
let path = entry.path();
|
||||||
|
|
||||||
|
if path.extension().map_or(false, |e| e == "json") {
|
||||||
|
match self.load_device_file(&path) {
|
||||||
|
Ok(device) => {
|
||||||
|
tracing::debug!(id = %device.id, "Loaded device");
|
||||||
|
devices.insert(device.id.clone(), device);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(
|
||||||
|
path = %path.display(),
|
||||||
|
error = %e,
|
||||||
|
"Failed to load device file"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let count = devices.len();
|
||||||
|
*self.devices.write().await = devices;
|
||||||
|
tracing::info!(count = count, "Loaded devices from storage");
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load a single device file
|
||||||
|
fn load_device_file(&self, path: &Path) -> Result<Device> {
|
||||||
|
let contents = fs::read_to_string(path).map_err(|e| AppError::IoError { source: e })?;
|
||||||
|
let device: Device = serde_json::from_str(&contents).map_err(|e| AppError::SerdeJson { source: e })?;
|
||||||
|
Ok(device)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Save a device to disk
|
||||||
|
fn save_device_file(&self, device: &Device) -> Result<()> {
|
||||||
|
let path = self.device_path(&device.id);
|
||||||
|
let contents = serde_json::to_string_pretty(device).map_err(|e| AppError::SerdeJson { source: e })?;
|
||||||
|
fs::write(&path, contents).map_err(|e| AppError::IoError { source: e })?;
|
||||||
|
tracing::debug!(id = %device.id, path = %path.display(), "Saved device to disk");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete a device file from disk
|
||||||
|
fn delete_device_file(&self, id: &str) -> Result<()> {
|
||||||
|
let path = self.device_path(id);
|
||||||
|
if path.exists() {
|
||||||
|
fs::remove_file(&path).map_err(|e| AppError::IoError { source: e })?;
|
||||||
|
tracing::debug!(id = %id, path = %path.display(), "Deleted device file");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the file path for a device
|
||||||
|
fn device_path(&self, id: &str) -> PathBuf {
|
||||||
|
self.path.join(format!("{}.json", id))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List all devices
|
||||||
|
pub async fn list(&self) -> Vec<Device> {
|
||||||
|
self.devices.read().await.values().cloned().collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a device by ID
|
||||||
|
pub async fn get(&self, id: &str) -> Option<Device> {
|
||||||
|
self.devices.read().await.get(id).cloned()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a device by MAC address
|
||||||
|
pub async fn get_by_mac(&self, mac: &MacAddress) -> Option<Device> {
|
||||||
|
self.devices
|
||||||
|
.read()
|
||||||
|
.await
|
||||||
|
.values()
|
||||||
|
.find(|d| d.mac == *mac)
|
||||||
|
.cloned()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a new device
|
||||||
|
pub async fn create(&self, request: CreateDeviceRequest) -> Result<Device> {
|
||||||
|
// Parse and validate MAC address
|
||||||
|
let mac = MacAddress::parse(&request.mac)
|
||||||
|
.map_err(|e| AppError::InvalidMacAddress {
|
||||||
|
mac: format!("{}: {}", request.mac, e),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// Generate ID if not provided
|
||||||
|
let id = request.id.unwrap_or_else(|| {
|
||||||
|
// Generate a slug from the name
|
||||||
|
let slug = request
|
||||||
|
.name
|
||||||
|
.to_lowercase()
|
||||||
|
.replace(' ', "-")
|
||||||
|
.chars()
|
||||||
|
.filter(|c| c.is_alphanumeric() || *c == '-')
|
||||||
|
.collect::<String>();
|
||||||
|
|
||||||
|
if slug.is_empty() {
|
||||||
|
Uuid::new_v4().to_string()
|
||||||
|
} else {
|
||||||
|
slug
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check if device already exists
|
||||||
|
if self.devices.read().await.contains_key(&id) {
|
||||||
|
return Err(AppError::DeviceAlreadyExists { id });
|
||||||
|
}
|
||||||
|
|
||||||
|
let now = Utc::now();
|
||||||
|
let device = Device {
|
||||||
|
id: id.clone(),
|
||||||
|
name: request.name,
|
||||||
|
description: request.description,
|
||||||
|
mac,
|
||||||
|
broadcast: request
|
||||||
|
.broadcast
|
||||||
|
.unwrap_or_else(|| "255.255.255.255".to_string()),
|
||||||
|
port: request.port.unwrap_or(9),
|
||||||
|
interface: request.interface,
|
||||||
|
tags: request.tags,
|
||||||
|
metadata: request.metadata,
|
||||||
|
enabled: true,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Save to disk
|
||||||
|
self.save_device_file(&device)?;
|
||||||
|
|
||||||
|
// Add to cache
|
||||||
|
self.devices.write().await.insert(id, device.clone());
|
||||||
|
|
||||||
|
tracing::info!(id = %device.id, name = %device.name, "Created device");
|
||||||
|
|
||||||
|
Ok(device)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update an existing device
|
||||||
|
pub async fn update(&self, id: &str, request: UpdateDeviceRequest) -> Result<Device> {
|
||||||
|
let mut devices = self.devices.write().await;
|
||||||
|
|
||||||
|
let device = devices
|
||||||
|
.get_mut(id)
|
||||||
|
.ok_or_else(|| AppError::DeviceNotFound { id: id.to_string() })?;
|
||||||
|
|
||||||
|
// Apply updates
|
||||||
|
if let Some(name) = request.name {
|
||||||
|
device.name = name;
|
||||||
|
}
|
||||||
|
if let Some(description) = request.description {
|
||||||
|
device.description = Some(description);
|
||||||
|
}
|
||||||
|
if let Some(mac_str) = request.mac {
|
||||||
|
device.mac = MacAddress::parse(&mac_str)
|
||||||
|
.map_err(|e| AppError::InvalidMacAddress {
|
||||||
|
mac: format!("{}: {}", mac_str, e),
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
if let Some(broadcast) = request.broadcast {
|
||||||
|
device.broadcast = broadcast;
|
||||||
|
}
|
||||||
|
if let Some(port) = request.port {
|
||||||
|
device.port = port;
|
||||||
|
}
|
||||||
|
if let Some(interface) = request.interface {
|
||||||
|
device.interface = Some(interface);
|
||||||
|
}
|
||||||
|
if let Some(tags) = request.tags {
|
||||||
|
device.tags = tags;
|
||||||
|
}
|
||||||
|
if let Some(metadata) = request.metadata {
|
||||||
|
device.metadata = metadata;
|
||||||
|
}
|
||||||
|
if let Some(enabled) = request.enabled {
|
||||||
|
device.enabled = enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
device.updated_at = Utc::now();
|
||||||
|
|
||||||
|
let device = device.clone();
|
||||||
|
|
||||||
|
// Save to disk
|
||||||
|
self.save_device_file(&device)?;
|
||||||
|
|
||||||
|
tracing::info!(id = %device.id, "Updated device");
|
||||||
|
|
||||||
|
Ok(device)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete a device
|
||||||
|
pub async fn delete(&self, id: &str) -> Result<()> {
|
||||||
|
let mut devices = self.devices.write().await;
|
||||||
|
|
||||||
|
if devices.remove(id).is_none() {
|
||||||
|
return Err(AppError::DeviceNotFound { id: id.to_string() });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete from disk
|
||||||
|
self.delete_device_file(id)?;
|
||||||
|
|
||||||
|
tracing::info!(id = %id, "Deleted device");
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get devices by tag
|
||||||
|
pub async fn get_by_tag(&self, tag: &str) -> Vec<Device> {
|
||||||
|
self.devices
|
||||||
|
.read()
|
||||||
|
.await
|
||||||
|
.values()
|
||||||
|
.filter(|d| d.tags.contains(&tag.to_string()))
|
||||||
|
.cloned()
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reload a specific device from disk
|
||||||
|
pub async fn reload(&self, id: &str) -> Result<()> {
|
||||||
|
let path = self.device_path(id);
|
||||||
|
|
||||||
|
if path.exists() {
|
||||||
|
let device = self.load_device_file(&path)?;
|
||||||
|
self.devices.write().await.insert(device.id.clone(), device);
|
||||||
|
tracing::debug!(id = %id, "Reloaded device from disk");
|
||||||
|
} else {
|
||||||
|
// File was deleted, remove from cache
|
||||||
|
self.devices.write().await.remove(id);
|
||||||
|
tracing::debug!(id = %id, "Device file deleted, removed from cache");
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
async fn create_test_storage() -> (TempDir, DeviceStorage) {
|
||||||
|
let temp_dir = TempDir::new().unwrap();
|
||||||
|
let storage = DeviceStorage::new(temp_dir.path().to_path_buf());
|
||||||
|
storage.init().await.unwrap();
|
||||||
|
(temp_dir, storage)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_create_device() {
|
||||||
|
let (_temp_dir, storage) = create_test_storage().await;
|
||||||
|
|
||||||
|
let request = CreateDeviceRequest {
|
||||||
|
id: Some("test-device".to_string()),
|
||||||
|
name: "Test Device".to_string(),
|
||||||
|
description: None,
|
||||||
|
mac: "AA:BB:CC:DD:EE:FF".to_string(),
|
||||||
|
broadcast: None,
|
||||||
|
port: None,
|
||||||
|
interface: None,
|
||||||
|
tags: vec!["test".to_string()],
|
||||||
|
metadata: HashMap::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let device = storage.create(request).await.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(device.id, "test-device");
|
||||||
|
assert_eq!(device.name, "Test Device");
|
||||||
|
assert_eq!(device.mac.to_string(), "AA:BB:CC:DD:EE:FF");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_list_devices() {
|
||||||
|
let (_temp_dir, storage) = create_test_storage().await;
|
||||||
|
|
||||||
|
// Create a few devices
|
||||||
|
for i in 0..3 {
|
||||||
|
let request = CreateDeviceRequest {
|
||||||
|
id: Some(format!("device-{}", i)),
|
||||||
|
name: format!("Device {}", i),
|
||||||
|
description: None,
|
||||||
|
mac: format!("AA:BB:CC:DD:EE:{:02X}", i),
|
||||||
|
broadcast: None,
|
||||||
|
port: None,
|
||||||
|
interface: None,
|
||||||
|
tags: vec![],
|
||||||
|
metadata: HashMap::new(),
|
||||||
|
};
|
||||||
|
storage.create(request).await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
let devices = storage.list().await;
|
||||||
|
assert_eq!(devices.len(), 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_update_device() {
|
||||||
|
let (_temp_dir, storage) = create_test_storage().await;
|
||||||
|
|
||||||
|
let request = CreateDeviceRequest {
|
||||||
|
id: Some("test-device".to_string()),
|
||||||
|
name: "Test Device".to_string(),
|
||||||
|
description: None,
|
||||||
|
mac: "AA:BB:CC:DD:EE:FF".to_string(),
|
||||||
|
broadcast: None,
|
||||||
|
port: None,
|
||||||
|
interface: None,
|
||||||
|
tags: vec![],
|
||||||
|
metadata: HashMap::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
storage.create(request).await.unwrap();
|
||||||
|
|
||||||
|
let update = UpdateDeviceRequest {
|
||||||
|
name: Some("Updated Name".to_string()),
|
||||||
|
description: Some("New description".to_string()),
|
||||||
|
mac: None,
|
||||||
|
broadcast: None,
|
||||||
|
port: None,
|
||||||
|
interface: None,
|
||||||
|
tags: None,
|
||||||
|
metadata: None,
|
||||||
|
enabled: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let device = storage.update("test-device", update).await.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(device.name, "Updated Name");
|
||||||
|
assert_eq!(device.description, Some("New description".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_delete_device() {
|
||||||
|
let (_temp_dir, storage) = create_test_storage().await;
|
||||||
|
|
||||||
|
let request = CreateDeviceRequest {
|
||||||
|
id: Some("test-device".to_string()),
|
||||||
|
name: "Test Device".to_string(),
|
||||||
|
description: None,
|
||||||
|
mac: "AA:BB:CC:DD:EE:FF".to_string(),
|
||||||
|
broadcast: None,
|
||||||
|
port: None,
|
||||||
|
interface: None,
|
||||||
|
tags: vec![],
|
||||||
|
metadata: HashMap::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
storage.create(request).await.unwrap();
|
||||||
|
|
||||||
|
storage.delete("test-device").await.unwrap();
|
||||||
|
|
||||||
|
assert!(storage.get("test-device").await.is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
11
src/storage/mod.rs
Normal file
11
src/storage/mod.rs
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
//! Storage module for Galvanize
|
||||||
|
//!
|
||||||
|
//! This module handles persistent storage of device configurations
|
||||||
|
//! as JSON files, with file system watching for hot-reload support.
|
||||||
|
|
||||||
|
pub mod devices;
|
||||||
|
pub mod watcher;
|
||||||
|
|
||||||
|
pub use devices::DeviceStorage;
|
||||||
|
pub use watcher::FileWatcher;
|
||||||
|
|
||||||
83
src/storage/watcher.rs
Normal file
83
src/storage/watcher.rs
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
//! File system watcher for configuration hot-reload
|
||||||
|
//!
|
||||||
|
//! This module watches the device configuration directory for changes
|
||||||
|
//! and triggers reloads when files are modified.
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use notify::{
|
||||||
|
event::ModifyKind, Config, Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher,
|
||||||
|
};
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
|
use super::devices::DeviceStorage;
|
||||||
|
|
||||||
|
/// File watcher for device configuration changes
|
||||||
|
pub struct FileWatcher {
|
||||||
|
_watcher: RecommendedWatcher,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FileWatcher {
|
||||||
|
/// Start watching a directory for changes
|
||||||
|
pub fn new(
|
||||||
|
path: PathBuf,
|
||||||
|
storage: Arc<DeviceStorage>,
|
||||||
|
debounce_ms: u64,
|
||||||
|
) -> notify::Result<Self> {
|
||||||
|
let (tx, mut rx) = mpsc::channel::<Event>(100);
|
||||||
|
|
||||||
|
let mut watcher = RecommendedWatcher::new(
|
||||||
|
move |res: notify::Result<Event>| {
|
||||||
|
if let Ok(event) = res {
|
||||||
|
let _ = tx.blocking_send(event);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Config::default().with_poll_interval(Duration::from_millis(debounce_ms)),
|
||||||
|
)?;
|
||||||
|
|
||||||
|
watcher.watch(&path, RecursiveMode::NonRecursive)?;
|
||||||
|
|
||||||
|
tracing::info!(path = %path.display(), "Started watching for configuration changes");
|
||||||
|
|
||||||
|
// Spawn background task to handle events
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let debounce_duration = Duration::from_millis(debounce_ms);
|
||||||
|
let mut pending_reload = false;
|
||||||
|
let mut last_event = std::time::Instant::now();
|
||||||
|
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
Some(event) = rx.recv() => {
|
||||||
|
if should_handle_event(&event) {
|
||||||
|
pending_reload = true;
|
||||||
|
last_event = std::time::Instant::now();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = tokio::time::sleep(debounce_duration) => {
|
||||||
|
if pending_reload && last_event.elapsed() >= debounce_duration {
|
||||||
|
pending_reload = false;
|
||||||
|
if let Err(e) = storage.reload_all().await {
|
||||||
|
tracing::error!(error = %e, "Failed to reload devices after file change");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(Self { _watcher: watcher })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if an event should trigger a reload
|
||||||
|
fn should_handle_event(event: &Event) -> bool {
|
||||||
|
match &event.kind {
|
||||||
|
EventKind::Create(_) | EventKind::Remove(_) => true,
|
||||||
|
EventKind::Modify(ModifyKind::Data(_)) => true,
|
||||||
|
EventKind::Modify(ModifyKind::Name(_)) => true,
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
368
src/types.rs
Normal file
368
src/types.rs
Normal file
@@ -0,0 +1,368 @@
|
|||||||
|
//! Core types for Galvanize
|
||||||
|
//!
|
||||||
|
//! This module defines the core data structures used throughout the application.
|
||||||
|
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::fmt;
|
||||||
|
use std::str::FromStr;
|
||||||
|
|
||||||
|
/// MAC address representation
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
|
#[serde(try_from = "String", into = "String")]
|
||||||
|
pub struct MacAddress([u8; 6]);
|
||||||
|
|
||||||
|
impl MacAddress {
|
||||||
|
/// Create a new MAC address from bytes
|
||||||
|
pub fn new(bytes: [u8; 6]) -> Self {
|
||||||
|
Self(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the raw bytes of the MAC address
|
||||||
|
pub fn as_bytes(&self) -> &[u8; 6] {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a MAC address from a string
|
||||||
|
pub fn parse(s: &str) -> Result<Self, MacAddressParseError> {
|
||||||
|
let s = s.trim();
|
||||||
|
|
||||||
|
// Try different separators: ':', '-', or none
|
||||||
|
let parts: Vec<&str> = if s.contains(':') {
|
||||||
|
s.split(':').collect()
|
||||||
|
} else if s.contains('-') {
|
||||||
|
s.split('-').collect()
|
||||||
|
} else if s.len() == 12 {
|
||||||
|
// No separator, split every 2 characters
|
||||||
|
(0..6).map(|i| &s[i * 2..i * 2 + 2]).collect()
|
||||||
|
} else {
|
||||||
|
return Err(MacAddressParseError::InvalidFormat);
|
||||||
|
};
|
||||||
|
|
||||||
|
if parts.len() != 6 {
|
||||||
|
return Err(MacAddressParseError::InvalidLength);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut bytes = [0u8; 6];
|
||||||
|
for (i, part) in parts.iter().enumerate() {
|
||||||
|
bytes[i] =
|
||||||
|
u8::from_str_radix(part, 16).map_err(|_| MacAddressParseError::InvalidHex)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Self(bytes))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for MacAddress {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
write!(
|
||||||
|
f,
|
||||||
|
"{:02X}:{:02X}:{:02X}:{:02X}:{:02X}:{:02X}",
|
||||||
|
self.0[0], self.0[1], self.0[2], self.0[3], self.0[4], self.0[5]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromStr for MacAddress {
|
||||||
|
type Err = MacAddressParseError;
|
||||||
|
|
||||||
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||||
|
Self::parse(s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TryFrom<String> for MacAddress {
|
||||||
|
type Error = MacAddressParseError;
|
||||||
|
|
||||||
|
fn try_from(s: String) -> Result<Self, Self::Error> {
|
||||||
|
Self::parse(&s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<MacAddress> for String {
|
||||||
|
fn from(mac: MacAddress) -> Self {
|
||||||
|
mac.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Error parsing a MAC address
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum MacAddressParseError {
|
||||||
|
InvalidFormat,
|
||||||
|
InvalidLength,
|
||||||
|
InvalidHex,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for MacAddressParseError {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::InvalidFormat => write!(f, "invalid MAC address format"),
|
||||||
|
Self::InvalidLength => write!(f, "invalid MAC address length"),
|
||||||
|
Self::InvalidHex => write!(f, "invalid hexadecimal in MAC address"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for MacAddressParseError {}
|
||||||
|
|
||||||
|
/// Device configuration
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Device {
|
||||||
|
/// Unique identifier for the device
|
||||||
|
pub id: String,
|
||||||
|
|
||||||
|
/// Human-readable name
|
||||||
|
pub name: String,
|
||||||
|
|
||||||
|
/// Optional description
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub description: Option<String>,
|
||||||
|
|
||||||
|
/// MAC address of the device
|
||||||
|
pub mac: MacAddress,
|
||||||
|
|
||||||
|
/// Broadcast address for WoL packet (defaults to 255.255.255.255)
|
||||||
|
#[serde(default = "default_broadcast")]
|
||||||
|
pub broadcast: String,
|
||||||
|
|
||||||
|
/// Port for WoL packet (defaults to 9)
|
||||||
|
#[serde(default = "default_port")]
|
||||||
|
pub port: u16,
|
||||||
|
|
||||||
|
/// Network interface to use (optional)
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub interface: Option<String>,
|
||||||
|
|
||||||
|
/// Tags for organizing devices
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub tags: Vec<String>,
|
||||||
|
|
||||||
|
/// Custom metadata
|
||||||
|
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
|
||||||
|
pub metadata: HashMap<String, String>,
|
||||||
|
|
||||||
|
/// Whether the device is enabled
|
||||||
|
#[serde(default = "default_enabled")]
|
||||||
|
pub enabled: bool,
|
||||||
|
|
||||||
|
/// Creation timestamp
|
||||||
|
#[serde(default = "Utc::now")]
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
|
||||||
|
/// Last update timestamp
|
||||||
|
#[serde(default = "Utc::now")]
|
||||||
|
pub updated_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_broadcast() -> String {
|
||||||
|
"255.255.255.255".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_port() -> u16 {
|
||||||
|
9
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_enabled() -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Request to create a new device
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct CreateDeviceRequest {
|
||||||
|
/// Optional ID (generated if not provided)
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub id: Option<String>,
|
||||||
|
|
||||||
|
/// Human-readable name
|
||||||
|
pub name: String,
|
||||||
|
|
||||||
|
/// Optional description
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub description: Option<String>,
|
||||||
|
|
||||||
|
/// MAC address of the device
|
||||||
|
pub mac: String,
|
||||||
|
|
||||||
|
/// Broadcast address for WoL packet
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub broadcast: Option<String>,
|
||||||
|
|
||||||
|
/// Port for WoL packet
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub port: Option<u16>,
|
||||||
|
|
||||||
|
/// Network interface to use
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub interface: Option<String>,
|
||||||
|
|
||||||
|
/// Tags for organizing devices
|
||||||
|
#[serde(default)]
|
||||||
|
pub tags: Vec<String>,
|
||||||
|
|
||||||
|
/// Custom metadata
|
||||||
|
#[serde(default)]
|
||||||
|
pub metadata: HashMap<String, String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Request to update a device
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct UpdateDeviceRequest {
|
||||||
|
/// Human-readable name
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub name: Option<String>,
|
||||||
|
|
||||||
|
/// Optional description
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub description: Option<String>,
|
||||||
|
|
||||||
|
/// MAC address of the device
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub mac: Option<String>,
|
||||||
|
|
||||||
|
/// Broadcast address for WoL packet
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub broadcast: Option<String>,
|
||||||
|
|
||||||
|
/// Port for WoL packet
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub port: Option<u16>,
|
||||||
|
|
||||||
|
/// Network interface to use
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub interface: Option<String>,
|
||||||
|
|
||||||
|
/// Tags for organizing devices
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub tags: Option<Vec<String>>,
|
||||||
|
|
||||||
|
/// Custom metadata
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub metadata: Option<HashMap<String, String>>,
|
||||||
|
|
||||||
|
/// Whether the device is enabled
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub enabled: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Request to wake a device by MAC address
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct WakeRequest {
|
||||||
|
/// MAC address of the device to wake
|
||||||
|
pub mac: String,
|
||||||
|
|
||||||
|
/// Optional broadcast address
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub broadcast: Option<String>,
|
||||||
|
|
||||||
|
/// Optional port
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub port: Option<u16>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Response after waking a device
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct WakeResponse {
|
||||||
|
pub success: bool,
|
||||||
|
pub message: String,
|
||||||
|
pub device_id: Option<String>,
|
||||||
|
pub mac: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// User information
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct User {
|
||||||
|
pub username: String,
|
||||||
|
#[serde(skip_serializing)]
|
||||||
|
pub password_hash: String,
|
||||||
|
pub roles: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// User role
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub enum Role {
|
||||||
|
Admin,
|
||||||
|
User,
|
||||||
|
Viewer,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
impl Role {
|
||||||
|
pub fn can_wake(&self) -> bool {
|
||||||
|
matches!(self, Role::Admin | Role::User)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn can_modify(&self) -> bool {
|
||||||
|
matches!(self, Role::Admin)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn can_view(&self) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromStr for Role {
|
||||||
|
type Err = ();
|
||||||
|
|
||||||
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||||
|
match s.to_lowercase().as_str() {
|
||||||
|
"admin" => Ok(Role::Admin),
|
||||||
|
"user" => Ok(Role::User),
|
||||||
|
"viewer" => Ok(Role::Viewer),
|
||||||
|
_ => Err(()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Health check response
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct HealthResponse {
|
||||||
|
pub status: String,
|
||||||
|
pub version: String,
|
||||||
|
pub uptime_seconds: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List response wrapper
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ListResponse<T> {
|
||||||
|
pub items: Vec<T>,
|
||||||
|
pub total: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_mac_address_parse_colon() {
|
||||||
|
let mac = MacAddress::parse("AA:BB:CC:DD:EE:FF").unwrap();
|
||||||
|
assert_eq!(mac.as_bytes(), &[0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_mac_address_parse_dash() {
|
||||||
|
let mac = MacAddress::parse("AA-BB-CC-DD-EE-FF").unwrap();
|
||||||
|
assert_eq!(mac.as_bytes(), &[0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_mac_address_parse_no_separator() {
|
||||||
|
let mac = MacAddress::parse("AABBCCDDEEFF").unwrap();
|
||||||
|
assert_eq!(mac.as_bytes(), &[0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_mac_address_display() {
|
||||||
|
let mac = MacAddress::new([0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF]);
|
||||||
|
assert_eq!(mac.to_string(), "AA:BB:CC:DD:EE:FF");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_mac_address_lowercase() {
|
||||||
|
let mac = MacAddress::parse("aa:bb:cc:dd:ee:ff").unwrap();
|
||||||
|
assert_eq!(mac.as_bytes(), &[0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
104
src/wol/magic_packet.rs
Normal file
104
src/wol/magic_packet.rs
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
//! Wake-on-LAN Magic Packet generation
|
||||||
|
//!
|
||||||
|
//! This module implements the WoL magic packet format.
|
||||||
|
|
||||||
|
use crate::types::MacAddress;
|
||||||
|
|
||||||
|
/// Magic packet size: 6 bytes of 0xFF + 16 repetitions of 6-byte MAC address
|
||||||
|
pub const MAGIC_PACKET_SIZE: usize = 6 + 16 * 6;
|
||||||
|
|
||||||
|
/// Generate a Wake-on-LAN magic packet
|
||||||
|
///
|
||||||
|
/// The magic packet consists of:
|
||||||
|
/// - 6 bytes of 0xFF (synchronization stream)
|
||||||
|
/// - 16 repetitions of the target MAC address
|
||||||
|
pub fn create_magic_packet(mac: &MacAddress) -> [u8; MAGIC_PACKET_SIZE] {
|
||||||
|
let mut packet = [0u8; MAGIC_PACKET_SIZE];
|
||||||
|
|
||||||
|
// Fill first 6 bytes with 0xFF
|
||||||
|
for byte in packet.iter_mut().take(6) {
|
||||||
|
*byte = 0xFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Repeat MAC address 16 times
|
||||||
|
let mac_bytes = mac.as_bytes();
|
||||||
|
for i in 0..16 {
|
||||||
|
let offset = 6 + i * 6;
|
||||||
|
packet[offset..offset + 6].copy_from_slice(mac_bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
packet
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate a secure Wake-on-LAN magic packet with SecureOn password
|
||||||
|
///
|
||||||
|
/// Some NICs support an optional 6-byte password appended to the magic packet.
|
||||||
|
pub fn create_magic_packet_with_password(
|
||||||
|
mac: &MacAddress,
|
||||||
|
password: &[u8; 6],
|
||||||
|
) -> [u8; MAGIC_PACKET_SIZE + 6] {
|
||||||
|
let mut packet = [0u8; MAGIC_PACKET_SIZE + 6];
|
||||||
|
|
||||||
|
// Fill first 6 bytes with 0xFF
|
||||||
|
for byte in packet.iter_mut().take(6) {
|
||||||
|
*byte = 0xFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Repeat MAC address 16 times
|
||||||
|
let mac_bytes = mac.as_bytes();
|
||||||
|
for i in 0..16 {
|
||||||
|
let offset = 6 + i * 6;
|
||||||
|
packet[offset..offset + 6].copy_from_slice(mac_bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Append password
|
||||||
|
packet[MAGIC_PACKET_SIZE..].copy_from_slice(password);
|
||||||
|
|
||||||
|
packet
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_magic_packet_size() {
|
||||||
|
let mac = MacAddress::new([0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF]);
|
||||||
|
let packet = create_magic_packet(&mac);
|
||||||
|
assert_eq!(packet.len(), MAGIC_PACKET_SIZE);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_magic_packet_header() {
|
||||||
|
let mac = MacAddress::new([0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF]);
|
||||||
|
let packet = create_magic_packet(&mac);
|
||||||
|
|
||||||
|
// First 6 bytes should be 0xFF
|
||||||
|
for byte in &packet[0..6] {
|
||||||
|
assert_eq!(*byte, 0xFF);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_magic_packet_mac_repetition() {
|
||||||
|
let mac = MacAddress::new([0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF]);
|
||||||
|
let packet = create_magic_packet(&mac);
|
||||||
|
|
||||||
|
// MAC should be repeated 16 times
|
||||||
|
for i in 0..16 {
|
||||||
|
let offset = 6 + i * 6;
|
||||||
|
assert_eq!(&packet[offset..offset + 6], mac.as_bytes());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_magic_packet_with_password() {
|
||||||
|
let mac = MacAddress::new([0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF]);
|
||||||
|
let password = [0x11, 0x22, 0x33, 0x44, 0x55, 0x66];
|
||||||
|
let packet = create_magic_packet_with_password(&mac, &password);
|
||||||
|
|
||||||
|
assert_eq!(packet.len(), MAGIC_PACKET_SIZE + 6);
|
||||||
|
assert_eq!(&packet[MAGIC_PACKET_SIZE..], &password);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
11
src/wol/mod.rs
Normal file
11
src/wol/mod.rs
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
//! Wake-on-LAN module for Galvanize
|
||||||
|
//!
|
||||||
|
//! This module implements the Wake-on-LAN magic packet generation
|
||||||
|
//! and sending functionality.
|
||||||
|
|
||||||
|
pub mod magic_packet;
|
||||||
|
pub mod sender;
|
||||||
|
|
||||||
|
pub use magic_packet::create_magic_packet;
|
||||||
|
pub use sender::WolSender;
|
||||||
|
|
||||||
134
src/wol/sender.rs
Normal file
134
src/wol/sender.rs
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
//! Wake-on-LAN packet sender
|
||||||
|
//!
|
||||||
|
//! This module handles sending WoL magic packets over the network.
|
||||||
|
|
||||||
|
use std::net::{SocketAddr, UdpSocket};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use crate::config::models::WolConfig;
|
||||||
|
use crate::error::{AppError, Result};
|
||||||
|
use crate::types::MacAddress;
|
||||||
|
|
||||||
|
use super::magic_packet::create_magic_packet;
|
||||||
|
|
||||||
|
/// Wake-on-LAN sender
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct WolSender {
|
||||||
|
config: WolConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WolSender {
|
||||||
|
/// Create a new WoL sender with configuration
|
||||||
|
pub fn new(config: WolConfig) -> Self {
|
||||||
|
Self { config }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send a Wake-on-LAN packet to a device
|
||||||
|
pub async fn wake(
|
||||||
|
&self,
|
||||||
|
mac: &MacAddress,
|
||||||
|
broadcast: Option<&str>,
|
||||||
|
port: Option<u16>,
|
||||||
|
) -> Result<()> {
|
||||||
|
let broadcast_addr = broadcast.unwrap_or(&self.config.default_broadcast);
|
||||||
|
let port = port.unwrap_or(self.config.default_port);
|
||||||
|
|
||||||
|
let target_addr: SocketAddr = format!("{}:{}", broadcast_addr, port)
|
||||||
|
.parse()
|
||||||
|
.map_err(|e| AppError::NetworkError {
|
||||||
|
message: format!("Invalid broadcast address: {}", e),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let packet = create_magic_packet(mac);
|
||||||
|
|
||||||
|
// Send packet multiple times for reliability
|
||||||
|
for i in 0..self.config.packet_count {
|
||||||
|
self.send_packet(&packet, target_addr)?;
|
||||||
|
|
||||||
|
if i < self.config.packet_count - 1 {
|
||||||
|
tokio::time::sleep(Duration::from_millis(self.config.packet_delay_ms)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
mac = %mac,
|
||||||
|
broadcast = %broadcast_addr,
|
||||||
|
port = %port,
|
||||||
|
packets = %self.config.packet_count,
|
||||||
|
"Wake-on-LAN packets sent"
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send a single UDP packet
|
||||||
|
fn send_packet(&self, packet: &[u8], target: SocketAddr) -> Result<()> {
|
||||||
|
// Bind to any available port
|
||||||
|
let bind_addr = if target.is_ipv4() {
|
||||||
|
"0.0.0.0:0"
|
||||||
|
} else {
|
||||||
|
"[::]:0"
|
||||||
|
};
|
||||||
|
|
||||||
|
let socket = UdpSocket::bind(bind_addr)
|
||||||
|
.map_err(|e| AppError::NetworkError {
|
||||||
|
message: format!("Failed to bind socket: {}", e),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// Enable broadcast
|
||||||
|
socket
|
||||||
|
.set_broadcast(true)
|
||||||
|
.map_err(|e| AppError::NetworkError {
|
||||||
|
message: format!("Failed to enable broadcast: {}", e),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// Send the packet
|
||||||
|
socket
|
||||||
|
.send_to(packet, target)
|
||||||
|
.map_err(|e| AppError::WolFailed {
|
||||||
|
message: format!("Failed to send magic packet: {}", e),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wake a device by MAC address string
|
||||||
|
pub async fn wake_by_mac_str(
|
||||||
|
&self,
|
||||||
|
mac_str: &str,
|
||||||
|
broadcast: Option<&str>,
|
||||||
|
port: Option<u16>,
|
||||||
|
) -> Result<()> {
|
||||||
|
let mac = MacAddress::parse(mac_str)
|
||||||
|
.map_err(|e| AppError::InvalidMacAddress {
|
||||||
|
mac: format!("{}: {}", mac_str, e),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
self.wake(&mac, broadcast, port).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for WolSender {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new(WolConfig::default())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_wol_sender_creation() {
|
||||||
|
let sender = WolSender::default();
|
||||||
|
assert_eq!(sender.config.default_broadcast, "255.255.255.255");
|
||||||
|
assert_eq!(sender.config.default_port, 9);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_wake_invalid_mac() {
|
||||||
|
let sender = WolSender::default();
|
||||||
|
let result = sender.wake_by_mac_str("invalid", None, None).await;
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user