feat: basic
Some checks failed
CI / Rust Check (push) Has been cancelled
CI / Web UI Check (push) Has been cancelled
CI / Security Audit (push) Has been cancelled

This commit is contained in:
2025-07-12 23:59:42 +08:00
parent c7fe5373e1
commit dd11bc70b5
44 changed files with 9164 additions and 1 deletions

46
.dockerignore Normal file
View 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
View 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.

View 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
View 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
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

96
Cargo.toml Normal file
View 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
View 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
View 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
View 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
View File

@@ -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
View 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
View File

@@ -0,0 +1,3 @@
# Documentation assets directory
# Place logo.svg and screenshots here

4
mise.toml Normal file
View File

@@ -0,0 +1,4 @@
[tools]
node = "24"
pnpm = "10.25.0"
rust = "nightly"

3
rust-toolchain.toml Normal file
View File

@@ -0,0 +1,3 @@
[toolchain]
channel = "nightly"
components = ["rustfmt", "clippy"]

7
rustfmt.toml Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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());
}
}