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