Compare commits
67 Commits
main
...
bf71a7a659
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf71a7a659 | ||
|
|
f154c7ff69 | ||
|
|
0476f1e227 | ||
|
|
1187ae0046 | ||
|
|
b7ddd54cf6 | ||
|
|
5b8b9b66f5 | ||
|
|
f0e26e4a0a | ||
|
|
dd62458515 | ||
|
|
17f4d52c8a | ||
|
|
4787f3b863 | ||
|
|
3631339f8b | ||
|
|
1c03a706d1 | ||
|
|
a3c2828ec2 | ||
|
|
d669adc094 | ||
|
|
ea36f03393 | ||
|
|
aadcfbf810 | ||
|
|
34951221d3 | ||
|
|
68939cdc08 | ||
|
|
5136484cd2 | ||
|
|
575faa8c68 | ||
|
|
8b49fbfd3a | ||
|
|
78a100112c | ||
|
|
707464e61e | ||
|
|
153c001483 | ||
|
|
23d1cae737 | ||
|
|
3f60ab8355 | ||
|
|
fd0a1c45e7 | ||
|
|
8fb3fe7031 | ||
|
|
e7d81674c8 | ||
|
|
d2dc361620 | ||
|
|
0798b72858 | ||
|
|
f1712b5f9e | ||
|
|
5d129562e2 | ||
|
|
e0f8210c17 | ||
|
|
85f2e521c9 | ||
|
|
26120b8bc2 | ||
|
|
5ac68db854 | ||
|
|
bbc8e77e99 | ||
|
|
320a9454fe | ||
|
|
aeea977884 | ||
|
|
a55629bb7f | ||
|
|
900cbad4cd | ||
|
|
c638800137 | ||
|
|
88d4ca79d1 | ||
|
|
427e3b402c | ||
|
|
9c7990c192 | ||
|
|
acab9f95b4 | ||
|
|
e86f968c5f | ||
|
|
cb7844c730 | ||
|
|
09a3524a71 | ||
|
|
93a3615a60 | ||
|
|
b53e1af96a | ||
|
|
efb8b5da72 | ||
|
|
f58bf18cf1 | ||
|
|
5982a5ddfb | ||
|
|
a393e64d20 | ||
|
|
e66de51d6d | ||
|
|
429bce1484 | ||
|
|
b9f781f548 | ||
|
|
6c9ee6ef3e | ||
|
|
718432911b | ||
|
|
1c76b64e2f | ||
|
|
b7e7454711 | ||
|
|
69f73516c5 | ||
|
|
fc4aa2a577 | ||
|
|
21e5608ef0 | ||
|
|
4eb122c0dc |
53
.agent/AGENTS.md
Normal file
53
.agent/AGENTS.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# Project-Specific Agent Instructions
|
||||
|
||||
## YOUR MANDATE
|
||||
0. **Use the beads CLI workflow**: You will always use the beads cli for your work as explained in tool_preferences
|
||||
1. **Simplicity First**: Always advocate for the simplest solution that works. Reject complexity unless it is proven necessary.
|
||||
2. **DRY & YAGNI**: These are your non-negotiable pillars. Identify redundancy and premature optimization immediately.
|
||||
3. **Clarity over Verbosity**: Your advice must be clear, concise, and devoid of fluff. Do not be overly descriptive. Get to the point.
|
||||
4. **Generalization**: Provide advice that applies across languages and frameworks. Focus on the *pattern*, not the *syntax*.
|
||||
5. **Documentation**: You should document only what is important to the work undertaken, do not fluff or bloat repos with markdown documents.
|
||||
6. **Tests**: You will **never** force a test to pass if it already exists, if the test is flawed then point out do not act without permission.
|
||||
7. **Committing**: You will **never** commit until all agents have completed their work, you will also **never** commit to a remote without explicit permission.
|
||||
8. **Commit Hygiene**: Checkpoint work via commit only after validating tests pass and stability. Ensure clean commit hygiene.
|
||||
|
||||
### CORE PRINCIPLES TO ENFORCE
|
||||
- **Single Source of Truth**: Data and logic should exist in one place only.
|
||||
- **Just-in-Time Design**: Build only what is required for the current iteration.
|
||||
- **Code is Liability**: Less code means fewer bugs. Delete unused code ruthlessly.
|
||||
- **Explicit over Implicit**: Magic is bad. Clear flow is good.
|
||||
- **Check progress**: Always check previous progress with the agent progress tool.
|
||||
- **Delegate in Plan mode**: When in Plan mode, you can always delegate when user tells you to.
|
||||
|
||||
### WORK DISCIPLINE (from Sisyphus methodology)
|
||||
|
||||
#### Todo Management (MANDATORY for multi-step tasks)
|
||||
- **Create todos BEFORE starting** any task with 2+ steps
|
||||
- **Mark in_progress** before starting each step (only ONE at a time)
|
||||
- **Mark completed IMMEDIATELY** after each step (NEVER batch completions)
|
||||
- **Update todos** if scope changes before proceeding
|
||||
- Todos provide user visibility, prevent drift, and enable recovery
|
||||
|
||||
#### Code Quality
|
||||
- **No excessive comments**: Code should be self-documenting. Only add comments that explain WHY, not WHAT.
|
||||
- **No type suppressions**: Never use `as any`, `@ts-ignore`, `@ts-expect-error`
|
||||
- **No empty catch blocks**: Always handle errors meaningfully
|
||||
- **Match existing patterns**: Your code should look like the team wrote it
|
||||
|
||||
#### Agent Delegation
|
||||
- **Use @mentions** to invoke specialized subagents: `@explore`, `@librarian`, `@oracle`, `@frontend-ui-ux-engineer`
|
||||
- **Frontend visual work** (styling, layout, animation) → delegate to `@frontend-ui-ux-engineer`
|
||||
- **Architecture decisions** or debugging after 2+ failed attempts → consult `@oracle`
|
||||
- **External docs/library questions** → delegate to `@librarian`
|
||||
- **Codebase exploration** → delegate to `@explore`
|
||||
|
||||
#### Failure Recovery
|
||||
- Fix root causes, not symptoms
|
||||
- Re-verify after EVERY fix attempt
|
||||
- After 3 consecutive failures: STOP, revert to working state, document what failed, consult oracle
|
||||
|
||||
#### Completion Criteria
|
||||
A task is complete when:
|
||||
- [ ] All planned todo items marked done
|
||||
- [ ] Build passes (if applicable)
|
||||
- [ ] User's original request fully addressed
|
||||
30
.agent/README.md
Normal file
30
.agent/README.md
Normal file
@@ -0,0 +1,30 @@
|
||||
# Agent Instructions Directory
|
||||
|
||||
This directory contains project-specific instructions for agents working on the wg-admin project.
|
||||
|
||||
## Required Reading
|
||||
|
||||
All agents MUST read `AGENTS.md` before starting any work.
|
||||
|
||||
## Available Instructions
|
||||
|
||||
- `AGENTS.md` - Core principles and work discipline (READ THIS FIRST)
|
||||
- `beads.md` - Beads workflow and command reference
|
||||
- `explore.md` - Instructions for codebase exploration
|
||||
- `librarian.md` - Instructions for research and documentation
|
||||
- `oracle.md` - Instructions for architecture and debugging
|
||||
|
||||
## Agent Workflow
|
||||
|
||||
1. Read `.agent/AGENTS.md` for project mandates
|
||||
2. Read agent-specific instructions (e.g., `explore.md`)
|
||||
3. Execute work following established patterns
|
||||
4. Use beads for tracking multi-session or complex work
|
||||
5. Run `bd sync` before ending session
|
||||
|
||||
## Project Context
|
||||
|
||||
- **Issue tracking**: beads/bd CLI
|
||||
- **External issues**: Gitea (https://gitea.calmcacil.dev)
|
||||
- **Technology**: Bash/shell scripting
|
||||
- **Purpose**: WireGuard VPN management tool
|
||||
88
.agent/beads.md
Normal file
88
.agent/beads.md
Normal file
@@ -0,0 +1,88 @@
|
||||
# Beads Workflow Guide
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
bd ready # Find available work
|
||||
bd show <id> # View issue details
|
||||
bd update <id> --status in_progress # Claim work
|
||||
bd close <id> # Complete work
|
||||
bd sync # Sync with git
|
||||
```
|
||||
|
||||
## Workflow Commands
|
||||
|
||||
### Finding Work
|
||||
- `bd ready` - Show issues ready to work (no blockers)
|
||||
- `bd list --status=open` - All open issues
|
||||
- `bd list --status=in_progress` - Your active work
|
||||
- `bd show <id>` - Detailed issue view with dependencies
|
||||
|
||||
### Creating & Updating
|
||||
- `bd create --title="..." --type=task|bug|feature --priority=2` - New issue
|
||||
- Priority: 0-4 or P0-P4 (0=critical, 2=medium, 4=backlog). NOT "high"/"medium"/"low"
|
||||
- `bd update <id> --status=in_progress` - Claim work
|
||||
- `bd update <id> --assignee=username` - Assign to someone
|
||||
- `bd close <id>` - Mark complete
|
||||
- `bd close <id1> <id2> ...` - Close multiple issues at once (more efficient)
|
||||
- `bd close <id> --reason="explanation"` - Close with reason
|
||||
- **Tip**: When creating multiple issues/tasks/epics, use parallel subagents for efficiency
|
||||
|
||||
### Dependencies & Blocking
|
||||
- `bd dep add <issue> <depends-on>` - Add dependency (issue depends on depends-on)
|
||||
- `bd blocked` - Show all blocked issues
|
||||
- `bd show <id>` - See what's blocking/blocked by this issue
|
||||
|
||||
### Sync & Collaboration
|
||||
- `bd sync` - Sync with git remote (run at session end)
|
||||
- `bd sync --status` - Check sync status without syncing
|
||||
|
||||
### Project Health
|
||||
- `bd stats` - Project statistics (open/closed/blocked counts)
|
||||
- `bd doctor` - Check for issues (sync problems, missing hooks)
|
||||
|
||||
## Common Workflows
|
||||
|
||||
### Starting Work
|
||||
```bash
|
||||
bd ready # Find available work
|
||||
bd show <id> # Review issue details
|
||||
bd update <id> --status=in_progress # Claim it
|
||||
```
|
||||
|
||||
### Completing Work
|
||||
```bash
|
||||
bd close <id1> <id2> ... # Close all completed issues at once
|
||||
bd sync # Push to remote
|
||||
```
|
||||
|
||||
### Creating Dependent Work
|
||||
```bash
|
||||
# Run bd create commands in parallel (use subagents for many items)
|
||||
bd create --title="Implement feature X" --type=feature
|
||||
bd create --title="Write tests for X" --type=task
|
||||
bd dep add beads-yyy beads-xxx # Tests depend on Feature (Feature blocks tests)
|
||||
```
|
||||
|
||||
## Integration with Gitea
|
||||
|
||||
When working on Gitea issues:
|
||||
1. Create a beads issue to track the work
|
||||
2. Link to Gitea issue in description or comments
|
||||
3. When committing, use `Closes #{gitea_number}` to auto-close
|
||||
4. Close the beads issue after push succeeds
|
||||
|
||||
## Session Close Protocol
|
||||
|
||||
**CRITICAL**: Before saying "done", run this checklist:
|
||||
|
||||
```
|
||||
[ ] 1. git status (check what changed)
|
||||
[ ] 2. git add <files> (stage code changes)
|
||||
[ ] 3. bd sync (commit beads changes)
|
||||
[ ] 4. git commit -m "..." (commit code)
|
||||
[ ] 5. bd sync (commit any new beads changes)
|
||||
[ ] 6. git push (push to remote)
|
||||
```
|
||||
|
||||
Work is NOT complete until `git push` succeeds.
|
||||
46
.agent/explore.md
Normal file
46
.agent/explore.md
Normal file
@@ -0,0 +1,46 @@
|
||||
# Explore Agent - Project-Specific Instructions
|
||||
|
||||
## Your Role in This Project
|
||||
|
||||
You are the codebase exploration expert for the wg-admin project.
|
||||
|
||||
## Project Context
|
||||
|
||||
- **Primary issue tracking**: `bd` (beads) CLI
|
||||
- **External issue tracking**: Gitea API at https://gitea.calmcacil.dev
|
||||
- **Key directories**:
|
||||
- `.agent/` - Project-specific agent instructions
|
||||
- `.beads/` - Beads issue tracking data
|
||||
- `wireguard.sh` - Main script for WireGuard management
|
||||
|
||||
## When to Use
|
||||
|
||||
Use the explore agent when you need to:
|
||||
- Understand code structure and organization
|
||||
- Find where specific functionality is implemented
|
||||
- Identify patterns across the codebase
|
||||
- Locate files matching specific criteria
|
||||
- Understand the project architecture
|
||||
|
||||
## Workflow
|
||||
|
||||
1. **Read `.agent/AGENTS.md`** first for project mandates
|
||||
2. Use grep and glob tools for codebase exploration
|
||||
3. Provide clear, concise findings with file paths and line numbers
|
||||
4. If you find issues, consider creating beads issues for follow-up work
|
||||
|
||||
## Key Search Targets
|
||||
|
||||
- WireGuard configuration handling
|
||||
- Firewall rule management
|
||||
- Peer connection logic
|
||||
- Configuration file parsing/generation
|
||||
- Error handling patterns
|
||||
|
||||
## Output Format
|
||||
|
||||
When reporting findings:
|
||||
- Use `file_path:line_number` format for references
|
||||
- Keep descriptions concise and actionable
|
||||
- Highlight patterns, not just locations
|
||||
- Note any code smells or anti-patterns you discover
|
||||
51
.agent/librarian.md
Normal file
51
.agent/librarian.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# Librarian Agent - Project-Specific Instructions
|
||||
|
||||
## Your Role in This Project
|
||||
|
||||
You are the research and documentation expert for the wg-admin project.
|
||||
|
||||
## Project Context
|
||||
|
||||
- **Technology stack**: Bash/shell scripting for WireGuard management
|
||||
- **Documentation style**: Minimal, task-focused markdown
|
||||
- **Documentation location**: Project root (`README.md`, `GITEA_ISSUES.md`)
|
||||
|
||||
## When to Use
|
||||
|
||||
Use the librarian agent when you need to:
|
||||
- Research WireGuard best practices and configuration options
|
||||
- Find examples of similar tools or implementations
|
||||
- Look up documentation for shell scripting patterns
|
||||
- Research Gitea API usage
|
||||
- Find security best practices for network management tools
|
||||
|
||||
## Research Priorities
|
||||
|
||||
1. **WireGuard**: Configuration syntax, peer management, routing
|
||||
2. **Shell scripting**: Best practices, error handling, security
|
||||
3. **Gitea API**: Issue management, webhooks, authentication
|
||||
4. **Bash**: Modern patterns, POSIX compatibility concerns
|
||||
|
||||
## Workflow
|
||||
|
||||
1. **Read `.agent/AGENTS.md`** first for project mandates
|
||||
2. Use websearch and context7 tools for research
|
||||
3. Focus on practical, actionable information
|
||||
4. Provide code examples when relevant
|
||||
5. Cite sources when providing specific recommendations
|
||||
|
||||
## Output Format
|
||||
|
||||
When providing research:
|
||||
- Prioritize official documentation over blog posts
|
||||
- Provide code snippets when relevant
|
||||
- Note version-specific information
|
||||
- Highlight security considerations
|
||||
- Keep responses concise and focused
|
||||
|
||||
## Documentation Principles
|
||||
|
||||
- **Don't create unnecessary docs** - Only document what's critical
|
||||
- **Code over docs** - Self-documenting code preferred
|
||||
- **Update in-place** - Modify existing docs, don't create new ones unless needed
|
||||
- **User-focused** - Write for the people using this tool
|
||||
71
.agent/oracle.md
Normal file
71
.agent/oracle.md
Normal file
@@ -0,0 +1,71 @@
|
||||
# Oracle Agent - Project-Specific Instructions
|
||||
|
||||
## Your Role in This Project
|
||||
|
||||
You are the architecture and debugging expert for the wg-admin project.
|
||||
|
||||
## Project Context
|
||||
|
||||
- **Type**: Network administration tool (WireGuard management)
|
||||
- **Language**: Bash/shell scripting
|
||||
- **Critical concerns**: Security, reliability, error handling
|
||||
- **Integration**: Gitea for external issue tracking
|
||||
|
||||
## When to Use
|
||||
|
||||
Use the oracle agent when:
|
||||
- Architecture decisions need to be made
|
||||
- Debugging complex issues after 2+ failed attempts
|
||||
- Security review is needed
|
||||
- Performance optimization is required
|
||||
- Multiple solutions exist and you need to recommend the best approach
|
||||
|
||||
## Your Approach
|
||||
|
||||
1. **Read `.agent/AGENTS.md`** first for project mandates
|
||||
2. Gather full context - read relevant code, logs, error messages
|
||||
3. Apply first principles thinking
|
||||
4. Consider trade-offs: simplicity vs completeness
|
||||
5. Recommend the simplest solution that works
|
||||
6. Explain the "why" behind your recommendation
|
||||
|
||||
## Key Concerns
|
||||
|
||||
### Security
|
||||
- Credential handling
|
||||
- File permissions
|
||||
- Input validation
|
||||
- Injection vulnerabilities (shell injection, command injection)
|
||||
- Privilege escalation risks
|
||||
|
||||
### Reliability
|
||||
- Error handling completeness
|
||||
- Idempotent operations
|
||||
- Transaction safety
|
||||
- Rollback mechanisms
|
||||
- State consistency
|
||||
|
||||
### Maintainability
|
||||
- Code organization
|
||||
- Testing approach (what tests exist, what's missing)
|
||||
- Dependency management
|
||||
- Documentation adequacy
|
||||
|
||||
## Debugging Process
|
||||
|
||||
1. **Understand the symptom** - What's failing?
|
||||
2. **Reproduce** - Can you create a minimal reproduction?
|
||||
3. **Isolate** - What's the minimal code path that exhibits the issue?
|
||||
4. **Hypothesize** - What's the likely root cause?
|
||||
5. **Test** - Verify or disprove your hypothesis
|
||||
6. **Fix** - Apply the minimal fix that resolves the root cause
|
||||
7. **Verify** - Ensure the fix doesn't break anything else
|
||||
|
||||
## Output Format
|
||||
|
||||
When providing recommendations:
|
||||
- Start with the recommended solution
|
||||
- Explain the reasoning concisely
|
||||
- Discuss trade-offs if relevant
|
||||
- Provide implementation guidance
|
||||
- Note potential pitfalls or edge cases
|
||||
File diff suppressed because one or more lines are too long
28
.gitignore
vendored
Normal file
28
.gitignore
vendored
Normal file
@@ -0,0 +1,28 @@
|
||||
# Binaries
|
||||
bin/
|
||||
*.exe
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
|
||||
# Test binary, built with `go test -c`
|
||||
*.test
|
||||
|
||||
# Output of the go coverage tool
|
||||
*.out
|
||||
coverage.html
|
||||
|
||||
# Go workspace file
|
||||
go.work
|
||||
|
||||
# IDE specific
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS specific
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
wg-tui
|
||||
50
AGENTS.md
50
AGENTS.md
@@ -1,22 +1,14 @@
|
||||
# Agent Instructions
|
||||
|
||||
**IMPORTANT**: All agents MUST read `.agent/AGENTS.md` for project-specific instructions before starting work.
|
||||
|
||||
## Project Overview
|
||||
|
||||
This project uses **bd** (beads) for issue tracking and **Gitea** for external issue tracking.
|
||||
|
||||
## Gitea Issues Workflow
|
||||
|
||||
When asked to work on Gitea issues:
|
||||
1. Read issue via API: `curl -s "https://gitea.calmcacil.dev/api/v1/repos/{owner}/{repo}/issues/{number}"`
|
||||
2. Analyze requirements and scope
|
||||
3. Create task list with `todowrite` for multi-step work
|
||||
4. Implement fix following existing patterns
|
||||
5. Commit with `Closes #{number}` in message (auto-closes issue)
|
||||
6. Push to remote
|
||||
|
||||
See `GITEA_ISSUES.md` for detailed workflow.
|
||||
|
||||
## Beads Workflow
|
||||
|
||||
Run `bd onboard` to get started with beads.
|
||||
- **Primary workflow**: Use `bd` CLI for issue tracking
|
||||
- **External issues**: Gitea API for external bug/feature tracking
|
||||
- **Agent instructions**: `.agent/` directory contains project-specific agent guidance
|
||||
|
||||
## Quick Reference
|
||||
|
||||
@@ -28,29 +20,9 @@ bd close <id> # Complete work
|
||||
bd sync # Sync with git
|
||||
```
|
||||
|
||||
## Landing the Plane (Session Completion)
|
||||
## Documentation
|
||||
|
||||
**When ending a work session**, you MUST complete ALL steps below. Work is NOT complete until `git push` succeeds.
|
||||
|
||||
**MANDATORY WORKFLOW:**
|
||||
|
||||
1. **File issues for remaining work** - Create issues for anything that needs follow-up
|
||||
2. **Run quality gates** (if code changed) - Tests, linters, builds
|
||||
3. **Update issue status** - Close finished work, update in-progress items
|
||||
4. **PUSH TO REMOTE** - This is MANDATORY:
|
||||
```bash
|
||||
git pull --rebase
|
||||
bd sync
|
||||
git push
|
||||
git status # MUST show "up to date with origin"
|
||||
```
|
||||
5. **Clean up** - Clear stashes, prune remote branches
|
||||
6. **Verify** - All changes committed AND pushed
|
||||
7. **Hand off** - Provide context for next session
|
||||
|
||||
**CRITICAL RULES:**
|
||||
- Work is NOT complete until `git push` succeeds
|
||||
- NEVER stop before pushing - that leaves work stranded locally
|
||||
- NEVER say "ready to push when you are" - YOU must push
|
||||
- If push fails, resolve and retry until it succeeds
|
||||
- `.agent/AGENTS.md` - Project-specific agent instructions (READ THIS FIRST)
|
||||
- `GITEA_ISSUES.md` - Gitea issue workflow details
|
||||
- `README.md` - Project overview and setup
|
||||
|
||||
|
||||
@@ -44,7 +44,6 @@ Closes #{issue_number}"
|
||||
|
||||
### 7. Push and Close
|
||||
```bash
|
||||
bd sync
|
||||
git push
|
||||
```
|
||||
|
||||
|
||||
543
GO_TUI_PLAN.md
Normal file
543
GO_TUI_PLAN.md
Normal file
@@ -0,0 +1,543 @@
|
||||
# WireGuard Client Manager - Go TUI Implementation Plan
|
||||
|
||||
## Executive Summary
|
||||
|
||||
Converting the bash-based `wg-client-manager` to a modern, responsive Go TUI application using Bubble Tea and the Charm ecosystem. This will provide a better user experience with interactive forms, real-time status updates, and intuitive navigation.
|
||||
|
||||
## Technology Stack
|
||||
|
||||
### Core Framework
|
||||
- **bubbletea** (v1.3.10) - Elm-architecture TUI framework
|
||||
- **lipgloss** - Expressive styling and theming
|
||||
|
||||
### Component Libraries
|
||||
- **huh** - Terminal forms for client creation
|
||||
- **bubble-table** - Interactive table for client list
|
||||
- **bubbles** - Text input and other UI components
|
||||
- **qrterminal** - QR code generation for terminal display
|
||||
|
||||
### Go Modules
|
||||
- WireGuard Go library (if available) or exec-based wrapper
|
||||
- Configuration management (Viper or native config)
|
||||
- File system operations (os, path/filepath)
|
||||
|
||||
## Functional Requirements Analysis
|
||||
|
||||
### Commands to Implement
|
||||
|
||||
| Command | Current Behavior | TUI Implementation |
|
||||
|---------|-----------------|-------------------|
|
||||
| **add** | Interactive prompts or WGI_ env vars | Form modal with fields: name, DNS, PSK option |
|
||||
| **remove** | Command-line with confirmation | Select from list → Confirm modal → Delete |
|
||||
| **list** | Table view with status | Interactive table with sorting, filtering |
|
||||
| **show** | Display client config | Detail view with copyable sections |
|
||||
| **qr** | Display QR code | Inline QR code in modal |
|
||||
|
||||
### Key Features
|
||||
|
||||
#### 1. Client Management
|
||||
- **Add client**
|
||||
- Name input (regex validation: `[a-zA-Z0-9_-]+`, max 64 chars)
|
||||
- Optional DNS configuration
|
||||
- PSK toggle (yes/no)
|
||||
- Auto-assign IPv4/IPv6 addresses
|
||||
- Generate client keys
|
||||
- Create server and client configs
|
||||
- Generate QR code
|
||||
|
||||
- **Remove client**
|
||||
- Select from list
|
||||
- Confirmation dialog
|
||||
- Remove config files
|
||||
- Reload WireGuard
|
||||
|
||||
- **List clients**
|
||||
- Table view: Name, IPv4, IPv6, Status (Connected/Disconnected)
|
||||
- Sorting by column
|
||||
- Filtering/search
|
||||
- Real-time status refresh
|
||||
|
||||
- **Show client details**
|
||||
- Full configuration display
|
||||
- Copy to clipboard functionality
|
||||
- Status information (last handshake, transfer stats)
|
||||
|
||||
- **QR code display**
|
||||
- ANSI-colored QR code in terminal
|
||||
- Toggle fullscreen/inline mode
|
||||
|
||||
#### 2. Configuration Loading
|
||||
- Read `/etc/wg-admin/config.conf`
|
||||
- Environment variable support
|
||||
- Validation of required settings (SERVER_DOMAIN, WG_PORT, etc.)
|
||||
|
||||
#### 3. Validation
|
||||
- Client name format
|
||||
- IP availability checks
|
||||
- DNS server format validation
|
||||
- Pre-install checks (WireGuard installed, config exists)
|
||||
|
||||
#### 4. Security
|
||||
- Run as root required
|
||||
- Proper file permissions (0600 for keys)
|
||||
- Atomic config writes
|
||||
- Temporary key cleanup
|
||||
|
||||
#### 5. Backup & Recovery
|
||||
- Auto-backup before add/remove
|
||||
- Config backup/restore functionality
|
||||
|
||||
## Architecture Design
|
||||
|
||||
### Package Structure
|
||||
|
||||
```
|
||||
wg-admin-tui/
|
||||
├── cmd/
|
||||
│ └── main.go # Entry point
|
||||
├── internal/
|
||||
│ ├── config/
|
||||
│ │ ├── config.go # Configuration loading
|
||||
│ │ └── defaults.go # Default values
|
||||
│ ├── wireguard/
|
||||
│ │ ├── client.go # Client management
|
||||
│ │ ├── keys.go # Key generation
|
||||
│ │ ├── config.go # WireGuard config parsing
|
||||
│ │ └── status.go # Status monitoring
|
||||
│ ├── tui/
|
||||
│ │ ├── app.go # Main TUI application
|
||||
│ │ ├── model.go # Application state
|
||||
│ │ ├── update.go # Message handlers
|
||||
│ │ ├── view.go # Rendering
|
||||
│ │ ├── screens/
|
||||
│ │ │ ├── list.go # Client list view
|
||||
│ │ │ ├── add.go # Add client form
|
||||
│ │ │ ├── detail.go # Client detail view
|
||||
│ │ │ └── qr.go # QR code view
|
||||
│ │ ├── components/
|
||||
│ │ │ ├── table.go # Client table
|
||||
│ │ │ ├── statusbar.go # Status bar
|
||||
│ │ │ └── modal.go # Modal dialogs
|
||||
│ │ └── theme/
|
||||
│ │ ├── colors.go # Color scheme
|
||||
│ │ └── style.go # Styling utilities
|
||||
│ ├── validation/
|
||||
│ │ ├── client.go # Client name validation
|
||||
│ │ ├── network.go # IP/DNS validation
|
||||
│ │ └── config.go # Config syntax validation
|
||||
│ └── backup/
|
||||
│ ├── backup.go # Backup operations
|
||||
│ └── restore.go # Restore operations
|
||||
├── pkg/
|
||||
│ └── util/
|
||||
│ ├── exec.go # Command execution helpers
|
||||
│ └── file.go # File operations
|
||||
└── go.mod
|
||||
```
|
||||
|
||||
### Component Design
|
||||
|
||||
#### 1. Main Application (app.go)
|
||||
```go
|
||||
type Application struct {
|
||||
model Model
|
||||
programs *tea.Program
|
||||
config *config.Config
|
||||
wireguard *wireguard.Client
|
||||
}
|
||||
|
||||
func NewApplication() (*Application, error)
|
||||
func (a *Application) Run() error
|
||||
```
|
||||
|
||||
#### 2. Model (model.go)
|
||||
```go
|
||||
type Model struct {
|
||||
screen Screen // Current active screen
|
||||
clients []Client // Client data
|
||||
selected int // Selected client
|
||||
loading bool // Loading state
|
||||
error error // Error message
|
||||
table table.Model // Client table
|
||||
form *huh.Form // Add client form
|
||||
modal *Modal // Active modal
|
||||
status Status // Status bar state
|
||||
}
|
||||
|
||||
type Screen interface {
|
||||
Init() tea.Cmd
|
||||
Update(msg tea.Msg) (Screen, tea.Cmd)
|
||||
View() string
|
||||
}
|
||||
```
|
||||
|
||||
#### 3. Screens
|
||||
- **ListScreen**: Display client table
|
||||
- **AddScreen**: Form for adding client
|
||||
- **DetailScreen**: Show client configuration
|
||||
- **QRScreen**: Display QR code
|
||||
|
||||
#### 4. WireGuard Integration
|
||||
```go
|
||||
type Client struct {
|
||||
Name string
|
||||
IPv4 string
|
||||
IPv6 string
|
||||
PublicKey string
|
||||
HasPSK bool
|
||||
Status ConnectionStatus
|
||||
LastSeen time.Time
|
||||
Handshake string
|
||||
ConfigPath string
|
||||
}
|
||||
|
||||
type Manager interface {
|
||||
ListClients() ([]Client, error)
|
||||
AddClient(name, dns string, usePSK bool) (*Client, error)
|
||||
RemoveClient(name string) error
|
||||
GetClient(name string) (*Client, error)
|
||||
GetStatus() (Status, error)
|
||||
ReloadConfig() error
|
||||
}
|
||||
```
|
||||
|
||||
## Implementation Roadmap
|
||||
|
||||
### Phase 1: Project Setup & Foundation (Days 1-2)
|
||||
|
||||
**Goal**: Establish project structure and core framework
|
||||
|
||||
**Tasks**:
|
||||
- [ ] Initialize Go module
|
||||
- [ ] Set up project structure
|
||||
- [ ] Add dependencies (bubbletea, lipgloss, huh, bubble-table, qrterminal)
|
||||
- [ ] Create configuration loading from `/etc/wg-admin/config.conf`
|
||||
- [ ] Implement root check
|
||||
- [ ] Create basic TUI skeleton with empty screens
|
||||
- [ ] Set up logging (both file and console)
|
||||
|
||||
**Deliverables**:
|
||||
- Working TUI that launches and shows a placeholder screen
|
||||
- Configuration system in place
|
||||
|
||||
### Phase 2: Client List View (Days 3-4)
|
||||
|
||||
**Goal**: Display clients in interactive table
|
||||
|
||||
**Tasks**:
|
||||
- [ ] Implement WireGuard client list parsing
|
||||
- [ ] Create `Client` struct
|
||||
- [ ] Set up bubble-table with columns: Name, IPv4, IPv6, Status
|
||||
- [ ] Parse client configs from `/etc/wireguard/conf.d/client-*.conf`
|
||||
- [ ] Check connection status using `wg show`
|
||||
- [ ] Implement keyboard navigation (j/k, arrows, Enter, q)
|
||||
- [ ] Add status bar with help text
|
||||
- [ ] Implement auto-refresh (every 30 seconds)
|
||||
|
||||
**Deliverables**:
|
||||
- Functional client list with real-time status
|
||||
|
||||
### Phase 3: Add Client Form (Days 5-6)
|
||||
|
||||
**Goal**: Create interactive form for adding clients
|
||||
|
||||
**Tasks**:
|
||||
- [ ] Create add screen with `huh` form
|
||||
- [ ] Implement fields:
|
||||
- Client name (text input)
|
||||
- DNS servers (text input with default)
|
||||
- Use PSK (toggle/confirm)
|
||||
- [ ] Add validation:
|
||||
- Client name regex
|
||||
- IP availability check
|
||||
- DNS format validation
|
||||
- [ ] Implement WireGuard key generation
|
||||
- [ ] Auto-assign IPv4/IPv6 addresses
|
||||
- [ ] Create server config file
|
||||
- [ ] Create client config file
|
||||
- [ ] Generate QR code
|
||||
- [ ] Implement atomic config writes
|
||||
- [ ] Auto-backup before add
|
||||
- [ ] Reload WireGuard config
|
||||
|
||||
**Deliverables**:
|
||||
- Working add client form with all validations
|
||||
|
||||
### Phase 4: Client Detail View (Days 7-8)
|
||||
|
||||
**Goal**: Show full client configuration
|
||||
|
||||
**Tasks**:
|
||||
- [ ] Create detail screen
|
||||
- [ ] Display:
|
||||
- Client name
|
||||
- IP addresses
|
||||
- Public key
|
||||
- Full configuration
|
||||
- Connection status
|
||||
- Last handshake
|
||||
- Transfer stats (Rx/Tx)
|
||||
- [ ] Implement copy to clipboard
|
||||
- [ ] Add "Back" and "Delete" buttons
|
||||
- [ ] Delete confirmation modal
|
||||
- [ ] Remove client with config deletion
|
||||
- [ ] Auto-backup before remove
|
||||
- [ ] Reload WireGuard config
|
||||
|
||||
**Deliverables**:
|
||||
- Functional detail view with delete capability
|
||||
|
||||
### Phase 5: QR Code Display (Days 9-10)
|
||||
|
||||
**Goal**: Display QR codes for mobile setup
|
||||
|
||||
**Tasks**:
|
||||
- [ ] Create QR screen
|
||||
- [ ] Read client config
|
||||
- [ ] Generate QR code using `qrterminal`
|
||||
- [ ] Display QR code inline
|
||||
- [ ] Implement fullscreen QR mode
|
||||
- [ ] Add resize handling for QR codes
|
||||
- [ ] Test with various terminal sizes
|
||||
|
||||
**Deliverables**:
|
||||
- Working QR code display
|
||||
|
||||
### Phase 6: Polish & UX Improvements (Days 11-12)
|
||||
|
||||
**Goal**: Improve user experience
|
||||
|
||||
**Tasks**:
|
||||
- [ ] Add color themes
|
||||
- [ ] Implement modal dialogs for confirmations
|
||||
- [ ] Add toast notifications for success/error
|
||||
- [ ] Implement search/filter clients
|
||||
- [ ] Add sorting by columns
|
||||
- [ ] Improve error messages with actionable guidance
|
||||
- [ ] Add keyboard shortcuts help
|
||||
- [ ] Implement loading indicators
|
||||
|
||||
**Deliverables**:
|
||||
- Polished, user-friendly interface
|
||||
|
||||
### Phase 7: Backup & Recovery (Days 13-14)
|
||||
|
||||
**Goal**: Add backup and restore functionality
|
||||
|
||||
**Tasks**:
|
||||
- [ ] Implement backup operations
|
||||
- [ ] Create restore functionality
|
||||
- [ ] Add backup history view
|
||||
- [ ] Implement retention policy
|
||||
- [ ] Add backup/restore screens
|
||||
|
||||
**Deliverables**:
|
||||
- Complete backup/restore system
|
||||
|
||||
## User Experience Design
|
||||
|
||||
### Screen Flow
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ Client List (Main) │
|
||||
│ ┌─────┬─────┬──────────┬─────────┐│
|
||||
│ │Name │IPv4 │ IPv6 │ Status ││
|
||||
│ ├─────┼─────┼──────────┼─────────┤│
|
||||
│ │laptop│ .2 │ ::2 │Connected││ ← Selected
|
||||
│ │phone │ .3 │ ::3 │ Disc ││
|
||||
│ └─────┴─────┴──────────┴─────────┘│
|
||||
│ │
|
||||
│ [a] Add [d] Detail [?] Help [q] Quit│
|
||||
└─────────────────────────────────────┘
|
||||
↓ Enter
|
||||
┌─────────────────────────────────────┐
|
||||
│ Client Detail: laptop │
|
||||
│ │
|
||||
│ Name: laptop │
|
||||
│ IPv4: 10.10.69.2 │
|
||||
│ IPv6: fd69:dead:beef:69::2 │
|
||||
│ Status: Connected │
|
||||
│ Last Handshake: 2m ago │
|
||||
│ Rx: 1.2 MB Tx: 3.4 MB │
|
||||
│ │
|
||||
│ [Interface] │
|
||||
│ PrivateKey = ... │
|
||||
│ Address = 10.10.69.2/24... │
|
||||
│ │
|
||||
│ [ESC] Back [x] Delete [c] Copy │
|
||||
└─────────────────────────────────────┘
|
||||
↓ 'a' from list
|
||||
┌─────────────────────────────────────┐
|
||||
│ Add New Client │
|
||||
│ │
|
||||
│ Client Name: [_________] │
|
||||
│ DNS Servers: [8.8.8.8, 8.8.4.4] │
|
||||
│ Use PSK? [x] Yes │
|
||||
│ │
|
||||
│ [Enter] Submit [ESC] Cancel │
|
||||
└─────────────────────────────────────┘
|
||||
↓ 'q' from detail
|
||||
┌─────────────────────────────────────┐
|
||||
│ Delete Confirmation │
|
||||
│ │
|
||||
│ Delete client 'laptop'? │
|
||||
│ │
|
||||
│ [Enter] Yes [ESC] No │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Keyboard Shortcuts
|
||||
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| `q` | Quit |
|
||||
| `a` | Add client |
|
||||
| `d` | Show details |
|
||||
| `x` | Delete |
|
||||
| `r` | Refresh |
|
||||
| `/` | Search/filter |
|
||||
| `↑/k` | Move up |
|
||||
| `↓/j` | Move down |
|
||||
| `Enter` | Select/Confirm |
|
||||
| `Esc` | Back/Cancel |
|
||||
| `?` | Help |
|
||||
| `c` | Copy to clipboard |
|
||||
|
||||
### Color Scheme
|
||||
|
||||
```go
|
||||
// Default theme
|
||||
const (
|
||||
ColorPrimary lipgloss.Color
|
||||
ColorSecondary lipgloss.Color
|
||||
ColorSuccess lipgloss.Color
|
||||
ColorWarning lipgloss.Color
|
||||
ColorError lipgloss.Color
|
||||
ColorMuted lipgloss.Color
|
||||
)
|
||||
|
||||
// Example
|
||||
ColorPrimary = lipgloss.Color("#007AFF") // Blue
|
||||
ColorSuccess = lipgloss.Color("#34C759") // Green
|
||||
ColorWarning = lipgloss.Color("#FF9500") // Orange
|
||||
ColorError = lipgloss.Color("#FF3B30") // Red
|
||||
ColorMuted = lipgloss.Color("#8E8E93") // Gray
|
||||
```
|
||||
|
||||
## Error Handling Strategy
|
||||
|
||||
### Validation Errors
|
||||
- Display inline validation messages
|
||||
- Show specific error with action guidance
|
||||
- Keep form state on error
|
||||
- Highlight invalid fields
|
||||
|
||||
### System Errors
|
||||
- Show modal with error message
|
||||
- Log full error to file
|
||||
- Provide actionable guidance
|
||||
- Offer retry or cancel options
|
||||
|
||||
### Examples
|
||||
```
|
||||
ERROR: Client 'laptop' already exists
|
||||
Action: Choose a different name or remove existing client first
|
||||
|
||||
ERROR: No available IPv4 addresses
|
||||
Action: Remove unused clients or expand VPN range
|
||||
```
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
1. **Lazy Loading**
|
||||
- Load clients on screen init
|
||||
- Cache client data between refreshes
|
||||
|
||||
2. **Asynchronous Operations**
|
||||
- Run WireGuard status checks in background
|
||||
- Use tea.Cmd for async operations
|
||||
|
||||
3. **Throttled Refresh**
|
||||
- Auto-refresh every 30 seconds (configurable)
|
||||
- Manual refresh with 'r' key
|
||||
|
||||
4. **QR Code Optimization**
|
||||
- Generate on demand, not cached
|
||||
- Use appropriate error correction level
|
||||
|
||||
## Security Considerations
|
||||
|
||||
1. **Root Privileges**
|
||||
- Check for root at startup
|
||||
- Display clear error if not root
|
||||
|
||||
2. **Key Storage**
|
||||
- Write keys with 0600 permissions
|
||||
- Clean up temporary key files
|
||||
|
||||
3. **Atomic Operations**
|
||||
- Write to temp file, then move
|
||||
- Prevent config corruption
|
||||
|
||||
4. **Input Validation**
|
||||
- Strict regex for client names
|
||||
- IP availability checks
|
||||
- DNS format validation
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Unit Tests
|
||||
- Validation functions
|
||||
- Configuration parsing
|
||||
- Client model methods
|
||||
|
||||
### Integration Tests
|
||||
- WireGuard config generation
|
||||
- Key generation
|
||||
- Backup/restore
|
||||
|
||||
### Manual Testing
|
||||
- End-to-end workflows
|
||||
- Terminal size handling
|
||||
- Error scenarios
|
||||
|
||||
## Milestones
|
||||
|
||||
| Milestone | Days | Goal |
|
||||
|-----------|------|------|
|
||||
| M1: Foundation | 2 | Project setup, basic TUI |
|
||||
| M2: List View | 4 | Client list with status |
|
||||
| M3: Add Client | 6 | Working add form |
|
||||
| M4: Detail & Delete | 8 | Full client management |
|
||||
| M5: QR Codes | 10 | QR code display |
|
||||
| M6: Polish | 12 | UX improvements |
|
||||
| M7: Complete | 14 | Fully functional TUI |
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- ✅ All bash functionality replicated in TUI
|
||||
- ✅ Responsive and performant UI
|
||||
- ✅ Clear error messages with actionable guidance
|
||||
- ✅ Keyboard shortcuts for all actions
|
||||
- ✅ Real-time client status updates
|
||||
- ✅ Working QR code generation
|
||||
- ✅ Backup/restore functionality
|
||||
- ✅ Polished, intuitive user experience
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
- Config import/export
|
||||
- Client statistics dashboard
|
||||
- Connection monitoring view
|
||||
- Multiple server support
|
||||
- Template-based client creation
|
||||
- Bulk operations
|
||||
- Dark/Light theme toggle
|
||||
|
||||
---
|
||||
|
||||
**Estimated Timeline**: 14 days for MVP
|
||||
**Primary Dependencies**: bubbletea, lipgloss, huh, bubble-table, qrterminal
|
||||
**Minimum Go Version**: 1.21+
|
||||
**Target Terminal**: ANSI-compatible terminals (Linux, macOS, Windows with WSL)
|
||||
86
Makefile
Normal file
86
Makefile
Normal file
@@ -0,0 +1,86 @@
|
||||
# WireGuard Admin TUI
|
||||
.PHONY: help build clean install test fmt lint run deps
|
||||
|
||||
# Project root (where go.mod is located)
|
||||
ROOTDIR:=$(shell dirname $(realpath $(firstword $(MAKEFILE_LIST))))
|
||||
|
||||
# Binary name
|
||||
BINARY=wg-tui
|
||||
CMD_PATH=$(ROOTDIR)/cmd/$(BINARY)
|
||||
|
||||
# Build directory
|
||||
BUILD_DIR=bin
|
||||
BINARY_PATH=$(BUILD_DIR)/$(BINARY)
|
||||
|
||||
# Go parameters
|
||||
GOCMD=go
|
||||
GOBUILD=$(GOCMD) build
|
||||
GOCLEAN=$(GOCMD) clean
|
||||
GOTEST=$(GOCMD) test
|
||||
GOGET=$(GOCMD) get
|
||||
GOMOD=$(GOCMD) mod
|
||||
|
||||
help: ## Show this help message
|
||||
@echo 'Usage: make [target]'
|
||||
@echo ''
|
||||
@echo 'Available targets:'
|
||||
@awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf " %-15s %s\n", $$1, $$2}' $(MAKEFILE_LIST)
|
||||
|
||||
build: ## Build the binary
|
||||
@echo "Building $(BINARY)..."
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
@$(GOBUILD) -C $(ROOTDIR) -o $(BINARY_PATH) cmd/$(BINARY)/main.go
|
||||
@echo "Build complete: $(BINARY_PATH)"
|
||||
|
||||
build-all: ## Build all binaries
|
||||
@echo "Building all binaries..."
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
@$(GOBUILD) -C $(ROOTDIR) -o $(BINARY_PATH) ./...
|
||||
|
||||
clean: ## Clean build artifacts
|
||||
@echo "Cleaning..."
|
||||
@$(GOCLEAN)
|
||||
@rm -rf $(BUILD_DIR)
|
||||
@echo "Clean complete"
|
||||
|
||||
install: ## Install the binary to $GOPATH/bin
|
||||
@echo "Installing $(BINARY)..."
|
||||
@$(GOBUILD) -C $(ROOTDIR) -o $$($(GOCMD) env GOPATH)/bin/$(BINARY) cmd/$(BINARY)/main.go
|
||||
@echo "Install complete: $$($(GOCMD) env GOPATH)/bin/$(BINARY)"
|
||||
|
||||
test: ## Run tests
|
||||
@echo "Running tests..."
|
||||
@$(GOTEST) -C $(ROOTDIR) -v ./...
|
||||
|
||||
test-coverage: ## Run tests with coverage
|
||||
@echo "Running tests with coverage..."
|
||||
@$(GOTEST) -C $(ROOTDIR) -v -coverprofile=coverage.out ./...
|
||||
@$(GOCMD) tool -C $(ROOTDIR) cover -html=coverage.out -o coverage.html
|
||||
|
||||
fmt: ## Format Go code
|
||||
@echo "Formatting code..."
|
||||
@$(GOCMD) fmt -C $(ROOTDIR) ./...
|
||||
|
||||
lint: ## Run golangci-lint (if installed)
|
||||
@echo "Running linter..."
|
||||
@cd $(ROOTDIR) && if command -v golangci-lint > /dev/null; then \
|
||||
golangci-lint run; \
|
||||
else \
|
||||
echo "golangci-lint not found. Install from https://golangci-lint.run/usage/install/"; \
|
||||
fi
|
||||
|
||||
deps: ## Download dependencies
|
||||
@echo "Downloading dependencies..."
|
||||
@$(GOMOD) -C $(ROOTDIR) download
|
||||
@$(GOMOD) -C $(ROOTDIR) tidy
|
||||
|
||||
run: build ## Build and run the binary
|
||||
@echo "Running $(BINARY)..."
|
||||
@./$(BINARY_PATH)
|
||||
|
||||
dev: ## Run in development mode with hot reload (requires air)
|
||||
@if command -v air > /dev/null; then \
|
||||
air; \
|
||||
else \
|
||||
echo "air not found. Install with: go install github.com/cosmtrek/air@latest"; \
|
||||
fi
|
||||
131
README.md
131
README.md
@@ -3,23 +3,119 @@
|
||||
## Overview
|
||||
Personal WireGuard VPN server with IPv4/IPv6 support, client management via `wireguard.sh`, designed for 1 CPU / 1GB RAM VPS.
|
||||
|
||||
## Development & Issue Tracking
|
||||
|
||||
This project uses **beads** (`bd` CLI) for issue tracking and **Gitea** for external issue tracking.
|
||||
|
||||
- **Agent instructions**: See `AGENTS.md` and `.agent/` directory for project-specific guidance
|
||||
- **Issue tracking**: Use `bd ready` to find available work
|
||||
- **External issues**: Gitea at https://gitea.calmcacil.dev
|
||||
|
||||
## Theming
|
||||
|
||||
The TUI supports multiple color themes. Set the `THEME` environment variable to switch themes:
|
||||
|
||||
### Available Themes
|
||||
|
||||
| Theme | Description | Default |
|
||||
|-------|-------------|---------|
|
||||
| `everforest` | Natural green/blue theme (default) | ✅ |
|
||||
| `dracula` | Popular dark purple theme | |
|
||||
| `default` | Standard blue-based theme | |
|
||||
| `dark` | Purple-based dark theme | |
|
||||
| `light` | Green-based light theme | |
|
||||
|
||||
### Usage
|
||||
|
||||
```bash
|
||||
# Use Everforest (default)
|
||||
sudo wg-admin-tui
|
||||
|
||||
# Use Dracula theme
|
||||
THEME=dracula sudo wg-admin-tui
|
||||
|
||||
# Use default theme
|
||||
THEME=default sudo wg-admin-tui
|
||||
|
||||
# Use light theme
|
||||
THEME=light sudo wg-admin-tui
|
||||
```
|
||||
|
||||
## Configuration
|
||||
- **Server Domain**: velkhana.calmcacil.dev
|
||||
- **Port**: 51820
|
||||
- **VPN IPv4 Range**: 10.10.69.0/24
|
||||
- **VPN IPv6 Range**: fd69:dead:beef:69::/64
|
||||
- **DNS**: 8.8.8.8, 8.8.4.4 (Google)
|
||||
- **Server-side peer configs**: /etc/wireguard/conf.d/client-*.conf (loaded dynamically)
|
||||
- **Client-side configs**: /etc/wireguard/clients/*.conf (for distribution)
|
||||
|
||||
Configuration is managed through `/etc/wg-admin/config.conf`. Copy `config.example` to this location and customize for your environment.
|
||||
|
||||
### Creating Configuration File
|
||||
|
||||
```bash
|
||||
sudo mkdir -p /etc/wg-admin
|
||||
sudo cp config.example /etc/wg-admin/config.conf
|
||||
sudo nano /etc/wg-admin/config.conf
|
||||
```
|
||||
|
||||
### Configuration Variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `SERVER_DOMAIN` | *Required* | Server domain or IP address (e.g., `vpn.example.com`) |
|
||||
| `WG_PORT` | 51820 | WireGuard UDP port |
|
||||
| `VPN_IPV4_RANGE` | 10.10.69.0/24 | VPN IPv4 address range |
|
||||
| `VPN_IPV6_RANGE` | fd69:dead:beef:69::/64 | VPN IPv6 address range |
|
||||
| `WG_INTERFACE` | wg0 | WireGuard interface name |
|
||||
| `DNS_SERVERS` | 8.8.8.8, 8.8.4.4 | DNS servers for clients |
|
||||
| `LOG_FILE` | /var/log/wireguard-admin.log | Log file location |
|
||||
|
||||
### Example Configuration
|
||||
|
||||
```ini
|
||||
# Server domain or IP address (required)
|
||||
SERVER_DOMAIN=vpn.example.com
|
||||
|
||||
# WireGuard UDP port (optional, default: 51820)
|
||||
WG_PORT=51820
|
||||
|
||||
# VPN IPv4 range (optional, default: 10.10.69.0/24)
|
||||
VPN_IPV4_RANGE=10.10.69.0/24
|
||||
|
||||
# VPN IPv6 range (optional, default: fd69:dead:beef:69::/64)
|
||||
VPN_IPV6_RANGE=fd69:dead:beef:69::/64
|
||||
|
||||
# DNS servers (optional, default: 8.8.8.8, 8.8.4.4)
|
||||
DNS_SERVERS=8.8.8.8, 8.8.4.4
|
||||
```
|
||||
|
||||
**Note**: All values are optional except `SERVER_DOMAIN`. The script will use defaults if not specified.
|
||||
|
||||
### Configuration Priority
|
||||
|
||||
1. `/etc/wg-admin/config.conf` file (highest priority)
|
||||
2. Environment variables (e.g., `SERVER_DOMAIN=vpn.example.com ./wireguard.sh install`)
|
||||
3. Built-in defaults (lowest priority)
|
||||
|
||||
### Other Directories
|
||||
- **Server-side peer configs**: `/etc/wireguard/conf.d/client-*.conf` (loaded dynamically)
|
||||
- **Client-side configs**: `/etc/wireguard/clients/*.conf` (for distribution)
|
||||
|
||||
## Installation
|
||||
|
||||
### 1. Upload script to VPS
|
||||
```bash
|
||||
scp wireguard.sh calmcacil@velkhana.calmcacil.dev:~/
|
||||
scp wireguard.sh calmcacil@your-vps.com:~/
|
||||
scp config.example calmcacil@your-vps.com:~/
|
||||
```
|
||||
|
||||
### 2. Run installation
|
||||
### 2. Configure the script
|
||||
```bash
|
||||
# Copy example config and customize
|
||||
sudo mkdir -p /etc/wg-admin
|
||||
sudo cp ~/config.example /etc/wg-admin/config.conf
|
||||
sudo nano /etc/wg-admin/config.conf
|
||||
|
||||
# Set at minimum:
|
||||
# SERVER_DOMAIN=vpn.yourdomain.com
|
||||
```
|
||||
|
||||
### 3. Run installation
|
||||
```bash
|
||||
chmod +x ~/wireguard.sh
|
||||
sudo ~/wireguard.sh install
|
||||
@@ -127,6 +223,23 @@ Then run:
|
||||
sudo ~/wireguard.sh load-clients
|
||||
```
|
||||
|
||||
## Text Selection & Copying
|
||||
|
||||
To copy client configurations or other text from the terminal UI:
|
||||
|
||||
### Text Selection
|
||||
- Hold **SHIFT key** while dragging your mouse with the left button
|
||||
- This bypasses TUI mouse handling and enables your terminal's native text selection
|
||||
- Then use your terminal's copy shortcut:
|
||||
- **Linux/WSL**: Ctrl+Shift+C (or terminal-specific shortcut)
|
||||
- **macOS**: Cmd+C
|
||||
- **Windows**: Click right (or use terminal copy)
|
||||
|
||||
### Copy Buttons (when available)
|
||||
- Some modals may have explicit copy buttons (e.g., "C" to copy config)
|
||||
- These work when clipboard API is available (native Linux, macOS, WSL)
|
||||
- Over SSH requires X11 forwarding (`ssh -X`) for clipboard access
|
||||
|
||||
## Client Setup
|
||||
|
||||
### Importing the config
|
||||
|
||||
385
cmd/wg-tui/main.go
Normal file
385
cmd/wg-tui/main.go
Normal file
@@ -0,0 +1,385 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
|
||||
"github.com/calmcacil/wg-admin/internal/config"
|
||||
"github.com/calmcacil/wg-admin/internal/tui/screens"
|
||||
)
|
||||
|
||||
const version = "0.1.0"
|
||||
|
||||
// TickMsg is sent for transition animation frames
|
||||
type TickMsg time.Time
|
||||
|
||||
// Transition settings
|
||||
const (
|
||||
transitionDuration = 200 * time.Millisecond
|
||||
transitionFPS = 60
|
||||
)
|
||||
|
||||
|
||||
var (
|
||||
styleTitle = lipgloss.NewStyle().Foreground(lipgloss.Color("62")).Bold(true)
|
||||
styleSubtitle = lipgloss.NewStyle().Foreground(lipgloss.Color("241"))
|
||||
styleSuccess = lipgloss.NewStyle().Foreground(lipgloss.Color("46"))
|
||||
styleError = lipgloss.NewStyle().Foreground(lipgloss.Color("196"))
|
||||
styleHelpKey = lipgloss.NewStyle().Foreground(lipgloss.Color("63")).Bold(true)
|
||||
)
|
||||
|
||||
type model struct {
|
||||
transitionFrom screens.Screen
|
||||
transitionTo screens.Screen
|
||||
transitionProgress float64
|
||||
transitionAnimating bool
|
||||
transitionStartTime time.Time
|
||||
transitionType screens.TransitionType
|
||||
transitionDuration time.Duration
|
||||
currentScreen screens.Screen
|
||||
previousScreen screens.Screen
|
||||
quitting bool
|
||||
initialized bool
|
||||
errorScreen *screens.ErrorScreen
|
||||
}
|
||||
|
||||
func (m model) Init() tea.Cmd {
|
||||
if os.Geteuid() != 0 {
|
||||
fmt.Println(styleError.Render("ERROR: Must run as root"))
|
||||
os.Exit(1)
|
||||
}
|
||||
_, _ = config.LoadConfig()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
|
||||
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
// Handle initialization on first update after Init
|
||||
if !m.initialized {
|
||||
m.initialized = true
|
||||
m.currentScreen = screens.NewListScreen()
|
||||
return m, m.currentScreen.Init()
|
||||
}
|
||||
|
||||
// Handle transition animation updates
|
||||
if m.transitionAnimating {
|
||||
switch msg.(type) {
|
||||
case TickMsg:
|
||||
// Update transition progress
|
||||
elapsed := time.Since(m.transitionStartTime)
|
||||
m.transitionProgress = float64(elapsed) / float64(m.transitionDuration)
|
||||
|
||||
// Ease-out cubic function for smoother animation
|
||||
easeProgress := 1 - ((1 - m.transitionProgress) * (1 - m.transitionProgress) * (1 - m.transitionProgress))
|
||||
|
||||
if m.transitionProgress >= 1 || easeProgress >= 1 {
|
||||
// Transition complete
|
||||
m.transitionAnimating = false
|
||||
m.currentScreen = m.transitionTo
|
||||
m.previousScreen = m.transitionFrom
|
||||
m.transitionFrom = nil
|
||||
m.transitionTo = nil
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// Continue animation
|
||||
return m, tea.Tick(time.Second/time.Duration(transitionFPS), func(t time.Time) tea.Msg {
|
||||
return TickMsg(t)
|
||||
})
|
||||
}
|
||||
// During transition, don't process other messages
|
||||
return m, nil
|
||||
}
|
||||
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
// Only quit on 'q' or ctrl+c when on list screen
|
||||
if msg.String() == "q" || msg.String() == "ctrl+c" {
|
||||
if _, ok := m.currentScreen.(*screens.ListScreen); ok {
|
||||
m.quitting = true
|
||||
return m, tea.Quit
|
||||
}
|
||||
}
|
||||
switch msg.String() {
|
||||
case "?":
|
||||
cmd := m.startTransition(screens.NewHelpScreen(m.previousScreen), screens.TransitionFade)
|
||||
return m, cmd
|
||||
case "l":
|
||||
cmd := m.startTransition(screens.NewListScreen(), screens.TransitionFade)
|
||||
return m, cmd
|
||||
case "a":
|
||||
cmd := m.startTransition(screens.NewAddScreen(), screens.TransitionSlideLeft)
|
||||
return m, cmd
|
||||
}
|
||||
case screens.ClientSelectedMsg:
|
||||
cmd := m.startTransition(screens.NewDetailScreen(msg.Client.Client), screens.TransitionSlideLeft)
|
||||
return m, cmd
|
||||
case screens.ClientDeletedMsg:
|
||||
cmd := m.startTransition(screens.NewListScreen(), screens.TransitionFade)
|
||||
return m, cmd
|
||||
case screens.ClientCreatedMsg:
|
||||
cmd := m.startTransition(screens.NewListScreen(), screens.TransitionFade)
|
||||
return m, cmd
|
||||
case screens.RestoreCompletedMsg:
|
||||
cmd := m.startTransition(screens.NewListScreen(), screens.TransitionFade)
|
||||
return m, cmd
|
||||
case screens.CloseDetailScreenMsg:
|
||||
if m.previousScreen != nil {
|
||||
cmd := m.startTransition(m.previousScreen, screens.TransitionSlideRight)
|
||||
return m, cmd
|
||||
}
|
||||
cmd := m.startTransition(screens.NewListScreen(), screens.TransitionFade)
|
||||
return m, cmd
|
||||
case screens.ErrMsg:
|
||||
m.previousScreen = m.currentScreen
|
||||
m.errorScreen = screens.NewErrorScreen(msg.Err)
|
||||
cmd := m.startTransition(m.errorScreen, screens.TransitionFade)
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
// Pass messages to current screen
|
||||
if m.currentScreen != nil {
|
||||
newScreen, cmd := m.currentScreen.Update(msg)
|
||||
if newScreen == nil {
|
||||
if m.previousScreen != nil {
|
||||
transitionCmd := m.startTransition(m.previousScreen, screens.TransitionSlideRight)
|
||||
return m, tea.Batch(cmd, transitionCmd)
|
||||
}
|
||||
} else if newScreen != m.currentScreen {
|
||||
m.previousScreen = m.currentScreen
|
||||
transitionCmd := m.startTransition(newScreen, screens.TransitionSlideLeft)
|
||||
return m, tea.Batch(cmd, transitionCmd)
|
||||
}
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (m model) View() string {
|
||||
|
||||
func (m model) View() string {
|
||||
if m.quitting {
|
||||
return "\nGoodbye!\n"
|
||||
}
|
||||
|
||||
// If transitioning, render the transition animation
|
||||
if m.transitionAnimating {
|
||||
return renderTransition(m)
|
||||
}
|
||||
|
||||
if m.currentScreen != nil && m.initialized {
|
||||
return m.currentScreen.View()
|
||||
}
|
||||
|
||||
title := styleTitle.Render("WireGuard Client Manager")
|
||||
subtitle := styleSubtitle.Render(fmt.Sprintf("v%s", version))
|
||||
bar := fmt.Sprintf("Press %s for help, %s to quit", styleHelpKey.Render("?"), styleHelpKey.Render("q"))
|
||||
|
||||
return title + " " + subtitle + "\n\nInitializing...\n\n" + bar
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
p := tea.NewProgram(model{}, tea.WithAltScreen())
|
||||
if _, err := p.Run(); err != nil {
|
||||
fmt.Printf("Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// startTransition begins a screen transition animation
|
||||
func (m *model) startTransition(toScreen screens.Screen, ttype screens.TransitionType) tea.Cmd {
|
||||
// Initialize new screen
|
||||
var initCmd tea.Cmd
|
||||
if toScreen != nil {
|
||||
initCmd = toScreen.Init()
|
||||
}
|
||||
|
||||
// Set up transition
|
||||
m.transitionFrom = m.currentScreen
|
||||
m.transitionTo = toScreen
|
||||
m.transitionType = ttype
|
||||
m.transitionDuration = transitionDuration
|
||||
m.transitionProgress = 0
|
||||
m.transitionAnimating = true
|
||||
m.transitionStartTime = time.Now()
|
||||
|
||||
// Return initialization command and tick command
|
||||
tickCmd := tea.Tick(time.Second/time.Duration(transitionFPS), func(t time.Time) tea.Msg {
|
||||
return TickMsg(t)
|
||||
})
|
||||
|
||||
if initCmd != nil {
|
||||
return tea.Batch(initCmd, tickCmd)
|
||||
}
|
||||
return tickCmd
|
||||
}
|
||||
|
||||
func renderTransition(m model) string {
|
||||
if m.transitionFrom == nil || m.transitionTo == nil {
|
||||
return m.transitionTo.View()
|
||||
}
|
||||
|
||||
fromView := m.transitionFrom.View()
|
||||
toView := m.transitionTo.View()
|
||||
|
||||
// Apply easing to progress
|
||||
easeProgress := 1 - ((1 - m.transitionProgress) * (1 - m.transitionProgress) * (1 - m.transitionProgress))
|
||||
|
||||
switch m.transitionType {
|
||||
case screens.TransitionFade:
|
||||
return renderFadeTransition(fromView, toView, easeProgress)
|
||||
case screens.TransitionSlideLeft:
|
||||
return renderSlideTransition(fromView, toView, easeProgress, true)
|
||||
case screens.TransitionSlideRight:
|
||||
return renderSlideTransition(fromView, toView, easeProgress, false)
|
||||
default:
|
||||
return toView
|
||||
}
|
||||
}
|
||||
|
||||
func renderFadeTransition(fromView, toView string, progress float64) string {
|
||||
fromLines := splitLines(fromView)
|
||||
toLines := splitLines(toView)
|
||||
|
||||
maxLines := max(len(fromLines), len(toLines))
|
||||
for len(fromLines) < maxLines {
|
||||
fromLines = append(fromLines, "")
|
||||
}
|
||||
for len(toLines) < maxLines {
|
||||
toLines = append(toLines, "")
|
||||
}
|
||||
|
||||
var result []string
|
||||
for i := 0; i < maxLines; i++ {
|
||||
threshold := progress * float64(maxLines)
|
||||
if float64(i) < threshold {
|
||||
result = append(result, toLines[i])
|
||||
} else {
|
||||
result = append(result, fromLines[i])
|
||||
}
|
||||
}
|
||||
|
||||
return joinLines(result)
|
||||
}
|
||||
|
||||
func renderSlideTransition(fromView, toView string, progress float64, slideLeft bool) string {
|
||||
fromLines := splitLines(fromView)
|
||||
toLines := splitLines(toView)
|
||||
|
||||
width := 80
|
||||
|
||||
for i := range fromLines {
|
||||
fromLines[i] = padLine(fromLines[i], width)
|
||||
}
|
||||
for i := range toLines {
|
||||
toLines[i] = padLine(toLines[i], width)
|
||||
}
|
||||
|
||||
offset := int(float64(width) * progress)
|
||||
|
||||
var result []string
|
||||
maxLines := max(len(fromLines), len(toLines))
|
||||
|
||||
for i := 0; i < maxLines; i++ {
|
||||
var fromLine, toLine string
|
||||
if i < len(fromLines) {
|
||||
fromLine = fromLines[i]
|
||||
} else {
|
||||
fromLine = ""
|
||||
}
|
||||
if i < len(toLines) {
|
||||
toLine = toLines[i]
|
||||
} else {
|
||||
toLine = ""
|
||||
}
|
||||
|
||||
if slideLeft {
|
||||
fromOffset := -offset
|
||||
toOffset := width - offset
|
||||
combined := combineSlidingLines(fromLine, toLine, fromOffset, toOffset, width)
|
||||
result = append(result, combined)
|
||||
} else {
|
||||
fromOffset := offset
|
||||
toOffset := -width + offset
|
||||
combined := combineSlidingLines(fromLine, toLine, fromOffset, toOffset, width)
|
||||
result = append(result, combined)
|
||||
}
|
||||
}
|
||||
|
||||
return joinLines(result)
|
||||
}
|
||||
|
||||
func combineSlidingLines(from, to string, fromOffset, toOffset, width int) string {
|
||||
result := make([]byte, width)
|
||||
for i := range result {
|
||||
result[i] = ' '
|
||||
}
|
||||
|
||||
if fromOffset >= -len(from) && fromOffset < width {
|
||||
for i := 0; i < len(from) && i+fromOffset < width && i+fromOffset >= 0; i++ {
|
||||
if i+fromOffset >= 0 && i+fromOffset < width {
|
||||
result[i+fromOffset] = from[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if toOffset >= -len(to) && toOffset < width {
|
||||
for i := 0; i < len(to) && i+toOffset < width && i+toOffset >= 0; i++ {
|
||||
if i+toOffset >= 0 && i+toOffset < width {
|
||||
result[i+toOffset] = to[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return string(result)
|
||||
}
|
||||
|
||||
func splitLines(s string) []string {
|
||||
lines := make([]string, 0)
|
||||
current := ""
|
||||
for _, c := range s {
|
||||
if c == '\n' {
|
||||
lines = append(lines, current)
|
||||
current = ""
|
||||
} else {
|
||||
current += string(c)
|
||||
}
|
||||
}
|
||||
if current != "" {
|
||||
lines = append(lines, current)
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
func joinLines(lines []string) string {
|
||||
result := ""
|
||||
for i, line := range lines {
|
||||
if i > 0 {
|
||||
result += "\n"
|
||||
}
|
||||
result += line
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func padLine(line string, width int) string {
|
||||
if len(line) >= width {
|
||||
return line[:width]
|
||||
}
|
||||
return fmt.Sprintf("%-*s", width, line)
|
||||
}
|
||||
|
||||
func max(a, b int) int {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
28
config.example
Normal file
28
config.example
Normal file
@@ -0,0 +1,28 @@
|
||||
# WireGuard VPN Configuration
|
||||
# Copy this file to /etc/wg-admin/config.conf and customize for your environment
|
||||
# All values are optional - script will use defaults if not set
|
||||
|
||||
# Server domain or IP address (required for client endpoints)
|
||||
# Example: vpn.example.com or 203.0.113.10
|
||||
#SERVER_DOMAIN=vpn.example.com
|
||||
|
||||
# WireGuard UDP port (default: 51820)
|
||||
#WG_PORT=51820
|
||||
|
||||
# VPN IPv4 address range (default: 10.10.69.0/24)
|
||||
#VPN_IPV4_RANGE=10.10.69.0/24
|
||||
|
||||
# VPN IPv6 address range (default: fd69:dead:beef:69::/64)
|
||||
#VPN_IPV6_RANGE=fd69:dead:beef:69::/64
|
||||
|
||||
# WireGuard interface name (default: wg0)
|
||||
#WG_INTERFACE=wg0
|
||||
|
||||
# DNS servers for clients (default: 8.8.8.8, 8.8.4.4)
|
||||
#DNS_SERVERS=8.8.8.8, 8.8.4.4
|
||||
|
||||
# Log file location (default: /var/log/wireguard-admin.log)
|
||||
#LOG_FILE=/var/log/wireguard-admin.log
|
||||
|
||||
# Minimum disk space required in MB (default: 100)
|
||||
#MIN_DISK_SPACE_MB=100
|
||||
36
go.mod
Normal file
36
go.mod
Normal file
@@ -0,0 +1,36 @@
|
||||
module github.com/calmcacil/wg-admin
|
||||
|
||||
go 1.24.4
|
||||
|
||||
require (
|
||||
github.com/atotto/clipboard v0.1.4 // indirect
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/catppuccin/go v0.3.0 // indirect
|
||||
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect
|
||||
github.com/charmbracelet/bubbletea v1.3.10 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
|
||||
github.com/charmbracelet/huh v0.8.0 // indirect
|
||||
github.com/charmbracelet/lipgloss v1.1.0 // indirect
|
||||
github.com/charmbracelet/x/ansi v0.10.1 // indirect
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13 // indirect
|
||||
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
|
||||
github.com/charmbracelet/x/term v0.2.1 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||
github.com/mdp/qrterminal/v3 v3.2.1 // indirect
|
||||
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
|
||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
github.com/muesli/termenv v0.16.0 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
golang.org/x/sys v0.36.0 // indirect
|
||||
golang.org/x/term v0.13.0 // indirect
|
||||
golang.org/x/text v0.23.0 // indirect
|
||||
rsc.io/qr v0.2.0 // indirect
|
||||
)
|
||||
63
go.sum
Normal file
63
go.sum
Normal file
@@ -0,0 +1,63 @@
|
||||
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||
github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY=
|
||||
github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
|
||||
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws=
|
||||
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7/go.mod h1:ISC1gtLcVilLOf23wvTfoQuYbW2q0JevFxPfUzZ9Ybw=
|
||||
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
|
||||
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
|
||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
|
||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
|
||||
github.com/charmbracelet/huh v0.8.0 h1:Xz/Pm2h64cXQZn/Jvele4J3r7DDiqFCNIVteYukxDvY=
|
||||
github.com/charmbracelet/huh v0.8.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4=
|
||||
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
|
||||
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
|
||||
github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ=
|
||||
github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
|
||||
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4=
|
||||
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ=
|
||||
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
|
||||
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
||||
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
||||
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/mdp/qrterminal/v3 v3.2.1 h1:6+yQjiiOsSuXT5n9/m60E54vdgFsw0zhADHhHLrFet4=
|
||||
github.com/mdp/qrterminal/v3 v3.2.1/go.mod h1:jOTmXvnBsMy5xqLniO0R++Jmjs2sTm9dFSuQ5kpz/SU=
|
||||
github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
|
||||
github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
|
||||
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
||||
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
|
||||
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
|
||||
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.13.0 h1:bb+I9cTfFazGW51MZqBVmZy7+JEJMouUHTUSKVQLBek=
|
||||
golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U=
|
||||
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
|
||||
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
|
||||
rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY=
|
||||
rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs=
|
||||
267
internal/backup/backup.go
Normal file
267
internal/backup/backup.go
Normal file
@@ -0,0 +1,267 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Backup represents a backup with metadata
|
||||
type Backup struct {
|
||||
Name string // Backup name (directory name)
|
||||
Path string // Full path to backup directory
|
||||
Operation string // Operation that triggered the backup
|
||||
Timestamp time.Time // When the backup was created
|
||||
Size int64 // Size in bytes
|
||||
}
|
||||
|
||||
// CreateBackup creates a new backup with the specified operation
|
||||
func CreateBackup(operation string) error {
|
||||
backupDir := "/etc/wg-admin/backups"
|
||||
|
||||
// Create backup directory if it doesn't exist
|
||||
if err := os.MkdirAll(backupDir, 0700); err != nil {
|
||||
return fmt.Errorf("failed to create backup directory: %w", err)
|
||||
}
|
||||
|
||||
// Create timestamped backup directory
|
||||
timestamp := time.Now().Format("20060102-150405")
|
||||
backupName := fmt.Sprintf("wg-backup-%s-%s", operation, timestamp)
|
||||
backupPath := filepath.Join(backupDir, backupName)
|
||||
|
||||
if err := os.MkdirAll(backupPath, 0700); err != nil {
|
||||
return fmt.Errorf("failed to create backup path: %w", err)
|
||||
}
|
||||
|
||||
// Backup entire wireguard directory to maintain structure expected by restore.go
|
||||
wgConfigPath := "/etc/wireguard"
|
||||
if _, err := os.Stat(wgConfigPath); err == nil {
|
||||
backupWgPath := filepath.Join(backupPath, "wireguard")
|
||||
if err := exec.Command("cp", "-a", wgConfigPath, backupWgPath).Run(); err != nil {
|
||||
return fmt.Errorf("failed to backup wireguard config: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Create backup metadata
|
||||
metadataPath := filepath.Join(backupPath, "backup-info.txt")
|
||||
metadata := fmt.Sprintf("Backup created: %s\nOperation: %s\nTimestamp: %s\n", time.Now().Format(time.RFC3339), operation, timestamp)
|
||||
if err := os.WriteFile(metadataPath, []byte(metadata), 0600); err != nil {
|
||||
return fmt.Errorf("failed to create backup metadata: %w", err)
|
||||
}
|
||||
|
||||
// Set restrictive permissions on backup directory
|
||||
if err := os.Chmod(backupPath, 0700); err != nil {
|
||||
return fmt.Errorf("failed to set backup directory permissions: %w", err)
|
||||
}
|
||||
|
||||
// Apply retention policy (keep last 10 backups)
|
||||
if err := applyRetentionPolicy(backupDir, 10); err != nil {
|
||||
// Log but don't fail on retention errors
|
||||
fmt.Fprintf(os.Stderr, "Warning: failed to apply retention policy: %v\n", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListBackups returns all available backups sorted by creation time (newest first)
|
||||
func ListBackups() ([]Backup, error) {
|
||||
backupDir := "/etc/wg-admin/backups"
|
||||
|
||||
// Check if backup directory exists
|
||||
if _, err := os.Stat(backupDir); os.IsNotExist(err) {
|
||||
return []Backup{}, nil
|
||||
}
|
||||
|
||||
// Read all entries
|
||||
entries, err := os.ReadDir(backupDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read backup directory: %w", err)
|
||||
}
|
||||
|
||||
var backups []Backup
|
||||
|
||||
// Parse backup directories
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
name := entry.Name()
|
||||
|
||||
// Check if it's a valid backup directory (starts with "wg-backup-")
|
||||
if !strings.HasPrefix(name, "wg-backup-") {
|
||||
continue
|
||||
}
|
||||
|
||||
// Parse backup name to extract operation and timestamp
|
||||
// Format: wg-backup-{operation}-{timestamp}
|
||||
parts := strings.SplitN(name, "-", 4)
|
||||
if len(parts) < 4 {
|
||||
continue
|
||||
}
|
||||
|
||||
operation := parts[2]
|
||||
timestampStr := parts[3]
|
||||
|
||||
// Parse timestamp
|
||||
timestamp, err := time.Parse("20060102-150405", timestampStr)
|
||||
if err != nil {
|
||||
// If timestamp parsing fails, use directory modification time
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
timestamp = info.ModTime()
|
||||
}
|
||||
|
||||
// Get backup size
|
||||
backupPath := filepath.Join(backupDir, name)
|
||||
size, err := getBackupSize(backupPath)
|
||||
if err != nil {
|
||||
size = 0
|
||||
}
|
||||
|
||||
backup := Backup{
|
||||
Name: name,
|
||||
Path: backupPath,
|
||||
Operation: operation,
|
||||
Timestamp: timestamp,
|
||||
Size: size,
|
||||
}
|
||||
|
||||
backups = append(backups, backup)
|
||||
}
|
||||
|
||||
// Sort by timestamp (newest first)
|
||||
sort.Slice(backups, func(i, j int) bool {
|
||||
return backups[i].Timestamp.After(backups[j].Timestamp)
|
||||
})
|
||||
|
||||
return backups, nil
|
||||
}
|
||||
|
||||
// getBackupSize calculates the total size of a backup directory
|
||||
func getBackupSize(backupPath string) (int64, error) {
|
||||
var size int64
|
||||
|
||||
err := filepath.Walk(backupPath, func(_ string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !info.IsDir() {
|
||||
size += info.Size()
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
return size, err
|
||||
}
|
||||
|
||||
// RestoreBackup restores WireGuard configurations from a backup by name
|
||||
func RestoreBackup(backupName string) error {
|
||||
backupDir := "/etc/wg-admin/backups"
|
||||
backupPath := filepath.Join(backupDir, backupName)
|
||||
|
||||
// Verify backup exists
|
||||
if _, err := os.Stat(backupPath); os.IsNotExist(err) {
|
||||
return fmt.Errorf("backup does not exist: %s", backupName)
|
||||
}
|
||||
|
||||
// Check for wireguard subdirectory
|
||||
wgSourcePath := filepath.Join(backupPath, "wireguard")
|
||||
if _, err := os.Stat(wgSourcePath); os.IsNotExist(err) {
|
||||
return fmt.Errorf("backup does not contain wireguard configuration")
|
||||
}
|
||||
|
||||
// Restore entire wireguard directory
|
||||
wgDestPath := "/etc/wireguard"
|
||||
if err := os.RemoveAll(wgDestPath); err != nil {
|
||||
return fmt.Errorf("failed to remove existing wireguard config: %w", err)
|
||||
}
|
||||
|
||||
if err := exec.Command("cp", "-a", wgSourcePath, wgDestPath).Run(); err != nil {
|
||||
return fmt.Errorf("failed to restore wireguard config: %w", err)
|
||||
}
|
||||
|
||||
// Set proper permissions on restored files
|
||||
if err := setRestoredPermissions(wgDestPath); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Warning: failed to set permissions on restored files: %v\n", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// setRestoredPermissions sets appropriate permissions on restored WireGuard files
|
||||
func setRestoredPermissions(wgPath string) error {
|
||||
// Set 0600 on .conf files
|
||||
return filepath.Walk(wgPath, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !info.IsDir() && strings.HasSuffix(path, ".conf") {
|
||||
if err := os.Chmod(path, 0600); err != nil {
|
||||
return fmt.Errorf("failed to chmod %s: %w", path, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// applyRetentionPolicy keeps only the last N backups
|
||||
func applyRetentionPolicy(backupDir string, keepCount int) error {
|
||||
// List all backup directories
|
||||
entries, err := os.ReadDir(backupDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Filter backup directories and sort by modification time
|
||||
var backups []os.FileInfo
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() && len(entry.Name()) > 10 && entry.Name()[:10] == "wg-backup-" {
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
backups = append(backups, info)
|
||||
}
|
||||
}
|
||||
|
||||
// If we have more backups than we want to keep, remove the oldest
|
||||
if len(backups) > keepCount {
|
||||
// Sort by modification time (oldest first)
|
||||
for i := 0; i < len(backups); i++ {
|
||||
for j := i + 1; j < len(backups); j++ {
|
||||
if backups[i].ModTime().After(backups[j].ModTime()) {
|
||||
backups[i], backups[j] = backups[j], backups[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove oldest backups
|
||||
toRemove := len(backups) - keepCount
|
||||
for i := 0; i < toRemove; i++ {
|
||||
backupPath := filepath.Join(backupDir, backups[i].Name())
|
||||
if err := os.RemoveAll(backupPath); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Warning: failed to remove old backup %s: %v\n", backupPath, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// BackupConfig is a compatibility wrapper that calls CreateBackup
|
||||
func BackupConfig(operation string) (string, error) {
|
||||
if err := CreateBackup(operation); err != nil {
|
||||
return "", err
|
||||
}
|
||||
// Return the backup path for compatibility
|
||||
timestamp := time.Now().Format("20060102-150405")
|
||||
backupName := fmt.Sprintf("wg-backup-%s-%s", operation, timestamp)
|
||||
return filepath.Join("/etc/wg-admin/backups", backupName), nil
|
||||
}
|
||||
122
internal/backup/restore.go
Normal file
122
internal/backup/restore.go
Normal file
@@ -0,0 +1,122 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ValidateBackup checks if a backup exists and is valid
|
||||
func ValidateBackup(backupName string) error {
|
||||
backupDir := "/etc/wg-admin/backups"
|
||||
backupPath := filepath.Join(backupDir, backupName)
|
||||
|
||||
// Check if backup directory exists
|
||||
info, err := os.Stat(backupPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return fmt.Errorf("backup '%s' does not exist", backupName)
|
||||
}
|
||||
return fmt.Errorf("failed to access backup '%s': %w", backupName, err)
|
||||
}
|
||||
|
||||
// Check if it's a directory
|
||||
if !info.IsDir() {
|
||||
return fmt.Errorf("'%s' is not a valid backup directory", backupName)
|
||||
}
|
||||
|
||||
// Check for required files
|
||||
metadataPath := filepath.Join(backupPath, "backup-info.txt")
|
||||
if _, err := os.Stat(metadataPath); os.IsNotExist(err) {
|
||||
return fmt.Errorf("backup '%s' is missing required metadata", backupName)
|
||||
}
|
||||
|
||||
// Check for wireguard directory
|
||||
wgBackupPath := filepath.Join(backupPath, "wireguard")
|
||||
if _, err := os.Stat(wgBackupPath); os.IsNotExist(err) {
|
||||
return fmt.Errorf("backup '%s' is missing wireguard configuration", backupName)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReloadWireGuard reloads the WireGuard interface to apply configuration changes
|
||||
func ReloadWireGuard() error {
|
||||
interfaceName := "wg0"
|
||||
|
||||
// Try to down the interface first
|
||||
cmdDown := exec.Command("wg-quick", "down", interfaceName)
|
||||
_ = cmdDown.Run() // Ignore errors if interface is not up
|
||||
|
||||
// Bring the interface up
|
||||
cmdUp := exec.Command("wg-quick", "up", interfaceName)
|
||||
output, err := cmdUp.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to reload wireguard interface: %w, output: %s", err, string(output))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetBackupSize calculates the total size of a backup directory
|
||||
func GetBackupSize(backupName string) (int64, error) {
|
||||
backupDir := "/etc/wg-admin/backups"
|
||||
backupPath := filepath.Join(backupDir, backupName)
|
||||
|
||||
var size int64
|
||||
|
||||
err := filepath.Walk(backupPath, func(_ string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !info.IsDir() {
|
||||
size += info.Size()
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
return size, err
|
||||
}
|
||||
|
||||
// GetBackupPath returns the full path for a backup name
|
||||
func GetBackupPath(backupName string) string {
|
||||
return filepath.Join("/etc/wg-admin/backups", backupName)
|
||||
}
|
||||
|
||||
// ParseBackupName extracts operation and timestamp from backup directory name
|
||||
// Format: wg-backup-{operation}-{timestamp}
|
||||
func ParseBackupName(backupName string) (operation, timestamp string, err error) {
|
||||
if !strings.HasPrefix(backupName, "wg-backup-") {
|
||||
return "", "", fmt.Errorf("invalid backup name format")
|
||||
}
|
||||
|
||||
nameWithoutPrefix := strings.TrimPrefix(backupName, "wg-backup-")
|
||||
|
||||
// Timestamp format: 20060102-150405 (15 chars)
|
||||
if len(nameWithoutPrefix) < 16 {
|
||||
return "", "", fmt.Errorf("backup name too short")
|
||||
}
|
||||
|
||||
// Extract operation (everything before last timestamp)
|
||||
timestampLen := 15
|
||||
if len(nameWithoutPrefix) > timestampLen+1 {
|
||||
operation = nameWithoutPrefix[:len(nameWithoutPrefix)-timestampLen-1]
|
||||
// Remove trailing dash if present
|
||||
if strings.HasSuffix(operation, "-") {
|
||||
operation = operation[:len(operation)-1]
|
||||
}
|
||||
}
|
||||
|
||||
// Extract timestamp
|
||||
timestamp = nameWithoutPrefix[len(nameWithoutPrefix)-timestampLen:]
|
||||
|
||||
// Validate timestamp format
|
||||
if _, err := time.Parse("20060102-150405", timestamp); err != nil {
|
||||
return "", "", fmt.Errorf("invalid timestamp format in backup name: %w", err)
|
||||
}
|
||||
|
||||
return operation, timestamp, nil
|
||||
}
|
||||
237
internal/config/config.go
Normal file
237
internal/config/config.go
Normal file
@@ -0,0 +1,237 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Config holds application configuration
|
||||
type Config struct {
|
||||
ServerDomain string `mapstructure:"SERVER_DOMAIN"`
|
||||
WGPort int `mapstructure:"WG_PORT"`
|
||||
VPNIPv4Range string `mapstructure:"VPN_IPV4_RANGE"`
|
||||
VPNIPv6Range string `mapstructure:"VPN_IPV6_RANGE"`
|
||||
WGInterface string `mapstructure:"WG_INTERFACE"`
|
||||
DNSServers string `mapstructure:"DNS_SERVERS"`
|
||||
LogFile string `mapstructure:"LOG_FILE"`
|
||||
Theme string `mapstructure:"THEME"`
|
||||
}
|
||||
|
||||
// Default values
|
||||
const (
|
||||
DefaultWGPort = 51820
|
||||
DefaultVPNIPv4Range = "10.10.69.0/24"
|
||||
DefaultVPNIPv6Range = "fd69:dead:beef:69::/64"
|
||||
DefaultWGInterface = "wg0"
|
||||
DefaultDNSServers = "8.8.8.8, 8.8.4.4"
|
||||
DefaultLogFile = "/var/log/wireguard-admin.log"
|
||||
DefaultTheme = "default"
|
||||
)
|
||||
|
||||
// LoadConfig loads configuration from file and environment variables
|
||||
func LoadConfig() (*Config, error) {
|
||||
cfg := &Config{}
|
||||
|
||||
// Load from config file if it exists
|
||||
if err := loadFromFile(cfg); err != nil {
|
||||
return nil, fmt.Errorf("failed to load config file: %w", err)
|
||||
}
|
||||
|
||||
// Override with environment variables
|
||||
if err := loadFromEnv(cfg); err != nil {
|
||||
return nil, fmt.Errorf("failed to load config from environment: %w", err)
|
||||
}
|
||||
|
||||
// Apply defaults for empty values
|
||||
applyDefaults(cfg)
|
||||
|
||||
// Validate required configuration
|
||||
if err := validateConfig(cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// loadFromFile reads configuration from /etc/wg-admin/config.conf
|
||||
func loadFromFile(cfg *Config) error {
|
||||
configPath := "/etc/wg-admin/config.conf"
|
||||
|
||||
// Check if config file exists
|
||||
if _, err := os.Stat(configPath); os.IsNotExist(err) {
|
||||
// Config file is optional, skip if not exists
|
||||
return nil
|
||||
}
|
||||
|
||||
// Read config file
|
||||
content, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Parse key=value pairs
|
||||
lines := strings.Split(string(content), "\n")
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
|
||||
parts := strings.SplitN(line, "=", 2)
|
||||
if len(parts) != 2 {
|
||||
continue
|
||||
}
|
||||
|
||||
key := strings.TrimSpace(parts[0])
|
||||
value := strings.TrimSpace(parts[1])
|
||||
|
||||
// Set value using mapstructure tags
|
||||
switch key {
|
||||
case "SERVER_DOMAIN":
|
||||
cfg.ServerDomain = value
|
||||
case "WG_PORT":
|
||||
var port int
|
||||
if _, err := fmt.Sscanf(value, "%d", &port); err == nil {
|
||||
cfg.WGPort = port
|
||||
}
|
||||
case "VPN_IPV4_RANGE":
|
||||
cfg.VPNIPv4Range = value
|
||||
case "VPN_IPV6_RANGE":
|
||||
cfg.VPNIPv6Range = value
|
||||
case "WG_INTERFACE":
|
||||
cfg.WGInterface = value
|
||||
case "DNS_SERVERS":
|
||||
cfg.DNSServers = value
|
||||
case "LOG_FILE":
|
||||
cfg.LogFile = value
|
||||
case "THEME":
|
||||
cfg.Theme = value
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadFromEnv loads configuration from environment variables
|
||||
func loadFromEnv(cfg *Config) error {
|
||||
// Read environment variables
|
||||
if val := os.Getenv("SERVER_DOMAIN"); val != "" {
|
||||
cfg.ServerDomain = val
|
||||
}
|
||||
if val := os.Getenv("WG_PORT"); val != "" {
|
||||
var port int
|
||||
if _, err := fmt.Sscanf(val, "%d", &port); err == nil {
|
||||
cfg.WGPort = port
|
||||
}
|
||||
}
|
||||
if val := os.Getenv("VPN_IPV4_RANGE"); val != "" {
|
||||
cfg.VPNIPv4Range = val
|
||||
}
|
||||
if val := os.Getenv("VPN_IPV6_RANGE"); val != "" {
|
||||
cfg.VPNIPv6Range = val
|
||||
}
|
||||
if val := os.Getenv("WG_INTERFACE"); val != "" {
|
||||
cfg.WGInterface = val
|
||||
}
|
||||
if val := os.Getenv("DNS_SERVERS"); val != "" {
|
||||
cfg.DNSServers = val
|
||||
}
|
||||
if val := os.Getenv("LOG_FILE"); val != "" {
|
||||
cfg.LogFile = val
|
||||
}
|
||||
if val := os.Getenv("THEME"); val != "" {
|
||||
cfg.Theme = val
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// applyDefaults sets default values for empty configuration
|
||||
func applyDefaults(cfg *Config) {
|
||||
if cfg.WGPort == 0 {
|
||||
cfg.WGPort = DefaultWGPort
|
||||
}
|
||||
if cfg.VPNIPv4Range == "" {
|
||||
cfg.VPNIPv4Range = DefaultVPNIPv4Range
|
||||
}
|
||||
if cfg.VPNIPv6Range == "" {
|
||||
cfg.VPNIPv6Range = DefaultVPNIPv6Range
|
||||
}
|
||||
if cfg.WGInterface == "" {
|
||||
cfg.WGInterface = DefaultWGInterface
|
||||
}
|
||||
if cfg.DNSServers == "" {
|
||||
cfg.DNSServers = DefaultDNSServers
|
||||
}
|
||||
if cfg.LogFile == "" {
|
||||
cfg.LogFile = DefaultLogFile
|
||||
}
|
||||
if cfg.Theme == "" {
|
||||
cfg.Theme = DefaultTheme
|
||||
}
|
||||
}
|
||||
|
||||
// validateConfig checks that required configuration is present
|
||||
func validateConfig(cfg *Config) error {
|
||||
if cfg.ServerDomain == "" {
|
||||
return fmt.Errorf("SERVER_DOMAIN is required. Set it in /etc/wg-admin/config.conf or via environment variable.")
|
||||
}
|
||||
|
||||
// Validate port range
|
||||
if cfg.WGPort < 1 || cfg.WGPort > 65535 {
|
||||
return fmt.Errorf("WG_PORT must be between 1 and 65535, got: %d", cfg.WGPort)
|
||||
}
|
||||
|
||||
// Validate CIDR format for IPv4 range
|
||||
if !isValidCIDR(cfg.VPNIPv4Range, true) {
|
||||
return fmt.Errorf("Invalid VPN_IPV4_RANGE format: %s", cfg.VPNIPv4Range)
|
||||
}
|
||||
|
||||
// Validate CIDR format for IPv6 range
|
||||
if !isValidCIDR(cfg.VPNIPv6Range, false) {
|
||||
return fmt.Errorf("Invalid VPN_IPV6_RANGE format: %s", cfg.VPNIPv6Range)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// isValidCIDR performs basic CIDR validation
|
||||
func isValidCIDR(cidr string, isIPv4 bool) bool {
|
||||
if cidr == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
// Split address and prefix
|
||||
parts := strings.Split(cidr, "/")
|
||||
if len(parts) != 2 {
|
||||
return false
|
||||
}
|
||||
|
||||
// Basic validation - more comprehensive validation could be added
|
||||
if isIPv4 {
|
||||
// IPv4 CIDR should have address like x.x.x.x
|
||||
return true // Simplified validation
|
||||
}
|
||||
|
||||
// IPv6 CIDR
|
||||
return true // Simplified validation
|
||||
}
|
||||
|
||||
// GetVPNIPv4Network extracts the IPv4 network from CIDR (e.g., "10.10.69.0" from "10.10.69.0/24")
|
||||
func (c *Config) GetVPNIPv4Network() string {
|
||||
parts := strings.Split(c.VPNIPv4Range, "/")
|
||||
if len(parts) == 0 {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSuffix(parts[0], "0")
|
||||
}
|
||||
|
||||
// GetVPNIPv6Network extracts the IPv6 network from CIDR (e.g., "fd69:dead:beef:69::" from "fd69:dead:beef:69::/64")
|
||||
func (c *Config) GetVPNIPv6Network() string {
|
||||
parts := strings.Split(c.VPNIPv6Range, "/")
|
||||
if len(parts) == 0 {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSuffix(parts[0], "::")
|
||||
}
|
||||
54
internal/tui/components/breadcrumb.go
Normal file
54
internal/tui/components/breadcrumb.go
Normal file
@@ -0,0 +1,54 @@
|
||||
package components
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
// BreadcrumbItem represents a single item in the breadcrumb trail
|
||||
type BreadcrumbItem struct {
|
||||
Label string
|
||||
ID string // Optional identifier for the screen
|
||||
}
|
||||
|
||||
// BreadcrumbStyle defines the appearance of breadcrumbs
|
||||
var (
|
||||
breadcrumbStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("241")).
|
||||
MarginBottom(1)
|
||||
breadcrumbSeparatorStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("240"))
|
||||
breadcrumbItemStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("241"))
|
||||
breadcrumbCurrentStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("62")).
|
||||
Bold(true)
|
||||
)
|
||||
|
||||
// RenderBreadcrumb renders a breadcrumb trail from a slice of items
|
||||
func RenderBreadcrumb(items []BreadcrumbItem) string {
|
||||
if len(items) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
var parts []string
|
||||
for i, item := range items {
|
||||
var text string
|
||||
if i == len(items)-1 {
|
||||
// Last item - current page
|
||||
text = breadcrumbCurrentStyle.Render(item.Label)
|
||||
} else {
|
||||
// Non-last items - clickable/previous pages
|
||||
text = breadcrumbItemStyle.Render(item.Label)
|
||||
}
|
||||
parts = append(parts, text)
|
||||
|
||||
// Add separator if not last item
|
||||
if i < len(items)-1 {
|
||||
parts = append(parts, breadcrumbSeparatorStyle.Render(" > "))
|
||||
}
|
||||
}
|
||||
|
||||
return breadcrumbStyle.Render(strings.Join(parts, ""))
|
||||
}
|
||||
149
internal/tui/components/config-display.go
Normal file
149
internal/tui/components/config-display.go
Normal file
@@ -0,0 +1,149 @@
|
||||
package components
|
||||
|
||||
import (
|
||||
"github.com/charmbracelet/bubbles/viewport"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
// ConfigDisplayModel represents a configuration display modal
|
||||
type ConfigDisplayModel struct {
|
||||
config string
|
||||
viewport viewport.Model
|
||||
Visible bool
|
||||
Width int
|
||||
Height int
|
||||
scrollPos int
|
||||
}
|
||||
|
||||
// Styles
|
||||
var (
|
||||
configModalStyle = lipgloss.NewStyle().
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(lipgloss.Color("62")).
|
||||
Padding(1, 2).
|
||||
Background(lipgloss.Color("235"))
|
||||
configTitleStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("62")).
|
||||
Bold(true).
|
||||
MarginBottom(1)
|
||||
configHelpStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("241")).
|
||||
MarginTop(1)
|
||||
configContentStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("255")).
|
||||
Width(76)
|
||||
)
|
||||
|
||||
// NewConfigDisplay creates a new configuration display modal
|
||||
func NewConfigDisplay(config string, width, height int) *ConfigDisplayModel {
|
||||
vp := viewport.New(76, 24)
|
||||
vp.SetContent(config)
|
||||
|
||||
return &ConfigDisplayModel{
|
||||
config: config,
|
||||
viewport: vp,
|
||||
Visible: true,
|
||||
Width: width,
|
||||
Height: height,
|
||||
scrollPos: 0,
|
||||
}
|
||||
}
|
||||
|
||||
// Init initializes the configuration display modal
|
||||
func (m *ConfigDisplayModel) Init() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Update handles messages for the configuration display modal
|
||||
func (m *ConfigDisplayModel) Update(msg tea.Msg) (*ConfigDisplayModel, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case "esc", "q":
|
||||
m.Visible = false
|
||||
return m, nil
|
||||
case "up", "k":
|
||||
m.viewport.LineUp(1)
|
||||
case "down", "j":
|
||||
m.viewport.LineDown(1)
|
||||
case "pgup", "b":
|
||||
m.viewport.HalfViewUp()
|
||||
case "pgdown", "f", " ":
|
||||
m.viewport.HalfViewDown()
|
||||
case "g", "home":
|
||||
m.viewport.GotoTop()
|
||||
case "G", "end":
|
||||
m.viewport.GotoBottom()
|
||||
}
|
||||
case tea.WindowSizeMsg:
|
||||
// Update modal dimensions on resize
|
||||
m.Width = msg.Width
|
||||
m.Height = msg.Height
|
||||
}
|
||||
|
||||
var cmd tea.Cmd
|
||||
m.viewport, cmd = m.viewport.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
// View renders the configuration display modal
|
||||
func (m *ConfigDisplayModel) View() string {
|
||||
if !m.Visible {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Build modal content
|
||||
title := configTitleStyle.Render("📋 Client Configuration")
|
||||
content := m.viewport.View()
|
||||
help := configHelpStyle.Render("↑/j: down • ↓/k: up • pgup/pgdn: page • g/G: top/bottom • Esc: close")
|
||||
|
||||
fullContent := lipgloss.JoinVertical(
|
||||
lipgloss.Left,
|
||||
title,
|
||||
"",
|
||||
content,
|
||||
help,
|
||||
)
|
||||
|
||||
// Apply modal style
|
||||
modal := configModalStyle.Render(fullContent)
|
||||
|
||||
// Center modal on screen
|
||||
modalWidth := lipgloss.Width(modal)
|
||||
modalHeight := lipgloss.Height(modal)
|
||||
|
||||
x := (m.Width - modalWidth) / 2
|
||||
if x < 0 {
|
||||
x = 0
|
||||
}
|
||||
y := (m.Height - modalHeight) / 2
|
||||
if y < 0 {
|
||||
y = 0
|
||||
}
|
||||
|
||||
return lipgloss.Place(m.Width, m.Height,
|
||||
lipgloss.Left, lipgloss.Top,
|
||||
modal,
|
||||
lipgloss.WithWhitespaceChars(" "),
|
||||
lipgloss.WithWhitespaceForeground(lipgloss.Color("235")),
|
||||
)
|
||||
}
|
||||
|
||||
// IsVisible returns true if the modal is visible
|
||||
func (m *ConfigDisplayModel) IsVisible() bool {
|
||||
return m.Visible
|
||||
}
|
||||
|
||||
// Hide hides the modal
|
||||
func (m *ConfigDisplayModel) Hide() {
|
||||
m.Visible = false
|
||||
}
|
||||
|
||||
// Show shows the modal
|
||||
func (m *ConfigDisplayModel) Show(config string) {
|
||||
m.config = config
|
||||
m.viewport.SetContent(config)
|
||||
m.viewport.GotoTop()
|
||||
m.Visible = true
|
||||
}
|
||||
143
internal/tui/components/confirm.go
Normal file
143
internal/tui/components/confirm.go
Normal file
@@ -0,0 +1,143 @@
|
||||
package components
|
||||
|
||||
import (
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
// ConfirmModel represents a confirmation modal
|
||||
type ConfirmModel struct {
|
||||
Message string
|
||||
Yes bool // true = yes, false = no
|
||||
Visible bool
|
||||
Width int
|
||||
Height int
|
||||
}
|
||||
|
||||
// Styles
|
||||
var (
|
||||
confirmModalStyle = lipgloss.NewStyle().
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(lipgloss.Color("196")).
|
||||
Padding(1, 2).
|
||||
Background(lipgloss.Color("235"))
|
||||
confirmTitleStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("226")).
|
||||
Bold(true)
|
||||
confirmMessageStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("255")).
|
||||
Width(50)
|
||||
confirmHelpStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("241")).
|
||||
MarginTop(1)
|
||||
)
|
||||
|
||||
// NewConfirm creates a new confirmation modal
|
||||
func NewConfirm(message string, width, height int) *ConfirmModel {
|
||||
return &ConfirmModel{
|
||||
Message: message,
|
||||
Yes: false,
|
||||
Visible: true,
|
||||
Width: width,
|
||||
Height: height,
|
||||
}
|
||||
}
|
||||
|
||||
// Init initializes the confirmation modal
|
||||
func (m *ConfirmModel) Init() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Update handles messages for the confirmation modal
|
||||
func (m *ConfirmModel) Update(msg tea.Msg) (*ConfirmModel, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case "y", "Y", "left":
|
||||
m.Yes = true
|
||||
case "n", "N", "right":
|
||||
m.Yes = false
|
||||
case "enter":
|
||||
// Confirmed - will be handled by parent
|
||||
return m, nil
|
||||
case "esc":
|
||||
m.Visible = false
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// View renders the confirmation modal
|
||||
func (m *ConfirmModel) View() string {
|
||||
if !m.Visible {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Build modal content
|
||||
content := lipgloss.JoinVertical(
|
||||
lipgloss.Left,
|
||||
confirmTitleStyle.Render("⚠️ Confirm Action"),
|
||||
"",
|
||||
confirmMessageStyle.Render(m.Message),
|
||||
"",
|
||||
m.renderOptions(),
|
||||
)
|
||||
|
||||
// Apply modal style
|
||||
modal := confirmModalStyle.Render(content)
|
||||
|
||||
// Center modal on screen
|
||||
modalWidth := lipgloss.Width(modal)
|
||||
modalHeight := lipgloss.Height(modal)
|
||||
|
||||
x := (m.Width - modalWidth) / 2
|
||||
if x < 0 {
|
||||
x = 0
|
||||
}
|
||||
y := (m.Height - modalHeight) / 2
|
||||
if y < 0 {
|
||||
y = 0
|
||||
}
|
||||
|
||||
return lipgloss.Place(m.Width, m.Height,
|
||||
lipgloss.Left, lipgloss.Top,
|
||||
modal,
|
||||
lipgloss.WithWhitespaceChars(" "),
|
||||
lipgloss.WithWhitespaceForeground(lipgloss.Color("235")),
|
||||
)
|
||||
}
|
||||
|
||||
// renderOptions renders the yes/no options
|
||||
func (m *ConfirmModel) renderOptions() string {
|
||||
yesStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("241"))
|
||||
noStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("241"))
|
||||
|
||||
selectedStyle := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("57")).
|
||||
Bold(true).
|
||||
Underline(true)
|
||||
|
||||
var yesText, noText string
|
||||
if m.Yes {
|
||||
yesText = selectedStyle.Render("[Yes]")
|
||||
noText = noStyle.Render(" No ")
|
||||
} else {
|
||||
yesText = yesStyle.Render(" Yes ")
|
||||
noText = selectedStyle.Render("[No]")
|
||||
}
|
||||
|
||||
helpText := confirmHelpStyle.Render("←/→ to choose • Enter to confirm • Esc to cancel")
|
||||
|
||||
return lipgloss.JoinHorizontal(lipgloss.Left, yesText, " ", noText, "\n", helpText)
|
||||
}
|
||||
|
||||
// IsConfirmed returns true if user confirmed with Yes
|
||||
func (m *ConfirmModel) IsConfirmed() bool {
|
||||
return m.Yes
|
||||
}
|
||||
|
||||
// IsCancelled returns true if user cancelled
|
||||
func (m *ConfirmModel) IsCancelled() bool {
|
||||
return !m.Visible
|
||||
}
|
||||
173
internal/tui/components/delete-confirm.go
Normal file
173
internal/tui/components/delete-confirm.go
Normal file
@@ -0,0 +1,173 @@
|
||||
package components
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/calmcacil/wg-admin/internal/tui/theme"
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
// DeleteConfirmModel represents a deletion confirmation modal with name verification
|
||||
type DeleteConfirmModel struct {
|
||||
clientName string
|
||||
input textinput.Model
|
||||
Visible bool
|
||||
Width int
|
||||
Height int
|
||||
showErrorMessage bool
|
||||
}
|
||||
|
||||
// Local styles for modal
|
||||
var (
|
||||
modalBaseStyle = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(lipgloss.Color("196")).Background(lipgloss.Color("235"))
|
||||
modalTitleStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("226")).Bold(true)
|
||||
modalMessageStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("255"))
|
||||
modalHelpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241"))
|
||||
)
|
||||
|
||||
// NewDeleteConfirm creates a new deletion confirmation modal
|
||||
func NewDeleteConfirm(clientName string, width, height int) *DeleteConfirmModel {
|
||||
ti := textinput.New()
|
||||
ti.Placeholder = "Type client name to confirm"
|
||||
ti.Focus()
|
||||
ti.CharLimit = 100
|
||||
ti.Width = 40
|
||||
|
||||
return &DeleteConfirmModel{
|
||||
clientName: clientName,
|
||||
input: ti,
|
||||
Visible: true,
|
||||
Width: width,
|
||||
Height: height,
|
||||
showErrorMessage: false,
|
||||
}
|
||||
}
|
||||
|
||||
// Init initializes the deletion confirmation modal
|
||||
func (m *DeleteConfirmModel) Init() tea.Cmd {
|
||||
return textinput.Blink
|
||||
}
|
||||
|
||||
// Update handles messages for the deletion confirmation modal
|
||||
func (m *DeleteConfirmModel) Update(msg tea.Msg) (*DeleteConfirmModel, tea.Cmd) {
|
||||
var cmd tea.Cmd
|
||||
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case "esc":
|
||||
m.Visible = false
|
||||
return m, nil
|
||||
case "enter":
|
||||
if m.input.Value() == m.clientName {
|
||||
m.Visible = false
|
||||
return m, nil
|
||||
}
|
||||
m.showErrorMessage = true
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
|
||||
m.input, cmd = m.input.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
// View renders the deletion confirmation modal
|
||||
func (m *DeleteConfirmModel) View() string {
|
||||
if !m.Visible {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Check if input matches client name
|
||||
matches := m.input.Value() == m.clientName
|
||||
|
||||
// Build warning section
|
||||
warningText := theme.StyleError.Bold(true).MarginTop(1).Render(
|
||||
fmt.Sprintf("⚠️ This will permanently delete client '%s'", m.clientName),
|
||||
)
|
||||
|
||||
// Build message section
|
||||
messageText := modalMessageStyle.Width(60).Render(
|
||||
fmt.Sprintf("This action cannot be undone. Please type the client name to confirm deletion."),
|
||||
)
|
||||
|
||||
// Build input section
|
||||
inputSection := lipgloss.JoinVertical(
|
||||
lipgloss.Left,
|
||||
"",
|
||||
theme.StyleValue.Width(60).Render("Client name:"),
|
||||
m.input.View(),
|
||||
)
|
||||
|
||||
// Build status section
|
||||
var statusText string
|
||||
if matches {
|
||||
statusText = theme.StyleSuccess.Bold(true).MarginTop(1).Render("✓ Client name matches. Press Enter to confirm deletion.")
|
||||
} else if m.showErrorMessage {
|
||||
statusText = theme.StyleError.Bold(true).MarginTop(1).Render("✗ Client name does not match. Please try again.")
|
||||
} else if m.input.Value() != "" {
|
||||
statusText = modalHelpStyle.Render("Client name does not match yet...")
|
||||
} else {
|
||||
statusText = modalHelpStyle.Render("Type the client name to enable confirmation.")
|
||||
}
|
||||
|
||||
// Build help section
|
||||
helpText := modalHelpStyle.Render("Esc to cancel • Enter to confirm (when name matches)")
|
||||
|
||||
// Build modal content
|
||||
content := lipgloss.JoinVertical(
|
||||
lipgloss.Left,
|
||||
modalTitleStyle.Render("🗑️ Delete Client"),
|
||||
"",
|
||||
warningText,
|
||||
"",
|
||||
messageText,
|
||||
inputSection,
|
||||
statusText,
|
||||
"",
|
||||
helpText,
|
||||
)
|
||||
|
||||
// Apply modal style
|
||||
modal := modalBaseStyle.Padding(1, 3).Render(content)
|
||||
|
||||
// Center modal on screen
|
||||
modalWidth := lipgloss.Width(modal)
|
||||
modalHeight := lipgloss.Height(modal)
|
||||
|
||||
x := (m.Width - modalWidth) / 2
|
||||
if x < 0 {
|
||||
x = 0
|
||||
}
|
||||
y := (m.Height - modalHeight) / 2
|
||||
if y < 0 {
|
||||
y = 0
|
||||
}
|
||||
|
||||
return lipgloss.Place(m.Width, m.Height,
|
||||
lipgloss.Left, lipgloss.Top,
|
||||
modal,
|
||||
lipgloss.WithWhitespaceChars(" "),
|
||||
lipgloss.WithWhitespaceForeground(theme.StyleBackground),
|
||||
)
|
||||
}
|
||||
|
||||
// IsConfirmed returns true if user confirmed the deletion
|
||||
func (m *DeleteConfirmModel) IsConfirmed() bool {
|
||||
return !m.Visible && m.input.Value() == m.clientName
|
||||
}
|
||||
|
||||
// IsCancelled returns true if user cancelled
|
||||
func (m *DeleteConfirmModel) IsCancelled() bool {
|
||||
return !m.Visible && m.input.Value() != m.clientName
|
||||
}
|
||||
|
||||
// Reset resets the modal for reuse
|
||||
func (m *DeleteConfirmModel) Reset() {
|
||||
m.input.SetValue("")
|
||||
m.input.Reset()
|
||||
m.Visible = true
|
||||
m.showErrorMessage = false
|
||||
}
|
||||
311
internal/tui/components/search.go
Normal file
311
internal/tui/components/search.go
Normal file
@@ -0,0 +1,311 @@
|
||||
package components
|
||||
|
||||
import (
|
||||
"github.com/calmcacil/wg-admin/internal/tui/theme"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
// SearchFilterType represents the type of filter
|
||||
type SearchFilterType string
|
||||
|
||||
const (
|
||||
FilterByName SearchFilterType = "name"
|
||||
FilterByIPv4 SearchFilterType = "ipv4"
|
||||
FilterByIPv6 SearchFilterType = "ipv6"
|
||||
FilterByStatus SearchFilterType = "status"
|
||||
)
|
||||
|
||||
// SearchModel represents the search component
|
||||
type SearchModel struct {
|
||||
input textinput.Model
|
||||
active bool
|
||||
filterType SearchFilterType
|
||||
matchCount int
|
||||
totalCount int
|
||||
visible bool
|
||||
}
|
||||
|
||||
// Styles
|
||||
|
||||
// Styles (using theme package)
|
||||
var (
|
||||
searchBarStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("255")).
|
||||
Background(theme.StyleBackground).
|
||||
Padding(0, 1).
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(theme.StyleBorder)
|
||||
searchPromptStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("226")).
|
||||
Bold(true)
|
||||
searchFilterStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("147"))
|
||||
searchCountStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("241"))
|
||||
searchHelpStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("243"))
|
||||
)
|
||||
|
||||
// NewSearch creates a new search component
|
||||
func NewSearch() *SearchModel {
|
||||
ti := textinput.New()
|
||||
ti.Placeholder = "Search clients..."
|
||||
ti.Focus()
|
||||
ti.CharLimit = 156
|
||||
ti.Width = 40
|
||||
ti.Prompt = ""
|
||||
|
||||
return &SearchModel{
|
||||
input: ti,
|
||||
active: false,
|
||||
filterType: FilterByName,
|
||||
matchCount: 0,
|
||||
totalCount: 0,
|
||||
visible: true,
|
||||
}
|
||||
}
|
||||
|
||||
// Init initializes the search component
|
||||
func (m *SearchModel) Init() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Update handles messages for the search component
|
||||
func (m *SearchModel) Update(msg tea.Msg) (*SearchModel, tea.Cmd) {
|
||||
if !m.active {
|
||||
return m, nil
|
||||
}
|
||||
|
||||
var cmd tea.Cmd
|
||||
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case "esc":
|
||||
m.active = false
|
||||
m.input.Reset()
|
||||
m.matchCount = m.totalCount
|
||||
return m, nil
|
||||
case "tab":
|
||||
m.cycleFilterType()
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
|
||||
m.input, cmd = m.input.Update(msg)
|
||||
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
// View renders the search component
|
||||
func (m *SearchModel) View() string {
|
||||
if !m.visible {
|
||||
return ""
|
||||
}
|
||||
|
||||
var filterLabel string
|
||||
switch m.filterType {
|
||||
case FilterByName:
|
||||
filterLabel = "Name"
|
||||
case FilterByIPv4:
|
||||
filterLabel = "IPv4"
|
||||
case FilterByIPv6:
|
||||
filterLabel = "IPv6"
|
||||
case FilterByStatus:
|
||||
filterLabel = "Status"
|
||||
}
|
||||
|
||||
searchIndicator := ""
|
||||
if m.active {
|
||||
searchIndicator = searchPromptStyle.Render("🔍 ")
|
||||
} else {
|
||||
searchIndicator = searchPromptStyle.Render("⌕ ")
|
||||
}
|
||||
|
||||
filterText := searchFilterStyle.Render("[" + filterLabel + "]")
|
||||
countText := ""
|
||||
if m.totalCount > 0 {
|
||||
countText = searchCountStyle.Render(
|
||||
lipgloss.JoinHorizontal(
|
||||
lipgloss.Left,
|
||||
strings.Repeat(" ", 4),
|
||||
"Matched: ",
|
||||
m.renderCount(m.matchCount),
|
||||
"/",
|
||||
m.renderCount(m.totalCount),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
helpText := ""
|
||||
if m.active {
|
||||
helpText = searchHelpStyle.Render(" | Tab: filter | Esc: clear")
|
||||
} else {
|
||||
helpText = searchHelpStyle.Render(" | /: search")
|
||||
}
|
||||
|
||||
content := lipgloss.JoinHorizontal(
|
||||
lipgloss.Left,
|
||||
searchIndicator,
|
||||
m.input.View(),
|
||||
filterText,
|
||||
countText,
|
||||
helpText,
|
||||
)
|
||||
|
||||
return searchBarStyle.Render(content)
|
||||
}
|
||||
|
||||
// IsActive returns true if search is active
|
||||
func (m *SearchModel) IsActive() bool {
|
||||
return m.active
|
||||
}
|
||||
|
||||
// Activate activates the search
|
||||
func (m *SearchModel) Activate() {
|
||||
m.active = true
|
||||
m.input.Focus()
|
||||
}
|
||||
|
||||
// Deactivate deactivates the search
|
||||
func (m *SearchModel) Deactivate() {
|
||||
m.active = false
|
||||
m.input.Reset()
|
||||
m.matchCount = m.totalCount
|
||||
}
|
||||
|
||||
// Clear clears the search input and filter
|
||||
func (m *SearchModel) Clear() {
|
||||
m.input.Reset()
|
||||
m.matchCount = m.totalCount
|
||||
}
|
||||
|
||||
// GetQuery returns the current search query
|
||||
func (m *SearchModel) GetQuery() string {
|
||||
return m.input.Value()
|
||||
}
|
||||
|
||||
// GetFilterType returns the current filter type
|
||||
func (m *SearchModel) GetFilterType() SearchFilterType {
|
||||
return m.filterType
|
||||
}
|
||||
|
||||
// SetTotalCount sets the total number of items
|
||||
func (m *SearchModel) SetTotalCount(count int) {
|
||||
m.totalCount = count
|
||||
if !m.active {
|
||||
m.matchCount = count
|
||||
}
|
||||
}
|
||||
|
||||
// SetMatchCount sets the number of matching items
|
||||
func (m *SearchModel) SetMatchCount(count int) {
|
||||
m.matchCount = count
|
||||
}
|
||||
|
||||
// Filter filters a list of client data based on the current search query
|
||||
func (m *SearchModel) Filter(clients []ClientData) []ClientData {
|
||||
query := strings.TrimSpace(m.input.Value())
|
||||
if query == "" || !m.active {
|
||||
m.matchCount = len(clients)
|
||||
return clients
|
||||
}
|
||||
|
||||
var filtered []ClientData
|
||||
queryLower := strings.ToLower(query)
|
||||
|
||||
for _, client := range clients {
|
||||
var matches bool
|
||||
|
||||
switch m.filterType {
|
||||
case FilterByName:
|
||||
matches = strings.Contains(strings.ToLower(client.Name), queryLower)
|
||||
case FilterByIPv4:
|
||||
matches = strings.Contains(strings.ToLower(client.IPv4), queryLower)
|
||||
case FilterByIPv6:
|
||||
matches = strings.Contains(strings.ToLower(client.IPv6), queryLower)
|
||||
case FilterByStatus:
|
||||
matches = strings.Contains(strings.ToLower(client.Status), queryLower)
|
||||
}
|
||||
|
||||
if matches {
|
||||
filtered = append(filtered, client)
|
||||
}
|
||||
}
|
||||
|
||||
m.matchCount = len(filtered)
|
||||
return filtered
|
||||
}
|
||||
|
||||
// HighlightMatches highlights matching text in the given value
|
||||
func (m *SearchModel) HighlightMatches(value string) string {
|
||||
if !m.active {
|
||||
return value
|
||||
}
|
||||
|
||||
query := strings.TrimSpace(m.input.Value())
|
||||
if query == "" {
|
||||
return value
|
||||
}
|
||||
|
||||
queryLower := strings.ToLower(query)
|
||||
valueLower := strings.ToLower(value)
|
||||
|
||||
index := strings.Index(valueLower, queryLower)
|
||||
if index == -1 {
|
||||
return value
|
||||
}
|
||||
|
||||
matchStyle := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("226")).
|
||||
Background(lipgloss.Color("57")).
|
||||
Bold(true)
|
||||
|
||||
before := value[:index]
|
||||
match := value[index : index+len(query)]
|
||||
after := value[index+len(query):]
|
||||
|
||||
return lipgloss.JoinHorizontal(
|
||||
lipgloss.Left,
|
||||
before,
|
||||
matchStyle.Render(string(match)),
|
||||
after,
|
||||
)
|
||||
}
|
||||
|
||||
// cycleFilterType cycles to the next filter type
|
||||
func (m *SearchModel) cycleFilterType() {
|
||||
switch m.filterType {
|
||||
case FilterByName:
|
||||
m.filterType = FilterByIPv4
|
||||
case FilterByIPv4:
|
||||
m.filterType = FilterByIPv6
|
||||
case FilterByIPv6:
|
||||
m.filterType = FilterByStatus
|
||||
case FilterByStatus:
|
||||
m.filterType = FilterByName
|
||||
}
|
||||
}
|
||||
|
||||
// renderCount renders a count number with proper styling
|
||||
func (m *SearchModel) renderCount(count int) string {
|
||||
if m.matchCount == 0 && m.active && m.input.Value() != "" {
|
||||
return lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("196")).
|
||||
Render("No matches")
|
||||
}
|
||||
return searchCountStyle.Render(string(rune('0' + count)))
|
||||
}
|
||||
|
||||
// ClientData represents client data for filtering
|
||||
type ClientData struct {
|
||||
Name string
|
||||
IPv4 string
|
||||
IPv6 string
|
||||
Status string
|
||||
}
|
||||
89
internal/tui/model.go
Normal file
89
internal/tui/model.go
Normal file
@@ -0,0 +1,89 @@
|
||||
package screens
|
||||
|
||||
import (
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
// Screen represents a UI screen (list, add, detail, etc.)
|
||||
type Screen interface {
|
||||
Init() tea.Cmd
|
||||
Update(tea.Msg) (Screen, tea.Cmd)
|
||||
View() string
|
||||
}
|
||||
|
||||
// Model is shared state across all screens
|
||||
type Model struct {
|
||||
err error
|
||||
isQuitting bool
|
||||
ready bool
|
||||
statusMessage string
|
||||
screen Screen
|
||||
}
|
||||
|
||||
// View renders the model (implements Screen interface)
|
||||
func (m *Model) View() string {
|
||||
if m.err != nil {
|
||||
return "\n" + lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("196")).
|
||||
Render(m.err.Error())
|
||||
}
|
||||
|
||||
if m.isQuitting {
|
||||
return "\nGoodbye!\n"
|
||||
}
|
||||
|
||||
if m.screen != nil {
|
||||
return m.screen.View()
|
||||
}
|
||||
|
||||
return "Initializing..."
|
||||
}
|
||||
|
||||
// Init initializes the model
|
||||
func (m *Model) Init() tea.Cmd {
|
||||
m.ready = true
|
||||
return nil
|
||||
}
|
||||
|
||||
// Update handles incoming messages (implements Screen interface)
|
||||
func (m *Model) Update(msg tea.Msg) (Screen, tea.Cmd) {
|
||||
// If we have an error, let it persist for display
|
||||
if m.err != nil {
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
if msg.String() == "q" || msg.String() == "ctrl+c" {
|
||||
m.isQuitting = true
|
||||
return m, tea.Quit
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// No error - handle normally
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case "q", "ctrl+c":
|
||||
m.isQuitting = true
|
||||
return m, tea.Quit
|
||||
}
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// SetScreen changes the current screen
|
||||
func (m *Model) SetScreen(screen Screen) {
|
||||
m.screen = screen
|
||||
}
|
||||
|
||||
// SetError sets an error message
|
||||
func (m *Model) SetError(err error) {
|
||||
m.err = err
|
||||
}
|
||||
|
||||
// ClearError clears the error message
|
||||
func (m *Model) ClearError() {
|
||||
m.err = nil
|
||||
}
|
||||
200
internal/tui/screens/add.go
Normal file
200
internal/tui/screens/add.go
Normal file
@@ -0,0 +1,200 @@
|
||||
package screens
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/calmcacil/wg-admin/internal/config"
|
||||
"github.com/calmcacil/wg-admin/internal/tui/components"
|
||||
"github.com/calmcacil/wg-admin/internal/validation"
|
||||
"github.com/calmcacil/wg-admin/internal/wireguard"
|
||||
"github.com/charmbracelet/bubbles/spinner"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/huh"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
// AddScreen is a form for adding new WireGuard clients
|
||||
type AddScreen struct {
|
||||
form *huh.Form
|
||||
quitting bool
|
||||
spinner spinner.Model
|
||||
isCreating bool
|
||||
}
|
||||
|
||||
// Styles
|
||||
var (
|
||||
addTitleStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("62")).
|
||||
Bold(true).
|
||||
MarginBottom(1)
|
||||
addHelpStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("241")).
|
||||
MarginTop(1)
|
||||
addLoadingStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("62")).
|
||||
Bold(true).
|
||||
MarginTop(1)
|
||||
)
|
||||
|
||||
// NewAddScreen creates a new add screen
|
||||
func NewAddScreen() *AddScreen {
|
||||
// Get default DNS from config
|
||||
cfg, err := config.LoadConfig()
|
||||
defaultDNS := "8.8.8.8, 8.8.4.4"
|
||||
if err == nil && cfg.DNSServers != "" {
|
||||
defaultDNS = cfg.DNSServers
|
||||
}
|
||||
|
||||
// Create the form
|
||||
form := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewInput().
|
||||
Key("name").
|
||||
Title("Client Name").
|
||||
Description("Name for the new client (alphanumeric, -, _)").
|
||||
Placeholder("e.g., laptop-john").
|
||||
Validate(func(s string) error {
|
||||
return validation.ValidateClientName(s)
|
||||
}),
|
||||
|
||||
huh.NewInput().
|
||||
Key("dns").
|
||||
Title("DNS Servers").
|
||||
Description("Comma-separated IPv4 addresses").
|
||||
Placeholder("e.g., 8.8.8.8, 8.8.4.4").
|
||||
Value(&defaultDNS).
|
||||
Validate(func(s string) error {
|
||||
return validation.ValidateDNSServers(s)
|
||||
}),
|
||||
|
||||
huh.NewConfirm().
|
||||
Key("use_psk").
|
||||
Title("Use Preshared Key").
|
||||
Description("Enable additional security layer with a preshared key").
|
||||
Affirmative("Yes").
|
||||
Negative("No"),
|
||||
),
|
||||
)
|
||||
|
||||
// Create spinner for loading states
|
||||
s := spinner.New()
|
||||
s.Spinner = spinner.Dot
|
||||
s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("62"))
|
||||
|
||||
return &AddScreen{
|
||||
form: form,
|
||||
quitting: false,
|
||||
spinner: s,
|
||||
isCreating: false,
|
||||
}
|
||||
}
|
||||
|
||||
// Init initializes the add screen
|
||||
func (s *AddScreen) Init() tea.Cmd {
|
||||
if s.isCreating {
|
||||
return s.spinner.Tick
|
||||
}
|
||||
return s.form.Init()
|
||||
}
|
||||
|
||||
// Update handles messages for the add screen
|
||||
func (s *AddScreen) Update(msg tea.Msg) (Screen, tea.Cmd) {
|
||||
var cmds []tea.Cmd
|
||||
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case "q", "ctrl+c", "esc":
|
||||
// Cancel and return to list (only if not creating)
|
||||
if !s.isCreating {
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If creating, update spinner instead of form
|
||||
if s.isCreating {
|
||||
var cmd tea.Cmd
|
||||
s.spinner, cmd = s.spinner.Update(msg)
|
||||
return s, cmd
|
||||
}
|
||||
|
||||
// Update the form
|
||||
form, cmd := s.form.Update(msg)
|
||||
if f, ok := form.(*huh.Form); ok {
|
||||
s.form = f
|
||||
}
|
||||
cmds = append(cmds, cmd)
|
||||
|
||||
// Check if form is completed
|
||||
if s.form.State == huh.StateCompleted {
|
||||
name := s.form.GetString("name")
|
||||
dns := s.form.GetString("dns")
|
||||
usePSK := s.form.GetBool("use_psk")
|
||||
|
||||
// Set creating state and start spinner
|
||||
s.isCreating = true
|
||||
// Create the client
|
||||
return s, s.createClient(name, dns, usePSK)
|
||||
}
|
||||
|
||||
return s, tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
// View renders the add screen
|
||||
func (s *AddScreen) View() string {
|
||||
// Breadcrumb: Clients > Add
|
||||
breadcrumb := components.RenderBreadcrumb([]components.BreadcrumbItem{
|
||||
{Label: "Clients", ID: "list"},
|
||||
{Label: "Add", ID: "add"},
|
||||
})
|
||||
|
||||
|
||||
if s.quitting {
|
||||
return ""
|
||||
}
|
||||
|
||||
if s.isCreating {
|
||||
return addLoadingStyle.Render(
|
||||
lipgloss.JoinVertical(
|
||||
lipgloss.Left,
|
||||
addTitleStyle.Render("Add New WireGuard Client"),
|
||||
s.spinner.View()+" Creating client configuration, please wait...",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
content := lipgloss.JoinVertical(
|
||||
lipgloss.Left,
|
||||
breadcrumb,
|
||||
"",
|
||||
addTitleStyle.Render("Add New WireGuard Client"),
|
||||
s.form.View(),
|
||||
addHelpStyle.Render("Press Enter to submit • Esc to cancel"),
|
||||
)
|
||||
|
||||
return content
|
||||
}
|
||||
|
||||
// createClient creates a new WireGuard client
|
||||
func (s *AddScreen) createClient(name, dns string, usePSK bool) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
// Create the client via wireguard package
|
||||
err := wireguard.CreateClient(name, dns, usePSK)
|
||||
if err != nil {
|
||||
return ErrMsg{Err: fmt.Errorf("failed to create client: %w", err)}
|
||||
}
|
||||
|
||||
// Return success message
|
||||
return ClientCreatedMsg{
|
||||
Name: name,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Messages
|
||||
|
||||
// ClientCreatedMsg is sent when a client is successfully created
|
||||
type ClientCreatedMsg struct {
|
||||
Name string
|
||||
}
|
||||
291
internal/tui/screens/detail.go
Normal file
291
internal/tui/screens/detail.go
Normal file
@@ -0,0 +1,291 @@
|
||||
package screens
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/calmcacil/wg-admin/internal/tui/components"
|
||||
"github.com/calmcacil/wg-admin/internal/tui/theme"
|
||||
"github.com/calmcacil/wg-admin/internal/wireguard"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
// DetailScreen displays detailed information about a single WireGuard client
|
||||
type DetailScreen struct {
|
||||
client wireguard.Client
|
||||
status string
|
||||
lastHandshake time.Time
|
||||
transferRx string
|
||||
transferTx string
|
||||
confirmModal *components.DeleteConfirmModel
|
||||
showConfirm bool
|
||||
configDisplay *components.ConfigDisplayModel
|
||||
showConfig bool
|
||||
}
|
||||
|
||||
// Styles
|
||||
var (
|
||||
detailValueStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("255"))
|
||||
dimmedContentStyle = theme.StyleMuted
|
||||
)
|
||||
|
||||
// NewDetailScreen creates a new detail screen for a client
|
||||
func NewDetailScreen(client wireguard.Client) *DetailScreen {
|
||||
return &DetailScreen{
|
||||
client: client,
|
||||
showConfirm: false,
|
||||
}
|
||||
}
|
||||
|
||||
// Init initializes the detail screen
|
||||
func (s *DetailScreen) Init() tea.Cmd {
|
||||
return s.loadClientStatus
|
||||
}
|
||||
|
||||
// Update handles messages for the detail screen
|
||||
func (s *DetailScreen) Update(msg tea.Msg) (Screen, tea.Cmd) {
|
||||
var cmd tea.Cmd
|
||||
|
||||
// Handle confirmation modal
|
||||
if s.showConfirm && s.confirmModal != nil {
|
||||
_, cmd = s.confirmModal.Update(msg)
|
||||
|
||||
// Handle confirmation result
|
||||
if !s.confirmModal.Visible {
|
||||
if s.confirmModal.IsConfirmed() {
|
||||
// User confirmed deletion
|
||||
return s, tea.Batch(s.deleteClient(), func() tea.Msg {
|
||||
return CloseDetailScreenMsg{}
|
||||
})
|
||||
}
|
||||
// User cancelled - close modal
|
||||
s.showConfirm = false
|
||||
return s, nil
|
||||
}
|
||||
|
||||
return s, cmd
|
||||
}
|
||||
|
||||
// Handle config display modal
|
||||
if s.showConfig && s.configDisplay != nil {
|
||||
_, cmd = s.configDisplay.Update(msg)
|
||||
|
||||
// Handle modal close
|
||||
if !s.configDisplay.IsVisible() {
|
||||
s.showConfig = false
|
||||
return s, nil
|
||||
}
|
||||
|
||||
return s, cmd
|
||||
}
|
||||
|
||||
// Handle normal screen messages
|
||||
switch msg := msg.(type) {
|
||||
case clientStatusLoadedMsg:
|
||||
s.status = msg.status
|
||||
s.lastHandshake = msg.lastHandshake
|
||||
s.transferRx = msg.transferRx
|
||||
s.transferTx = msg.transferTx
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case "q", "b", "esc":
|
||||
// Return to list screen - signal parent to switch screens
|
||||
return nil, nil
|
||||
case "d":
|
||||
// Show delete confirmation
|
||||
s.confirmModal = components.NewDeleteConfirm(
|
||||
s.client.Name,
|
||||
80,
|
||||
24,
|
||||
)
|
||||
s.showConfirm = true
|
||||
case "c":
|
||||
// Show client configuration
|
||||
return s, s.loadConfig()
|
||||
}
|
||||
}
|
||||
|
||||
return s, cmd
|
||||
}
|
||||
|
||||
// View renders the detail screen
|
||||
func (s *DetailScreen) View() string {
|
||||
// Handle config display modal
|
||||
if s.showConfig && s.configDisplay != nil {
|
||||
// Render underlying content dimmed
|
||||
content := s.renderContent()
|
||||
dimmedContent := dimmedContentStyle.Render(content)
|
||||
|
||||
// Overlay config display modal
|
||||
return lipgloss.JoinVertical(
|
||||
lipgloss.Left,
|
||||
dimmedContent,
|
||||
s.configDisplay.View(),
|
||||
)
|
||||
}
|
||||
|
||||
// Handle confirmation modal
|
||||
if s.showConfirm && s.confirmModal != nil {
|
||||
// Render underlying content dimmed
|
||||
content := s.renderContent()
|
||||
dimmedContent := dimmedContentStyle.Render(content)
|
||||
|
||||
// Overlay confirmation modal
|
||||
return lipgloss.JoinVertical(
|
||||
lipgloss.Left,
|
||||
dimmedContent,
|
||||
s.confirmModal.View(),
|
||||
)
|
||||
}
|
||||
|
||||
return s.renderContent()
|
||||
}
|
||||
|
||||
// renderContent renders the main detail screen content
|
||||
func (s *DetailScreen) renderContent() string {
|
||||
// Breadcrumb: Clients > Client Name
|
||||
breadcrumb := components.RenderBreadcrumb([]components.BreadcrumbItem{
|
||||
{Label: "Clients", ID: "list"},
|
||||
{Label: s.client.Name, ID: "detail"},
|
||||
})
|
||||
|
||||
statusText := s.status
|
||||
if s.status == wireguard.StatusConnected {
|
||||
duration := time.Since(s.lastHandshake)
|
||||
quality := wireguard.CalculateQuality(duration)
|
||||
statusText = theme.StyleSuccess.Bold(true).Render("● " + s.status + " (" + quality + ")")
|
||||
} else {
|
||||
statusText = theme.StyleError.Bold(true).Render("● " + s.status)
|
||||
}
|
||||
|
||||
// Build content
|
||||
content := lipgloss.JoinVertical(
|
||||
lipgloss.Left,
|
||||
breadcrumb,
|
||||
"",
|
||||
theme.StyleTitle.Render(fmt.Sprintf("Client Details: %s", s.client.Name)),
|
||||
"",
|
||||
s.renderField("Status", statusText),
|
||||
s.renderField("IPv4 Address", detailValueStyle.Render(s.client.IPv4)),
|
||||
s.renderField("IPv6 Address", detailValueStyle.Render(s.client.IPv6)),
|
||||
"",
|
||||
theme.StyleSubtitle.Bold(true).MarginTop(1).Render("WireGuard Configuration"),
|
||||
s.renderField("Public Key", detailValueStyle.Render(s.client.PublicKey)),
|
||||
s.renderField("Preshared Key", detailValueStyle.Render(func() string {
|
||||
if s.client.HasPSK {
|
||||
return "✓ Configured"
|
||||
}
|
||||
return "Not configured"
|
||||
}())),
|
||||
"",
|
||||
theme.StyleSubtitle.Bold(true).MarginTop(1).Render("Connection Info"),
|
||||
s.renderField("Last Handshake", detailValueStyle.Render(s.formatHandshake())),
|
||||
s.renderField("Transfer (Rx/Tx)", detailValueStyle.Render(fmt.Sprintf("%s / %s", s.transferRx, s.transferTx))),
|
||||
s.renderField("Config Path", detailValueStyle.Render(s.client.ConfigPath)),
|
||||
"",
|
||||
)
|
||||
|
||||
// Add help text with all keyboard shortcuts
|
||||
helpText := theme.StyleHelpKey.MarginTop(1).Render("Shortcuts: [d] Delete • [c] View Config • [q/b/esc] Back")
|
||||
content = lipgloss.JoinVertical(lipgloss.Left, content, helpText)
|
||||
|
||||
return content
|
||||
}
|
||||
|
||||
// renderField renders a label-value pair
|
||||
func (s *DetailScreen) renderField(label string, value string) string {
|
||||
return lipgloss.JoinHorizontal(lipgloss.Left,
|
||||
theme.StyleSubtitle.Width(18).Render(label),
|
||||
value,
|
||||
)
|
||||
}
|
||||
|
||||
// formatHandshake formats the last handshake time
|
||||
func (s *DetailScreen) formatHandshake() string {
|
||||
if s.lastHandshake.IsZero() {
|
||||
return "Never"
|
||||
}
|
||||
|
||||
duration := time.Since(s.lastHandshake)
|
||||
if duration < time.Minute {
|
||||
return "Just now"
|
||||
} else if duration < time.Hour {
|
||||
return fmt.Sprintf("%d min ago", int(duration.Minutes()))
|
||||
} else if duration < 24*time.Hour {
|
||||
return fmt.Sprintf("%d hours ago", int(duration.Hours()))
|
||||
} else if duration < 7*24*time.Hour {
|
||||
return fmt.Sprintf("%d days ago", int(duration.Hours()/24))
|
||||
}
|
||||
return s.lastHandshake.Format("2006-01-02 15:04")
|
||||
}
|
||||
|
||||
// loadClientStatus loads the current status of the client
|
||||
func (s *DetailScreen) loadClientStatus() tea.Msg {
|
||||
peers, err := wireguard.GetAllPeers()
|
||||
if err != nil {
|
||||
return ErrMsg{Err: err}
|
||||
}
|
||||
|
||||
// Find peer by public key
|
||||
for _, peer := range peers {
|
||||
if peer.PublicKey == s.client.PublicKey {
|
||||
return clientStatusLoadedMsg{
|
||||
status: peer.Status,
|
||||
lastHandshake: peer.LatestHandshake,
|
||||
transferRx: peer.TransferRx,
|
||||
transferTx: peer.TransferTx,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Peer not found in active list
|
||||
return clientStatusLoadedMsg{
|
||||
status: wireguard.StatusDisconnected,
|
||||
lastHandshake: time.Time{},
|
||||
transferRx: "",
|
||||
transferTx: "",
|
||||
}
|
||||
}
|
||||
|
||||
// loadConfig loads and displays the client configuration
|
||||
func (s *DetailScreen) loadConfig() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
config, err := wireguard.GetClientConfigContent(s.client.Name)
|
||||
if err != nil {
|
||||
return ErrMsg{Err: fmt.Errorf("failed to load client config: %w", err)}
|
||||
}
|
||||
|
||||
// Create or update config display modal
|
||||
if s.configDisplay == nil {
|
||||
s.configDisplay = components.NewConfigDisplay(config, 80, 24)
|
||||
} else {
|
||||
s.configDisplay.Show(config)
|
||||
}
|
||||
s.showConfig = true
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// deleteClient deletes the client
|
||||
func (s *DetailScreen) deleteClient() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
err := wireguard.DeleteClient(s.client.Name)
|
||||
if err != nil {
|
||||
return ErrMsg{fmt.Errorf("failed to delete client: %w", err)}
|
||||
}
|
||||
return ClientDeletedMsg{
|
||||
Name: s.client.Name,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Messages
|
||||
|
||||
// clientStatusLoadedMsg is sent when client status is loaded
|
||||
type clientStatusLoadedMsg struct {
|
||||
status string
|
||||
lastHandshake time.Time
|
||||
transferRx string
|
||||
transferTx string
|
||||
}
|
||||
242
internal/tui/screens/error.go
Normal file
242
internal/tui/screens/error.go
Normal file
@@ -0,0 +1,242 @@
|
||||
package screens
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/calmcacil/wg-admin/internal/tui/theme"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
// ErrorScreen displays user-friendly error messages with recovery options
|
||||
type ErrorScreen struct {
|
||||
err error
|
||||
friendly string
|
||||
actions []ErrorAction
|
||||
quitting bool
|
||||
}
|
||||
|
||||
// ErrorAction represents a recovery action
|
||||
type ErrorAction struct {
|
||||
Key string
|
||||
Label string
|
||||
Description string
|
||||
}
|
||||
|
||||
// NewErrorScreen creates a new error screen with mapped error information
|
||||
func NewErrorScreen(err error) *ErrorScreen {
|
||||
screen := &ErrorScreen{
|
||||
err: err,
|
||||
friendly: err.Error(), // Fallback to raw error
|
||||
actions: []ErrorAction{{Key: "enter", Label: "OK", Description: "Dismiss and return"}},
|
||||
quitting: false,
|
||||
}
|
||||
|
||||
// Map error to user-friendly message and recovery options
|
||||
screen.mapError(err)
|
||||
|
||||
return screen
|
||||
}
|
||||
|
||||
// Init initializes the error screen
|
||||
func (s *ErrorScreen) Init() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Update handles messages for the error screen
|
||||
func (s *ErrorScreen) Update(msg tea.Msg) (Screen, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case "q", "ctrl+c":
|
||||
// Quit application
|
||||
s.quitting = true
|
||||
return s, tea.Quit
|
||||
case "enter", "esc":
|
||||
// Dismiss error screen
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// View renders the error screen
|
||||
func (s *ErrorScreen) View() string {
|
||||
if s.quitting {
|
||||
return ""
|
||||
}
|
||||
|
||||
titleStyle := theme.StyleTitle.Copy().MarginBottom(1)
|
||||
errorStyle := theme.StyleError.Copy().Bold(true)
|
||||
msgStyle := theme.StyleSubtitle.Copy().MarginTop(1).MarginBottom(2)
|
||||
actionTitleStyle := theme.StylePrimary.Copy().Bold(true).MarginTop(1)
|
||||
actionStyle := theme.StyleMuted.Copy().MarginLeft(2)
|
||||
|
||||
// Build the error display
|
||||
content := lipgloss.JoinVertical(
|
||||
lipgloss.Left,
|
||||
titleStyle.Render("Error Occurred"),
|
||||
errorStyle.Render("⚠ "+s.friendly),
|
||||
msgStyle.Render("Technical Details: "+s.err.Error()),
|
||||
)
|
||||
|
||||
// Add recovery actions
|
||||
if len(s.actions) > 1 {
|
||||
content += "\n" + actionTitleStyle.Render("Recovery Options:")
|
||||
for _, action := range s.actions {
|
||||
key := theme.StyleHelpKey.Render("[" + action.Key + "]")
|
||||
content += "\n" + actionStyle.Render(
|
||||
lipgloss.JoinHorizontal(lipgloss.Left,
|
||||
key+" ",
|
||||
action.Label+" - "+action.Description,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
content += "\n\n" + theme.StyleMuted.Render("Press Enter to dismiss • Press q to quit")
|
||||
|
||||
return lipgloss.NewStyle().
|
||||
Padding(1, 2).
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(theme.GetCurrentTheme().Scheme.Error).
|
||||
Render(content)
|
||||
}
|
||||
|
||||
// mapError converts technical errors to user-friendly messages and recovery options
|
||||
func (s *ErrorScreen) mapError(err error) {
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
|
||||
errStr := strings.ToLower(err.Error())
|
||||
|
||||
// Permission errors
|
||||
if strings.Contains(errStr, "permission") ||
|
||||
strings.Contains(errStr, "denied") ||
|
||||
strings.Contains(errStr, "operation not permitted") {
|
||||
s.friendly = "Permission Denied"
|
||||
s.actions = []ErrorAction{
|
||||
{Key: "r", Label: "Run with sudo", Description: "Restart with elevated privileges"},
|
||||
{Key: "enter", Label: "Dismiss", Description: "Return to previous screen"},
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// File not found errors
|
||||
if strings.Contains(errStr, "no such file") ||
|
||||
strings.Contains(errStr, "file not found") ||
|
||||
strings.Contains(errStr, "does not exist") {
|
||||
s.friendly = "Configuration File Missing"
|
||||
s.actions = []ErrorAction{
|
||||
{Key: "r", Label: "Restore", Description: "Restore from backup (if available)"},
|
||||
{Key: "enter", Label: "Dismiss", Description: "Return to previous screen"},
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Client already exists errors
|
||||
if strings.Contains(errStr, "already exists") ||
|
||||
strings.Contains(errStr, "duplicate") {
|
||||
s.friendly = "Client Already Exists"
|
||||
s.actions = []ErrorAction{
|
||||
{Key: "n", Label: "New Name", Description: "Try a different client name"},
|
||||
{Key: "enter", Label: "Dismiss", Description: "Return to previous screen"},
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// IP address exhaustion
|
||||
if strings.Contains(errStr, "no available") ||
|
||||
strings.Contains(errStr, "exhausted") ||
|
||||
strings.Contains(errStr, "out of") {
|
||||
s.friendly = "No Available IP Addresses"
|
||||
s.actions = []ErrorAction{
|
||||
{Key: "d", Label: "Delete Client", Description: "Remove an unused client to free IPs"},
|
||||
{Key: "enter", Label: "Dismiss", Description: "Return to previous screen"},
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Config file parsing errors
|
||||
if strings.Contains(errStr, "parse") ||
|
||||
strings.Contains(errStr, "invalid") ||
|
||||
strings.Contains(errStr, "malformed") {
|
||||
s.friendly = "Invalid Configuration"
|
||||
s.actions = []ErrorAction{
|
||||
{Key: "r", Label: "Restore", Description: "Restore from backup"},
|
||||
{Key: "m", Label: "Manual Fix", Description: "Edit configuration manually"},
|
||||
{Key: "enter", Label: "Dismiss", Description: "Return to previous screen"},
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// WireGuard command failures
|
||||
if strings.Contains(errStr, "wg genkey") ||
|
||||
strings.Contains(errStr, "wg pubkey") ||
|
||||
strings.Contains(errStr, "wg genpsk") ||
|
||||
strings.Contains(errStr, "wg set") {
|
||||
s.friendly = "WireGuard Command Failed"
|
||||
s.actions = []ErrorAction{
|
||||
{Key: "i", Label: "Install WG", Description: "Ensure WireGuard is installed"},
|
||||
{Key: "s", Label: "Check Service", Description: "Verify WireGuard service is running"},
|
||||
{Key: "enter", Label: "Dismiss", Description: "Return to previous screen"},
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Network errors
|
||||
if strings.Contains(errStr, "network") ||
|
||||
strings.Contains(errStr, "connection") ||
|
||||
strings.Contains(errStr, "timeout") {
|
||||
s.friendly = "Network Error"
|
||||
s.actions = []ErrorAction{
|
||||
{Key: "r", Label: "Retry", Description: "Attempt the operation again"},
|
||||
{Key: "c", Label: "Check Connection", Description: "Verify network connectivity"},
|
||||
{Key: "enter", Label: "Dismiss", Description: "Return to previous screen"},
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Config directory not found
|
||||
if strings.Contains(errStr, "config directory") ||
|
||||
strings.Contains(errStr, "wireguard") {
|
||||
s.friendly = "WireGuard Not Configured"
|
||||
s.actions = []ErrorAction{
|
||||
{Key: "i", Label: "Install WireGuard", Description: "Set up WireGuard on this server"},
|
||||
{Key: "enter", Label: "Dismiss", Description: "Return to previous screen"},
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// DNS validation errors
|
||||
if strings.Contains(errStr, "dns") ||
|
||||
strings.Contains(errStr, "invalid address") {
|
||||
s.friendly = "Invalid DNS Configuration"
|
||||
s.actions = []ErrorAction{
|
||||
{Key: "e", Label: "Edit DNS", Description: "Update DNS server settings"},
|
||||
{Key: "d", Label: "Use Default", Description: "Use default DNS (8.8.8.8, 8.8.4.4)"},
|
||||
{Key: "enter", Label: "Dismiss", Description: "Return to previous screen"},
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Backup/restore errors
|
||||
if strings.Contains(errStr, "backup") ||
|
||||
strings.Contains(errStr, "restore") {
|
||||
s.friendly = "Backup Operation Failed"
|
||||
s.actions = []ErrorAction{
|
||||
{Key: "r", Label: "Retry", Description: "Attempt backup/restore again"},
|
||||
{Key: "c", Label: "Check Space", Description: "Verify disk space is available"},
|
||||
{Key: "enter", Label: "Dismiss", Description: "Return to previous screen"},
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Default: generic error
|
||||
s.friendly = "An Error Occurred"
|
||||
s.actions = []ErrorAction{
|
||||
{Key: "r", Label: "Retry", Description: "Attempt the operation again"},
|
||||
{Key: "enter", Label: "Dismiss", Description: "Return to previous screen"},
|
||||
}
|
||||
}
|
||||
106
internal/tui/screens/help.go
Normal file
106
internal/tui/screens/help.go
Normal file
@@ -0,0 +1,106 @@
|
||||
package screens
|
||||
|
||||
import (
|
||||
"github.com/calmcacil/wg-admin/internal/tui/components"
|
||||
"github.com/calmcacil/wg-admin/internal/tui/theme"
|
||||
"github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
// HelpScreen displays keyboard shortcuts
|
||||
type HelpScreen struct {
|
||||
previousScreen Screen
|
||||
}
|
||||
|
||||
// NewHelpScreen creates a new help screen
|
||||
func NewHelpScreen(previous Screen) *HelpScreen {
|
||||
return &HelpScreen{
|
||||
previousScreen: previous,
|
||||
}
|
||||
}
|
||||
|
||||
// Init initializes the help screen
|
||||
func (s *HelpScreen) Init() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Update handles messages for the help screen
|
||||
func (s *HelpScreen) Update(msg tea.Msg) (Screen, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case "q", "esc":
|
||||
// Return to previous screen
|
||||
return s.previousScreen, nil
|
||||
}
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// View renders the help screen
|
||||
func (s *HelpScreen) View() string {
|
||||
// Breadcrumb: Help
|
||||
breadcrumb := components.RenderBreadcrumb([]components.BreadcrumbItem{{Label: "Help", ID: "help"}})
|
||||
|
||||
// Styles using theme
|
||||
borderStyle := lipgloss.NewStyle().
|
||||
BorderStyle(lipgloss.RoundedBorder()).
|
||||
BorderForeground(theme.StyleBorder).
|
||||
Padding(1, 2)
|
||||
|
||||
keyStyle := theme.StyleHelpKey.Width(12)
|
||||
|
||||
descStyle := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("250"))
|
||||
|
||||
// Header
|
||||
header := theme.StyleTitle.MarginBottom(1).Render("Keyboard Shortcuts")
|
||||
|
||||
// Shortcut groups
|
||||
navigationGroup := theme.StyleWarning.Bold(true).MarginTop(1).MarginBottom(0).Render("Navigation") + "\n" +
|
||||
keyStyle.Render("j / ↓") + descStyle.Render("Move down") + "\n" +
|
||||
keyStyle.Render("k / ↑") + descStyle.Render("Move up") + "\n" +
|
||||
keyStyle.Render("← →") + descStyle.Render("Move left/right") + "\n" +
|
||||
keyStyle.Render("Enter") + descStyle.Render("Select") + "\n" +
|
||||
keyStyle.Render("Esc") + descStyle.Render("Go back")
|
||||
|
||||
actionsGroup := theme.StyleWarning.Bold(true).MarginTop(1).MarginBottom(0).Render("Actions") + "\n" +
|
||||
keyStyle.Render("a") + descStyle.Render("Add client") + "\n" +
|
||||
keyStyle.Render("d") + descStyle.Render("Delete client") + "\n" +
|
||||
keyStyle.Render("Q") + descStyle.Render("Show QR code") + "\n" +
|
||||
keyStyle.Render("r") + descStyle.Render("Refresh list") + "\n" +
|
||||
keyStyle.Render("l") + descStyle.Render("List view")
|
||||
|
||||
otherGroup := theme.StyleWarning.Bold(true).MarginTop(1).MarginBottom(0).Render("Other") + "\n" +
|
||||
keyStyle.Render("?") + descStyle.Render("Show this help") + "\n" +
|
||||
keyStyle.Render("/") + descStyle.Render("Search") + "\n" +
|
||||
keyStyle.Render("q") + descStyle.Render("Quit")
|
||||
|
||||
copyGroup := theme.StyleWarning.Bold(true).MarginTop(1).MarginBottom(0).Render("Text Selection & Copy") + "\n" +
|
||||
keyStyle.Render("SHIFT+drag") + descStyle.Render("Select text") + "\n" +
|
||||
keyStyle.Render("Ctrl+Shift+C") + descStyle.Render("Copy (Linux)") + "\n" +
|
||||
keyStyle.Render("Cmd+C") + descStyle.Render("Copy (macOS)")
|
||||
|
||||
// Two-column layout
|
||||
leftColumn := lipgloss.JoinVertical(lipgloss.Left,
|
||||
navigationGroup,
|
||||
"",
|
||||
actionsGroup,
|
||||
)
|
||||
|
||||
rightColumn := lipgloss.JoinVertical(lipgloss.Left,
|
||||
otherGroup,
|
||||
"",
|
||||
copyGroup,
|
||||
)
|
||||
|
||||
content := lipgloss.JoinHorizontal(lipgloss.Top, leftColumn, " ", rightColumn)
|
||||
|
||||
// Footer
|
||||
footer := theme.StyleMuted.MarginTop(1).Render("Press q or Esc to return")
|
||||
|
||||
// Combine all
|
||||
return breadcrumb + "\n\n" + borderStyle.Render(
|
||||
lipgloss.JoinVertical(lipgloss.Left, header, content, footer),
|
||||
)
|
||||
}
|
||||
41
internal/tui/screens/interface.go
Normal file
41
internal/tui/screens/interface.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package screens
|
||||
|
||||
import (
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
)
|
||||
|
||||
// TransitionType defines the type of transition animation
|
||||
type TransitionType int
|
||||
|
||||
const (
|
||||
TransitionNone TransitionType = iota
|
||||
TransitionFade
|
||||
TransitionSlideLeft
|
||||
TransitionSlideRight
|
||||
)
|
||||
|
||||
// Screen represents a UI screen (list, add, detail, etc.)
|
||||
type Screen interface {
|
||||
Init() tea.Cmd
|
||||
Update(tea.Msg) (Screen, tea.Cmd)
|
||||
View() string
|
||||
}
|
||||
|
||||
// ClientSelectedMsg is sent when a client is selected from the list
|
||||
type ClientSelectedMsg struct {
|
||||
Client ClientWithStatus
|
||||
}
|
||||
|
||||
// ClientDeletedMsg is sent when a client is successfully deleted
|
||||
type ClientDeletedMsg struct {
|
||||
Name string
|
||||
}
|
||||
|
||||
// CloseDetailScreenMsg signals to close detail screen
|
||||
type CloseDetailScreenMsg struct{}
|
||||
|
||||
// RestoreCompletedMsg is sent when a restore operation completes
|
||||
type RestoreCompletedMsg struct {
|
||||
Err error
|
||||
SafetyBackupPath string
|
||||
}
|
||||
462
internal/tui/screens/list.go
Normal file
462
internal/tui/screens/list.go
Normal file
@@ -0,0 +1,462 @@
|
||||
package screens
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/calmcacil/wg-admin/internal/tui/components"
|
||||
"github.com/calmcacil/wg-admin/internal/wireguard"
|
||||
"github.com/charmbracelet/bubbles/table"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
const statusRefreshInterval = 3 // seconds
|
||||
|
||||
// ListScreen displays a table of WireGuard clients
|
||||
type ListScreen struct {
|
||||
table table.Model
|
||||
search *components.SearchModel
|
||||
clients []ClientWithStatus
|
||||
filtered []ClientWithStatus
|
||||
sortedBy string // Column name being sorted by
|
||||
ascending bool // Sort direction
|
||||
lastUpdated time.Time
|
||||
}
|
||||
|
||||
// ClientWithStatus wraps a client with its connection status
|
||||
type ClientWithStatus struct {
|
||||
Client wireguard.Client
|
||||
Status string
|
||||
Quality string
|
||||
}
|
||||
|
||||
// NewListScreen creates a new list screen
|
||||
func NewListScreen() *ListScreen {
|
||||
return &ListScreen{
|
||||
search: components.NewSearch(),
|
||||
sortedBy: "Name",
|
||||
ascending: true,
|
||||
}
|
||||
}
|
||||
|
||||
// Init initializes the list screen
|
||||
func (s *ListScreen) Init() tea.Cmd {
|
||||
return tea.Batch(
|
||||
s.loadClients,
|
||||
wireguard.Tick(statusRefreshInterval),
|
||||
ticker(),
|
||||
)
|
||||
}
|
||||
|
||||
// ticker sends a message every second to update the time display
|
||||
func ticker() tea.Cmd {
|
||||
return tea.Tick(time.Second, func(t time.Time) tea.Msg {
|
||||
return timeTickMsg(t)
|
||||
})
|
||||
}
|
||||
|
||||
// timeTickMsg is sent every second to update the time display
|
||||
type timeTickMsg time.Time
|
||||
|
||||
// Update handles messages for the list screen
|
||||
func (s *ListScreen) Update(msg tea.Msg) (Screen, tea.Cmd) {
|
||||
var cmd tea.Cmd
|
||||
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
// Handle search activation
|
||||
if msg.String() == "/" && !s.search.IsActive() {
|
||||
s.search.Activate()
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// If search is active, pass input to search
|
||||
if s.search.IsActive() {
|
||||
s.search, cmd = s.search.Update(msg)
|
||||
// Apply filter to clients
|
||||
s.applyFilter()
|
||||
return s, cmd
|
||||
}
|
||||
|
||||
// Normal key handling when search is not active
|
||||
switch msg.String() {
|
||||
case "q", "ctrl+c":
|
||||
// Handle quit in parent model
|
||||
return s, nil
|
||||
case "r":
|
||||
// Refresh clients
|
||||
return s, s.loadClients
|
||||
case "R":
|
||||
// Show restore screen
|
||||
return NewRestoreScreen(), nil
|
||||
case "Q":
|
||||
// Show QR code for selected client
|
||||
if len(s.table.Rows()) > 0 {
|
||||
selected := s.table.SelectedRow()
|
||||
clientName := selected[0] // First column is Name
|
||||
return NewQRScreen(clientName), nil
|
||||
}
|
||||
case "enter":
|
||||
// Open detail view for selected client
|
||||
if len(s.table.Rows()) > 0 {
|
||||
selectedRow := s.table.SelectedRow()
|
||||
selectedName := selectedRow[0] // First column is Name
|
||||
// Find the client with this name
|
||||
for _, cws := range s.clients {
|
||||
if cws.Client.Name == selectedName {
|
||||
return s, func() tea.Msg {
|
||||
return ClientSelectedMsg{Client: cws}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
case "1", "2", "3", "4":
|
||||
// Sort by column number (Name, IPv4, IPv6, Status)
|
||||
s.sortByColumn(msg.String())
|
||||
}
|
||||
case clientsLoadedMsg:
|
||||
s.clients = msg.clients
|
||||
s.lastUpdated = time.Now()
|
||||
s.search.SetTotalCount(len(s.clients))
|
||||
s.applyFilter()
|
||||
case timeTickMsg:
|
||||
// Trigger a re-render to update "Last updated" display
|
||||
case wireguard.StatusTickMsg:
|
||||
// Refresh status on periodic tick
|
||||
return s, s.loadClients
|
||||
case wireguard.RefreshStatusMsg:
|
||||
// Refresh status on manual refresh
|
||||
return s, s.loadClients
|
||||
}
|
||||
|
||||
s.table, cmd = s.table.Update(msg)
|
||||
return s, cmd
|
||||
}
|
||||
|
||||
// View renders the list screen
|
||||
func (s *ListScreen) View() string {
|
||||
// Breadcrumb: Home
|
||||
breadcrumb := components.RenderBreadcrumb([]components.BreadcrumbItem{
|
||||
{Label: "Clients", ID: "list"},
|
||||
})
|
||||
if len(s.clients) == 0 {
|
||||
// Empty state with helpful guidance
|
||||
return breadcrumb + "\n" + s.search.View() + "\n\n" +
|
||||
lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("226")).
|
||||
Bold(true).
|
||||
Render("No clients yet") + "\n" +
|
||||
lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("241")).
|
||||
Render("Let's get started! Here are your options:") + "\n" +
|
||||
lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("109")).
|
||||
Render(" • Press [a] to add your first client") + "\n" +
|
||||
lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("109")).
|
||||
Render(" • Press [R] to restore from backup") + "\n" +
|
||||
lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("109")).
|
||||
Render(" • Press [r] to refresh the client list") + "\n" +
|
||||
lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("109")).
|
||||
Render(" • Press [q] to quit")
|
||||
}
|
||||
|
||||
// Check if there are no matches
|
||||
if s.search.IsActive() && len(s.filtered) == 0 && s.search.GetQuery() != "" {
|
||||
// Empty search results with helpful tips
|
||||
return breadcrumb + "\n" + s.search.View() + "\n\n" +
|
||||
lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("226")).
|
||||
Bold(true).
|
||||
Render("No matching clients found") + "\n" +
|
||||
lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("241")).
|
||||
Render("Search tips:") + "\n" +
|
||||
lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("109")).
|
||||
Render(" • Check your spelling") + "\n" +
|
||||
lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("109")).
|
||||
Render(" • Try a shorter search term") + "\n" +
|
||||
lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("109")).
|
||||
Render(" • Search by name, IP, or status") + "\n" +
|
||||
lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("109")).
|
||||
Render(" • Press [esc] to clear search") + "\n" +
|
||||
lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("109")).
|
||||
Render(" • Press [r] to refresh client list")
|
||||
}
|
||||
|
||||
// Calculate time since last update
|
||||
timeAgo := "never"
|
||||
if !s.lastUpdated.IsZero() {
|
||||
duration := time.Since(s.lastUpdated)
|
||||
timeAgo = formatDuration(duration)
|
||||
}
|
||||
|
||||
lastUpdatedText := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("241")).
|
||||
Render("Last updated: " + timeAgo)
|
||||
|
||||
// Add keyboard shortcuts help
|
||||
helpText := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("63")).
|
||||
MarginTop(1).
|
||||
Render("Shortcuts: [?/h] Help • [a] Add • [R] Restore • [r] Refresh • [Q] QR Code • [/] Search • [1-4] Sort • [Enter] Details • [q] Quit")
|
||||
|
||||
return breadcrumb + "\n" + s.search.View() + "\n" + s.table.View() + "\n" + lastUpdatedText + "\n" + helpText
|
||||
}
|
||||
|
||||
// formatDuration returns a human-readable string for the duration
|
||||
func formatDuration(d time.Duration) string {
|
||||
if d < time.Minute {
|
||||
return "just now"
|
||||
}
|
||||
if d < time.Hour {
|
||||
return fmt.Sprintf("%d min ago", int(d.Minutes()))
|
||||
}
|
||||
return fmt.Sprintf("%d hr ago", int(d.Hours()))
|
||||
}
|
||||
|
||||
// loadClients loads clients from wireguard config
|
||||
|
||||
// loadClients loads clients from wireguard config
|
||||
func (s *ListScreen) loadClients() tea.Msg {
|
||||
clients, err := wireguard.ListClients()
|
||||
if err != nil {
|
||||
return ErrMsg{Err: err}
|
||||
}
|
||||
|
||||
// Get all peer statuses to retrieve quality information
|
||||
peerStatuses, err := wireguard.GetAllPeers()
|
||||
if err != nil {
|
||||
return ErrMsg{Err: err}
|
||||
}
|
||||
|
||||
// Match clients with their peer status
|
||||
clientsWithStatus := make([]ClientWithStatus, len(clients))
|
||||
for i, client := range clients {
|
||||
status := wireguard.StatusDisconnected
|
||||
quality := ""
|
||||
|
||||
// Find matching peer status
|
||||
for _, peerStatus := range peerStatuses {
|
||||
if peerStatus.PublicKey == client.PublicKey {
|
||||
status = peerStatus.Status
|
||||
quality = peerStatus.Quality
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
clientsWithStatus[i] = ClientWithStatus{
|
||||
Client: client,
|
||||
Status: status,
|
||||
Quality: quality,
|
||||
}
|
||||
}
|
||||
|
||||
return clientsLoadedMsg{clients: clientsWithStatus}
|
||||
}
|
||||
|
||||
// applyFilter applies the current search filter to clients
|
||||
func (s *ListScreen) applyFilter() {
|
||||
// Convert clients to ClientData for filtering
|
||||
clientData := make([]components.ClientData, len(s.clients))
|
||||
for i, cws := range s.clients {
|
||||
clientData[i] = components.ClientData{
|
||||
Name: cws.Client.Name,
|
||||
IPv4: cws.Client.IPv4,
|
||||
IPv6: cws.Client.IPv6,
|
||||
Status: cws.Status,
|
||||
}
|
||||
}
|
||||
|
||||
// Filter clients
|
||||
filteredData := s.search.Filter(clientData)
|
||||
|
||||
// Convert back to ClientWithStatus
|
||||
s.filtered = make([]ClientWithStatus, len(filteredData))
|
||||
for i, cd := range filteredData {
|
||||
// Find the matching client
|
||||
for _, cws := range s.clients {
|
||||
if cws.Client.Name == cd.Name {
|
||||
s.filtered[i] = cws
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Rebuild table with filtered clients
|
||||
s.buildTable()
|
||||
}
|
||||
|
||||
// formatStatusWithIcon formats the status with a colored circle icon
|
||||
func (s *ListScreen) formatStatusWithIcon(status string, quality string) string {
|
||||
if status == wireguard.StatusConnected {
|
||||
if quality != "" {
|
||||
return lipgloss.NewStyle().Foreground(lipgloss.Color("46")).Render("●") + " " + status + " (" + quality + ")"
|
||||
}
|
||||
return lipgloss.NewStyle().Foreground(lipgloss.Color("46")).Render("●") + " " + status
|
||||
}
|
||||
return lipgloss.NewStyle().Foreground(lipgloss.Color("196")).Render("●") + " " + status
|
||||
}
|
||||
|
||||
// buildTable creates and configures the table
|
||||
func (s *ListScreen) buildTable() {
|
||||
columns := []table.Column{
|
||||
{Title: "Name", Width: 20},
|
||||
{Title: "IPv4", Width: 15},
|
||||
{Title: "IPv6", Width: 35},
|
||||
{Title: "Status", Width: 14},
|
||||
}
|
||||
|
||||
// Use filtered clients if search is active, otherwise use all clients
|
||||
displayClients := s.filtered
|
||||
if !s.search.IsActive() {
|
||||
displayClients = s.clients
|
||||
}
|
||||
|
||||
var rows []table.Row
|
||||
for _, cws := range displayClients {
|
||||
// Apply highlighting based on filter type
|
||||
name := cws.Client.Name
|
||||
ipv4 := cws.Client.IPv4
|
||||
ipv6 := cws.Client.IPv6
|
||||
status := cws.Status
|
||||
|
||||
if s.search.IsActive() {
|
||||
switch s.search.GetFilterType() {
|
||||
case components.FilterByName:
|
||||
name = s.search.HighlightMatches(name)
|
||||
case components.FilterByIPv4:
|
||||
ipv4 = s.search.HighlightMatches(ipv4)
|
||||
case components.FilterByIPv6:
|
||||
ipv6 = s.search.HighlightMatches(ipv6)
|
||||
case components.FilterByStatus:
|
||||
status = s.search.HighlightMatches(status)
|
||||
}
|
||||
}
|
||||
|
||||
statusText := s.formatStatusWithIcon(status, "")
|
||||
row := table.Row{
|
||||
name,
|
||||
ipv4,
|
||||
ipv6,
|
||||
statusText,
|
||||
}
|
||||
rows = append(rows, row)
|
||||
}
|
||||
|
||||
// Sort rows based on current sort settings
|
||||
s.sortRows(rows)
|
||||
|
||||
// Determine table height
|
||||
tableHeight := len(rows) + 2 // Header + rows
|
||||
if tableHeight < 5 {
|
||||
tableHeight = 5
|
||||
}
|
||||
|
||||
s.table = table.New(
|
||||
table.WithColumns(columns),
|
||||
table.WithRows(rows),
|
||||
table.WithFocused(true),
|
||||
table.WithHeight(tableHeight),
|
||||
)
|
||||
|
||||
// Apply styles
|
||||
s.setTableStyles()
|
||||
}
|
||||
|
||||
// setTableStyles applies styling to the table
|
||||
func (s *ListScreen) setTableStyles() {
|
||||
styles := table.DefaultStyles()
|
||||
styles.Header = styles.Header.
|
||||
BorderStyle(lipgloss.NormalBorder()).
|
||||
BorderForeground(lipgloss.Color("240")).
|
||||
BorderBottom(true).
|
||||
Bold(true)
|
||||
styles.Selected = styles.Selected.
|
||||
Foreground(lipgloss.Color("229")).
|
||||
Background(lipgloss.Color("57")).
|
||||
Bold(false)
|
||||
s.table.SetStyles(styles)
|
||||
}
|
||||
|
||||
// sortRows sorts the rows based on the current sort settings
|
||||
func (s *ListScreen) sortRows(rows []table.Row) {
|
||||
colIndex := s.getColumnIndex(s.sortedBy)
|
||||
|
||||
sort.Slice(rows, func(i, j int) bool {
|
||||
var valI, valJ string
|
||||
if colIndex < len(rows[i]) {
|
||||
valI = rows[i][colIndex]
|
||||
}
|
||||
if colIndex < len(rows[j]) {
|
||||
valJ = rows[j][colIndex]
|
||||
}
|
||||
|
||||
if s.ascending {
|
||||
return strings.ToLower(valI) < strings.ToLower(valJ)
|
||||
}
|
||||
return strings.ToLower(valI) > strings.ToLower(valJ)
|
||||
})
|
||||
}
|
||||
|
||||
// sortByColumn changes the sort column
|
||||
func (s *ListScreen) sortByColumn(col string) {
|
||||
sortedBy := "Name"
|
||||
switch col {
|
||||
case "1":
|
||||
sortedBy = "Name"
|
||||
case "2":
|
||||
sortedBy = "IPv4"
|
||||
case "3":
|
||||
sortedBy = "IPv6"
|
||||
case "4":
|
||||
sortedBy = "Status"
|
||||
}
|
||||
|
||||
// Toggle direction if clicking same column
|
||||
if s.sortedBy == sortedBy {
|
||||
s.ascending = !s.ascending
|
||||
} else {
|
||||
s.sortedBy = sortedBy
|
||||
s.ascending = true
|
||||
}
|
||||
|
||||
s.buildTable()
|
||||
}
|
||||
|
||||
// getColumnIndex returns the index of a column by name
|
||||
func (s *ListScreen) getColumnIndex(name string) int {
|
||||
switch name {
|
||||
case "Name":
|
||||
return 0
|
||||
case "IPv4":
|
||||
return 1
|
||||
case "IPv6":
|
||||
return 2
|
||||
case "Status":
|
||||
return 3
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// Messages
|
||||
|
||||
// clientsLoadedMsg is sent when clients are loaded
|
||||
type clientsLoadedMsg struct {
|
||||
clients []ClientWithStatus
|
||||
}
|
||||
|
||||
// ErrMsg is sent when an error occurs
|
||||
type ErrMsg struct {
|
||||
Err error
|
||||
}
|
||||
220
internal/tui/screens/list_go_append.txt
Normal file
220
internal/tui/screens/list_go_append.txt
Normal file
@@ -0,0 +1,220 @@
|
||||
lastUpdatedText := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("241")).
|
||||
Render("Last updated: " + timeAgo)
|
||||
|
||||
// Add keyboard shortcuts help
|
||||
helpText := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("63")).
|
||||
MarginTop(1).
|
||||
Render("Shortcuts: [?/h] Help • [a] Add • [R] Restore • [r] Refresh • [Q] QR Code • [/] Search • [1-4] Sort • [Enter] Details • [q] Quit")
|
||||
|
||||
return breadcrumb + "\n" + s.search.View() + "\n" + s.table.View() + "\n" + lastUpdatedText + "\n" + helpText
|
||||
}
|
||||
|
||||
// formatDuration returns a human-readable string for the duration
|
||||
func formatDuration(d time.Duration) string {
|
||||
if d < time.Minute {
|
||||
return "just now"
|
||||
}
|
||||
if d < time.Hour {
|
||||
return fmt.Sprintf("%d min ago", int(d.Minutes()))
|
||||
}
|
||||
return fmt.Sprintf("%d hr ago", int(d.Hours()))
|
||||
}
|
||||
|
||||
// loadClients loads clients from wireguard config
|
||||
func (s *ListScreen) loadClients() tea.Msg {
|
||||
clients, err := wireguard.ListClients()
|
||||
if err != nil {
|
||||
return ErrMsg{Err: err}
|
||||
}
|
||||
|
||||
// Get status for each client
|
||||
clientsWithStatus := make([]ClientWithStatus, len(clients))
|
||||
for i, client := range clients {
|
||||
status, err := wireguard.GetClientStatus(client.PublicKey)
|
||||
if err != nil {
|
||||
status = wireguard.StatusDisconnected
|
||||
}
|
||||
clientsWithStatus[i] = ClientWithStatus{
|
||||
Client: client,
|
||||
Status: status,
|
||||
}
|
||||
}
|
||||
|
||||
return clientsLoadedMsg{clients: clientsWithStatus}
|
||||
}
|
||||
|
||||
// applyFilter applies the current search filter to clients
|
||||
func (s *ListScreen) applyFilter() {
|
||||
// Convert clients to ClientData for filtering
|
||||
clientData := make([]components.ClientData, len(s.clients))
|
||||
for i, cws := range s.clients {
|
||||
clientData[i] = components.ClientData{
|
||||
Name: cws.Client.Name,
|
||||
IPv4: cws.Client.IPv4,
|
||||
IPv6: cws.Client.IPv6,
|
||||
Status: cws.Status,
|
||||
}
|
||||
}
|
||||
|
||||
// Filter clients
|
||||
filteredData := s.search.Filter(clientData)
|
||||
|
||||
// Convert back to ClientWithStatus
|
||||
s.filtered = make([]ClientWithStatus, len(filteredData))
|
||||
for i, cd := range filteredData {
|
||||
// Find the matching client
|
||||
for _, cws := range s.clients {
|
||||
if cws.Client.Name == cd.Name {
|
||||
s.filtered[i] = cws
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Rebuild table with filtered clients
|
||||
s.buildTable()
|
||||
}
|
||||
|
||||
// formatStatusWithIcon formats the status with a colored circle icon
|
||||
func (s *ListScreen) formatStatusWithIcon(status string) string {
|
||||
if status == wireguard.StatusConnected {
|
||||
return lipgloss.NewStyle().Foreground(lipgloss.Color("46")).Render("●") + " " + status
|
||||
}
|
||||
return lipgloss.NewStyle().Foreground(lipgloss.Color("196")).Render("●") + " " + status
|
||||
}
|
||||
|
||||
// buildTable creates and configures the table
|
||||
func (s *ListScreen) buildTable() {
|
||||
columns := []table.Column{
|
||||
{Title: "Name", Width: 20},
|
||||
{Title: "IPv4", Width: 15},
|
||||
{Title: "IPv6", Width: 35},
|
||||
{Title: "Status", Width: 14},
|
||||
}
|
||||
|
||||
// Use filtered clients if search is active, otherwise use all clients
|
||||
displayClients := s.filtered
|
||||
if !s.search.IsActive() {
|
||||
displayClients = s.clients
|
||||
}
|
||||
|
||||
var rows []table.Row
|
||||
for _, cws := range displayClients {
|
||||
statusText := s.formatStatusWithIcon(cws.Status)
|
||||
row := table.Row{
|
||||
cws.Client.Name,
|
||||
cws.Client.IPv4,
|
||||
cws.Client.IPv6,
|
||||
statusText,
|
||||
}
|
||||
rows = append(rows, row)
|
||||
}
|
||||
|
||||
// Sort rows based on current sort settings
|
||||
s.sortRows(rows)
|
||||
|
||||
// Determine table height
|
||||
tableHeight := len(rows) + 2 // Header + rows
|
||||
if tableHeight < 5 {
|
||||
tableHeight = 5
|
||||
}
|
||||
|
||||
s.table = table.New(
|
||||
table.WithColumns(columns),
|
||||
table.WithRows(rows),
|
||||
table.WithFocused(true),
|
||||
table.WithHeight(tableHeight),
|
||||
)
|
||||
|
||||
// Apply styles
|
||||
s.setTableStyles()
|
||||
}
|
||||
|
||||
// setTableStyles applies styling to the table
|
||||
func (s *ListScreen) setTableStyles() {
|
||||
styles := table.DefaultStyles()
|
||||
styles.Header = styles.Header.
|
||||
BorderStyle(lipgloss.NormalBorder()).
|
||||
BorderForeground(lipgloss.Color("240")).
|
||||
BorderBottom(true).
|
||||
Bold(true)
|
||||
styles.Selected = styles.Selected.
|
||||
Foreground(lipgloss.Color("229")).
|
||||
Background(lipgloss.Color("57")).
|
||||
Bold(false)
|
||||
s.table.SetStyles(styles)
|
||||
}
|
||||
|
||||
// sortRows sorts the rows based on the current sort settings
|
||||
func (s *ListScreen) sortRows(rows []table.Row) {
|
||||
colIndex := s.getColumnIndex(s.sortedBy)
|
||||
|
||||
sort.Slice(rows, func(i, j int) bool {
|
||||
var valI, valJ string
|
||||
if colIndex < len(rows[i]) {
|
||||
valI = rows[i][colIndex]
|
||||
}
|
||||
if colIndex < len(rows[j]) {
|
||||
valJ = rows[j][colIndex]
|
||||
}
|
||||
|
||||
if s.ascending {
|
||||
return strings.ToLower(valI) < strings.ToLower(valJ)
|
||||
}
|
||||
return strings.ToLower(valI) > strings.ToLower(valJ)
|
||||
})
|
||||
}
|
||||
|
||||
// sortByColumn changes the sort column
|
||||
func (s *ListScreen) sortByColumn(col string) {
|
||||
sortedBy := "Name"
|
||||
switch col {
|
||||
case "1":
|
||||
sortedBy = "Name"
|
||||
case "2":
|
||||
sortedBy = "IPv4"
|
||||
case "3":
|
||||
sortedBy = "IPv6"
|
||||
case "4":
|
||||
sortedBy = "Status"
|
||||
}
|
||||
|
||||
// Toggle direction if clicking same column
|
||||
if s.sortedBy == sortedBy {
|
||||
s.ascending = !s.ascending
|
||||
} else {
|
||||
s.sortedBy = sortedBy
|
||||
s.ascending = true
|
||||
}
|
||||
|
||||
s.buildTable()
|
||||
}
|
||||
|
||||
// getColumnIndex returns the index of a column by name
|
||||
func (s *ListScreen) getColumnIndex(name string) int {
|
||||
switch name {
|
||||
case "Name":
|
||||
return 0
|
||||
case "IPv4":
|
||||
return 1
|
||||
case "IPv6":
|
||||
return 2
|
||||
case "Status":
|
||||
return 3
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// Messages
|
||||
|
||||
// clientsLoadedMsg is sent when clients are loaded
|
||||
type clientsLoadedMsg struct {
|
||||
clients []ClientWithStatus
|
||||
}
|
||||
|
||||
// ErrMsg is sent when an error occurs
|
||||
type ErrMsg struct {
|
||||
Err error
|
||||
}
|
||||
129
internal/tui/screens/qr.go
Normal file
129
internal/tui/screens/qr.go
Normal file
@@ -0,0 +1,129 @@
|
||||
package screens
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/calmcacil/wg-admin/internal/tui/components"
|
||||
"github.com/calmcacil/wg-admin/internal/tui/theme"
|
||||
"github.com/calmcacil/wg-admin/internal/wireguard"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/mdp/qrterminal/v3"
|
||||
)
|
||||
|
||||
// QRScreen displays a QR code for a WireGuard client configuration
|
||||
type QRScreen struct {
|
||||
clientName string
|
||||
configContent string
|
||||
qrCode string
|
||||
inlineMode bool
|
||||
width, height int
|
||||
errorMsg string
|
||||
}
|
||||
|
||||
// NewQRScreen creates a new QR screen for displaying client config QR codes
|
||||
func NewQRScreen(clientName string) *QRScreen {
|
||||
return &QRScreen{
|
||||
clientName: clientName,
|
||||
inlineMode: true, // Start in inline mode
|
||||
}
|
||||
}
|
||||
|
||||
// Init initializes the QR screen
|
||||
func (s *QRScreen) Init() tea.Cmd {
|
||||
return s.loadConfig
|
||||
}
|
||||
|
||||
// Update handles messages for the QR screen
|
||||
func (s *QRScreen) Update(msg tea.Msg) (Screen, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case "q", "escape":
|
||||
// Return to list screen (parent should handle this)
|
||||
return nil, nil
|
||||
case "f":
|
||||
// Toggle between inline and fullscreen mode
|
||||
s.inlineMode = !s.inlineMode
|
||||
s.generateQRCode()
|
||||
}
|
||||
case tea.WindowSizeMsg:
|
||||
// Handle terminal resize
|
||||
s.width = msg.Width
|
||||
s.height = msg.Height
|
||||
s.generateQRCode()
|
||||
case configLoadedMsg:
|
||||
s.configContent = msg.content
|
||||
s.generateQRCode()
|
||||
case ErrMsg:
|
||||
s.errorMsg = msg.Err.Error()
|
||||
}
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// View renders the QR screen
|
||||
func (s *QRScreen) View() string {
|
||||
// Breadcrumb: Clients > Client > QR Code
|
||||
breadcrumb := components.RenderBreadcrumb([]components.BreadcrumbItem{{Label: "Clients", ID: "list"}, {Label: s.clientName, ID: "detail"}, {Label: "QR Code", ID: "qr"}})
|
||||
|
||||
if s.errorMsg != "" {
|
||||
return s.renderError()
|
||||
}
|
||||
if s.qrCode == "" {
|
||||
return "Loading QR code..."
|
||||
}
|
||||
return breadcrumb + "\n" + s.renderQR()
|
||||
}
|
||||
|
||||
// loadConfig loads the client configuration
|
||||
func (s *QRScreen) loadConfig() tea.Msg {
|
||||
content, err := wireguard.GetClientConfigContent(s.clientName)
|
||||
if err != nil {
|
||||
return ErrMsg{Err: err}
|
||||
}
|
||||
return configLoadedMsg{content: content}
|
||||
}
|
||||
|
||||
// generateQRCode generates the QR code based on current mode and terminal size
|
||||
func (s *QRScreen) generateQRCode() {
|
||||
if s.configContent == "" {
|
||||
return
|
||||
}
|
||||
|
||||
// Generate QR code and capture output
|
||||
var builder strings.Builder
|
||||
|
||||
// Generate ANSI QR code using half-block characters
|
||||
qrterminal.GenerateHalfBlock(s.configContent, qrterminal.L, &builder)
|
||||
|
||||
s.qrCode = builder.String()
|
||||
}
|
||||
|
||||
// renderQR renders the QR code with styling
|
||||
func (s *QRScreen) renderQR() string {
|
||||
styleQR := lipgloss.NewStyle().
|
||||
MarginLeft(2)
|
||||
|
||||
title := theme.StyleTitle.MarginBottom(1).Render(fmt.Sprintf("QR Code: %s", s.clientName))
|
||||
help := "Press [f] to toggle fullscreen • Press [q/Escape] to return"
|
||||
|
||||
return title + "\n\n" + styleQR.Render(s.qrCode) + "\n" + theme.StyleMuted.MarginTop(1).Render(help)
|
||||
}
|
||||
|
||||
// renderError renders an error message
|
||||
func (s *QRScreen) renderError() string {
|
||||
title := theme.StyleError.Bold(true).Render("Error")
|
||||
message := s.errorMsg
|
||||
help := "Press [q/Escape] to return"
|
||||
|
||||
return title + "\n\n" + message + "\n" + theme.StyleMuted.MarginTop(1).Render(help)
|
||||
}
|
||||
|
||||
// Messages
|
||||
|
||||
// configLoadedMsg is sent when the client configuration is loaded
|
||||
type configLoadedMsg struct {
|
||||
content string
|
||||
}
|
||||
329
internal/tui/screens/restore.go
Normal file
329
internal/tui/screens/restore.go
Normal file
@@ -0,0 +1,329 @@
|
||||
package screens
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/calmcacil/wg-admin/internal/backup"
|
||||
"github.com/calmcacil/wg-admin/internal/tui/components"
|
||||
"github.com/calmcacil/wg-admin/internal/tui/theme"
|
||||
"github.com/charmbracelet/bubbles/spinner"
|
||||
"github.com/charmbracelet/bubbles/table"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
// RestoreScreen displays a list of available backups for restoration
|
||||
type RestoreScreen struct {
|
||||
table table.Model
|
||||
backups []backup.Backup
|
||||
selectedBackup *backup.Backup
|
||||
confirmModal *components.ConfirmModel
|
||||
showConfirm bool
|
||||
isRestoring bool
|
||||
restoreError error
|
||||
restoreSuccess bool
|
||||
message string
|
||||
spinner spinner.Model
|
||||
}
|
||||
|
||||
// No local styles - all use theme package
|
||||
|
||||
// NewRestoreScreen creates a new restore screen
|
||||
func NewRestoreScreen() *RestoreScreen {
|
||||
// Create spinner for loading states
|
||||
s := spinner.New()
|
||||
s.Spinner = spinner.Dot
|
||||
s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("62"))
|
||||
|
||||
return &RestoreScreen{
|
||||
showConfirm: false,
|
||||
spinner: s,
|
||||
}
|
||||
}
|
||||
|
||||
// Init initializes the restore screen
|
||||
func (s *RestoreScreen) Init() tea.Cmd {
|
||||
return s.loadBackups
|
||||
}
|
||||
|
||||
// Update handles messages for the restore screen
|
||||
func (s *RestoreScreen) Update(msg tea.Msg) (Screen, tea.Cmd) {
|
||||
var cmd tea.Cmd
|
||||
|
||||
// If restoring, only update spinner
|
||||
if s.isRestoring && !s.showConfirm {
|
||||
s.spinner, cmd = s.spinner.Update(msg)
|
||||
return s, cmd
|
||||
}
|
||||
|
||||
// Handle confirmation modal
|
||||
if s.showConfirm && s.confirmModal != nil {
|
||||
_, cmd = s.confirmModal.Update(msg)
|
||||
|
||||
// Handle confirmation result
|
||||
if !s.confirmModal.Visible {
|
||||
if s.confirmModal.IsConfirmed() && s.selectedBackup != nil {
|
||||
// User confirmed restore
|
||||
s.isRestoring = true
|
||||
s.showConfirm = false
|
||||
return s, tea.Sequence(s.spinner.Tick, s.performRestore())
|
||||
}
|
||||
// User cancelled - close modal
|
||||
s.showConfirm = false
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Handle Enter key to confirm
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
if msg.String() == "enter" && s.confirmModal.IsConfirmed() && s.selectedBackup != nil {
|
||||
s.isRestoring = true
|
||||
s.showConfirm = false
|
||||
return s, tea.Sequence(s.spinner.Tick, s.performRestore())
|
||||
}
|
||||
}
|
||||
|
||||
return s, cmd
|
||||
}
|
||||
|
||||
// Handle normal screen messages
|
||||
switch msg := msg.(type) {
|
||||
case backupsLoadedMsg:
|
||||
s.backups = msg.backups
|
||||
s.buildTable()
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case "q", "esc":
|
||||
// Return to list screen - signal parent to switch screens
|
||||
return nil, nil
|
||||
case "enter":
|
||||
// Show confirmation for selected backup
|
||||
if len(s.table.Rows()) > 0 {
|
||||
selected := s.table.SelectedRow()
|
||||
if len(selected) > 0 {
|
||||
// Find the backup by name
|
||||
for _, b := range s.backups {
|
||||
if b.Name == selected[0] {
|
||||
s.selectedBackup = &b
|
||||
s.confirmModal = components.NewConfirm(
|
||||
fmt.Sprintf(
|
||||
"Are you sure you want to restore from backup '%s'?\n\nOperation: %s\nDate: %s\n\nThis will replace current WireGuard configuration.\nA safety backup will be created first.",
|
||||
b.Name,
|
||||
b.Operation,
|
||||
b.Timestamp.Format("2006-01-02 15:04:05"),
|
||||
),
|
||||
80,
|
||||
24,
|
||||
)
|
||||
s.showConfirm = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
case RestoreCompletedMsg:
|
||||
s.isRestoring = false
|
||||
if msg.Err != nil {
|
||||
s.restoreError = msg.Err
|
||||
s.message = fmt.Sprintf("Restore failed: %v", msg.Err)
|
||||
} else {
|
||||
s.restoreSuccess = true
|
||||
s.message = fmt.Sprintf("Restore successful! Safety backup created at: %s", msg.SafetyBackupPath)
|
||||
}
|
||||
}
|
||||
|
||||
if !s.showConfirm && s.confirmModal != nil {
|
||||
s.table, cmd = s.table.Update(msg)
|
||||
}
|
||||
return s, cmd
|
||||
}
|
||||
|
||||
// View renders the restore screen
|
||||
func (s *RestoreScreen) View() string {
|
||||
if s.showConfirm && s.confirmModal != nil {
|
||||
// Render underlying content dimmed
|
||||
content := s.renderContent()
|
||||
dimmedContent := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("244")).
|
||||
Render(content)
|
||||
|
||||
// Overlay confirmation modal
|
||||
return lipgloss.JoinVertical(
|
||||
lipgloss.Left,
|
||||
dimmedContent,
|
||||
s.confirmModal.View(),
|
||||
)
|
||||
}
|
||||
|
||||
return s.renderContent()
|
||||
}
|
||||
|
||||
// renderContent renders the main restore screen content
|
||||
func (s *RestoreScreen) renderContent() string {
|
||||
// Breadcrumb: Clients > Restore
|
||||
breadcrumb := components.RenderBreadcrumb([]components.BreadcrumbItem{{Label: "Clients", ID: "list"}, {Label: "Restore", ID: "restore"}})
|
||||
|
||||
var content strings.Builder
|
||||
|
||||
content.WriteString(breadcrumb)
|
||||
content.WriteString("\n")
|
||||
content.WriteString(theme.StyleTitle.Render("Restore WireGuard Configuration"))
|
||||
content.WriteString("\n\n")
|
||||
|
||||
if len(s.backups) == 0 && !s.isRestoring && s.message == "" {
|
||||
content.WriteString("No backups found. Press 'q' to return.")
|
||||
return content.String()
|
||||
}
|
||||
|
||||
if s.isRestoring {
|
||||
content.WriteString(theme.StyleTitle.Render(s.spinner.View() + " Restoring from backup, please wait..."))
|
||||
return content.String()
|
||||
}
|
||||
|
||||
if s.restoreSuccess {
|
||||
content.WriteString(theme.StyleSuccess.Render("✓ " + s.message))
|
||||
content.WriteString("\n\n")
|
||||
content.WriteString(theme.StyleMuted.Render("Press 'q' to return to client list."))
|
||||
return content.String()
|
||||
}
|
||||
|
||||
if s.restoreError != nil {
|
||||
content.WriteString(theme.StyleError.Render("✗ " + s.message))
|
||||
content.WriteString("\n\n")
|
||||
content.WriteString(s.table.View())
|
||||
content.WriteString("\n\n")
|
||||
content.WriteString(theme.StyleHelpKey.Render("Actions: [Enter] Restore Selected • [↑/↓] Navigate • [q] Back"))
|
||||
return content.String()
|
||||
}
|
||||
|
||||
// Show backup list
|
||||
content.WriteString(s.table.View())
|
||||
content.WriteString("\n\n")
|
||||
|
||||
// Show selected backup details
|
||||
if len(s.table.Rows()) > 0 && s.selectedBackup != nil {
|
||||
content.WriteString(theme.StyleMuted.Render(
|
||||
fmt.Sprintf(
|
||||
"Selected: %s (%s) - %s\nSize: %s",
|
||||
s.selectedBackup.Operation,
|
||||
s.selectedBackup.Timestamp.Format("2006-01-02 15:04:05"),
|
||||
s.selectedBackup.Name,
|
||||
formatBytes(s.selectedBackup.Size),
|
||||
),
|
||||
))
|
||||
content.WriteString("\n")
|
||||
}
|
||||
|
||||
content.WriteString(theme.StyleHelpKey.Render("Actions: [Enter] Restore Selected • [↑/↓] Navigate • [q] Back"))
|
||||
|
||||
return content.String()
|
||||
}
|
||||
|
||||
// loadBackups loads the list of available backups
|
||||
func (s *RestoreScreen) loadBackups() tea.Msg {
|
||||
backups, err := backup.ListBackups()
|
||||
if err != nil {
|
||||
return ErrMsg{Err: err}
|
||||
}
|
||||
return backupsLoadedMsg{backups: backups}
|
||||
}
|
||||
|
||||
// buildTable creates and configures the backup list table
|
||||
func (s *RestoreScreen) buildTable() {
|
||||
columns := []table.Column{
|
||||
{Title: "Name", Width: 40},
|
||||
{Title: "Operation", Width: 15},
|
||||
{Title: "Date", Width: 20},
|
||||
{Title: "Size", Width: 12},
|
||||
}
|
||||
|
||||
var rows []table.Row
|
||||
for _, b := range s.backups {
|
||||
row := table.Row{
|
||||
b.Name,
|
||||
b.Operation,
|
||||
b.Timestamp.Format("2006-01-02 15:04"),
|
||||
formatBytes(b.Size),
|
||||
}
|
||||
rows = append(rows, row)
|
||||
}
|
||||
|
||||
s.table = table.New(
|
||||
table.WithColumns(columns),
|
||||
table.WithRows(rows),
|
||||
table.WithFocused(true),
|
||||
table.WithHeight(len(rows)+2), // Header + rows
|
||||
)
|
||||
|
||||
// Apply styles
|
||||
s.setTableStyles()
|
||||
}
|
||||
|
||||
// setTableStyles applies styling to the table
|
||||
func (s *RestoreScreen) setTableStyles() {
|
||||
styles := table.DefaultStyles()
|
||||
styles.Header = theme.StyleTableHeader
|
||||
styles.Selected = theme.StyleTableSelected
|
||||
s.table.SetStyles(styles)
|
||||
}
|
||||
|
||||
// performRestore performs the restore operation
|
||||
func (s *RestoreScreen) performRestore() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
if s.selectedBackup == nil {
|
||||
return restoreCompletedMsg{
|
||||
err: fmt.Errorf("no backup selected"),
|
||||
}
|
||||
}
|
||||
|
||||
// Get safety backup path from backup.BackupConfig
|
||||
safetyBackupPath, err := backup.BackupConfig(fmt.Sprintf("pre-restore-from-%s", s.selectedBackup.Name))
|
||||
if err != nil {
|
||||
return restoreCompletedMsg{
|
||||
err: fmt.Errorf("failed to create safety backup: %w", err),
|
||||
}
|
||||
}
|
||||
|
||||
// Perform restore
|
||||
if err := backup.RestoreBackup(s.selectedBackup.Name); err != nil {
|
||||
return restoreCompletedMsg{
|
||||
err: err,
|
||||
safetyBackupPath: safetyBackupPath,
|
||||
}
|
||||
}
|
||||
|
||||
// Restore succeeded - trigger client list refresh
|
||||
return restoreCompletedMsg{
|
||||
safetyBackupPath: safetyBackupPath,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// formatBytes formats a byte count into human-readable format
|
||||
func formatBytes(bytes int64) string {
|
||||
const unit = 1024
|
||||
if bytes < unit {
|
||||
return fmt.Sprintf("%d B", bytes)
|
||||
}
|
||||
div, exp := int64(unit), 0
|
||||
for n := bytes / unit; n >= unit; n /= unit {
|
||||
div *= unit
|
||||
exp++
|
||||
}
|
||||
return fmt.Sprintf("%.1f %ciB", float64(bytes)/float64(div), "KMGTPE"[exp])
|
||||
}
|
||||
|
||||
// Messages
|
||||
|
||||
// backupsLoadedMsg is sent when backups are loaded
|
||||
type backupsLoadedMsg struct {
|
||||
backups []backup.Backup
|
||||
}
|
||||
|
||||
// restoreCompletedMsg is sent when a restore operation completes
|
||||
type restoreCompletedMsg struct {
|
||||
err error
|
||||
safetyBackupPath string
|
||||
}
|
||||
294
internal/tui/theme/theme.go
Normal file
294
internal/tui/theme/theme.go
Normal file
@@ -0,0 +1,294 @@
|
||||
package theme
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"sync"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
// ColorScheme defines the color palette for the theme
|
||||
type ColorScheme struct {
|
||||
Primary lipgloss.Color
|
||||
Success lipgloss.Color
|
||||
Warning lipgloss.Color
|
||||
Error lipgloss.Color
|
||||
Muted lipgloss.Color
|
||||
Background lipgloss.Color
|
||||
}
|
||||
|
||||
// Theme represents a color theme with its name and color scheme
|
||||
type Theme struct {
|
||||
Name string
|
||||
Scheme ColorScheme
|
||||
}
|
||||
|
||||
// Global variables for current theme and styles
|
||||
var (
|
||||
currentTheme *Theme
|
||||
once sync.Once
|
||||
|
||||
// Global styles that can be used throughout the application
|
||||
StylePrimary lipgloss.Style
|
||||
StyleSuccess lipgloss.Style
|
||||
StyleWarning lipgloss.Style
|
||||
StyleError lipgloss.Style
|
||||
StyleMuted lipgloss.Style
|
||||
StyleTitle lipgloss.Style
|
||||
StyleSubtitle lipgloss.Style
|
||||
StyleHelpKey lipgloss.Style
|
||||
StyleValue lipgloss.Style
|
||||
StyleDimmed lipgloss.Style
|
||||
StyleTableHeader lipgloss.Style
|
||||
StyleTableSelected lipgloss.Style
|
||||
StyleBorder lipgloss.Color
|
||||
StyleBackground lipgloss.Color
|
||||
)
|
||||
|
||||
// DefaultTheme is the standard blue-based theme
|
||||
var DefaultTheme = &Theme{
|
||||
Name: "default",
|
||||
Scheme: ColorScheme{
|
||||
Primary: lipgloss.Color("62"), // Blue
|
||||
Success: lipgloss.Color("46"), // Green
|
||||
Warning: lipgloss.Color("208"), // Orange
|
||||
Error: lipgloss.Color("196"), // Red
|
||||
Muted: lipgloss.Color("241"), // Gray
|
||||
Background: lipgloss.Color(""), // Default terminal background
|
||||
},
|
||||
}
|
||||
|
||||
// DarkTheme is a purple-based dark theme
|
||||
var DarkTheme = &Theme{
|
||||
Name: "dark",
|
||||
Scheme: ColorScheme{
|
||||
Primary: lipgloss.Color("141"), // Purple
|
||||
Success: lipgloss.Color("51"), // Cyan
|
||||
Warning: lipgloss.Color("226"), // Yellow
|
||||
Error: lipgloss.Color("196"), // Red
|
||||
Muted: lipgloss.Color("245"), // Light gray
|
||||
Background: lipgloss.Color(""), // Default terminal background
|
||||
},
|
||||
}
|
||||
|
||||
// LightTheme is a green-based light theme
|
||||
var LightTheme = &Theme{
|
||||
Name: "light",
|
||||
Scheme: ColorScheme{
|
||||
Primary: lipgloss.Color("34"), // Green
|
||||
Success: lipgloss.Color("36"), // Teal
|
||||
Warning: lipgloss.Color("214"), // Amber
|
||||
Error: lipgloss.Color("196"), // Red
|
||||
Muted: lipgloss.Color("244"), // Gray
|
||||
Background: lipgloss.Color(""), // Default terminal background
|
||||
},
|
||||
}
|
||||
|
||||
// DraculaTheme is the popular Dracula dark color scheme
|
||||
var DraculaTheme = &Theme{
|
||||
Name: "dracula",
|
||||
Scheme: ColorScheme{
|
||||
Primary: lipgloss.Color("#BD93F9"), // Purple
|
||||
Success: lipgloss.Color("#50FA7B"), // Green
|
||||
Warning: lipgloss.Color("#FFB86C"), // Orange
|
||||
Error: lipgloss.Color("#FF5555"), // Red
|
||||
Muted: lipgloss.Color("#6272A4"), // Light purple-gray
|
||||
Background: lipgloss.Color("#282A36"), // Dark background
|
||||
},
|
||||
}
|
||||
|
||||
// EverforestTheme is the natural Everforest dark color scheme
|
||||
var EverforestTheme = &Theme{
|
||||
Name: "everforest",
|
||||
Scheme: ColorScheme{
|
||||
Primary: lipgloss.Color("#7fbbb3"), // Blue
|
||||
Success: lipgloss.Color("#a7c080"), // Green
|
||||
Warning: lipgloss.Color("#dbbc7f"), // Yellow
|
||||
Error: lipgloss.Color("#e67e80"), // Red
|
||||
Muted: lipgloss.Color("#414b50"), // Gray
|
||||
Background: lipgloss.Color("#272e33"), // Dark background
|
||||
},
|
||||
}
|
||||
|
||||
// ThemeRegistry holds all available themes
|
||||
var ThemeRegistry = map[string]*Theme{
|
||||
"default": DefaultTheme,
|
||||
"dark": DarkTheme,
|
||||
"light": LightTheme,
|
||||
"dracula": DraculaTheme,
|
||||
"everforest": EverforestTheme,
|
||||
}
|
||||
|
||||
// GetTheme loads the theme from config or environment variable
|
||||
// Returns the default theme if no theme is specified
|
||||
func GetTheme() (*Theme, error) {
|
||||
once.Do(func() {
|
||||
// Try to get theme from environment variable first
|
||||
themeName := os.Getenv("THEME")
|
||||
if themeName == "" {
|
||||
themeName = "everforest"
|
||||
}
|
||||
|
||||
// Look up the theme in the registry
|
||||
if theme, ok := ThemeRegistry[themeName]; ok {
|
||||
currentTheme = theme
|
||||
} else {
|
||||
// If theme not found, use everforest as default
|
||||
currentTheme = EverforestTheme
|
||||
}
|
||||
|
||||
// Apply the theme to initialize styles
|
||||
ApplyTheme(currentTheme)
|
||||
})
|
||||
|
||||
return currentTheme, nil
|
||||
}
|
||||
|
||||
// ApplyTheme applies the given theme to all global styles
|
||||
func ApplyTheme(theme *Theme) {
|
||||
currentTheme = theme
|
||||
|
||||
// Primary style
|
||||
StylePrimary = lipgloss.NewStyle().
|
||||
Foreground(theme.Scheme.Primary)
|
||||
|
||||
// Success style
|
||||
StyleSuccess = lipgloss.NewStyle().
|
||||
Foreground(theme.Scheme.Success)
|
||||
|
||||
// Warning style
|
||||
StyleWarning = lipgloss.NewStyle().
|
||||
Foreground(theme.Scheme.Warning)
|
||||
|
||||
// Error style
|
||||
StyleError = lipgloss.NewStyle().
|
||||
Foreground(theme.Scheme.Error)
|
||||
|
||||
// Muted style
|
||||
StyleMuted = lipgloss.NewStyle().
|
||||
Foreground(theme.Scheme.Muted)
|
||||
|
||||
// Title style (bold primary)
|
||||
StyleTitle = lipgloss.NewStyle().
|
||||
Foreground(theme.Scheme.Primary).
|
||||
Bold(true)
|
||||
|
||||
// Subtitle style (muted)
|
||||
StyleSubtitle = lipgloss.NewStyle().
|
||||
Foreground(theme.Scheme.Muted)
|
||||
|
||||
// Help key style (bold primary, slightly different shade)
|
||||
StyleHelpKey = lipgloss.NewStyle().
|
||||
Foreground(theme.Scheme.Primary).
|
||||
Bold(true)
|
||||
|
||||
// Value style for content values
|
||||
StyleValue = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("255"))
|
||||
|
||||
// Dimmed style for overlay content
|
||||
StyleDimmed = lipgloss.NewStyle().
|
||||
Foreground(theme.Scheme.Muted)
|
||||
|
||||
// Table header style
|
||||
StyleTableHeader = lipgloss.NewStyle().
|
||||
BorderStyle(lipgloss.NormalBorder()).
|
||||
BorderForeground(lipgloss.Color("240")).
|
||||
BorderBottom(true).
|
||||
Bold(true)
|
||||
|
||||
// Table selected style
|
||||
StyleTableSelected = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("229")).
|
||||
Background(lipgloss.Color("57")).
|
||||
Bold(false)
|
||||
|
||||
// Border color
|
||||
StyleBorder = lipgloss.Color("240")
|
||||
|
||||
// Background color for modals
|
||||
StyleBackground = lipgloss.Color("235")
|
||||
}
|
||||
|
||||
// Modal styles
|
||||
var (
|
||||
// ModalBaseStyle is the base style for all modals
|
||||
ModalBaseStyle = func() lipgloss.Style {
|
||||
return lipgloss.NewStyle().
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(lipgloss.Color("196")).
|
||||
Padding(1, 2).
|
||||
Background(StyleBackground)
|
||||
}
|
||||
|
||||
// ModalTitleStyle is the style for modal titles
|
||||
ModalTitleStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("226")).
|
||||
Bold(true)
|
||||
|
||||
// ModalMessageStyle is the style for modal messages
|
||||
ModalMessageStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("255")).
|
||||
Width(50)
|
||||
|
||||
// ModalHelpStyle is the style for modal help text
|
||||
ModalHelpStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("241")).
|
||||
MarginTop(1)
|
||||
|
||||
// ModalSelectedStyle is the style for selected modal options
|
||||
ModalSelectedStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("57")).
|
||||
Bold(true).
|
||||
Underline(true)
|
||||
|
||||
// ModalUnselectedStyle is the style for unselected modal options
|
||||
ModalUnselectedStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("241"))
|
||||
)
|
||||
|
||||
// Status icon styles
|
||||
var (
|
||||
// StatusConnectedStyle is the style for connected status icons
|
||||
StatusConnectedStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("46"))
|
||||
|
||||
// StatusDisconnectedStyle is the style for disconnected status icons
|
||||
StatusDisconnectedStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("196"))
|
||||
)
|
||||
|
||||
// GetThemeNames returns a list of available theme names
|
||||
func GetThemeNames() []string {
|
||||
names := make([]string, 0, len(ThemeRegistry))
|
||||
for name := range ThemeRegistry {
|
||||
names = append(names, name)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// SetTheme changes the current theme by name
|
||||
func SetTheme(name string) error {
|
||||
theme, ok := ThemeRegistry[name]
|
||||
if !ok {
|
||||
return fmt.Errorf("theme '%s' not found. Available themes: %v", name, GetThemeNames())
|
||||
}
|
||||
|
||||
ApplyTheme(theme)
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetCurrentTheme returns the currently active theme
|
||||
func GetCurrentTheme() *Theme {
|
||||
if currentTheme == nil {
|
||||
currentTheme = DefaultTheme
|
||||
ApplyTheme(currentTheme)
|
||||
}
|
||||
return currentTheme
|
||||
}
|
||||
|
||||
// GetColorScheme returns the color scheme of the current theme
|
||||
func GetColorScheme() ColorScheme {
|
||||
return GetCurrentTheme().Scheme
|
||||
}
|
||||
86
internal/validation/client.go
Normal file
86
internal/validation/client.go
Normal file
@@ -0,0 +1,86 @@
|
||||
package validation
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ValidateClientName validates the client name format
|
||||
// Requirements: alphanumeric, hyphens, underscores only, max 64 characters
|
||||
func ValidateClientName(name string) error {
|
||||
if name == "" {
|
||||
return fmt.Errorf("client name cannot be empty")
|
||||
}
|
||||
|
||||
if len(name) > 64 {
|
||||
return fmt.Errorf("client name must be 64 characters or less (got %d)", len(name))
|
||||
}
|
||||
|
||||
// Check for valid characters: a-z, A-Z, 0-9, hyphen, underscore
|
||||
matched, err := regexp.MatchString(`^[a-zA-Z0-9_-]+$`, name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to validate client name: %w", err)
|
||||
}
|
||||
|
||||
if !matched {
|
||||
return fmt.Errorf("client name contains invalid characters (allowed: a-z, A-Z, 0-9, -, _)")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateIPAvailability checks if an IP address is already assigned to a client
|
||||
// This function is meant to be called after getting next available IPs
|
||||
func ValidateIPAvailability(ipv4, ipv6 string, existingIPs map[string]bool) error {
|
||||
if ipv4 != "" && existingIPs[ipv4] {
|
||||
return fmt.Errorf("IPv4 address %s is already assigned", ipv4)
|
||||
}
|
||||
|
||||
if ipv6 != "" && existingIPs[ipv6] {
|
||||
return fmt.Errorf("IPv6 address %s is already assigned", ipv6)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateDNSServers validates DNS server addresses
|
||||
// Requirements: comma-separated list of IPv4 addresses
|
||||
func ValidateDNSServers(dns string) error {
|
||||
if dns == "" {
|
||||
return fmt.Errorf("DNS servers cannot be empty")
|
||||
}
|
||||
|
||||
// Split by comma and trim whitespace
|
||||
servers := strings.Split(dns, ",")
|
||||
for _, server := range servers {
|
||||
server = strings.TrimSpace(server)
|
||||
if server == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Basic IPv4 validation (4 octets, each 0-255)
|
||||
matched, err := regexp.MatchString(`^(\d{1,3}\.){3}\d{1,3}$`, server)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to validate DNS server: %w", err)
|
||||
}
|
||||
|
||||
if !matched {
|
||||
return fmt.Errorf("invalid DNS server format: %s (expected IPv4 address)", server)
|
||||
}
|
||||
|
||||
// Validate each octet is in range 0-255
|
||||
parts := strings.Split(server, ".")
|
||||
for _, part := range parts {
|
||||
var num int
|
||||
if _, err := fmt.Sscanf(part, "%d", &num); err != nil {
|
||||
return fmt.Errorf("invalid DNS server: %s", server)
|
||||
}
|
||||
if num < 0 || num > 255 {
|
||||
return fmt.Errorf("DNS server octet out of range (0-255) in: %s", server)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
585
internal/wireguard/client.go
Normal file
585
internal/wireguard/client.go
Normal file
@@ -0,0 +1,585 @@
|
||||
package wireguard
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/calmcacil/wg-admin/internal/backup"
|
||||
"github.com/calmcacil/wg-admin/internal/config"
|
||||
)
|
||||
|
||||
// Client represents a WireGuard peer configuration
|
||||
type Client struct {
|
||||
Name string // Client name extracted from filename
|
||||
IPv4 string // IPv4 address from AllowedIPs
|
||||
IPv6 string // IPv6 address from AllowedIPs
|
||||
PublicKey string // WireGuard public key
|
||||
HasPSK bool // Whether PresharedKey is configured
|
||||
ConfigPath string // Path to the client config file
|
||||
}
|
||||
|
||||
// ParseClientConfig parses a single WireGuard client configuration file
|
||||
func ParseClientConfig(path string) (*Client, error) {
|
||||
// Read the file
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read config file %s: %w", path, err)
|
||||
}
|
||||
|
||||
// Extract client name from filename
|
||||
base := filepath.Base(path)
|
||||
name := strings.TrimPrefix(base, "client-")
|
||||
name = strings.TrimSuffix(name, ".conf")
|
||||
|
||||
if name == "" || name == "client-" {
|
||||
return nil, fmt.Errorf("invalid client filename: %s", base)
|
||||
}
|
||||
|
||||
client := &Client{
|
||||
Name: name,
|
||||
ConfigPath: path,
|
||||
}
|
||||
|
||||
// Parse the INI-style config
|
||||
inPeerSection := false
|
||||
hasPublicKey := false
|
||||
|
||||
for i, line := range strings.Split(string(content), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
|
||||
// Skip empty lines and comments
|
||||
if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, ";") {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check for Peer section
|
||||
if line == "[Peer]" {
|
||||
inPeerSection = true
|
||||
continue
|
||||
}
|
||||
|
||||
// Parse key-value pairs within Peer section
|
||||
if inPeerSection {
|
||||
parts := strings.SplitN(line, "=", 2)
|
||||
if len(parts) != 2 {
|
||||
log.Printf("Warning: malformed line %d in %s: %s", i+1, path, line)
|
||||
continue
|
||||
}
|
||||
|
||||
key := strings.TrimSpace(parts[0])
|
||||
value := strings.TrimSpace(parts[1])
|
||||
|
||||
switch key {
|
||||
case "PublicKey":
|
||||
client.PublicKey = value
|
||||
hasPublicKey = true
|
||||
case "PresharedKey":
|
||||
client.HasPSK = true
|
||||
case "AllowedIPs":
|
||||
if err := parseAllowedIPs(client, value); err != nil {
|
||||
log.Printf("Warning: %v (file: %s, line: %d)", err, path, i+1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
if !hasPublicKey {
|
||||
return nil, fmt.Errorf("missing required PublicKey in %s", path)
|
||||
}
|
||||
|
||||
if client.IPv4 == "" && client.IPv6 == "" {
|
||||
return nil, fmt.Errorf("no valid IP addresses found in AllowedIPs in %s", path)
|
||||
}
|
||||
|
||||
return client, nil
|
||||
}
|
||||
|
||||
// parseAllowedIPs extracts IPv4 and IPv6 addresses from AllowedIPs value
|
||||
func parseAllowedIPs(client *Client, allowedIPs string) error {
|
||||
// AllowedIPs format: "ipv4/32, ipv6/128"
|
||||
addresses := strings.Split(allowedIPs, ",")
|
||||
|
||||
for _, addr := range addresses {
|
||||
addr = strings.TrimSpace(addr)
|
||||
if addr == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Split IP from CIDR suffix
|
||||
parts := strings.Split(addr, "/")
|
||||
if len(parts) != 2 {
|
||||
return fmt.Errorf("invalid AllowedIP format: %s", addr)
|
||||
}
|
||||
|
||||
ip := strings.TrimSpace(parts[0])
|
||||
|
||||
// Detect if IPv4 or IPv6 based on presence of colon
|
||||
if strings.Contains(ip, ":") {
|
||||
client.IPv6 = ip
|
||||
} else {
|
||||
client.IPv4 = ip
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListClients finds and parses all client configurations from /etc/wireguard/conf.d/
|
||||
func ListClients() ([]Client, error) {
|
||||
configDir := "/etc/wireguard/conf.d"
|
||||
|
||||
// Check if directory exists
|
||||
if _, err := os.Stat(configDir); os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("wireguard config directory does not exist: %s", configDir)
|
||||
}
|
||||
|
||||
// Find all client-*.conf files
|
||||
pattern := filepath.Join(configDir, "client-*.conf")
|
||||
matches, err := filepath.Glob(pattern)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to find client config files: %w", err)
|
||||
}
|
||||
|
||||
if len(matches) == 0 {
|
||||
return []Client{}, nil // No clients found, return empty slice
|
||||
}
|
||||
|
||||
// Parse each config file
|
||||
var clients []Client
|
||||
var parseErrors []string
|
||||
|
||||
for _, match := range matches {
|
||||
client, err := ParseClientConfig(match)
|
||||
if err != nil {
|
||||
parseErrors = append(parseErrors, err.Error())
|
||||
log.Printf("Warning: failed to parse %s: %v", match, err)
|
||||
continue
|
||||
}
|
||||
clients = append(clients, *client)
|
||||
}
|
||||
|
||||
// If all files failed to parse, return an error
|
||||
if len(clients) == 0 && len(parseErrors) > 0 {
|
||||
return nil, fmt.Errorf("failed to parse any client configs: %s", strings.Join(parseErrors, "; "))
|
||||
}
|
||||
|
||||
return clients, nil
|
||||
}
|
||||
|
||||
// GetClientConfigContent reads the raw configuration content for a client
|
||||
func GetClientConfigContent(name string) (string, error) {
|
||||
configDir := "/etc/wireguard/clients"
|
||||
configPath := filepath.Join(configDir, fmt.Sprintf("%s.conf", name))
|
||||
|
||||
content, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return "", fmt.Errorf("client config not found: %s", configPath)
|
||||
}
|
||||
return "", fmt.Errorf("failed to read client config %s: %w", configPath, err)
|
||||
}
|
||||
|
||||
return string(content), nil
|
||||
}
|
||||
|
||||
// DeleteClient removes a WireGuard client configuration and associated files
|
||||
func DeleteClient(name string) error {
|
||||
// First, find the client config to get public key for removal from interface
|
||||
configDir := "/etc/wireguard/conf.d"
|
||||
configPath := filepath.Join(configDir, fmt.Sprintf("client-%s.conf", name))
|
||||
|
||||
client, err := ParseClientConfig(configPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse client config for deletion: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("Deleting client: %s (public key: %s)", name, client.PublicKey)
|
||||
|
||||
// Create backup before deletion
|
||||
backupPath, err := backup.BackupConfig(fmt.Sprintf("delete-%s", name))
|
||||
if err != nil {
|
||||
log.Printf("Warning: failed to create backup before deletion: %v", err)
|
||||
} else {
|
||||
log.Printf("Created backup: %s", backupPath)
|
||||
}
|
||||
|
||||
// Remove peer from WireGuard interface using wg command
|
||||
if err := removePeerFromInterface(client.PublicKey); err != nil {
|
||||
log.Printf("Warning: failed to remove peer from interface: %v", err)
|
||||
}
|
||||
|
||||
// Remove client config from /etc/wireguard/conf.d/
|
||||
if err := os.Remove(configPath); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("failed to remove client config %s: %w", configPath, err)
|
||||
}
|
||||
log.Printf("Removed client config: %s", configPath)
|
||||
|
||||
// Remove client files from /etc/wireguard/clients/
|
||||
clientsDir := "/etc/wireguard/clients"
|
||||
clientFile := filepath.Join(clientsDir, fmt.Sprintf("%s.conf", name))
|
||||
if err := os.Remove(clientFile); err != nil && !os.IsNotExist(err) {
|
||||
log.Printf("Warning: failed to remove client file %s: %v", clientFile, err)
|
||||
} else {
|
||||
log.Printf("Removed client file: %s", clientFile)
|
||||
}
|
||||
|
||||
// Remove QR code PNG if it exists
|
||||
qrFile := filepath.Join(clientsDir, fmt.Sprintf("%s.png", name))
|
||||
if err := os.Remove(qrFile); err != nil && !os.IsNotExist(err) {
|
||||
log.Printf("Warning: failed to remove QR code %s: %v", qrFile, err)
|
||||
} else {
|
||||
log.Printf("Removed QR code: %s", qrFile)
|
||||
}
|
||||
|
||||
log.Printf("Successfully deleted client: %s", name)
|
||||
return nil
|
||||
}
|
||||
|
||||
// removePeerFromInterface removes a peer from the WireGuard interface
|
||||
func removePeerFromInterface(publicKey string) error {
|
||||
// Use wg command to remove peer
|
||||
cmd := exec.Command("wg", "set", "wg0", "peer", publicKey, "remove")
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("wg set peer remove failed: %w, output: %s", err, string(output))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateClient creates a new WireGuard client configuration
|
||||
func CreateClient(name, dns string, usePSK bool) error {
|
||||
log.Printf("Creating client: %s (PSK: %v)", name, usePSK)
|
||||
|
||||
// Create backup before creating client
|
||||
backupPath, err := backup.BackupConfig(fmt.Sprintf("create-%s", name))
|
||||
if err != nil {
|
||||
log.Printf("Warning: failed to create backup before creating client: %v", err)
|
||||
} else {
|
||||
log.Printf("Created backup: %s", backupPath)
|
||||
}
|
||||
|
||||
// Generate keys
|
||||
privateKey, publicKey, err := generateKeyPair()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to generate key pair: %w", err)
|
||||
}
|
||||
|
||||
var psk string
|
||||
if usePSK {
|
||||
psk, err = generatePSK()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to generate PSK: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Get next available IP addresses
|
||||
cfg, err := config.LoadConfig()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load config: %w", err)
|
||||
}
|
||||
|
||||
clients, err := ListClients()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list existing clients: %w", err)
|
||||
}
|
||||
|
||||
ipv4, err := getNextAvailableIP(cfg.VPNIPv4Range, clients)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get IPv4 address: %w", err)
|
||||
}
|
||||
|
||||
ipv6, err := getNextAvailableIP(cfg.VPNIPv6Range, clients)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get IPv6 address: %w", err)
|
||||
}
|
||||
|
||||
// Create server config
|
||||
serverConfigPath := fmt.Sprintf("/etc/wireguard/conf.d/client-%s.conf", name)
|
||||
serverConfig, err := generateServerConfig(name, publicKey, ipv4, ipv6, psk, cfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to generate server config: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(serverConfigPath, []byte(serverConfig), 0600); err != nil {
|
||||
return fmt.Errorf("failed to write server config: %w", err)
|
||||
}
|
||||
log.Printf("Created server config: %s", serverConfigPath)
|
||||
|
||||
// Create client config
|
||||
clientsDir := "/etc/wireguard/clients"
|
||||
if err := os.MkdirAll(clientsDir, 0700); err != nil {
|
||||
return fmt.Errorf("failed to create clients directory: %w", err)
|
||||
}
|
||||
|
||||
clientConfigPath := filepath.Join(clientsDir, fmt.Sprintf("%s.conf", name))
|
||||
clientConfig, err := generateClientConfig(name, privateKey, ipv4, ipv6, dns, cfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to generate client config: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(clientConfigPath, []byte(clientConfig), 0600); err != nil {
|
||||
return fmt.Errorf("failed to write client config: %w", err)
|
||||
}
|
||||
log.Printf("Created client config: %s", clientConfigPath)
|
||||
|
||||
// Add peer to WireGuard interface
|
||||
if err := addPeerToInterface(publicKey, ipv4, ipv6, psk); err != nil {
|
||||
log.Printf("Warning: failed to add peer to interface: %v", err)
|
||||
} else {
|
||||
log.Printf("Added peer to WireGuard interface")
|
||||
}
|
||||
|
||||
// Generate QR code
|
||||
qrPath := filepath.Join(clientsDir, fmt.Sprintf("%s.png", name))
|
||||
if err := generateQRCode(clientConfigPath, qrPath); err != nil {
|
||||
log.Printf("Warning: failed to generate QR code: %v", err)
|
||||
} else {
|
||||
log.Printf("Generated QR code: %s", qrPath)
|
||||
}
|
||||
|
||||
log.Printf("Successfully created client: %s", name)
|
||||
return nil
|
||||
}
|
||||
|
||||
// generateKeyPair generates a WireGuard private and public key pair
|
||||
func generateKeyPair() (privateKey, publicKey string, err error) {
|
||||
// Generate private key
|
||||
privateKeyBytes, err := exec.Command("wg", "genkey").Output()
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("wg genkey failed: %w", err)
|
||||
}
|
||||
privateKey = strings.TrimSpace(string(privateKeyBytes))
|
||||
|
||||
// Derive public key
|
||||
pubKeyCmd := exec.Command("wg", "pubkey")
|
||||
pubKeyCmd.Stdin = strings.NewReader(privateKey)
|
||||
publicKeyBytes, err := pubKeyCmd.Output()
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("wg pubkey failed: %w", err)
|
||||
}
|
||||
publicKey = strings.TrimSpace(string(publicKeyBytes))
|
||||
|
||||
return privateKey, publicKey, nil
|
||||
}
|
||||
|
||||
// generatePSK generates a WireGuard preshared key
|
||||
func generatePSK() (string, error) {
|
||||
psk, err := exec.Command("wg", "genpsk").Output()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("wg genpsk failed: %w", err)
|
||||
}
|
||||
return strings.TrimSpace(string(psk)), nil
|
||||
}
|
||||
|
||||
// getNextAvailableIP finds the next available IP address in the given CIDR range
|
||||
func getNextAvailableIP(cidr string, existingClients []Client) (string, error) {
|
||||
// Parse CIDR to get network
|
||||
parts := strings.Split(cidr, "/")
|
||||
if len(parts) != 2 {
|
||||
return "", fmt.Errorf("invalid CIDR format: %s", cidr)
|
||||
}
|
||||
|
||||
network := strings.TrimSpace(parts[0])
|
||||
|
||||
// For IPv4, extract base network and assign next available host
|
||||
if !strings.Contains(network, ":") {
|
||||
// IPv4: Simple implementation - use .1, .2, etc.
|
||||
// In production, this would parse the CIDR properly
|
||||
usedHosts := make(map[string]bool)
|
||||
for _, client := range existingClients {
|
||||
if client.IPv4 != "" {
|
||||
ipParts := strings.Split(client.IPv4, ".")
|
||||
if len(ipParts) == 4 {
|
||||
usedHosts[ipParts[3]] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Find next available host (skip 0 and 1 as they may be reserved)
|
||||
for i := 2; i < 255; i++ {
|
||||
host := fmt.Sprintf("%d", i)
|
||||
if !usedHosts[host] {
|
||||
return fmt.Sprintf("%s.%s", network, host), nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("no available IPv4 addresses in range: %s", cidr)
|
||||
}
|
||||
|
||||
// IPv6: Similar simplified approach
|
||||
usedHosts := make(map[string]bool)
|
||||
for _, client := range existingClients {
|
||||
if client.IPv6 != "" {
|
||||
// Extract last segment for IPv6
|
||||
lastColon := strings.LastIndex(client.IPv6, ":")
|
||||
if lastColon > 0 {
|
||||
host := client.IPv6[lastColon+1:]
|
||||
usedHosts[host] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Find next available host
|
||||
for i := 1; i < 65536; i++ {
|
||||
host := fmt.Sprintf("%x", i)
|
||||
if !usedHosts[host] {
|
||||
return fmt.Sprintf("%s:%s", network, host), nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("no available IPv6 addresses in range: %s", cidr)
|
||||
}
|
||||
|
||||
// generateServerConfig generates the server-side configuration for a client
|
||||
func generateServerConfig(name, publicKey, ipv4, ipv6, psk string, cfg *config.Config) (string, error) {
|
||||
var builder strings.Builder
|
||||
|
||||
builder.WriteString(fmt.Sprintf("# Client: %s\n", name))
|
||||
builder.WriteString(fmt.Sprintf("[Peer]\n"))
|
||||
builder.WriteString(fmt.Sprintf("PublicKey = %s\n", publicKey))
|
||||
|
||||
allowedIPs := ""
|
||||
if ipv4 != "" {
|
||||
allowedIPs = ipv4 + "/32"
|
||||
}
|
||||
if ipv6 != "" {
|
||||
if allowedIPs != "" {
|
||||
allowedIPs += ", "
|
||||
}
|
||||
allowedIPs += ipv6 + "/128"
|
||||
}
|
||||
builder.WriteString(fmt.Sprintf("AllowedIPs = %s\n", allowedIPs))
|
||||
|
||||
if psk != "" {
|
||||
builder.WriteString(fmt.Sprintf("PresharedKey = %s\n", psk))
|
||||
}
|
||||
|
||||
return builder.String(), nil
|
||||
}
|
||||
|
||||
// generateClientConfig generates the client-side configuration
|
||||
func generateClientConfig(name, privateKey, ipv4, ipv6, dns string, cfg *config.Config) (string, error) {
|
||||
// Get server's public key from the main config
|
||||
serverConfigPath := "/etc/wireguard/wg0.conf"
|
||||
serverPublicKey, serverEndpoint, err := getServerConfig(serverConfigPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read server config: %w", err)
|
||||
}
|
||||
|
||||
var builder strings.Builder
|
||||
|
||||
builder.WriteString(fmt.Sprintf("# WireGuard client configuration for %s\n", name))
|
||||
builder.WriteString("[Interface]\n")
|
||||
builder.WriteString(fmt.Sprintf("PrivateKey = %s\n", privateKey))
|
||||
builder.WriteString(fmt.Sprintf("Address = %s/32", ipv4))
|
||||
if ipv6 != "" {
|
||||
builder.WriteString(fmt.Sprintf(", %s/128", ipv6))
|
||||
}
|
||||
builder.WriteString("\n")
|
||||
builder.WriteString(fmt.Sprintf("DNS = %s\n", dns))
|
||||
|
||||
builder.WriteString("\n")
|
||||
builder.WriteString("[Peer]\n")
|
||||
builder.WriteString(fmt.Sprintf("PublicKey = %s\n", serverPublicKey))
|
||||
builder.WriteString(fmt.Sprintf("Endpoint = %s:%d\n", serverEndpoint, cfg.WGPort))
|
||||
builder.WriteString("AllowedIPs = 0.0.0.0/0, ::/0\n")
|
||||
|
||||
return builder.String(), nil
|
||||
}
|
||||
|
||||
// getServerConfig reads the server's public key and endpoint from the main config
|
||||
func getServerConfig(path string) (publicKey, endpoint string, err error) {
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("failed to read server config: %w", err)
|
||||
}
|
||||
|
||||
inInterfaceSection := false
|
||||
for _, line := range strings.Split(string(content), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
|
||||
if line == "[Interface]" {
|
||||
inInterfaceSection = true
|
||||
continue
|
||||
}
|
||||
if line == "[Peer]" {
|
||||
inInterfaceSection = false
|
||||
continue
|
||||
}
|
||||
|
||||
if inInterfaceSection {
|
||||
if strings.HasPrefix(line, "PublicKey") {
|
||||
parts := strings.SplitN(line, "=", 2)
|
||||
if len(parts) == 2 {
|
||||
publicKey = strings.TrimSpace(parts[1])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Use SERVER_DOMAIN as endpoint if available, otherwise fallback
|
||||
cfg, err := config.LoadConfig()
|
||||
if err == nil && cfg.ServerDomain != "" {
|
||||
endpoint = cfg.ServerDomain
|
||||
} else {
|
||||
endpoint = "0.0.0.0"
|
||||
}
|
||||
|
||||
return publicKey, endpoint, nil
|
||||
}
|
||||
|
||||
// addPeerToInterface adds a peer to the WireGuard interface
|
||||
func addPeerToInterface(publicKey, ipv4, ipv6, psk string) error {
|
||||
args := []string{"set", "wg0", "peer", publicKey}
|
||||
|
||||
if ipv4 != "" {
|
||||
args = append(args, "allowed-ips", ipv4+"/32")
|
||||
}
|
||||
if ipv6 != "" {
|
||||
args = append(args, ipv6+"/128")
|
||||
}
|
||||
|
||||
if psk != "" {
|
||||
args = append(args, "preshared-key", "/dev/stdin")
|
||||
cmd := exec.Command("wg", args...)
|
||||
cmd.Stdin = strings.NewReader(psk)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("wg set peer failed: %w, output: %s", err, string(output))
|
||||
}
|
||||
} else {
|
||||
cmd := exec.Command("wg", args...)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("wg set peer failed: %w, output: %s", err, string(output))
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// generateQRCode generates a QR code from the client config
|
||||
func generateQRCode(configPath, qrPath string) error {
|
||||
// Read config file
|
||||
content, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read config file: %w", err)
|
||||
}
|
||||
|
||||
// Generate QR code using qrencode or similar
|
||||
// For now, use a simple approach with qrencode if available
|
||||
cmd := exec.Command("qrencode", "-o", qrPath, "-t", "PNG")
|
||||
cmd.Stdin = bytes.NewReader(content)
|
||||
if err := cmd.Run(); err != nil {
|
||||
// qrencode not available, try alternative method
|
||||
return fmt.Errorf("qrencode not available: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
106
internal/wireguard/config.go
Normal file
106
internal/wireguard/config.go
Normal file
@@ -0,0 +1,106 @@
|
||||
package wireguard
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// GenerateServerConfig generates a server-side WireGuard [Peer] configuration
|
||||
// and writes it to /etc/wireguard/conf.d/client-<name>.conf
|
||||
func GenerateServerConfig(name, publicKey string, hasPSK bool, ipv4, ipv6, psk string) (string, error) {
|
||||
var builder strings.Builder
|
||||
|
||||
builder.WriteString(fmt.Sprintf("[Peer]\n"))
|
||||
builder.WriteString(fmt.Sprintf("# %s\n", name))
|
||||
builder.WriteString(fmt.Sprintf("PublicKey = %s\n", publicKey))
|
||||
|
||||
if hasPSK {
|
||||
builder.WriteString(fmt.Sprintf("PresharedKey = %s\n", psk))
|
||||
}
|
||||
|
||||
builder.WriteString(fmt.Sprintf("AllowedIPs = %s/32", ipv4))
|
||||
if ipv6 != "" {
|
||||
builder.WriteString(fmt.Sprintf(", %s/128", ipv6))
|
||||
}
|
||||
builder.WriteString("\n")
|
||||
|
||||
configContent := builder.String()
|
||||
|
||||
configDir := "/etc/wireguard/conf.d"
|
||||
configPath := filepath.Join(configDir, fmt.Sprintf("client-%s.conf", name))
|
||||
|
||||
if err := atomicWrite(configPath, configContent); err != nil {
|
||||
return "", fmt.Errorf("failed to write server config: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("Generated server config: %s", configPath)
|
||||
return configPath, nil
|
||||
}
|
||||
|
||||
// GenerateClientConfig generates a client-side WireGuard configuration
|
||||
// with [Interface] and [Peer] sections and writes it to /etc/wireguard/clients/<name>.conf
|
||||
func GenerateClientConfig(name, privateKey, ipv4, ipv6, dns, serverPublicKey, endpoint string, port int, hasPSK bool, psk string) (string, error) {
|
||||
var builder strings.Builder
|
||||
|
||||
// [Interface] section
|
||||
builder.WriteString("[Interface]\n")
|
||||
builder.WriteString(fmt.Sprintf("PrivateKey = %s\n", privateKey))
|
||||
builder.WriteString(fmt.Sprintf("Address = %s/24", ipv4))
|
||||
if ipv6 != "" {
|
||||
builder.WriteString(fmt.Sprintf(", %s/64", ipv6))
|
||||
}
|
||||
builder.WriteString("\n")
|
||||
builder.WriteString(fmt.Sprintf("DNS = %s\n", dns))
|
||||
|
||||
// [Peer] section
|
||||
builder.WriteString("\n[Peer]\n")
|
||||
builder.WriteString(fmt.Sprintf("PublicKey = %s\n", serverPublicKey))
|
||||
|
||||
if hasPSK {
|
||||
builder.WriteString(fmt.Sprintf("PresharedKey = %s\n", psk))
|
||||
}
|
||||
|
||||
builder.WriteString(fmt.Sprintf("Endpoint = %s:%d\n", endpoint, port))
|
||||
builder.WriteString("AllowedIPs = 0.0.0.0/0, ::/0\n")
|
||||
builder.WriteString("PersistentKeepalive = 25\n")
|
||||
|
||||
configContent := builder.String()
|
||||
|
||||
clientsDir := "/etc/wireguard/clients"
|
||||
configPath := filepath.Join(clientsDir, fmt.Sprintf("%s.conf", name))
|
||||
|
||||
if err := atomicWrite(configPath, configContent); err != nil {
|
||||
return "", fmt.Errorf("failed to write client config: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("Generated client config: %s", configPath)
|
||||
return configPath, nil
|
||||
}
|
||||
|
||||
// atomicWrite writes content to a file atomically
|
||||
// Uses temp file + rename pattern for atomicity and sets permissions to 0600
|
||||
func atomicWrite(path, content string) error {
|
||||
// Ensure parent directory exists
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create directory %s: %w", dir, err)
|
||||
}
|
||||
|
||||
// Write to temp file first
|
||||
tempPath := path + ".tmp"
|
||||
if err := os.WriteFile(tempPath, []byte(content), 0600); err != nil {
|
||||
return fmt.Errorf("failed to write temp file: %w", err)
|
||||
}
|
||||
|
||||
// Atomic rename
|
||||
if err := os.Rename(tempPath, path); err != nil {
|
||||
// Clean up temp file if rename fails
|
||||
_ = os.Remove(tempPath)
|
||||
return fmt.Errorf("failed to rename temp file: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
243
internal/wireguard/keys.go
Normal file
243
internal/wireguard/keys.go
Normal file
@@ -0,0 +1,243 @@
|
||||
package wireguard
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
const (
|
||||
// KeyLength is the standard WireGuard key length in bytes (32 bytes = 256 bits)
|
||||
KeyLength = 32
|
||||
// Base64KeyLength is the length of a base64-encoded WireGuard key (44 characters)
|
||||
Base64KeyLength = 44
|
||||
// TempDir is the directory for temporary key storage
|
||||
TempDir = "/tmp/wg-admin"
|
||||
)
|
||||
|
||||
var (
|
||||
tempKeys = make(map[string]bool)
|
||||
tempKeysMutex sync.Mutex
|
||||
)
|
||||
|
||||
// KeyPair represents a WireGuard key pair
|
||||
type KeyPair struct {
|
||||
PrivateKey string // Base64-encoded private key
|
||||
PublicKey string // Base64-encoded public key
|
||||
}
|
||||
|
||||
// GeneratePrivateKey generates a new WireGuard private key using wg genkey
|
||||
func GeneratePrivateKey() (string, error) {
|
||||
cmd := exec.Command("wg", "genkey")
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("wg genkey failed: %w, output: %s", err, string(output))
|
||||
}
|
||||
|
||||
key := string(output)
|
||||
if err := ValidateKey(key); err != nil {
|
||||
return "", fmt.Errorf("generated invalid private key: %w", err)
|
||||
}
|
||||
|
||||
// Store as temporary key
|
||||
tempKeyPath := filepath.Join(TempDir, "private.key")
|
||||
if err := storeTempKey(tempKeyPath, key); err != nil {
|
||||
log.Printf("Warning: failed to store temporary private key: %v", err)
|
||||
}
|
||||
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// GeneratePublicKey generates a public key from a private key using wg pubkey
|
||||
func GeneratePublicKey(privateKey string) (string, error) {
|
||||
if err := ValidateKey(privateKey); err != nil {
|
||||
return "", fmt.Errorf("invalid private key: %w", err)
|
||||
}
|
||||
|
||||
cmd := exec.Command("wg", "pubkey")
|
||||
cmd.Stdin = strings.NewReader(privateKey)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("wg pubkey failed: %w, output: %s", err, string(output))
|
||||
}
|
||||
|
||||
key := string(output)
|
||||
if err := ValidateKey(key); err != nil {
|
||||
return "", fmt.Errorf("generated invalid public key: %w", err)
|
||||
}
|
||||
|
||||
// Store as temporary key
|
||||
tempKeyPath := filepath.Join(TempDir, "public.key")
|
||||
if err := storeTempKey(tempKeyPath, key); err != nil {
|
||||
log.Printf("Warning: failed to store temporary public key: %v", err)
|
||||
}
|
||||
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// GeneratePSK generates a new pre-shared key using wg genpsk
|
||||
func GeneratePSK() (string, error) {
|
||||
cmd := exec.Command("wg", "genpsk")
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("wg genpsk failed: %w, output: %s", err, string(output))
|
||||
}
|
||||
|
||||
key := string(output)
|
||||
if err := ValidateKey(key); err != nil {
|
||||
return "", fmt.Errorf("generated invalid PSK: %w", err)
|
||||
}
|
||||
|
||||
// Store as temporary key
|
||||
tempKeyPath := filepath.Join(TempDir, "psk.key")
|
||||
if err := storeTempKey(tempKeyPath, key); err != nil {
|
||||
log.Printf("Warning: failed to store temporary PSK: %v", err)
|
||||
}
|
||||
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// GenerateKeyPair generates a complete WireGuard key pair (private + public)
|
||||
func GenerateKeyPair() (*KeyPair, error) {
|
||||
privateKey, err := GeneratePrivateKey()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
publicKey, err := GeneratePublicKey(privateKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate public key: %w", err)
|
||||
}
|
||||
|
||||
return &KeyPair{
|
||||
PrivateKey: privateKey,
|
||||
PublicKey: publicKey,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ValidateKey validates that a key is properly formatted (44 base64 characters)
|
||||
func ValidateKey(key string) error {
|
||||
// Trim whitespace
|
||||
key = strings.TrimSpace(key)
|
||||
|
||||
// Check length (44 base64 characters for 32 bytes)
|
||||
if len(key) != Base64KeyLength {
|
||||
return fmt.Errorf("invalid key length: expected %d characters, got %d", Base64KeyLength, len(key))
|
||||
}
|
||||
|
||||
// Verify it's valid base64
|
||||
decoded, err := base64.StdEncoding.DecodeString(key)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid base64 encoding: %w", err)
|
||||
}
|
||||
|
||||
// Verify decoded length is 32 bytes
|
||||
if len(decoded) != KeyLength {
|
||||
return fmt.Errorf("invalid decoded key length: expected %d bytes, got %d", KeyLength, len(decoded))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// StoreKey atomically writes a key to a file with 0600 permissions
|
||||
func StoreKey(path string, key string) error {
|
||||
// Validate key before storing
|
||||
if err := ValidateKey(key); err != nil {
|
||||
return fmt.Errorf("invalid key: %w", err)
|
||||
}
|
||||
|
||||
// Trim whitespace
|
||||
key = strings.TrimSpace(key)
|
||||
|
||||
// Create parent directories if needed
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create directory %s: %w", dir, err)
|
||||
}
|
||||
|
||||
// Write to temporary file
|
||||
tempPath := path + ".tmp"
|
||||
if err := os.WriteFile(tempPath, []byte(key), 0600); err != nil {
|
||||
return fmt.Errorf("failed to write temp file %s: %w", tempPath, err)
|
||||
}
|
||||
|
||||
// Atomic rename
|
||||
if err := os.Rename(tempPath, path); err != nil {
|
||||
os.Remove(tempPath) // Clean up temp file on failure
|
||||
return fmt.Errorf("failed to rename temp file to %s: %w", path, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadKey reads a key from a file and validates it
|
||||
func LoadKey(path string) (string, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read key file %s: %w", path, err)
|
||||
}
|
||||
|
||||
key := strings.TrimSpace(string(data))
|
||||
if err := ValidateKey(key); err != nil {
|
||||
return "", fmt.Errorf("invalid key in file %s: %w", path, err)
|
||||
}
|
||||
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// storeTempKey stores a temporary key and tracks it for cleanup
|
||||
func storeTempKey(path string, key string) error {
|
||||
// Create temp directory if needed
|
||||
if err := os.MkdirAll(TempDir, 0700); err != nil {
|
||||
return fmt.Errorf("failed to create temp directory %s: %w", TempDir, err)
|
||||
}
|
||||
|
||||
// Trim whitespace
|
||||
key = strings.TrimSpace(key)
|
||||
|
||||
// Write to file with 0600 permissions
|
||||
if err := os.WriteFile(path, []byte(key), 0600); err != nil {
|
||||
return fmt.Errorf("failed to write temp key to %s: %w", path, err)
|
||||
}
|
||||
|
||||
// Track for cleanup
|
||||
tempKeysMutex.Lock()
|
||||
tempKeys[path] = true
|
||||
tempKeysMutex.Unlock()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CleanupTempKeys removes all temporary keys
|
||||
func CleanupTempKeys() error {
|
||||
tempKeysMutex.Lock()
|
||||
defer tempKeysMutex.Unlock()
|
||||
|
||||
var cleanupErrors []string
|
||||
|
||||
for path := range tempKeys {
|
||||
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
||||
cleanupErrors = append(cleanupErrors, fmt.Sprintf("%s: %v", path, err))
|
||||
log.Printf("Warning: failed to remove temp key %s: %v", path, err)
|
||||
}
|
||||
delete(tempKeys, path)
|
||||
}
|
||||
|
||||
// Also attempt to clean up the temp directory if empty
|
||||
if _, err := os.ReadDir(TempDir); err == nil {
|
||||
if err := os.Remove(TempDir); err != nil && !os.IsNotExist(err) {
|
||||
log.Printf("Warning: failed to remove temp directory %s: %v", TempDir, err)
|
||||
}
|
||||
}
|
||||
|
||||
if len(cleanupErrors) > 0 {
|
||||
return fmt.Errorf("cleanup errors: %s", strings.Join(cleanupErrors, "; "))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
246
internal/wireguard/status.go
Normal file
246
internal/wireguard/status.go
Normal file
@@ -0,0 +1,246 @@
|
||||
package wireguard
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// StatusConnected indicates a peer has an active connection
|
||||
StatusConnected = "Connected"
|
||||
// StatusDisconnected indicates a peer is not connected
|
||||
StatusDisconnected = "Disconnected"
|
||||
|
||||
// QualityExcellent indicates handshake was very recent (< 30s)
|
||||
QualityExcellent = "Excellent"
|
||||
// QualityGood indicates handshake was recent (< 2m)
|
||||
QualityGood = "Good"
|
||||
// QualityFair indicates handshake was acceptable (< 5m)
|
||||
QualityFair = "Fair"
|
||||
// QualityPoor indicates handshake was old (> 5m)
|
||||
QualityPoor = "Poor"
|
||||
)
|
||||
|
||||
// PeerStatus represents the status of a WireGuard peer
|
||||
type PeerStatus struct {
|
||||
PublicKey string `json:"public_key"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
AllowedIPs string `json:"allowed_ips"`
|
||||
LatestHandshake time.Time `json:"latest_handshake"`
|
||||
TransferRx string `json:"transfer_rx"`
|
||||
TransferTx string `json:"transfer_tx"`
|
||||
Status string `json:"status"` // "Connected" or "Disconnected"
|
||||
Quality string `json:"quality,omitempty"` // "Excellent", "Good", "Fair", "Poor" (if connected)
|
||||
}
|
||||
|
||||
// GetClientStatus checks if a specific client is connected
|
||||
// Returns "Connected" if the peer appears in the active peers list, "Disconnected" otherwise
|
||||
func GetClientStatus(publicKey string) (string, error) {
|
||||
peers, err := GetAllPeers()
|
||||
if err != nil {
|
||||
return StatusDisconnected, fmt.Errorf("failed to get peer status: %w", err)
|
||||
}
|
||||
|
||||
for _, peer := range peers {
|
||||
if peer.PublicKey == publicKey {
|
||||
return peer.Status, nil
|
||||
}
|
||||
}
|
||||
|
||||
return StatusDisconnected, nil
|
||||
}
|
||||
|
||||
// GetAllPeers retrieves all peers with their current status from WireGuard
|
||||
func GetAllPeers() ([]PeerStatus, error) {
|
||||
output, err := exec.Command("wg", "show", "wg0").Output()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to execute wg show: %w", err)
|
||||
}
|
||||
|
||||
return parsePeersOutput(string(output)), nil
|
||||
}
|
||||
|
||||
// parsePeersOutput parses the output of 'wg show wg0' command
|
||||
func parsePeersOutput(output string) []PeerStatus {
|
||||
var peers []PeerStatus
|
||||
var currentPeer *PeerStatus
|
||||
var handshake string
|
||||
var transfer string
|
||||
|
||||
lines := strings.Split(output, "\n")
|
||||
peerLineRegex := regexp.MustCompile(`^peer:\s*(.+)$`)
|
||||
handshakeRegex := regexp.MustCompile(`^latest handshake:\s*(.+)\s+ago$`)
|
||||
transferRegex := regexp.MustCompile(`^transfer:\s*(.+),\s+(.+)$`)
|
||||
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
|
||||
// Check for new peer
|
||||
if match := peerLineRegex.FindStringSubmatch(line); match != nil {
|
||||
// Save previous peer if exists
|
||||
if currentPeer != nil {
|
||||
peers = append(peers, finalizePeerStatus(currentPeer, handshake, transfer))
|
||||
}
|
||||
|
||||
// Start new peer
|
||||
currentPeer = &PeerStatus{
|
||||
PublicKey: match[1],
|
||||
}
|
||||
handshake = ""
|
||||
transfer = ""
|
||||
continue
|
||||
}
|
||||
|
||||
if currentPeer == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Parse endpoint
|
||||
if strings.HasPrefix(line, "endpoint:") {
|
||||
currentPeer.Endpoint = strings.TrimSpace(strings.TrimPrefix(line, "endpoint:"))
|
||||
}
|
||||
|
||||
// Parse allowed ips
|
||||
if strings.HasPrefix(line, "allowed ips:") {
|
||||
currentPeer.AllowedIPs = strings.TrimSpace(strings.TrimPrefix(line, "allowed ips:"))
|
||||
}
|
||||
|
||||
// Parse latest handshake
|
||||
if match := handshakeRegex.FindStringSubmatch(line); match != nil {
|
||||
handshake = match[1]
|
||||
}
|
||||
|
||||
// Parse transfer
|
||||
if match := transferRegex.FindStringSubmatch(line); match != nil {
|
||||
transfer = fmt.Sprintf("%s, %s", match[1], match[2])
|
||||
}
|
||||
}
|
||||
|
||||
// Don't forget the last peer
|
||||
if currentPeer != nil {
|
||||
peers = append(peers, finalizePeerStatus(currentPeer, handshake, transfer))
|
||||
}
|
||||
|
||||
return peers
|
||||
}
|
||||
|
||||
// CalculateQuality returns the connection quality based on handshake time
|
||||
func CalculateQuality(timeSinceHandshake time.Duration) string {
|
||||
if timeSinceHandshake < 30*time.Second {
|
||||
return QualityExcellent
|
||||
}
|
||||
if timeSinceHandshake < 2*time.Minute {
|
||||
return QualityGood
|
||||
}
|
||||
if timeSinceHandshake < 5*time.Minute {
|
||||
return QualityFair
|
||||
}
|
||||
return QualityPoor
|
||||
}
|
||||
|
||||
// finalizePeerStatus determines the peer's status based on handshake time
|
||||
func finalizePeerStatus(peer *PeerStatus, handshake string, transfer string) PeerStatus {
|
||||
peer.TransferRx = ""
|
||||
peer.TransferTx = ""
|
||||
|
||||
// Parse transfer
|
||||
if transfer != "" {
|
||||
parts := strings.Split(transfer, ", ")
|
||||
if len(parts) == 2 {
|
||||
// Extract received and sent values
|
||||
rxParts := strings.Fields(parts[0])
|
||||
if len(rxParts) >= 2 {
|
||||
peer.TransferRx = strings.Join(rxParts[:2], " ")
|
||||
}
|
||||
txParts := strings.Fields(parts[1])
|
||||
if len(txParts) >= 2 {
|
||||
peer.TransferTx = strings.Join(txParts[:2], " ")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Determine status and quality based on handshake
|
||||
if handshake != "" {
|
||||
peer.LatestHandshake = parseHandshake(handshake)
|
||||
timeSinceHandshake := time.Since(peer.LatestHandshake)
|
||||
|
||||
// Peer is considered connected if handshake is recent (within 5 minutes)
|
||||
// This allows for ~12 missed keepalive intervals (at 25 seconds each)
|
||||
if timeSinceHandshake < 5*time.Minute {
|
||||
peer.Status = StatusConnected
|
||||
peer.Quality = CalculateQuality(timeSinceHandshake)
|
||||
} else {
|
||||
peer.Status = StatusDisconnected
|
||||
}
|
||||
} else {
|
||||
peer.Status = StatusDisconnected
|
||||
}
|
||||
|
||||
return *peer
|
||||
}
|
||||
|
||||
// parseHandshake converts handshake string to time.Time
|
||||
func parseHandshake(handshake string) time.Time {
|
||||
now := time.Now()
|
||||
var totalDuration time.Duration
|
||||
parts := strings.Fields(handshake)
|
||||
|
||||
for i, part := range parts {
|
||||
// Clean up commas
|
||||
cleanPart := strings.TrimSuffix(part, ",")
|
||||
|
||||
// Check if this part is a time unit
|
||||
if strings.HasSuffix(cleanPart, "second") || strings.HasSuffix(cleanPart, "seconds") {
|
||||
// The number is the previous part
|
||||
if i > 0 {
|
||||
if val, err := strconv.Atoi(parts[i-1]); err == nil {
|
||||
totalDuration += time.Duration(val) * time.Second
|
||||
}
|
||||
}
|
||||
}
|
||||
if strings.HasSuffix(cleanPart, "minute") || strings.HasSuffix(cleanPart, "minutes") {
|
||||
if i > 0 {
|
||||
if val, err := strconv.Atoi(parts[i-1]); err == nil {
|
||||
totalDuration += time.Duration(val) * time.Minute
|
||||
}
|
||||
}
|
||||
}
|
||||
if strings.HasSuffix(cleanPart, "hour") || strings.HasSuffix(cleanPart, "hours") {
|
||||
if i > 0 {
|
||||
if val, err := strconv.Atoi(parts[i-1]); err == nil {
|
||||
totalDuration += time.Duration(val) * time.Hour
|
||||
}
|
||||
}
|
||||
}
|
||||
if strings.HasSuffix(cleanPart, "day") || strings.HasSuffix(cleanPart, "days") {
|
||||
if i > 0 {
|
||||
if val, err := strconv.Atoi(parts[i-1]); err == nil {
|
||||
totalDuration += time.Duration(val) * 24 * time.Hour
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if totalDuration == 0 {
|
||||
return now.Add(-time.Hour) // Default to 1 hour ago if parsing fails
|
||||
}
|
||||
|
||||
return now.Add(-totalDuration)
|
||||
}
|
||||
|
||||
// CheckInterface verifies if the WireGuard interface exists and is accessible
|
||||
func CheckInterface(interfaceName string) error {
|
||||
cmd := exec.Command("wg", "show", interfaceName)
|
||||
var out bytes.Buffer
|
||||
cmd.Stdout = &out
|
||||
err := cmd.Run()
|
||||
if err != nil {
|
||||
return fmt.Errorf("wireguard interface '%s' not accessible: %w", interfaceName, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
27
internal/wireguard/tea_messages.go
Normal file
27
internal/wireguard/tea_messages.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package wireguard
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
)
|
||||
|
||||
// StatusTickMsg is sent when it's time to refresh the status
|
||||
type StatusTickMsg struct{}
|
||||
|
||||
// RefreshStatusMsg is sent when manual refresh is triggered
|
||||
type RefreshStatusMsg struct{}
|
||||
|
||||
// Tick returns a tea.Cmd that will send StatusTickMsg at the specified interval
|
||||
func Tick(interval int) tea.Cmd {
|
||||
return tea.Tick(time.Duration(interval)*time.Second, func(t time.Time) tea.Msg {
|
||||
return StatusTickMsg{}
|
||||
})
|
||||
}
|
||||
|
||||
// ManualRefresh returns a tea.Cmd to trigger immediate status refresh
|
||||
func ManualRefresh() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
return RefreshStatusMsg{}
|
||||
}
|
||||
}
|
||||
124
test-wg-install.sh
Executable file
124
test-wg-install.sh
Executable file
@@ -0,0 +1,124 @@
|
||||
#!/usr/bin/env bash
|
||||
# Test script for wg-install.sh validation functions
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Override log location for tests
|
||||
export WGI_LOG_FILE="/tmp/wg-admin-install-test.log"
|
||||
|
||||
# Source the main script to get functions
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "${SCRIPT_DIR}/wg-install.sh"
|
||||
|
||||
# Test counter
|
||||
TESTS_RUN=0
|
||||
TESTS_PASSED=0
|
||||
TESTS_FAILED=0
|
||||
|
||||
# Test function
|
||||
run_test() {
|
||||
local test_name="$1"
|
||||
local expected="$2"
|
||||
shift 2
|
||||
local result
|
||||
|
||||
TESTS_RUN=$((TESTS_RUN + 1))
|
||||
|
||||
# Run function and capture output and exit code
|
||||
if result=$("$@" 2>&1); then
|
||||
local exit_code=$?
|
||||
else
|
||||
local exit_code=$?
|
||||
fi
|
||||
|
||||
if [[ "$expected" == "success" && "$exit_code" -eq 0 ]]; then
|
||||
TESTS_PASSED=$((TESTS_PASSED + 1))
|
||||
echo "✓ PASS: $test_name"
|
||||
return 0
|
||||
elif [[ "$expected" == "failure" && "$exit_code" -ne 0 ]]; then
|
||||
TESTS_PASSED=$((TESTS_PASSED + 1))
|
||||
echo "✓ PASS: $test_name"
|
||||
return 0
|
||||
else
|
||||
TESTS_FAILED=$((TESTS_FAILED + 1))
|
||||
echo "✗ FAIL: $test_name"
|
||||
echo " Expected: $expected"
|
||||
echo " Exit code: $exit_code"
|
||||
echo " Output: $result"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
echo "=== Running wg-install.sh Tests ==="
|
||||
echo ""
|
||||
|
||||
# Test validate_server_domain
|
||||
echo "Testing validate_server_domain..."
|
||||
run_test "Valid domain" success validate_server_domain "vpn.example.com"
|
||||
run_test "Valid subdomain" success validate_server_domain "wg.vpn.example.com"
|
||||
run_test "Valid domain with hyphen" success validate_server_domain "my-vpn.example.com"
|
||||
run_test "Empty domain" failure validate_server_domain ""
|
||||
run_test "Invalid domain - spaces" failure validate_server_domain "vpn example.com"
|
||||
run_test "Invalid domain - special chars" failure validate_server_domain "vpn@example.com"
|
||||
echo ""
|
||||
|
||||
# Test validate_port_range
|
||||
echo "Testing validate_port_range..."
|
||||
run_test "Valid default port" success validate_port_range "51820"
|
||||
run_test "Valid port 80" success validate_port_range "80"
|
||||
run_test "Valid port 443" success validate_port_range "443"
|
||||
run_test "Valid max port" success validate_port_range "65535"
|
||||
run_test "Empty port" failure validate_port_range ""
|
||||
run_test "Invalid port - negative" failure validate_port_range "-1"
|
||||
run_test "Invalid port - zero" failure validate_port_range "0"
|
||||
run_test "Invalid port - too high" failure validate_port_range "65536"
|
||||
run_test "Invalid port - non-numeric" failure validate_port_range "abc"
|
||||
echo ""
|
||||
|
||||
# Test validate_cidr (IPv4)
|
||||
echo "Testing validate_cidr (IPv4)..."
|
||||
run_test "Valid IPv4 /24" success validate_cidr "10.10.69.0/24" false
|
||||
run_test "Valid IPv4 /32" success validate_cidr "192.168.1.1/32" false
|
||||
run_test "Valid IPv4 /16" success validate_cidr "172.16.0.0/16" false
|
||||
run_test "Empty IPv4 CIDR" failure validate_cidr "" false
|
||||
run_test "Invalid IPv4 - no prefix" failure validate_cidr "10.10.69.0" false
|
||||
run_test "Invalid IPv4 - prefix too large" failure validate_cidr "10.10.69.0/33" false
|
||||
run_test "Invalid IPv4 - negative prefix" failure validate_cidr "10.10.69.0/-1" false
|
||||
run_test "Invalid IPv4 - bad IP" failure validate_cidr "256.1.1.1/24" false
|
||||
echo ""
|
||||
|
||||
# Test validate_cidr (IPv6)
|
||||
echo "Testing validate_cidr (IPv6)..."
|
||||
run_test "Valid IPv6 /64" success validate_cidr "fd69:dead:beef:69::/64" true
|
||||
run_test "Valid IPv6 /128" success validate_cidr "fd69:dead:beef:69::1/128" true
|
||||
run_test "Valid IPv6 /48" success validate_cidr "fd69:dead::/48" true
|
||||
run_test "Empty IPv6 CIDR" failure validate_cidr "" true
|
||||
run_test "Invalid IPv6 - no prefix" failure validate_cidr "fd69:dead:beef:69::" true
|
||||
run_test "Invalid IPv6 - prefix too large" failure validate_cidr "fd69:dead:beef:69::/129" true
|
||||
echo ""
|
||||
|
||||
# Test validate_dns_servers
|
||||
echo "Testing validate_dns_servers..."
|
||||
run_test "Valid single DNS" success validate_dns_servers "8.8.8.8"
|
||||
run_test "Valid multiple DNS" success validate_dns_servers "8.8.8.8, 8.8.4.4"
|
||||
run_test "Valid DNS with spaces" success validate_dns_servers "8.8.8.8, 1.1.1.1"
|
||||
run_test "Empty DNS" success validate_dns_servers ""
|
||||
run_test "Invalid DNS - bad format" failure validate_dns_servers "8.8.8"
|
||||
run_test "Invalid DNS - special chars" failure validate_dns_servers "dns.example.com"
|
||||
echo ""
|
||||
|
||||
# Summary
|
||||
echo ""
|
||||
echo "=== Test Summary ==="
|
||||
echo "Tests run: $TESTS_RUN"
|
||||
echo "Tests passed: $TESTS_PASSED"
|
||||
echo "Tests failed: $TESTS_FAILED"
|
||||
echo ""
|
||||
|
||||
if [[ $TESTS_FAILED -eq 0 ]]; then
|
||||
echo "All tests passed! ✓"
|
||||
exit 0
|
||||
else
|
||||
echo "Some tests failed! ✗"
|
||||
exit 1
|
||||
fi
|
||||
921
wg-install.sh
Executable file
921
wg-install.sh
Executable file
@@ -0,0 +1,921 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# wg-install.sh - WireGuard VPN Server Installation Script
|
||||
#
|
||||
# This script handles the complete installation of a WireGuard VPN server on Debian 13.
|
||||
# It includes dependency checks, package installation, firewall setup (nftables),
|
||||
# server key generation, interface initialization, and systemd service setup.
|
||||
#
|
||||
# Settings can be provided via interactive prompts or environment variables prefixed with WGI_
|
||||
# (e.g., WGI_SERVER_DOMAIN, WGI_WG_PORT, WGI_VPN_IPV4_RANGE, etc.)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Default Configuration (can be overridden by environment variables with WGI_ prefix)
|
||||
WGI_SERVER_DOMAIN="${WGI_SERVER_DOMAIN:-}"
|
||||
WGI_WG_PORT="${WGI_WG_PORT:-51820}"
|
||||
WGI_VPN_IPV4_RANGE="${WGI_VPN_IPV4_RANGE:-10.10.69.0/24}"
|
||||
WGI_VPN_IPV6_RANGE="${WGI_VPN_IPV6_RANGE:-fd69:dead:beef:69::/64}"
|
||||
WGI_WG_INTERFACE="${WGI_WG_INTERFACE:-wg0}"
|
||||
WGI_DNS_SERVERS="${WGI_DNS_SERVERS:-8.8.8.8, 8.8.4.4}"
|
||||
WGI_LOG_FILE="${WGI_LOG_FILE:-/var/log/wg-admin-install.log}"
|
||||
WGI_MIN_DISK_SPACE_MB=100
|
||||
|
||||
# Derived paths
|
||||
CONF_D_DIR="/etc/wireguard/conf.d"
|
||||
SERVER_CONF="${CONF_D_DIR}/server.conf"
|
||||
CLIENT_OUTPUT_DIR="/etc/wireguard/clients"
|
||||
WG_CONFIG="/etc/wireguard/${WGI_WG_INTERFACE}.conf"
|
||||
BACKUP_DIR="/etc/wg-admin/backups"
|
||||
|
||||
# Global variables for cleanup and rollback
|
||||
TEMP_DIR=""
|
||||
ROLLBACK_BACKUP_DIR=""
|
||||
ROLLBACK_NEEDED=false
|
||||
PUBLIC_INTERFACE=""
|
||||
SERVER_PRIVATE_KEY=""
|
||||
SERVER_PUBLIC_KEY=""
|
||||
|
||||
# ============================================================================
|
||||
# Logging Functions
|
||||
# ============================================================================
|
||||
|
||||
log() {
|
||||
local level="$1"
|
||||
shift
|
||||
local message="$*"
|
||||
local timestamp=$(date '+%Y-%m-%d %H:%M:%S')
|
||||
echo "[${timestamp}] [${level}] ${message}" | tee -a "${WGI_LOG_FILE}"
|
||||
}
|
||||
|
||||
log_info() {
|
||||
log "INFO" "$@"
|
||||
}
|
||||
|
||||
log_error() {
|
||||
log "ERROR" "$@" >&2
|
||||
}
|
||||
|
||||
log_warn() {
|
||||
log "WARN" "$@"
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Cleanup and Error Handling
|
||||
# ============================================================================
|
||||
|
||||
cleanup_handler() {
|
||||
local exit_code=$?
|
||||
|
||||
# Remove temporary directories
|
||||
if [[ -n "${TEMP_DIR}" ]] && [[ -d "${TEMP_DIR}" ]]; then
|
||||
log_info "Cleaning up temporary directory: ${TEMP_DIR}"
|
||||
rm -rf "${TEMP_DIR}"
|
||||
fi
|
||||
|
||||
# Rollback on failure if needed
|
||||
if [[ ${ROLLBACK_NEEDED} == true ]] && [[ ${exit_code} -ne 0 ]]; then
|
||||
log_error "Installation failed, attempting rollback..."
|
||||
rollback_installation
|
||||
fi
|
||||
|
||||
exit ${exit_code}
|
||||
}
|
||||
|
||||
# Set up traps for cleanup
|
||||
trap cleanup_handler EXIT INT TERM HUP
|
||||
|
||||
# ============================================================================
|
||||
# Input Validation Functions
|
||||
# ============================================================================
|
||||
|
||||
validate_dns_servers() {
|
||||
local dns="$1"
|
||||
if [[ -z "$dns" ]]; then
|
||||
return 0 # Empty DNS is allowed
|
||||
fi
|
||||
|
||||
# Split by comma and validate each DNS server
|
||||
IFS=',' read -ra dns_array <<< "$dns"
|
||||
for dns_server in "${dns_array[@]}"; do
|
||||
dns_server=$(echo "$dns_server" | xargs) # Trim whitespace
|
||||
if [[ ! "$dns_server" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
log_error "Invalid DNS server format: ${dns_server}"
|
||||
return 1
|
||||
fi
|
||||
done
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
validate_port_range() {
|
||||
local port="$1"
|
||||
if [[ -z "$port" ]]; then
|
||||
log_error "Port cannot be empty"
|
||||
return 1
|
||||
fi
|
||||
if [[ ! "$port" =~ ^[0-9]+$ ]]; then
|
||||
log_error "Port must be a number"
|
||||
return 1
|
||||
fi
|
||||
if [[ "$port" -lt 1 ]] || [[ "$port" -gt 65535 ]]; then
|
||||
log_error "Port must be between 1 and 65535"
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
validate_cidr() {
|
||||
local cidr="$1"
|
||||
local is_ipv6="$2"
|
||||
|
||||
if [[ -z "$cidr" ]]; then
|
||||
log_error "CIDR cannot be empty"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [[ "$is_ipv6" == "true" ]]; then
|
||||
# Basic IPv6 CIDR validation
|
||||
if [[ ! "$cidr" =~ ^[0-9a-fA-F:]+/[0-9]+$ ]]; then
|
||||
log_error "Invalid IPv6 CIDR format: ${cidr}"
|
||||
return 1
|
||||
fi
|
||||
local prefix="${cidr#*/}"
|
||||
if [[ "$prefix" -lt 0 ]] || [[ "$prefix" -gt 128 ]]; then
|
||||
log_error "Invalid IPv6 prefix length: ${prefix}"
|
||||
return 1
|
||||
fi
|
||||
else
|
||||
# IPv4 CIDR validation
|
||||
if [[ ! "$cidr" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+/[0-9]+$ ]]; then
|
||||
log_error "Invalid IPv4 CIDR format: ${cidr}"
|
||||
return 1
|
||||
fi
|
||||
local ip="${cidr%/*}"
|
||||
local prefix="${cidr#*/}"
|
||||
|
||||
# Validate each octet
|
||||
IFS='.' read -ra octets <<< "$ip"
|
||||
for octet in "${octets[@]}"; do
|
||||
if [[ "$octet" -lt 0 ]] || [[ "$octet" -gt 255 ]]; then
|
||||
log_error "Invalid IPv4 octet: ${octet} in ${cidr}"
|
||||
return 1
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ "$prefix" -lt 0 ]] || [[ "$prefix" -gt 32 ]]; then
|
||||
log_error "Invalid IPv4 prefix length: ${prefix}"
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
validate_server_domain() {
|
||||
local domain="$1"
|
||||
|
||||
if [[ -z "$domain" ]]; then
|
||||
log_error "Server domain cannot be empty"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Basic domain validation (alphanumeric, hyphens, dots)
|
||||
if [[ ! "$domain" =~ ^[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?)*$ ]]; then
|
||||
log_error "Invalid server domain format: ${domain}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Pre-installation Validation
|
||||
# ============================================================================
|
||||
|
||||
pre_install_validation() {
|
||||
log_info "Running pre-installation validation..."
|
||||
|
||||
# Check root privileges
|
||||
if [[ $EUID -ne 0 ]]; then
|
||||
log_error "This script must be run as root"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check disk space (at least MIN_DISK_SPACE_MB free)
|
||||
local free_space_kb=$(df / | awk 'NR==2 {print $4}')
|
||||
local free_space_mb=$((free_space_kb / 1024))
|
||||
if [[ ${free_space_mb} -lt ${WGI_MIN_DISK_SPACE_MB} ]]; then
|
||||
log_error "Insufficient disk space (${free_space_mb}MB free, ${WGI_MIN_DISK_SPACE_MB}MB required)"
|
||||
exit 1
|
||||
fi
|
||||
log_info "Disk space validation passed (${free_space_mb}MB free)"
|
||||
|
||||
# Check port availability
|
||||
if ss -ulnp 2>/dev/null | grep -q ":${WGI_WG_PORT}"; then
|
||||
log_error "Port ${WGI_WG_PORT} is already in use"
|
||||
echo "ERROR: WireGuard port ${WGI_WG_PORT} is already in use."
|
||||
echo "Action: Stop the service using port ${WGI_WG_PORT} or change the port."
|
||||
echo "To find what's using the port: 'sudo ss -tulnp | grep ${WGI_WG_PORT}'"
|
||||
exit 1
|
||||
fi
|
||||
log_info "Port ${WGI_WG_PORT} is available"
|
||||
|
||||
log_info "Pre-installation validation passed"
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Backup Functions
|
||||
# ============================================================================
|
||||
|
||||
backup_config() {
|
||||
local operation="${1:-manual}"
|
||||
local timestamp=$(date +"%Y%m%d_%H%M%S")
|
||||
local backup_name="wg-backup-${operation}-${timestamp}"
|
||||
local backup_path="${BACKUP_DIR}/${backup_name}"
|
||||
|
||||
log_info "Creating configuration backup: ${backup_name}"
|
||||
|
||||
# Create backup directory if it doesn't exist
|
||||
mkdir -p "${BACKUP_DIR}"
|
||||
chmod 700 "${BACKUP_DIR}"
|
||||
|
||||
# Create backup directory
|
||||
mkdir -p "${backup_path}"
|
||||
|
||||
# Backup WireGuard configurations
|
||||
if [[ -d "/etc/wireguard" ]]; then
|
||||
cp -a /etc/wireguard "${backup_path}/" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Backup nftables configurations
|
||||
if [[ -f "/etc/nftables.conf" ]]; then
|
||||
cp -a /etc/nftables.conf "${backup_path}/" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
if [[ -d "/etc/nftables.d" ]]; then
|
||||
cp -a /etc/nftables.d "${backup_path}/" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Backup sysctl configuration
|
||||
if [[ -f "/etc/sysctl.d/99-wireguard.conf" ]]; then
|
||||
cp -a /etc/sysctl.d/99-wireguard.conf "${backup_path}/" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Create backup metadata
|
||||
cat > "${backup_path}/backup-info.txt" <<EOF
|
||||
Backup created: ${timestamp}
|
||||
Operation: ${operation}
|
||||
Hostname: $(hostname)
|
||||
WireGuard interface: ${WGI_WG_INTERFACE}
|
||||
EOF
|
||||
|
||||
# Set restrictive permissions
|
||||
chmod -R 600 "${backup_path}"
|
||||
|
||||
# Apply retention policy - keep only last 10 backups
|
||||
apply_retention_policy
|
||||
|
||||
log_info "Backup created successfully: ${backup_path}"
|
||||
echo "${backup_path}"
|
||||
}
|
||||
|
||||
apply_retention_policy() {
|
||||
if [[ ! -d "${BACKUP_DIR}" ]]; then
|
||||
return
|
||||
fi
|
||||
|
||||
# List all backups sorted by modification time (oldest first)
|
||||
local backups=($(ls -t "${BACKUP_DIR}" 2>/dev/null | grep -E '^wg-backup-'))
|
||||
|
||||
# If we have more than 10 backups, remove oldest backups
|
||||
if [[ ${#backups[@]} -gt 10 ]]; then
|
||||
log_info "Applying retention policy (keeping last 10 backups)..."
|
||||
local to_remove=(${backups[@]:10})
|
||||
|
||||
for backup in "${to_remove[@]}"; do
|
||||
log_info "Removing old backup: ${backup}"
|
||||
rm -rf "${BACKUP_DIR}/${backup}"
|
||||
done
|
||||
fi
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Rollback Functions
|
||||
# ============================================================================
|
||||
|
||||
rollback_installation() {
|
||||
log_warn "Rolling back installation..."
|
||||
|
||||
# Stop services
|
||||
systemctl stop wg-quick@${WGI_WG_INTERFACE}.service 2>/dev/null || true
|
||||
systemctl disable wg-quick@${WGI_WG_INTERFACE}.service 2>/dev/null || true
|
||||
|
||||
# Restore from backup if exists
|
||||
if [[ -d "${ROLLBACK_BACKUP_DIR}" ]]; then
|
||||
log_info "Restoring from backup: ${ROLLBACK_BACKUP_DIR}"
|
||||
|
||||
if [[ -f "${ROLLBACK_BACKUP_DIR}/wireguard.conf" ]]; then
|
||||
cp "${ROLLBACK_BACKUP_DIR}/wireguard.conf" "${WG_CONFIG}"
|
||||
fi
|
||||
|
||||
if [[ -f "${ROLLBACK_BACKUP_DIR}/nftables.conf" ]]; then
|
||||
cp "${ROLLBACK_BACKUP_DIR}/nftables.conf" /etc/nftables.conf
|
||||
nft -f /etc/nftables.conf 2>/dev/null || true
|
||||
fi
|
||||
|
||||
if [[ -d "${ROLLBACK_BACKUP_DIR}/conf.d" ]]; then
|
||||
rm -rf "${CONF_D_DIR}"
|
||||
cp -r "${ROLLBACK_BACKUP_DIR}/conf.d" "${CONF_D_DIR}"
|
||||
chmod 700 "${CONF_D_DIR}"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Bring down interface
|
||||
wg-quick down ${WGI_WG_INTERFACE} 2>/dev/null || true
|
||||
|
||||
log_warn "Rollback complete. Please review the logs and try again."
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Interactive Configuration
|
||||
# ============================================================================
|
||||
|
||||
prompt_configuration() {
|
||||
echo ""
|
||||
echo "=== WireGuard VPN Server Configuration ==="
|
||||
echo ""
|
||||
echo "Press Enter to accept the default value (shown in brackets)"
|
||||
echo ""
|
||||
|
||||
# Server Domain
|
||||
if [[ -z "${WGI_SERVER_DOMAIN}" ]]; then
|
||||
read -p "Server Domain [e.g., vpn.example.com]: " input_domain
|
||||
WGI_SERVER_DOMAIN="${input_domain}"
|
||||
else
|
||||
echo "Server Domain: ${WGI_SERVER_DOMAIN}"
|
||||
fi
|
||||
|
||||
# Port
|
||||
if [[ -z "${WGI_WG_PORT}" ]] || [[ "${WGI_WG_PORT}" == "51820" ]]; then
|
||||
read -p "WireGuard Port [${WGI_WG_PORT}]: " input_port
|
||||
if [[ -n "$input_port" ]]; then
|
||||
WGI_WG_PORT="$input_port"
|
||||
fi
|
||||
else
|
||||
echo "WireGuard Port: ${WGI_WG_PORT}"
|
||||
fi
|
||||
|
||||
# IPv4 Range
|
||||
if [[ -z "${WGI_VPN_IPV4_RANGE}" ]] || [[ "${WGI_VPN_IPV4_RANGE}" == "10.10.69.0/24" ]]; then
|
||||
read -p "VPN IPv4 Range [${WGI_VPN_IPV4_RANGE}]: " input_ipv4
|
||||
if [[ -n "$input_ipv4" ]]; then
|
||||
WGI_VPN_IPV4_RANGE="$input_ipv4"
|
||||
fi
|
||||
else
|
||||
echo "VPN IPv4 Range: ${WGI_VPN_IPV4_RANGE}"
|
||||
fi
|
||||
|
||||
# IPv6 Range
|
||||
if [[ -z "${WGI_VPN_IPV6_RANGE}" ]] || [[ "${WGI_VPN_IPV6_RANGE}" == "fd69:dead:beef:69::/64" ]]; then
|
||||
read -p "VPN IPv6 Range [${WGI_VPN_IPV6_RANGE}]: " input_ipv6
|
||||
if [[ -n "$input_ipv6" ]]; then
|
||||
WGI_VPN_IPV6_RANGE="$input_ipv6"
|
||||
fi
|
||||
else
|
||||
echo "VPN IPv6 Range: ${WGI_VPN_IPV6_RANGE}"
|
||||
fi
|
||||
|
||||
# DNS Servers
|
||||
if [[ -z "${WGI_DNS_SERVERS}" ]] || [[ "${WGI_DNS_SERVERS}" == "8.8.8.8, 8.8.4.4" ]]; then
|
||||
read -p "DNS Servers [${WGI_DNS_SERVERS}]: " input_dns
|
||||
if [[ -n "$input_dns" ]]; then
|
||||
WGI_DNS_SERVERS="$input_dns"
|
||||
fi
|
||||
else
|
||||
echo "DNS Servers: ${WGI_DNS_SERVERS}"
|
||||
fi
|
||||
|
||||
# Interface Name
|
||||
if [[ -z "${WGI_WG_INTERFACE}" ]] || [[ "${WGI_WG_INTERFACE}" == "wg0" ]]; then
|
||||
read -p "WireGuard Interface [${WGI_WG_INTERFACE}]: " input_interface
|
||||
if [[ -n "$input_interface" ]]; then
|
||||
WGI_WG_INTERFACE="$input_interface"
|
||||
# Update derived paths
|
||||
WG_CONFIG="/etc/wireguard/${WGI_WG_INTERFACE}.conf"
|
||||
fi
|
||||
else
|
||||
echo "WireGuard Interface: ${WGI_WG_INTERFACE}"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=== Configuration Summary ==="
|
||||
echo "Server Domain: ${WGI_SERVER_DOMAIN}"
|
||||
echo "WireGuard Port: ${WGI_WG_PORT}"
|
||||
echo "VPN IPv4 Range: ${WGI_VPN_IPV4_RANGE}"
|
||||
echo "VPN IPv6 Range: ${WGI_VPN_IPV6_RANGE}"
|
||||
echo "DNS Servers: ${WGI_DNS_SERVERS}"
|
||||
echo "WireGuard Interface: ${WGI_WG_INTERFACE}"
|
||||
echo ""
|
||||
|
||||
read -p "Proceed with installation? (yes/no): " confirm
|
||||
if [[ "${confirm}" != "yes" ]]; then
|
||||
log_info "Installation cancelled by user"
|
||||
exit 0
|
||||
fi
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Package Installation
|
||||
# ============================================================================
|
||||
|
||||
install_packages() {
|
||||
log_info "Installing packages..."
|
||||
echo "Updating package lists..."
|
||||
apt-get update -qq
|
||||
|
||||
echo "Installing WireGuard and dependencies..."
|
||||
apt-get install -y wireguard wireguard-tools qrencode nftables
|
||||
|
||||
log_info "Package installation complete"
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Cleanup Existing Installation
|
||||
# ============================================================================
|
||||
|
||||
cleanup_existing_installation() {
|
||||
log_info "Checking for existing WireGuard installation..."
|
||||
|
||||
# Stop and disable WireGuard service
|
||||
if systemctl is-enabled --quiet wg-quick@${WGI_WG_INTERFACE}.service 2>/dev/null || systemctl list-unit-files | grep -q wg-quick@${WGI_WG_INTERFACE}.service; then
|
||||
log_info "Stopping WireGuard service..."
|
||||
echo "Stopping WireGuard service..."
|
||||
systemctl stop wg-quick@${WGI_WG_INTERFACE}.service 2>/dev/null || true
|
||||
echo "Disabling WireGuard service..."
|
||||
systemctl disable wg-quick@${WGI_WG_INTERFACE}.service 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Stop and disable old client loader service
|
||||
if systemctl is-enabled --quiet wg-load-clients.service 2>/dev/null || systemctl list-unit-files | grep -q wg-load-clients.service; then
|
||||
echo "Removing old WireGuard client loader service..."
|
||||
systemctl disable wg-load-clients.service 2>/dev/null || true
|
||||
systemctl stop wg-load-clients.service 2>/dev/null || true
|
||||
rm -f /etc/systemd/system/wg-load-clients.service
|
||||
systemctl daemon-reload 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Bring down interface if running
|
||||
if wg show ${WGI_WG_INTERFACE} &>/dev/null; then
|
||||
echo "WireGuard interface is active, bringing down..."
|
||||
wg-quick down ${WGI_WG_INTERFACE} 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Remove existing configuration directories
|
||||
if [[ -d "/etc/wireguard" ]]; then
|
||||
echo "Removing existing WireGuard configuration..."
|
||||
rm -rf /etc/wireguard
|
||||
fi
|
||||
|
||||
if [[ -d "/etc/clients.d" ]]; then
|
||||
echo "Removing old client directory..."
|
||||
rm -rf /etc/clients.d
|
||||
fi
|
||||
|
||||
if [[ -d "/etc/wireguard/conf.d" ]]; then
|
||||
echo "Removing existing config.d directory..."
|
||||
rm -rf /etc/wireguard/conf.d
|
||||
fi
|
||||
|
||||
if [[ -d "/etc/wireguard/peer.d" ]]; then
|
||||
echo "Removing old peer.d directory..."
|
||||
rm -rf /etc/wireguard/peer.d
|
||||
fi
|
||||
|
||||
if [[ -d "/root/wireguard-clients" ]]; then
|
||||
echo "Removing old client config directory..."
|
||||
rm -rf /root/wireguard-clients
|
||||
fi
|
||||
|
||||
# Flush nftables rules
|
||||
if command -v nft &> /dev/null; then
|
||||
echo "Flushing nftables rules..."
|
||||
nft flush ruleset 2>/dev/null || true
|
||||
fi
|
||||
|
||||
echo "Cleanup complete."
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Network Configuration
|
||||
# ============================================================================
|
||||
|
||||
detect_public_interface() {
|
||||
PUBLIC_INTERFACE=$(ip route get 8.8.8.8 | grep -oP 'dev \K\S+' | head -1)
|
||||
log_info "Public interface detected: ${PUBLIC_INTERFACE}"
|
||||
echo "Public interface: ${PUBLIC_INTERFACE}"
|
||||
}
|
||||
|
||||
enable_ip_forwarding() {
|
||||
log_info "Enabling IP forwarding..."
|
||||
echo "Enabling IP forwarding..."
|
||||
|
||||
cat > /etc/sysctl.d/99-wireguard.conf <<EOF
|
||||
net.ipv4.ip_forward = 1
|
||||
net.ipv6.conf.all.forwarding = 1
|
||||
EOF
|
||||
|
||||
sysctl -p /etc/sysctl.d/99-wireguard.conf
|
||||
log_info "IP forwarding enabled"
|
||||
}
|
||||
|
||||
configure_nftables() {
|
||||
log_info "Configuring nftables firewall..."
|
||||
echo "Configuring nftables firewall..."
|
||||
|
||||
mkdir -p /etc/nftables.d
|
||||
|
||||
# Create wireguard config in /etc/nftables.d
|
||||
cat > /etc/nftables.d/wireguard.conf <<EOF
|
||||
#!/usr/sbin/nft -f
|
||||
# nftables configuration for WireGuard VPN
|
||||
|
||||
flush ruleset
|
||||
|
||||
table inet wireguard {
|
||||
chain prerouting {
|
||||
type filter hook prerouting priority -150;
|
||||
|
||||
# Rate limiting for SSH (3 connections per minute, burst 5)
|
||||
tcp dport 22 ct state new limit rate 3/minute burst 5 packets accept
|
||||
tcp dport 22 ct state new limit rate 3/minute burst 5 packets drop
|
||||
|
||||
# Rate limiting for WireGuard (${WGI_WG_PORT})
|
||||
udp dport ${WGI_WG_PORT} limit rate 10/second burst 20 packets accept
|
||||
udp dport ${WGI_WG_PORT} limit rate 10/second burst 20 packets drop
|
||||
}
|
||||
|
||||
chain input {
|
||||
type filter hook input priority 0; policy drop;
|
||||
|
||||
iifname lo accept
|
||||
|
||||
# Connection tracking bypass for WireGuard UDP traffic
|
||||
iifname "${PUBLIC_INTERFACE}" udp dport ${WGI_WG_PORT} notrack
|
||||
|
||||
ct state established,related accept
|
||||
ct state invalid drop
|
||||
|
||||
# Allow SSH
|
||||
tcp dport 22 accept
|
||||
|
||||
# Allow WireGuard UDP traffic (already tracked via notrack)
|
||||
udp dport ${WGI_WG_PORT} accept
|
||||
|
||||
# ICMPv4
|
||||
icmp type { echo-request, echo-reply } accept
|
||||
|
||||
# ICMPv6 - ensure neighbor discovery is allowed
|
||||
icmpv6 type { echo-request, echo-reply, nd-neighbor-solicit, nd-neighbor-advert, nd-router-solicit, nd-router-advert } accept
|
||||
}
|
||||
|
||||
chain forward {
|
||||
type filter hook forward priority 0; policy drop;
|
||||
|
||||
ct state established,related accept
|
||||
iifname ${WGI_WG_INTERFACE} accept
|
||||
oifname ${WGI_WG_INTERFACE} accept
|
||||
}
|
||||
|
||||
chain output {
|
||||
type filter hook output priority 0; policy accept;
|
||||
|
||||
# Connection tracking bypass for WireGuard UDP traffic
|
||||
oifname "${PUBLIC_INTERFACE}" udp dport ${WGI_WG_PORT} notrack
|
||||
}
|
||||
}
|
||||
|
||||
table ip nat {
|
||||
chain postrouting {
|
||||
type nat hook postrouting priority 100; policy accept;
|
||||
|
||||
# TCP MSS clamping for MTU issues (clamp to 1360)
|
||||
oifname "${PUBLIC_INTERFACE}" tcp flags syn tcp option maxseg size set 1360
|
||||
|
||||
oifname "${PUBLIC_INTERFACE}" ip saddr ${WGI_VPN_IPV4_RANGE} masquerade
|
||||
}
|
||||
}
|
||||
|
||||
table ip6 nat {
|
||||
chain postrouting {
|
||||
type nat hook postrouting priority 100; policy accept;
|
||||
|
||||
# TCP MSS clamping for IPv6 MTU issues
|
||||
oifname "${PUBLIC_INTERFACE}" tcp flags syn tcp option maxseg size set 1360
|
||||
|
||||
oifname "${PUBLIC_INTERFACE}" ip6 saddr ${WGI_VPN_IPV6_RANGE} masquerade
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
# Create /etc/nftables.conf that includes wireguard.conf
|
||||
cat > /etc/nftables.conf <<EOF
|
||||
#!/usr/sbin/nft -f
|
||||
# nftables configuration automatically generated by wg-install.sh
|
||||
flush ruleset
|
||||
include "/etc/nftables.d/wireguard.conf"
|
||||
EOF
|
||||
|
||||
chmod 600 /etc/nftables.conf
|
||||
chmod 600 /etc/nftables.d/wireguard.conf
|
||||
|
||||
# Validate nftables configuration before applying
|
||||
echo "Validating nftables configuration..."
|
||||
if ! nft check -f /etc/nftables.conf; then
|
||||
log_error "nftables configuration validation failed"
|
||||
exit 1
|
||||
fi
|
||||
echo "nftables configuration is valid"
|
||||
|
||||
# Apply nftables rules
|
||||
nft -f /etc/nftables.conf
|
||||
|
||||
# Enable and start nftables service
|
||||
echo "Enabling nftables service..."
|
||||
systemctl enable nftables.service
|
||||
systemctl start nftables.service
|
||||
|
||||
# Verify nftables is running
|
||||
if ! systemctl is-active --quiet nftables.service; then
|
||||
log_error "nftables service failed to start"
|
||||
echo "=== Service Status ==="
|
||||
systemctl status nftables.service
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "nftables service started successfully"
|
||||
log_info "nftables configuration complete"
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Server Key Generation
|
||||
# ============================================================================
|
||||
|
||||
generate_server_keys() {
|
||||
log_info "Generating server keys..."
|
||||
echo "Generating server keys..."
|
||||
|
||||
mkdir -p /etc/wireguard
|
||||
cd /etc/wireguard
|
||||
|
||||
# Generate keys with atomic write
|
||||
local temp_private=$(mktemp)
|
||||
local temp_public=$(mktemp)
|
||||
wg genkey > "$temp_private"
|
||||
wg pubkey < "$temp_private" > "$temp_public"
|
||||
SERVER_PRIVATE_KEY=$(cat "$temp_private")
|
||||
SERVER_PUBLIC_KEY=$(cat "$temp_public")
|
||||
|
||||
# Move to final location with proper permissions
|
||||
mv "$temp_private" server_private.key
|
||||
mv "$temp_public" server_public.key
|
||||
chmod 600 server_private.key
|
||||
chmod 644 server_public.key
|
||||
|
||||
log_info "Server keys generated and secured"
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# WireGuard Configuration
|
||||
# ============================================================================
|
||||
|
||||
configure_wireguard() {
|
||||
log_info "Configuring WireGuard interface..."
|
||||
echo "Configuring WireGuard interface..."
|
||||
|
||||
# Create config.d directory
|
||||
mkdir -p "${CONF_D_DIR}"
|
||||
|
||||
# Create server.conf in conf.d directory
|
||||
cat > "${SERVER_CONF}" <<EOF
|
||||
[Interface]
|
||||
PrivateKey = ${SERVER_PRIVATE_KEY}
|
||||
Address = ${WGI_VPN_IPV4_RANGE%.*}.1/${WGI_VPN_IPV4_RANGE#*/}, ${WGI_VPN_IPV6_RANGE%:*}:1/${WGI_VPN_IPV6_RANGE#*/}
|
||||
ListenPort = ${WGI_WG_PORT}
|
||||
EOF
|
||||
|
||||
# Set permissions
|
||||
chmod 600 "${SERVER_CONF}"
|
||||
chmod 700 "${CONF_D_DIR}"
|
||||
chmod 600 /etc/wireguard/server_private.key
|
||||
|
||||
log_info "WireGuard configuration created"
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Systemd Service Setup
|
||||
# ============================================================================
|
||||
|
||||
setup_systemd_service() {
|
||||
log_info "Setting up systemd service..."
|
||||
echo "Setting up WireGuard systemd service..."
|
||||
|
||||
# Create main config file (will be populated by load-clients)
|
||||
cat > "${WG_CONFIG}" <<EOF
|
||||
[Interface]
|
||||
PrivateKey = ${SERVER_PRIVATE_KEY}
|
||||
Address = ${WGI_VPN_IPV4_RANGE%.*}.1/${WGI_VPN_IPV4_RANGE#*/}, ${WGI_VPN_IPV6_RANGE%:*}:1/${WGI_VPN_IPV6_RANGE#*/}
|
||||
ListenPort = ${WGI_WG_PORT}
|
||||
EOF
|
||||
chmod 600 "${WG_CONFIG}"
|
||||
|
||||
# Enable WireGuard service
|
||||
systemctl enable wg-quick@${WGI_WG_INTERFACE}.service
|
||||
|
||||
# Start WireGuard service
|
||||
echo ""
|
||||
echo "Starting WireGuard..."
|
||||
|
||||
# Ensure interface is down before starting
|
||||
if wg show ${WGI_WG_INTERFACE} &>/dev/null; then
|
||||
wg-quick down ${WGI_WG_INTERFACE} 2>/dev/null || true
|
||||
fi
|
||||
|
||||
systemctl start wg-quick@${WGI_WG_INTERFACE}.service
|
||||
|
||||
# Wait for WireGuard to initialize
|
||||
sleep 2
|
||||
|
||||
# Verify WireGuard is running
|
||||
if ! systemctl is-active --quiet wg-quick@${WGI_WG_INTERFACE}.service; then
|
||||
log_error "WireGuard service failed to start"
|
||||
echo "=== Service Status ==="
|
||||
systemctl status wg-quick@${WGI_WG_INTERFACE}.service
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Verify correct listening port
|
||||
ACTUAL_PORT=$(wg show ${WGI_WG_INTERFACE} listen-port)
|
||||
if [[ "$ACTUAL_PORT" != "$WGI_WG_PORT" ]]; then
|
||||
log_error "WireGuard listening on port $ACTUAL_PORT instead of $WGI_WG_PORT"
|
||||
echo "=== Config File ==="
|
||||
grep ListenPort "${WG_CONFIG}"
|
||||
echo "=== Running Interface ==="
|
||||
wg show ${WGI_WG_INTERFACE}
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "WireGuard started successfully on port $ACTUAL_PORT"
|
||||
log_info "WireGuard service started successfully"
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Main Installation Function
|
||||
# ============================================================================
|
||||
|
||||
main() {
|
||||
log_info "=== WireGuard VPN Installation for Debian 13 ==="
|
||||
|
||||
# Validate configuration
|
||||
validate_server_domain "${WGI_SERVER_DOMAIN}"
|
||||
validate_port_range "${WGI_WG_PORT}"
|
||||
validate_cidr "${WGI_VPN_IPV4_RANGE}" false
|
||||
validate_cidr "${WGI_VPN_IPV6_RANGE}" true
|
||||
validate_dns_servers "${WGI_DNS_SERVERS}"
|
||||
|
||||
# Interactive configuration if not all values set
|
||||
if [[ -z "${WGI_SERVER_DOMAIN}" ]] || \
|
||||
[[ -z "${WGI_WG_PORT}" ]] || \
|
||||
[[ -z "${WGI_VPN_IPV4_RANGE}" ]] || \
|
||||
[[ -z "${WGI_VPN_IPV6_RANGE}" ]]; then
|
||||
prompt_configuration
|
||||
fi
|
||||
|
||||
# Print configuration
|
||||
echo ""
|
||||
echo "=== WireGuard VPN Installation for Debian 13 ==="
|
||||
echo "Server: ${WGI_SERVER_DOMAIN}"
|
||||
echo "IPv4 VPN range: ${WGI_VPN_IPV4_RANGE}"
|
||||
echo "IPv6 VPN range: ${WGI_VPN_IPV6_RANGE}"
|
||||
echo "Port: ${WGI_WG_PORT}"
|
||||
echo "Interface: ${WGI_WG_INTERFACE}"
|
||||
echo ""
|
||||
|
||||
# Pre-installation validation
|
||||
pre_install_validation
|
||||
|
||||
# Auto-backup before install (only if config exists)
|
||||
if [[ -f "${WG_CONFIG}" ]] || [[ -f "/etc/nftables.conf" ]]; then
|
||||
backup_config "install-pre"
|
||||
fi
|
||||
|
||||
# Enable rollback flag
|
||||
ROLLBACK_NEEDED=true
|
||||
|
||||
# Create backup directory for potential rollback
|
||||
ROLLBACK_BACKUP_DIR=$(mktemp -d)
|
||||
log_info "Created rollback backup directory: ${ROLLBACK_BACKUP_DIR}"
|
||||
|
||||
# Backup existing configs if they exist
|
||||
if [[ -f "${WG_CONFIG}" ]]; then
|
||||
cp "${WG_CONFIG}" "${ROLLBACK_BACKUP_DIR}/wireguard.conf"
|
||||
log_info "Backed up existing WireGuard config"
|
||||
fi
|
||||
if [[ -f "/etc/nftables.conf" ]]; then
|
||||
cp /etc/nftables.conf "${ROLLBACK_BACKUP_DIR}/nftables.conf"
|
||||
log_info "Backed up existing nftables config"
|
||||
fi
|
||||
if [[ -d "${CONF_D_DIR}" ]]; then
|
||||
cp -r "${CONF_D_DIR}" "${ROLLBACK_BACKUP_DIR}/conf.d"
|
||||
log_info "Backed up existing client configs"
|
||||
fi
|
||||
|
||||
# Cleanup existing installation
|
||||
cleanup_existing_installation
|
||||
|
||||
# Install packages
|
||||
install_packages
|
||||
|
||||
# Detect public interface
|
||||
detect_public_interface
|
||||
|
||||
# Enable IP forwarding
|
||||
enable_ip_forwarding
|
||||
|
||||
# Configure nftables firewall
|
||||
configure_nftables
|
||||
|
||||
# Generate server keys
|
||||
generate_server_keys
|
||||
|
||||
# Configure WireGuard
|
||||
configure_wireguard
|
||||
|
||||
# Setup systemd service
|
||||
setup_systemd_service
|
||||
|
||||
# Disable rollback flag - installation successful
|
||||
ROLLBACK_NEEDED=false
|
||||
|
||||
# Clean up rollback backup directory
|
||||
if [[ -d "${ROLLBACK_BACKUP_DIR}" ]]; then
|
||||
rm -rf "${ROLLBACK_BACKUP_DIR}"
|
||||
log_info "Cleaned up rollback backup directory"
|
||||
fi
|
||||
|
||||
# Installation complete
|
||||
echo ""
|
||||
log_info "=== Installation Complete ==="
|
||||
echo "=== Installation Complete ==="
|
||||
echo ""
|
||||
echo "Server Public Key: ${SERVER_PUBLIC_KEY}"
|
||||
echo "Endpoint: ${WGI_SERVER_DOMAIN}:${WGI_WG_PORT}"
|
||||
echo "VPN IPv4 Range: ${WGI_VPN_IPV4_RANGE}"
|
||||
echo "VPN IPv6 Range: ${WGI_VPN_IPV6_RANGE}"
|
||||
echo ""
|
||||
echo "Use 'wireguard.sh add <name> [--psk]' to add clients"
|
||||
echo "Use 'wireguard.sh list' to list clients"
|
||||
echo "Configs will be merged from: ${CONF_D_DIR}"
|
||||
echo " - ${SERVER_CONF} (server interface)"
|
||||
echo " - ${CONF_D_DIR}/client-*.conf (client peers)"
|
||||
echo ""
|
||||
echo "Check status: wg show"
|
||||
echo "System status: systemctl status wg-quick@${WGI_WG_INTERFACE}"
|
||||
echo ""
|
||||
|
||||
log_info "Installation completed successfully"
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Entry Point
|
||||
# ============================================================================
|
||||
|
||||
# Only run main if script is executed directly (not sourced)
|
||||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
# Show usage if help requested
|
||||
if [[ "${1:-}" == "-h" ]] || [[ "${1:-}" == "--help" ]] || [[ "${1:-}" == "help" ]]; then
|
||||
echo "Usage: $0 [options]"
|
||||
echo ""
|
||||
echo "Environment Variables (prefix with WGI_):"
|
||||
echo " WGI_SERVER_DOMAIN Server domain (e.g., vpn.example.com)"
|
||||
echo " WGI_WG_PORT WireGuard UDP port (default: 51820)"
|
||||
echo " WGI_VPN_IPV4_RANGE IPv4 VPN range (default: 10.10.69.0/24)"
|
||||
echo " WGI_VPN_IPV6_RANGE IPv6 VPN range (default: fd69:dead:beef:69::/64)"
|
||||
echo " WGI_DNS_SERVERS DNS servers (default: 8.8.8.8, 8.8.4.4)"
|
||||
echo " WGI_WG_INTERFACE WireGuard interface name (default: wg0)"
|
||||
echo ""
|
||||
echo "Examples:"
|
||||
echo " # Interactive installation"
|
||||
echo " sudo $0"
|
||||
echo ""
|
||||
echo " # Non-interactive with environment variables"
|
||||
echo " sudo WGI_SERVER_DOMAIN=vpn.example.com $0"
|
||||
echo ""
|
||||
echo " # Custom port and VPN range"
|
||||
echo " sudo WGI_SERVER_DOMAIN=vpn.example.com WGI_WG_PORT=443 $0"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Run main installation
|
||||
main
|
||||
fi
|
||||
843
wireguard.sh
843
wireguard.sh
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user