Compare commits

...

69 Commits

Author SHA1 Message Date
Calmcacil
6629598574 bd sync: 2026-01-12 23:42:48 2026-01-12 23:42:48 +01:00
Calmcacil
50321a8471 Add connection quality indicators based on handshake time 2026-01-12 23:42:38 +01:00
Calmcacil
bf71a7a659 bd sync: 2026-01-12 23:41:27 2026-01-12 23:41:27 +01:00
Calmcacil
f154c7ff69 Standardize TUI formatting and styling across all screens 2026-01-12 23:41:07 +01:00
Calmcacil
0476f1e227 bd sync: 2026-01-12 23:40:16 2026-01-12 23:40:16 +01:00
Calmcacil
1187ae0046 Add screen transition animations for polished UX 2026-01-12 23:40:01 +01:00
Calmcacil
b7ddd54cf6 bd sync: 2026-01-12 23:34:33 2026-01-12 23:34:33 +01:00
Calmcacil
5b8b9b66f5 Add keyboard shortcut discoverability hints on each screen 2026-01-12 23:34:14 +01:00
Calmcacil
f0e26e4a0a bd sync: 2026-01-12 23:24:02 2026-01-12 23:24:02 +01:00
Calmcacil
dd62458515 Add text selection and copy capability to terminal UI 2026-01-12 23:23:48 +01:00
Calmcacil
17f4d52c8a bd sync: 2026-01-12 23:21:29 2026-01-12 23:21:29 +01:00
Calmcacil
4787f3b863 bd sync: 2026-01-12 23:19:12 2026-01-12 23:19:12 +01:00
Calmcacil
3631339f8b Add loading spinners for async operations 2026-01-12 23:18:57 +01:00
Calmcacil
1c03a706d1 bd sync: 2026-01-12 23:12:04 2026-01-12 23:12:04 +01:00
Calmcacil
a3c2828ec2 Integrate theme system across all screens 2026-01-12 23:11:53 +01:00
Calmcacil
d669adc094 bd sync: 2026-01-12 23:07:02 2026-01-12 23:07:02 +01:00
Calmcacil
ea36f03393 bd sync: 2026-01-12 23:05:10 2026-01-12 23:05:10 +01:00
Calmcacil
aadcfbf810 Create dedicated error screen with user-friendly messages and recovery options 2026-01-12 23:04:58 +01:00
Calmcacil
34951221d3 bd sync: 2026-01-12 23:04:56 2026-01-12 23:04:56 +01:00
Calmcacil
68939cdc08 Reduce status refresh interval to 3 seconds and add last updated indicator 2026-01-12 23:04:48 +01:00
Calmcacil
5136484cd2 bd sync: 2026-01-12 23:03:09 2026-01-12 23:03:09 +01:00
Calmcacil
575faa8c68 Improve empty state messages with actionable guidance 2026-01-12 23:03:00 +01:00
Calmcacil
8b49fbfd3a bd sync: 2026-01-12 23:02:08 2026-01-12 23:02:08 +01:00
Calmcacil
78a100112c Fix q key behavior in client details view 2026-01-12 23:01:59 +01:00
Calmcacil
707464e61e Fix help screen documentation - incorrect key binding for viewing details 2026-01-12 23:00:16 +01:00
Calmcacil
153c001483 bd sync: 2026-01-12 22:57:05 2026-01-12 22:57:05 +01:00
Calmcacil
23d1cae737 bd sync: 2026-01-12 22:54:34 2026-01-12 22:54:34 +01:00
Calmcacil
3f60ab8355 fix: add background dimming to config display modal
Config display modal now dims background content before showing,
following the same pattern as delete confirmation modal.
This provides visual consistency across all modals and makes
it clear to users when a modal is active.

Fixes: wg-admin-bfe
2026-01-12 22:44:11 +01:00
Calmcacil
fd0a1c45e7 feat: replace clipboard copy with config display for SSH sessions
Created ConfigDisplay component that shows full client configuration
in a scrollable modal window, replacing non-functional clipboard copy.

Benefits:
- Works over SSH sessions (no clipboard API needed)
- Shows complete configuration, not just public key
- Scrollable for long configs with keyboard navigation
- Users can select and copy text directly in terminal

Changes:
- Created internal/tui/components/config-display.go
- Updated detail.go to replace copyPublicKey with loadConfig
- Removed clipboard-related fields and message type
- Updated help text: 'c' now shows config
- Key bindings for scrolling: ↑↓, pgup/pgdn, g/G, Esc/q to close

Fixes: wg-admin-qtb
2026-01-12 22:36:40 +01:00
Calmcacil
8fb3fe7031 fix: critical navigation bug - users trapped in detail and restore screens
Changed return value from 'return s, nil' to 'return nil, nil' in:
- detail.go (line 116): Back navigation from client details
- restore.go (line 101): Back navigation from restore screen

Root cause: Main model's navigation logic checks if newScreen == nil.
When screens returned (s, nil), the check failed and screen never changed.
Now properly returns (nil, nil) to signal screen change to parent.

Fixes: wg-admin-rfo
2026-01-12 22:27:16 +01:00
Calmcacil
e7d81674c8 chore: add wg-tui binary to gitignore 2026-01-12 22:18:09 +01:00
Calmcacil
d2dc361620 fix: change back navigation to 'b' key and improve deletion confirmation with name verification
- Changed 'q' key to 'b' for back navigation in client details
- Added 'esc' key binding for back navigation
- Updated help text to reflect new key bindings
- Created new DeleteConfirmModal component with name verification
- User must type exact client name to confirm deletion (safety feature)
- Improved modal styling with visual feedback (red/green indicators)
- Case-sensitive name matching to prevent accidental deletions

Fixes: wg-admin-az7
2026-01-12 22:17:27 +01:00
Calmcacil
0798b72858 bd sync: 2026-01-12 22:17:09 2026-01-12 22:17:09 +01:00
Calmcacil
f1712b5f9e bd sync: 2026-01-12 21:41:27 2026-01-12 21:41:27 +01:00
Calmcacil
5d129562e2 fix: Properly parse compound handshake time and fix q key handling
- Fix parseHandshake to correctly parse number-unit pairs from '14 hours, 24 minutes, 40 seconds ago'
  Previous logic tried to parse unit words instead of finding associated numbers
  Now correctly accumulates all time units (hours, minutes, seconds)
- Fix q key handling to properly check screen type before quitting
  Only quit application when 'q' is pressed on list screen
  Other screens (detail, add, help) now handle 'q' to navigate back

Note: q key navigation may still need investigation for edge cases
2026-01-12 19:27:21 +01:00
Calmcacil
e0f8210c17 fix: Fix 'q' key handling and connectivity status issues
- Fix 'q' key in detail/add/help screens to return to list instead of quitting
- Only quit application with 'q' when on main list screen
- Fix parseHandshake to accumulate all time units instead of returning early
  This resolves handshake timing discrepancy with wg show output
- Increase connection status threshold from 3 to 5 minutes
  Allows ~12 missed keepalive intervals (25s each)
  Improves connectivity status accuracy for peers with marginal connections
2026-01-12 19:17:51 +01:00
Calmcacil
85f2e521c9 feat: Add Dracula and Everforest color themes, set Everforest as default
- Add DraculaTheme with official Dracula color palette
- Add EverforestTheme with natural Everforest dark color scheme
- Set Everforest as default theme (was 'default')
- Update README with theming documentation and THEME env var usage
- Build verified successfully
2026-01-12 19:10:46 +01:00
Calmcacil
26120b8bc2 Add WireGuard TUI implementation
- Add Go TUI with bubbletea for WireGuard management
- Implement client CRUD operations with QR code generation
- Add configuration and validation modules
- Install/update scripts for client setup
- Update Makefile to build binaries to bin/ directory
- Add .gitignore for Go projects
2026-01-12 19:03:35 +01:00
Calmcacil
5ac68db854 Fix Makefile: use -C flag to set module root 2026-01-12 19:00:35 +01:00
Calmcacil
bbc8e77e99 Fix Makefile: run go commands from project root 2026-01-12 18:59:07 +01:00
Calmcacil
320a9454fe Add Makefile for build automation 2026-01-12 18:54:55 +01:00
Calmcacil
aeea977884 bd sync: 2026-01-12 18:06:59 2026-01-12 18:06:59 +01:00
Calmcacil
a55629bb7f bd sync: 2026-01-12 18:06:08 2026-01-12 18:06:08 +01:00
Calmcacil
900cbad4cd bd sync: 2026-01-12 18:05:24 2026-01-12 18:05:24 +01:00
Calmcacil
c638800137 bd sync: 2026-01-12 18:04:46 2026-01-12 18:04:46 +01:00
Calmcacil
88d4ca79d1 bd sync: 2026-01-12 17:59:34 2026-01-12 17:59:34 +01:00
Calmcacil
427e3b402c bd sync: 2026-01-12 17:56:53 2026-01-12 17:56:53 +01:00
Calmcacil
9c7990c192 bd sync: 2026-01-12 17:54:28 2026-01-12 17:54:28 +01:00
Calmcacil
acab9f95b4 bd sync: 2026-01-12 17:51:29 2026-01-12 17:51:29 +01:00
Calmcacil
e86f968c5f bd sync: 2026-01-12 17:51:02 2026-01-12 17:51:02 +01:00
Calmcacil
cb7844c730 bd sync: 2026-01-12 17:48:32 2026-01-12 17:48:32 +01:00
Calmcacil
09a3524a71 bd sync: 2026-01-12 17:47:26 2026-01-12 17:47:26 +01:00
Calmcacil
93a3615a60 Add keyboard shortcuts help screen
- Created help.go with Screen interface implementation
- Help screen displays all shortcuts in two-column layout
- Categories: Navigation (j/k, arrows, Enter, Esc), Actions (a, d, D, Q, r, l), Other (?, /, q)
- Added '?' key handler in main.go to switch to help screen
- Help reference added to status bar
- Press q or Esc to return from help screen
- Styled with lipgloss for clear readability
2026-01-12 17:46:38 +01:00
Calmcacil
b53e1af96a bd sync: 2026-01-12 17:46:34 2026-01-12 17:46:34 +01:00
Calmcacil
efb8b5da72 bd sync: 2026-01-12 17:46:25 2026-01-12 17:46:25 +01:00
Calmcacil
f58bf18cf1 bd sync: 2026-01-12 17:42:41 2026-01-12 17:42:41 +01:00
Calmcacil
5982a5ddfb bd sync: 2026-01-12 17:41:35 2026-01-12 17:41:35 +01:00
Calmcacil
a393e64d20 bd sync: 2026-01-12 17:40:18 2026-01-12 17:40:18 +01:00
Calmcacil
e66de51d6d bd sync: 2026-01-12 17:38:49 2026-01-12 17:38:49 +01:00
Calmcacil
429bce1484 bd sync: 2026-01-12 17:34:30 2026-01-12 17:34:30 +01:00
Calmcacil
b9f781f548 bd sync: 2026-01-12 17:32:36 2026-01-12 17:32:36 +01:00
Calmcacil
6c9ee6ef3e bd sync: 2026-01-12 17:29:26 2026-01-12 17:29:26 +01:00
Calmcacil
718432911b bd sync: 2026-01-12 17:21:52 2026-01-12 17:21:52 +01:00
Calmcacil
1c76b64e2f bd sync: 2026-01-12 17:20:35 2026-01-12 17:20:35 +01:00
Calmcacil
b7e7454711 bd sync: 2026-01-12 17:16:28 2026-01-12 17:16:28 +01:00
Calmcacil
69f73516c5 bd sync: 2026-01-12 17:13:13 2026-01-12 17:13:13 +01:00
Calmcacil
fc4aa2a577 bd sync: 2026-01-12 17:11:08 2026-01-12 17:11:08 +01:00
Calmcacil
21e5608ef0 bd sync: 2026-01-12 17:05:01 2026-01-12 17:05:01 +01:00
Calmcacil
4eb122c0dc Add Go TUI implementation plan
- Comprehensive research on Go TUI libraries
- Architecture design with Bubble Tea stack
- 14-day implementation roadmap
- Package structure and component design
- User experience wireframes
- Security and performance considerations
2026-01-12 16:59:41 +01:00
45 changed files with 8690 additions and 92 deletions

53
.agent/AGENTS.md Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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

View File

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

View File

@@ -44,7 +44,6 @@ Closes #{issue_number}"
### 7. Push and Close
```bash
bd sync
git push
```

543
GO_TUI_PLAN.md Normal file
View 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
View 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
View File

@@ -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
View 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
View 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
View 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
View 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
View 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
View 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
View 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], "::")
}

View 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, ""))
}

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

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

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

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

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

View 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"},
}
}

View 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),
)
}

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

View 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, cws.Quality)
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
}

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

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

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

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

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

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

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

File diff suppressed because it is too large Load Diff