Compare commits
69 Commits
main
...
6629598574
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6629598574 | ||
|
|
50321a8471 | ||
|
|
bf71a7a659 | ||
|
|
f154c7ff69 | ||
|
|
0476f1e227 | ||
|
|
1187ae0046 | ||
|
|
b7ddd54cf6 | ||
|
|
5b8b9b66f5 | ||
|
|
f0e26e4a0a | ||
|
|
dd62458515 | ||
|
|
17f4d52c8a | ||
|
|
4787f3b863 | ||
|
|
3631339f8b | ||
|
|
1c03a706d1 | ||
|
|
a3c2828ec2 | ||
|
|
d669adc094 | ||
|
|
ea36f03393 | ||
|
|
aadcfbf810 | ||
|
|
34951221d3 | ||
|
|
68939cdc08 | ||
|
|
5136484cd2 | ||
|
|
575faa8c68 | ||
|
|
8b49fbfd3a | ||
|
|
78a100112c | ||
|
|
707464e61e | ||
|
|
153c001483 | ||
|
|
23d1cae737 | ||
|
|
3f60ab8355 | ||
|
|
fd0a1c45e7 | ||
|
|
8fb3fe7031 | ||
|
|
e7d81674c8 | ||
|
|
d2dc361620 | ||
|
|
0798b72858 | ||
|
|
f1712b5f9e | ||
|
|
5d129562e2 | ||
|
|
e0f8210c17 | ||
|
|
85f2e521c9 | ||
|
|
26120b8bc2 | ||
|
|
5ac68db854 | ||
|
|
bbc8e77e99 | ||
|
|
320a9454fe | ||
|
|
aeea977884 | ||
|
|
a55629bb7f | ||
|
|
900cbad4cd | ||
|
|
c638800137 | ||
|
|
88d4ca79d1 | ||
|
|
427e3b402c | ||
|
|
9c7990c192 | ||
|
|
acab9f95b4 | ||
|
|
e86f968c5f | ||
|
|
cb7844c730 | ||
|
|
09a3524a71 | ||
|
|
93a3615a60 | ||
|
|
b53e1af96a | ||
|
|
efb8b5da72 | ||
|
|
f58bf18cf1 | ||
|
|
5982a5ddfb | ||
|
|
a393e64d20 | ||
|
|
e66de51d6d | ||
|
|
429bce1484 | ||
|
|
b9f781f548 | ||
|
|
6c9ee6ef3e | ||
|
|
718432911b | ||
|
|
1c76b64e2f | ||
|
|
b7e7454711 | ||
|
|
69f73516c5 | ||
|
|
fc4aa2a577 | ||
|
|
21e5608ef0 | ||
|
|
4eb122c0dc |
53
.agent/AGENTS.md
Normal file
53
.agent/AGENTS.md
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
# Project-Specific Agent Instructions
|
||||||
|
|
||||||
|
## YOUR MANDATE
|
||||||
|
0. **Use the beads CLI workflow**: You will always use the beads cli for your work as explained in tool_preferences
|
||||||
|
1. **Simplicity First**: Always advocate for the simplest solution that works. Reject complexity unless it is proven necessary.
|
||||||
|
2. **DRY & YAGNI**: These are your non-negotiable pillars. Identify redundancy and premature optimization immediately.
|
||||||
|
3. **Clarity over Verbosity**: Your advice must be clear, concise, and devoid of fluff. Do not be overly descriptive. Get to the point.
|
||||||
|
4. **Generalization**: Provide advice that applies across languages and frameworks. Focus on the *pattern*, not the *syntax*.
|
||||||
|
5. **Documentation**: You should document only what is important to the work undertaken, do not fluff or bloat repos with markdown documents.
|
||||||
|
6. **Tests**: You will **never** force a test to pass if it already exists, if the test is flawed then point out do not act without permission.
|
||||||
|
7. **Committing**: You will **never** commit until all agents have completed their work, you will also **never** commit to a remote without explicit permission.
|
||||||
|
8. **Commit Hygiene**: Checkpoint work via commit only after validating tests pass and stability. Ensure clean commit hygiene.
|
||||||
|
|
||||||
|
### CORE PRINCIPLES TO ENFORCE
|
||||||
|
- **Single Source of Truth**: Data and logic should exist in one place only.
|
||||||
|
- **Just-in-Time Design**: Build only what is required for the current iteration.
|
||||||
|
- **Code is Liability**: Less code means fewer bugs. Delete unused code ruthlessly.
|
||||||
|
- **Explicit over Implicit**: Magic is bad. Clear flow is good.
|
||||||
|
- **Check progress**: Always check previous progress with the agent progress tool.
|
||||||
|
- **Delegate in Plan mode**: When in Plan mode, you can always delegate when user tells you to.
|
||||||
|
|
||||||
|
### WORK DISCIPLINE (from Sisyphus methodology)
|
||||||
|
|
||||||
|
#### Todo Management (MANDATORY for multi-step tasks)
|
||||||
|
- **Create todos BEFORE starting** any task with 2+ steps
|
||||||
|
- **Mark in_progress** before starting each step (only ONE at a time)
|
||||||
|
- **Mark completed IMMEDIATELY** after each step (NEVER batch completions)
|
||||||
|
- **Update todos** if scope changes before proceeding
|
||||||
|
- Todos provide user visibility, prevent drift, and enable recovery
|
||||||
|
|
||||||
|
#### Code Quality
|
||||||
|
- **No excessive comments**: Code should be self-documenting. Only add comments that explain WHY, not WHAT.
|
||||||
|
- **No type suppressions**: Never use `as any`, `@ts-ignore`, `@ts-expect-error`
|
||||||
|
- **No empty catch blocks**: Always handle errors meaningfully
|
||||||
|
- **Match existing patterns**: Your code should look like the team wrote it
|
||||||
|
|
||||||
|
#### Agent Delegation
|
||||||
|
- **Use @mentions** to invoke specialized subagents: `@explore`, `@librarian`, `@oracle`, `@frontend-ui-ux-engineer`
|
||||||
|
- **Frontend visual work** (styling, layout, animation) → delegate to `@frontend-ui-ux-engineer`
|
||||||
|
- **Architecture decisions** or debugging after 2+ failed attempts → consult `@oracle`
|
||||||
|
- **External docs/library questions** → delegate to `@librarian`
|
||||||
|
- **Codebase exploration** → delegate to `@explore`
|
||||||
|
|
||||||
|
#### Failure Recovery
|
||||||
|
- Fix root causes, not symptoms
|
||||||
|
- Re-verify after EVERY fix attempt
|
||||||
|
- After 3 consecutive failures: STOP, revert to working state, document what failed, consult oracle
|
||||||
|
|
||||||
|
#### Completion Criteria
|
||||||
|
A task is complete when:
|
||||||
|
- [ ] All planned todo items marked done
|
||||||
|
- [ ] Build passes (if applicable)
|
||||||
|
- [ ] User's original request fully addressed
|
||||||
30
.agent/README.md
Normal file
30
.agent/README.md
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# Agent Instructions Directory
|
||||||
|
|
||||||
|
This directory contains project-specific instructions for agents working on the wg-admin project.
|
||||||
|
|
||||||
|
## Required Reading
|
||||||
|
|
||||||
|
All agents MUST read `AGENTS.md` before starting any work.
|
||||||
|
|
||||||
|
## Available Instructions
|
||||||
|
|
||||||
|
- `AGENTS.md` - Core principles and work discipline (READ THIS FIRST)
|
||||||
|
- `beads.md` - Beads workflow and command reference
|
||||||
|
- `explore.md` - Instructions for codebase exploration
|
||||||
|
- `librarian.md` - Instructions for research and documentation
|
||||||
|
- `oracle.md` - Instructions for architecture and debugging
|
||||||
|
|
||||||
|
## Agent Workflow
|
||||||
|
|
||||||
|
1. Read `.agent/AGENTS.md` for project mandates
|
||||||
|
2. Read agent-specific instructions (e.g., `explore.md`)
|
||||||
|
3. Execute work following established patterns
|
||||||
|
4. Use beads for tracking multi-session or complex work
|
||||||
|
5. Run `bd sync` before ending session
|
||||||
|
|
||||||
|
## Project Context
|
||||||
|
|
||||||
|
- **Issue tracking**: beads/bd CLI
|
||||||
|
- **External issues**: Gitea (https://gitea.calmcacil.dev)
|
||||||
|
- **Technology**: Bash/shell scripting
|
||||||
|
- **Purpose**: WireGuard VPN management tool
|
||||||
88
.agent/beads.md
Normal file
88
.agent/beads.md
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
# Beads Workflow Guide
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bd ready # Find available work
|
||||||
|
bd show <id> # View issue details
|
||||||
|
bd update <id> --status in_progress # Claim work
|
||||||
|
bd close <id> # Complete work
|
||||||
|
bd sync # Sync with git
|
||||||
|
```
|
||||||
|
|
||||||
|
## Workflow Commands
|
||||||
|
|
||||||
|
### Finding Work
|
||||||
|
- `bd ready` - Show issues ready to work (no blockers)
|
||||||
|
- `bd list --status=open` - All open issues
|
||||||
|
- `bd list --status=in_progress` - Your active work
|
||||||
|
- `bd show <id>` - Detailed issue view with dependencies
|
||||||
|
|
||||||
|
### Creating & Updating
|
||||||
|
- `bd create --title="..." --type=task|bug|feature --priority=2` - New issue
|
||||||
|
- Priority: 0-4 or P0-P4 (0=critical, 2=medium, 4=backlog). NOT "high"/"medium"/"low"
|
||||||
|
- `bd update <id> --status=in_progress` - Claim work
|
||||||
|
- `bd update <id> --assignee=username` - Assign to someone
|
||||||
|
- `bd close <id>` - Mark complete
|
||||||
|
- `bd close <id1> <id2> ...` - Close multiple issues at once (more efficient)
|
||||||
|
- `bd close <id> --reason="explanation"` - Close with reason
|
||||||
|
- **Tip**: When creating multiple issues/tasks/epics, use parallel subagents for efficiency
|
||||||
|
|
||||||
|
### Dependencies & Blocking
|
||||||
|
- `bd dep add <issue> <depends-on>` - Add dependency (issue depends on depends-on)
|
||||||
|
- `bd blocked` - Show all blocked issues
|
||||||
|
- `bd show <id>` - See what's blocking/blocked by this issue
|
||||||
|
|
||||||
|
### Sync & Collaboration
|
||||||
|
- `bd sync` - Sync with git remote (run at session end)
|
||||||
|
- `bd sync --status` - Check sync status without syncing
|
||||||
|
|
||||||
|
### Project Health
|
||||||
|
- `bd stats` - Project statistics (open/closed/blocked counts)
|
||||||
|
- `bd doctor` - Check for issues (sync problems, missing hooks)
|
||||||
|
|
||||||
|
## Common Workflows
|
||||||
|
|
||||||
|
### Starting Work
|
||||||
|
```bash
|
||||||
|
bd ready # Find available work
|
||||||
|
bd show <id> # Review issue details
|
||||||
|
bd update <id> --status=in_progress # Claim it
|
||||||
|
```
|
||||||
|
|
||||||
|
### Completing Work
|
||||||
|
```bash
|
||||||
|
bd close <id1> <id2> ... # Close all completed issues at once
|
||||||
|
bd sync # Push to remote
|
||||||
|
```
|
||||||
|
|
||||||
|
### Creating Dependent Work
|
||||||
|
```bash
|
||||||
|
# Run bd create commands in parallel (use subagents for many items)
|
||||||
|
bd create --title="Implement feature X" --type=feature
|
||||||
|
bd create --title="Write tests for X" --type=task
|
||||||
|
bd dep add beads-yyy beads-xxx # Tests depend on Feature (Feature blocks tests)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Integration with Gitea
|
||||||
|
|
||||||
|
When working on Gitea issues:
|
||||||
|
1. Create a beads issue to track the work
|
||||||
|
2. Link to Gitea issue in description or comments
|
||||||
|
3. When committing, use `Closes #{gitea_number}` to auto-close
|
||||||
|
4. Close the beads issue after push succeeds
|
||||||
|
|
||||||
|
## Session Close Protocol
|
||||||
|
|
||||||
|
**CRITICAL**: Before saying "done", run this checklist:
|
||||||
|
|
||||||
|
```
|
||||||
|
[ ] 1. git status (check what changed)
|
||||||
|
[ ] 2. git add <files> (stage code changes)
|
||||||
|
[ ] 3. bd sync (commit beads changes)
|
||||||
|
[ ] 4. git commit -m "..." (commit code)
|
||||||
|
[ ] 5. bd sync (commit any new beads changes)
|
||||||
|
[ ] 6. git push (push to remote)
|
||||||
|
```
|
||||||
|
|
||||||
|
Work is NOT complete until `git push` succeeds.
|
||||||
46
.agent/explore.md
Normal file
46
.agent/explore.md
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
# Explore Agent - Project-Specific Instructions
|
||||||
|
|
||||||
|
## Your Role in This Project
|
||||||
|
|
||||||
|
You are the codebase exploration expert for the wg-admin project.
|
||||||
|
|
||||||
|
## Project Context
|
||||||
|
|
||||||
|
- **Primary issue tracking**: `bd` (beads) CLI
|
||||||
|
- **External issue tracking**: Gitea API at https://gitea.calmcacil.dev
|
||||||
|
- **Key directories**:
|
||||||
|
- `.agent/` - Project-specific agent instructions
|
||||||
|
- `.beads/` - Beads issue tracking data
|
||||||
|
- `wireguard.sh` - Main script for WireGuard management
|
||||||
|
|
||||||
|
## When to Use
|
||||||
|
|
||||||
|
Use the explore agent when you need to:
|
||||||
|
- Understand code structure and organization
|
||||||
|
- Find where specific functionality is implemented
|
||||||
|
- Identify patterns across the codebase
|
||||||
|
- Locate files matching specific criteria
|
||||||
|
- Understand the project architecture
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
1. **Read `.agent/AGENTS.md`** first for project mandates
|
||||||
|
2. Use grep and glob tools for codebase exploration
|
||||||
|
3. Provide clear, concise findings with file paths and line numbers
|
||||||
|
4. If you find issues, consider creating beads issues for follow-up work
|
||||||
|
|
||||||
|
## Key Search Targets
|
||||||
|
|
||||||
|
- WireGuard configuration handling
|
||||||
|
- Firewall rule management
|
||||||
|
- Peer connection logic
|
||||||
|
- Configuration file parsing/generation
|
||||||
|
- Error handling patterns
|
||||||
|
|
||||||
|
## Output Format
|
||||||
|
|
||||||
|
When reporting findings:
|
||||||
|
- Use `file_path:line_number` format for references
|
||||||
|
- Keep descriptions concise and actionable
|
||||||
|
- Highlight patterns, not just locations
|
||||||
|
- Note any code smells or anti-patterns you discover
|
||||||
51
.agent/librarian.md
Normal file
51
.agent/librarian.md
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
# Librarian Agent - Project-Specific Instructions
|
||||||
|
|
||||||
|
## Your Role in This Project
|
||||||
|
|
||||||
|
You are the research and documentation expert for the wg-admin project.
|
||||||
|
|
||||||
|
## Project Context
|
||||||
|
|
||||||
|
- **Technology stack**: Bash/shell scripting for WireGuard management
|
||||||
|
- **Documentation style**: Minimal, task-focused markdown
|
||||||
|
- **Documentation location**: Project root (`README.md`, `GITEA_ISSUES.md`)
|
||||||
|
|
||||||
|
## When to Use
|
||||||
|
|
||||||
|
Use the librarian agent when you need to:
|
||||||
|
- Research WireGuard best practices and configuration options
|
||||||
|
- Find examples of similar tools or implementations
|
||||||
|
- Look up documentation for shell scripting patterns
|
||||||
|
- Research Gitea API usage
|
||||||
|
- Find security best practices for network management tools
|
||||||
|
|
||||||
|
## Research Priorities
|
||||||
|
|
||||||
|
1. **WireGuard**: Configuration syntax, peer management, routing
|
||||||
|
2. **Shell scripting**: Best practices, error handling, security
|
||||||
|
3. **Gitea API**: Issue management, webhooks, authentication
|
||||||
|
4. **Bash**: Modern patterns, POSIX compatibility concerns
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
1. **Read `.agent/AGENTS.md`** first for project mandates
|
||||||
|
2. Use websearch and context7 tools for research
|
||||||
|
3. Focus on practical, actionable information
|
||||||
|
4. Provide code examples when relevant
|
||||||
|
5. Cite sources when providing specific recommendations
|
||||||
|
|
||||||
|
## Output Format
|
||||||
|
|
||||||
|
When providing research:
|
||||||
|
- Prioritize official documentation over blog posts
|
||||||
|
- Provide code snippets when relevant
|
||||||
|
- Note version-specific information
|
||||||
|
- Highlight security considerations
|
||||||
|
- Keep responses concise and focused
|
||||||
|
|
||||||
|
## Documentation Principles
|
||||||
|
|
||||||
|
- **Don't create unnecessary docs** - Only document what's critical
|
||||||
|
- **Code over docs** - Self-documenting code preferred
|
||||||
|
- **Update in-place** - Modify existing docs, don't create new ones unless needed
|
||||||
|
- **User-focused** - Write for the people using this tool
|
||||||
71
.agent/oracle.md
Normal file
71
.agent/oracle.md
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
# Oracle Agent - Project-Specific Instructions
|
||||||
|
|
||||||
|
## Your Role in This Project
|
||||||
|
|
||||||
|
You are the architecture and debugging expert for the wg-admin project.
|
||||||
|
|
||||||
|
## Project Context
|
||||||
|
|
||||||
|
- **Type**: Network administration tool (WireGuard management)
|
||||||
|
- **Language**: Bash/shell scripting
|
||||||
|
- **Critical concerns**: Security, reliability, error handling
|
||||||
|
- **Integration**: Gitea for external issue tracking
|
||||||
|
|
||||||
|
## When to Use
|
||||||
|
|
||||||
|
Use the oracle agent when:
|
||||||
|
- Architecture decisions need to be made
|
||||||
|
- Debugging complex issues after 2+ failed attempts
|
||||||
|
- Security review is needed
|
||||||
|
- Performance optimization is required
|
||||||
|
- Multiple solutions exist and you need to recommend the best approach
|
||||||
|
|
||||||
|
## Your Approach
|
||||||
|
|
||||||
|
1. **Read `.agent/AGENTS.md`** first for project mandates
|
||||||
|
2. Gather full context - read relevant code, logs, error messages
|
||||||
|
3. Apply first principles thinking
|
||||||
|
4. Consider trade-offs: simplicity vs completeness
|
||||||
|
5. Recommend the simplest solution that works
|
||||||
|
6. Explain the "why" behind your recommendation
|
||||||
|
|
||||||
|
## Key Concerns
|
||||||
|
|
||||||
|
### Security
|
||||||
|
- Credential handling
|
||||||
|
- File permissions
|
||||||
|
- Input validation
|
||||||
|
- Injection vulnerabilities (shell injection, command injection)
|
||||||
|
- Privilege escalation risks
|
||||||
|
|
||||||
|
### Reliability
|
||||||
|
- Error handling completeness
|
||||||
|
- Idempotent operations
|
||||||
|
- Transaction safety
|
||||||
|
- Rollback mechanisms
|
||||||
|
- State consistency
|
||||||
|
|
||||||
|
### Maintainability
|
||||||
|
- Code organization
|
||||||
|
- Testing approach (what tests exist, what's missing)
|
||||||
|
- Dependency management
|
||||||
|
- Documentation adequacy
|
||||||
|
|
||||||
|
## Debugging Process
|
||||||
|
|
||||||
|
1. **Understand the symptom** - What's failing?
|
||||||
|
2. **Reproduce** - Can you create a minimal reproduction?
|
||||||
|
3. **Isolate** - What's the minimal code path that exhibits the issue?
|
||||||
|
4. **Hypothesize** - What's the likely root cause?
|
||||||
|
5. **Test** - Verify or disprove your hypothesis
|
||||||
|
6. **Fix** - Apply the minimal fix that resolves the root cause
|
||||||
|
7. **Verify** - Ensure the fix doesn't break anything else
|
||||||
|
|
||||||
|
## Output Format
|
||||||
|
|
||||||
|
When providing recommendations:
|
||||||
|
- Start with the recommended solution
|
||||||
|
- Explain the reasoning concisely
|
||||||
|
- Discuss trade-offs if relevant
|
||||||
|
- Provide implementation guidance
|
||||||
|
- Note potential pitfalls or edge cases
|
||||||
File diff suppressed because one or more lines are too long
28
.gitignore
vendored
Normal file
28
.gitignore
vendored
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
# Binaries
|
||||||
|
bin/
|
||||||
|
*.exe
|
||||||
|
*.dll
|
||||||
|
*.so
|
||||||
|
*.dylib
|
||||||
|
|
||||||
|
# Test binary, built with `go test -c`
|
||||||
|
*.test
|
||||||
|
|
||||||
|
# Output of the go coverage tool
|
||||||
|
*.out
|
||||||
|
coverage.html
|
||||||
|
|
||||||
|
# Go workspace file
|
||||||
|
go.work
|
||||||
|
|
||||||
|
# IDE specific
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# OS specific
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
wg-tui
|
||||||
50
AGENTS.md
50
AGENTS.md
@@ -1,22 +1,14 @@
|
|||||||
# Agent Instructions
|
# 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.
|
This project uses **bd** (beads) for issue tracking and **Gitea** for external issue tracking.
|
||||||
|
|
||||||
## Gitea Issues Workflow
|
- **Primary workflow**: Use `bd` CLI for issue tracking
|
||||||
|
- **External issues**: Gitea API for external bug/feature tracking
|
||||||
When asked to work on Gitea issues:
|
- **Agent instructions**: `.agent/` directory contains project-specific agent guidance
|
||||||
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.
|
|
||||||
|
|
||||||
## Quick Reference
|
## Quick Reference
|
||||||
|
|
||||||
@@ -28,29 +20,9 @@ bd close <id> # Complete work
|
|||||||
bd sync # Sync with git
|
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.
|
- `.agent/AGENTS.md` - Project-specific agent instructions (READ THIS FIRST)
|
||||||
|
- `GITEA_ISSUES.md` - Gitea issue workflow details
|
||||||
**MANDATORY WORKFLOW:**
|
- `README.md` - Project overview and setup
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|||||||
@@ -44,7 +44,6 @@ Closes #{issue_number}"
|
|||||||
|
|
||||||
### 7. Push and Close
|
### 7. Push and Close
|
||||||
```bash
|
```bash
|
||||||
bd sync
|
|
||||||
git push
|
git push
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
543
GO_TUI_PLAN.md
Normal file
543
GO_TUI_PLAN.md
Normal file
@@ -0,0 +1,543 @@
|
|||||||
|
# WireGuard Client Manager - Go TUI Implementation Plan
|
||||||
|
|
||||||
|
## Executive Summary
|
||||||
|
|
||||||
|
Converting the bash-based `wg-client-manager` to a modern, responsive Go TUI application using Bubble Tea and the Charm ecosystem. This will provide a better user experience with interactive forms, real-time status updates, and intuitive navigation.
|
||||||
|
|
||||||
|
## Technology Stack
|
||||||
|
|
||||||
|
### Core Framework
|
||||||
|
- **bubbletea** (v1.3.10) - Elm-architecture TUI framework
|
||||||
|
- **lipgloss** - Expressive styling and theming
|
||||||
|
|
||||||
|
### Component Libraries
|
||||||
|
- **huh** - Terminal forms for client creation
|
||||||
|
- **bubble-table** - Interactive table for client list
|
||||||
|
- **bubbles** - Text input and other UI components
|
||||||
|
- **qrterminal** - QR code generation for terminal display
|
||||||
|
|
||||||
|
### Go Modules
|
||||||
|
- WireGuard Go library (if available) or exec-based wrapper
|
||||||
|
- Configuration management (Viper or native config)
|
||||||
|
- File system operations (os, path/filepath)
|
||||||
|
|
||||||
|
## Functional Requirements Analysis
|
||||||
|
|
||||||
|
### Commands to Implement
|
||||||
|
|
||||||
|
| Command | Current Behavior | TUI Implementation |
|
||||||
|
|---------|-----------------|-------------------|
|
||||||
|
| **add** | Interactive prompts or WGI_ env vars | Form modal with fields: name, DNS, PSK option |
|
||||||
|
| **remove** | Command-line with confirmation | Select from list → Confirm modal → Delete |
|
||||||
|
| **list** | Table view with status | Interactive table with sorting, filtering |
|
||||||
|
| **show** | Display client config | Detail view with copyable sections |
|
||||||
|
| **qr** | Display QR code | Inline QR code in modal |
|
||||||
|
|
||||||
|
### Key Features
|
||||||
|
|
||||||
|
#### 1. Client Management
|
||||||
|
- **Add client**
|
||||||
|
- Name input (regex validation: `[a-zA-Z0-9_-]+`, max 64 chars)
|
||||||
|
- Optional DNS configuration
|
||||||
|
- PSK toggle (yes/no)
|
||||||
|
- Auto-assign IPv4/IPv6 addresses
|
||||||
|
- Generate client keys
|
||||||
|
- Create server and client configs
|
||||||
|
- Generate QR code
|
||||||
|
|
||||||
|
- **Remove client**
|
||||||
|
- Select from list
|
||||||
|
- Confirmation dialog
|
||||||
|
- Remove config files
|
||||||
|
- Reload WireGuard
|
||||||
|
|
||||||
|
- **List clients**
|
||||||
|
- Table view: Name, IPv4, IPv6, Status (Connected/Disconnected)
|
||||||
|
- Sorting by column
|
||||||
|
- Filtering/search
|
||||||
|
- Real-time status refresh
|
||||||
|
|
||||||
|
- **Show client details**
|
||||||
|
- Full configuration display
|
||||||
|
- Copy to clipboard functionality
|
||||||
|
- Status information (last handshake, transfer stats)
|
||||||
|
|
||||||
|
- **QR code display**
|
||||||
|
- ANSI-colored QR code in terminal
|
||||||
|
- Toggle fullscreen/inline mode
|
||||||
|
|
||||||
|
#### 2. Configuration Loading
|
||||||
|
- Read `/etc/wg-admin/config.conf`
|
||||||
|
- Environment variable support
|
||||||
|
- Validation of required settings (SERVER_DOMAIN, WG_PORT, etc.)
|
||||||
|
|
||||||
|
#### 3. Validation
|
||||||
|
- Client name format
|
||||||
|
- IP availability checks
|
||||||
|
- DNS server format validation
|
||||||
|
- Pre-install checks (WireGuard installed, config exists)
|
||||||
|
|
||||||
|
#### 4. Security
|
||||||
|
- Run as root required
|
||||||
|
- Proper file permissions (0600 for keys)
|
||||||
|
- Atomic config writes
|
||||||
|
- Temporary key cleanup
|
||||||
|
|
||||||
|
#### 5. Backup & Recovery
|
||||||
|
- Auto-backup before add/remove
|
||||||
|
- Config backup/restore functionality
|
||||||
|
|
||||||
|
## Architecture Design
|
||||||
|
|
||||||
|
### Package Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
wg-admin-tui/
|
||||||
|
├── cmd/
|
||||||
|
│ └── main.go # Entry point
|
||||||
|
├── internal/
|
||||||
|
│ ├── config/
|
||||||
|
│ │ ├── config.go # Configuration loading
|
||||||
|
│ │ └── defaults.go # Default values
|
||||||
|
│ ├── wireguard/
|
||||||
|
│ │ ├── client.go # Client management
|
||||||
|
│ │ ├── keys.go # Key generation
|
||||||
|
│ │ ├── config.go # WireGuard config parsing
|
||||||
|
│ │ └── status.go # Status monitoring
|
||||||
|
│ ├── tui/
|
||||||
|
│ │ ├── app.go # Main TUI application
|
||||||
|
│ │ ├── model.go # Application state
|
||||||
|
│ │ ├── update.go # Message handlers
|
||||||
|
│ │ ├── view.go # Rendering
|
||||||
|
│ │ ├── screens/
|
||||||
|
│ │ │ ├── list.go # Client list view
|
||||||
|
│ │ │ ├── add.go # Add client form
|
||||||
|
│ │ │ ├── detail.go # Client detail view
|
||||||
|
│ │ │ └── qr.go # QR code view
|
||||||
|
│ │ ├── components/
|
||||||
|
│ │ │ ├── table.go # Client table
|
||||||
|
│ │ │ ├── statusbar.go # Status bar
|
||||||
|
│ │ │ └── modal.go # Modal dialogs
|
||||||
|
│ │ └── theme/
|
||||||
|
│ │ ├── colors.go # Color scheme
|
||||||
|
│ │ └── style.go # Styling utilities
|
||||||
|
│ ├── validation/
|
||||||
|
│ │ ├── client.go # Client name validation
|
||||||
|
│ │ ├── network.go # IP/DNS validation
|
||||||
|
│ │ └── config.go # Config syntax validation
|
||||||
|
│ └── backup/
|
||||||
|
│ ├── backup.go # Backup operations
|
||||||
|
│ └── restore.go # Restore operations
|
||||||
|
├── pkg/
|
||||||
|
│ └── util/
|
||||||
|
│ ├── exec.go # Command execution helpers
|
||||||
|
│ └── file.go # File operations
|
||||||
|
└── go.mod
|
||||||
|
```
|
||||||
|
|
||||||
|
### Component Design
|
||||||
|
|
||||||
|
#### 1. Main Application (app.go)
|
||||||
|
```go
|
||||||
|
type Application struct {
|
||||||
|
model Model
|
||||||
|
programs *tea.Program
|
||||||
|
config *config.Config
|
||||||
|
wireguard *wireguard.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewApplication() (*Application, error)
|
||||||
|
func (a *Application) Run() error
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. Model (model.go)
|
||||||
|
```go
|
||||||
|
type Model struct {
|
||||||
|
screen Screen // Current active screen
|
||||||
|
clients []Client // Client data
|
||||||
|
selected int // Selected client
|
||||||
|
loading bool // Loading state
|
||||||
|
error error // Error message
|
||||||
|
table table.Model // Client table
|
||||||
|
form *huh.Form // Add client form
|
||||||
|
modal *Modal // Active modal
|
||||||
|
status Status // Status bar state
|
||||||
|
}
|
||||||
|
|
||||||
|
type Screen interface {
|
||||||
|
Init() tea.Cmd
|
||||||
|
Update(msg tea.Msg) (Screen, tea.Cmd)
|
||||||
|
View() string
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. Screens
|
||||||
|
- **ListScreen**: Display client table
|
||||||
|
- **AddScreen**: Form for adding client
|
||||||
|
- **DetailScreen**: Show client configuration
|
||||||
|
- **QRScreen**: Display QR code
|
||||||
|
|
||||||
|
#### 4. WireGuard Integration
|
||||||
|
```go
|
||||||
|
type Client struct {
|
||||||
|
Name string
|
||||||
|
IPv4 string
|
||||||
|
IPv6 string
|
||||||
|
PublicKey string
|
||||||
|
HasPSK bool
|
||||||
|
Status ConnectionStatus
|
||||||
|
LastSeen time.Time
|
||||||
|
Handshake string
|
||||||
|
ConfigPath string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Manager interface {
|
||||||
|
ListClients() ([]Client, error)
|
||||||
|
AddClient(name, dns string, usePSK bool) (*Client, error)
|
||||||
|
RemoveClient(name string) error
|
||||||
|
GetClient(name string) (*Client, error)
|
||||||
|
GetStatus() (Status, error)
|
||||||
|
ReloadConfig() error
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Implementation Roadmap
|
||||||
|
|
||||||
|
### Phase 1: Project Setup & Foundation (Days 1-2)
|
||||||
|
|
||||||
|
**Goal**: Establish project structure and core framework
|
||||||
|
|
||||||
|
**Tasks**:
|
||||||
|
- [ ] Initialize Go module
|
||||||
|
- [ ] Set up project structure
|
||||||
|
- [ ] Add dependencies (bubbletea, lipgloss, huh, bubble-table, qrterminal)
|
||||||
|
- [ ] Create configuration loading from `/etc/wg-admin/config.conf`
|
||||||
|
- [ ] Implement root check
|
||||||
|
- [ ] Create basic TUI skeleton with empty screens
|
||||||
|
- [ ] Set up logging (both file and console)
|
||||||
|
|
||||||
|
**Deliverables**:
|
||||||
|
- Working TUI that launches and shows a placeholder screen
|
||||||
|
- Configuration system in place
|
||||||
|
|
||||||
|
### Phase 2: Client List View (Days 3-4)
|
||||||
|
|
||||||
|
**Goal**: Display clients in interactive table
|
||||||
|
|
||||||
|
**Tasks**:
|
||||||
|
- [ ] Implement WireGuard client list parsing
|
||||||
|
- [ ] Create `Client` struct
|
||||||
|
- [ ] Set up bubble-table with columns: Name, IPv4, IPv6, Status
|
||||||
|
- [ ] Parse client configs from `/etc/wireguard/conf.d/client-*.conf`
|
||||||
|
- [ ] Check connection status using `wg show`
|
||||||
|
- [ ] Implement keyboard navigation (j/k, arrows, Enter, q)
|
||||||
|
- [ ] Add status bar with help text
|
||||||
|
- [ ] Implement auto-refresh (every 30 seconds)
|
||||||
|
|
||||||
|
**Deliverables**:
|
||||||
|
- Functional client list with real-time status
|
||||||
|
|
||||||
|
### Phase 3: Add Client Form (Days 5-6)
|
||||||
|
|
||||||
|
**Goal**: Create interactive form for adding clients
|
||||||
|
|
||||||
|
**Tasks**:
|
||||||
|
- [ ] Create add screen with `huh` form
|
||||||
|
- [ ] Implement fields:
|
||||||
|
- Client name (text input)
|
||||||
|
- DNS servers (text input with default)
|
||||||
|
- Use PSK (toggle/confirm)
|
||||||
|
- [ ] Add validation:
|
||||||
|
- Client name regex
|
||||||
|
- IP availability check
|
||||||
|
- DNS format validation
|
||||||
|
- [ ] Implement WireGuard key generation
|
||||||
|
- [ ] Auto-assign IPv4/IPv6 addresses
|
||||||
|
- [ ] Create server config file
|
||||||
|
- [ ] Create client config file
|
||||||
|
- [ ] Generate QR code
|
||||||
|
- [ ] Implement atomic config writes
|
||||||
|
- [ ] Auto-backup before add
|
||||||
|
- [ ] Reload WireGuard config
|
||||||
|
|
||||||
|
**Deliverables**:
|
||||||
|
- Working add client form with all validations
|
||||||
|
|
||||||
|
### Phase 4: Client Detail View (Days 7-8)
|
||||||
|
|
||||||
|
**Goal**: Show full client configuration
|
||||||
|
|
||||||
|
**Tasks**:
|
||||||
|
- [ ] Create detail screen
|
||||||
|
- [ ] Display:
|
||||||
|
- Client name
|
||||||
|
- IP addresses
|
||||||
|
- Public key
|
||||||
|
- Full configuration
|
||||||
|
- Connection status
|
||||||
|
- Last handshake
|
||||||
|
- Transfer stats (Rx/Tx)
|
||||||
|
- [ ] Implement copy to clipboard
|
||||||
|
- [ ] Add "Back" and "Delete" buttons
|
||||||
|
- [ ] Delete confirmation modal
|
||||||
|
- [ ] Remove client with config deletion
|
||||||
|
- [ ] Auto-backup before remove
|
||||||
|
- [ ] Reload WireGuard config
|
||||||
|
|
||||||
|
**Deliverables**:
|
||||||
|
- Functional detail view with delete capability
|
||||||
|
|
||||||
|
### Phase 5: QR Code Display (Days 9-10)
|
||||||
|
|
||||||
|
**Goal**: Display QR codes for mobile setup
|
||||||
|
|
||||||
|
**Tasks**:
|
||||||
|
- [ ] Create QR screen
|
||||||
|
- [ ] Read client config
|
||||||
|
- [ ] Generate QR code using `qrterminal`
|
||||||
|
- [ ] Display QR code inline
|
||||||
|
- [ ] Implement fullscreen QR mode
|
||||||
|
- [ ] Add resize handling for QR codes
|
||||||
|
- [ ] Test with various terminal sizes
|
||||||
|
|
||||||
|
**Deliverables**:
|
||||||
|
- Working QR code display
|
||||||
|
|
||||||
|
### Phase 6: Polish & UX Improvements (Days 11-12)
|
||||||
|
|
||||||
|
**Goal**: Improve user experience
|
||||||
|
|
||||||
|
**Tasks**:
|
||||||
|
- [ ] Add color themes
|
||||||
|
- [ ] Implement modal dialogs for confirmations
|
||||||
|
- [ ] Add toast notifications for success/error
|
||||||
|
- [ ] Implement search/filter clients
|
||||||
|
- [ ] Add sorting by columns
|
||||||
|
- [ ] Improve error messages with actionable guidance
|
||||||
|
- [ ] Add keyboard shortcuts help
|
||||||
|
- [ ] Implement loading indicators
|
||||||
|
|
||||||
|
**Deliverables**:
|
||||||
|
- Polished, user-friendly interface
|
||||||
|
|
||||||
|
### Phase 7: Backup & Recovery (Days 13-14)
|
||||||
|
|
||||||
|
**Goal**: Add backup and restore functionality
|
||||||
|
|
||||||
|
**Tasks**:
|
||||||
|
- [ ] Implement backup operations
|
||||||
|
- [ ] Create restore functionality
|
||||||
|
- [ ] Add backup history view
|
||||||
|
- [ ] Implement retention policy
|
||||||
|
- [ ] Add backup/restore screens
|
||||||
|
|
||||||
|
**Deliverables**:
|
||||||
|
- Complete backup/restore system
|
||||||
|
|
||||||
|
## User Experience Design
|
||||||
|
|
||||||
|
### Screen Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────┐
|
||||||
|
│ Client List (Main) │
|
||||||
|
│ ┌─────┬─────┬──────────┬─────────┐│
|
||||||
|
│ │Name │IPv4 │ IPv6 │ Status ││
|
||||||
|
│ ├─────┼─────┼──────────┼─────────┤│
|
||||||
|
│ │laptop│ .2 │ ::2 │Connected││ ← Selected
|
||||||
|
│ │phone │ .3 │ ::3 │ Disc ││
|
||||||
|
│ └─────┴─────┴──────────┴─────────┘│
|
||||||
|
│ │
|
||||||
|
│ [a] Add [d] Detail [?] Help [q] Quit│
|
||||||
|
└─────────────────────────────────────┘
|
||||||
|
↓ Enter
|
||||||
|
┌─────────────────────────────────────┐
|
||||||
|
│ Client Detail: laptop │
|
||||||
|
│ │
|
||||||
|
│ Name: laptop │
|
||||||
|
│ IPv4: 10.10.69.2 │
|
||||||
|
│ IPv6: fd69:dead:beef:69::2 │
|
||||||
|
│ Status: Connected │
|
||||||
|
│ Last Handshake: 2m ago │
|
||||||
|
│ Rx: 1.2 MB Tx: 3.4 MB │
|
||||||
|
│ │
|
||||||
|
│ [Interface] │
|
||||||
|
│ PrivateKey = ... │
|
||||||
|
│ Address = 10.10.69.2/24... │
|
||||||
|
│ │
|
||||||
|
│ [ESC] Back [x] Delete [c] Copy │
|
||||||
|
└─────────────────────────────────────┘
|
||||||
|
↓ 'a' from list
|
||||||
|
┌─────────────────────────────────────┐
|
||||||
|
│ Add New Client │
|
||||||
|
│ │
|
||||||
|
│ Client Name: [_________] │
|
||||||
|
│ DNS Servers: [8.8.8.8, 8.8.4.4] │
|
||||||
|
│ Use PSK? [x] Yes │
|
||||||
|
│ │
|
||||||
|
│ [Enter] Submit [ESC] Cancel │
|
||||||
|
└─────────────────────────────────────┘
|
||||||
|
↓ 'q' from detail
|
||||||
|
┌─────────────────────────────────────┐
|
||||||
|
│ Delete Confirmation │
|
||||||
|
│ │
|
||||||
|
│ Delete client 'laptop'? │
|
||||||
|
│ │
|
||||||
|
│ [Enter] Yes [ESC] No │
|
||||||
|
└─────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Keyboard Shortcuts
|
||||||
|
|
||||||
|
| Key | Action |
|
||||||
|
|-----|--------|
|
||||||
|
| `q` | Quit |
|
||||||
|
| `a` | Add client |
|
||||||
|
| `d` | Show details |
|
||||||
|
| `x` | Delete |
|
||||||
|
| `r` | Refresh |
|
||||||
|
| `/` | Search/filter |
|
||||||
|
| `↑/k` | Move up |
|
||||||
|
| `↓/j` | Move down |
|
||||||
|
| `Enter` | Select/Confirm |
|
||||||
|
| `Esc` | Back/Cancel |
|
||||||
|
| `?` | Help |
|
||||||
|
| `c` | Copy to clipboard |
|
||||||
|
|
||||||
|
### Color Scheme
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Default theme
|
||||||
|
const (
|
||||||
|
ColorPrimary lipgloss.Color
|
||||||
|
ColorSecondary lipgloss.Color
|
||||||
|
ColorSuccess lipgloss.Color
|
||||||
|
ColorWarning lipgloss.Color
|
||||||
|
ColorError lipgloss.Color
|
||||||
|
ColorMuted lipgloss.Color
|
||||||
|
)
|
||||||
|
|
||||||
|
// Example
|
||||||
|
ColorPrimary = lipgloss.Color("#007AFF") // Blue
|
||||||
|
ColorSuccess = lipgloss.Color("#34C759") // Green
|
||||||
|
ColorWarning = lipgloss.Color("#FF9500") // Orange
|
||||||
|
ColorError = lipgloss.Color("#FF3B30") // Red
|
||||||
|
ColorMuted = lipgloss.Color("#8E8E93") // Gray
|
||||||
|
```
|
||||||
|
|
||||||
|
## Error Handling Strategy
|
||||||
|
|
||||||
|
### Validation Errors
|
||||||
|
- Display inline validation messages
|
||||||
|
- Show specific error with action guidance
|
||||||
|
- Keep form state on error
|
||||||
|
- Highlight invalid fields
|
||||||
|
|
||||||
|
### System Errors
|
||||||
|
- Show modal with error message
|
||||||
|
- Log full error to file
|
||||||
|
- Provide actionable guidance
|
||||||
|
- Offer retry or cancel options
|
||||||
|
|
||||||
|
### Examples
|
||||||
|
```
|
||||||
|
ERROR: Client 'laptop' already exists
|
||||||
|
Action: Choose a different name or remove existing client first
|
||||||
|
|
||||||
|
ERROR: No available IPv4 addresses
|
||||||
|
Action: Remove unused clients or expand VPN range
|
||||||
|
```
|
||||||
|
|
||||||
|
## Performance Considerations
|
||||||
|
|
||||||
|
1. **Lazy Loading**
|
||||||
|
- Load clients on screen init
|
||||||
|
- Cache client data between refreshes
|
||||||
|
|
||||||
|
2. **Asynchronous Operations**
|
||||||
|
- Run WireGuard status checks in background
|
||||||
|
- Use tea.Cmd for async operations
|
||||||
|
|
||||||
|
3. **Throttled Refresh**
|
||||||
|
- Auto-refresh every 30 seconds (configurable)
|
||||||
|
- Manual refresh with 'r' key
|
||||||
|
|
||||||
|
4. **QR Code Optimization**
|
||||||
|
- Generate on demand, not cached
|
||||||
|
- Use appropriate error correction level
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
1. **Root Privileges**
|
||||||
|
- Check for root at startup
|
||||||
|
- Display clear error if not root
|
||||||
|
|
||||||
|
2. **Key Storage**
|
||||||
|
- Write keys with 0600 permissions
|
||||||
|
- Clean up temporary key files
|
||||||
|
|
||||||
|
3. **Atomic Operations**
|
||||||
|
- Write to temp file, then move
|
||||||
|
- Prevent config corruption
|
||||||
|
|
||||||
|
4. **Input Validation**
|
||||||
|
- Strict regex for client names
|
||||||
|
- IP availability checks
|
||||||
|
- DNS format validation
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
### Unit Tests
|
||||||
|
- Validation functions
|
||||||
|
- Configuration parsing
|
||||||
|
- Client model methods
|
||||||
|
|
||||||
|
### Integration Tests
|
||||||
|
- WireGuard config generation
|
||||||
|
- Key generation
|
||||||
|
- Backup/restore
|
||||||
|
|
||||||
|
### Manual Testing
|
||||||
|
- End-to-end workflows
|
||||||
|
- Terminal size handling
|
||||||
|
- Error scenarios
|
||||||
|
|
||||||
|
## Milestones
|
||||||
|
|
||||||
|
| Milestone | Days | Goal |
|
||||||
|
|-----------|------|------|
|
||||||
|
| M1: Foundation | 2 | Project setup, basic TUI |
|
||||||
|
| M2: List View | 4 | Client list with status |
|
||||||
|
| M3: Add Client | 6 | Working add form |
|
||||||
|
| M4: Detail & Delete | 8 | Full client management |
|
||||||
|
| M5: QR Codes | 10 | QR code display |
|
||||||
|
| M6: Polish | 12 | UX improvements |
|
||||||
|
| M7: Complete | 14 | Fully functional TUI |
|
||||||
|
|
||||||
|
## Success Criteria
|
||||||
|
|
||||||
|
- ✅ All bash functionality replicated in TUI
|
||||||
|
- ✅ Responsive and performant UI
|
||||||
|
- ✅ Clear error messages with actionable guidance
|
||||||
|
- ✅ Keyboard shortcuts for all actions
|
||||||
|
- ✅ Real-time client status updates
|
||||||
|
- ✅ Working QR code generation
|
||||||
|
- ✅ Backup/restore functionality
|
||||||
|
- ✅ Polished, intuitive user experience
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
- Config import/export
|
||||||
|
- Client statistics dashboard
|
||||||
|
- Connection monitoring view
|
||||||
|
- Multiple server support
|
||||||
|
- Template-based client creation
|
||||||
|
- Bulk operations
|
||||||
|
- Dark/Light theme toggle
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Estimated Timeline**: 14 days for MVP
|
||||||
|
**Primary Dependencies**: bubbletea, lipgloss, huh, bubble-table, qrterminal
|
||||||
|
**Minimum Go Version**: 1.21+
|
||||||
|
**Target Terminal**: ANSI-compatible terminals (Linux, macOS, Windows with WSL)
|
||||||
86
Makefile
Normal file
86
Makefile
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
# WireGuard Admin TUI
|
||||||
|
.PHONY: help build clean install test fmt lint run deps
|
||||||
|
|
||||||
|
# Project root (where go.mod is located)
|
||||||
|
ROOTDIR:=$(shell dirname $(realpath $(firstword $(MAKEFILE_LIST))))
|
||||||
|
|
||||||
|
# Binary name
|
||||||
|
BINARY=wg-tui
|
||||||
|
CMD_PATH=$(ROOTDIR)/cmd/$(BINARY)
|
||||||
|
|
||||||
|
# Build directory
|
||||||
|
BUILD_DIR=bin
|
||||||
|
BINARY_PATH=$(BUILD_DIR)/$(BINARY)
|
||||||
|
|
||||||
|
# Go parameters
|
||||||
|
GOCMD=go
|
||||||
|
GOBUILD=$(GOCMD) build
|
||||||
|
GOCLEAN=$(GOCMD) clean
|
||||||
|
GOTEST=$(GOCMD) test
|
||||||
|
GOGET=$(GOCMD) get
|
||||||
|
GOMOD=$(GOCMD) mod
|
||||||
|
|
||||||
|
help: ## Show this help message
|
||||||
|
@echo 'Usage: make [target]'
|
||||||
|
@echo ''
|
||||||
|
@echo 'Available targets:'
|
||||||
|
@awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf " %-15s %s\n", $$1, $$2}' $(MAKEFILE_LIST)
|
||||||
|
|
||||||
|
build: ## Build the binary
|
||||||
|
@echo "Building $(BINARY)..."
|
||||||
|
@mkdir -p $(BUILD_DIR)
|
||||||
|
@$(GOBUILD) -C $(ROOTDIR) -o $(BINARY_PATH) cmd/$(BINARY)/main.go
|
||||||
|
@echo "Build complete: $(BINARY_PATH)"
|
||||||
|
|
||||||
|
build-all: ## Build all binaries
|
||||||
|
@echo "Building all binaries..."
|
||||||
|
@mkdir -p $(BUILD_DIR)
|
||||||
|
@$(GOBUILD) -C $(ROOTDIR) -o $(BINARY_PATH) ./...
|
||||||
|
|
||||||
|
clean: ## Clean build artifacts
|
||||||
|
@echo "Cleaning..."
|
||||||
|
@$(GOCLEAN)
|
||||||
|
@rm -rf $(BUILD_DIR)
|
||||||
|
@echo "Clean complete"
|
||||||
|
|
||||||
|
install: ## Install the binary to $GOPATH/bin
|
||||||
|
@echo "Installing $(BINARY)..."
|
||||||
|
@$(GOBUILD) -C $(ROOTDIR) -o $$($(GOCMD) env GOPATH)/bin/$(BINARY) cmd/$(BINARY)/main.go
|
||||||
|
@echo "Install complete: $$($(GOCMD) env GOPATH)/bin/$(BINARY)"
|
||||||
|
|
||||||
|
test: ## Run tests
|
||||||
|
@echo "Running tests..."
|
||||||
|
@$(GOTEST) -C $(ROOTDIR) -v ./...
|
||||||
|
|
||||||
|
test-coverage: ## Run tests with coverage
|
||||||
|
@echo "Running tests with coverage..."
|
||||||
|
@$(GOTEST) -C $(ROOTDIR) -v -coverprofile=coverage.out ./...
|
||||||
|
@$(GOCMD) tool -C $(ROOTDIR) cover -html=coverage.out -o coverage.html
|
||||||
|
|
||||||
|
fmt: ## Format Go code
|
||||||
|
@echo "Formatting code..."
|
||||||
|
@$(GOCMD) fmt -C $(ROOTDIR) ./...
|
||||||
|
|
||||||
|
lint: ## Run golangci-lint (if installed)
|
||||||
|
@echo "Running linter..."
|
||||||
|
@cd $(ROOTDIR) && if command -v golangci-lint > /dev/null; then \
|
||||||
|
golangci-lint run; \
|
||||||
|
else \
|
||||||
|
echo "golangci-lint not found. Install from https://golangci-lint.run/usage/install/"; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
deps: ## Download dependencies
|
||||||
|
@echo "Downloading dependencies..."
|
||||||
|
@$(GOMOD) -C $(ROOTDIR) download
|
||||||
|
@$(GOMOD) -C $(ROOTDIR) tidy
|
||||||
|
|
||||||
|
run: build ## Build and run the binary
|
||||||
|
@echo "Running $(BINARY)..."
|
||||||
|
@./$(BINARY_PATH)
|
||||||
|
|
||||||
|
dev: ## Run in development mode with hot reload (requires air)
|
||||||
|
@if command -v air > /dev/null; then \
|
||||||
|
air; \
|
||||||
|
else \
|
||||||
|
echo "air not found. Install with: go install github.com/cosmtrek/air@latest"; \
|
||||||
|
fi
|
||||||
131
README.md
131
README.md
@@ -3,23 +3,119 @@
|
|||||||
## Overview
|
## Overview
|
||||||
Personal WireGuard VPN server with IPv4/IPv6 support, client management via `wireguard.sh`, designed for 1 CPU / 1GB RAM VPS.
|
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
|
## Configuration
|
||||||
- **Server Domain**: velkhana.calmcacil.dev
|
|
||||||
- **Port**: 51820
|
Configuration is managed through `/etc/wg-admin/config.conf`. Copy `config.example` to this location and customize for your environment.
|
||||||
- **VPN IPv4 Range**: 10.10.69.0/24
|
|
||||||
- **VPN IPv6 Range**: fd69:dead:beef:69::/64
|
### Creating Configuration File
|
||||||
- **DNS**: 8.8.8.8, 8.8.4.4 (Google)
|
|
||||||
- **Server-side peer configs**: /etc/wireguard/conf.d/client-*.conf (loaded dynamically)
|
```bash
|
||||||
- **Client-side configs**: /etc/wireguard/clients/*.conf (for distribution)
|
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
|
## Installation
|
||||||
|
|
||||||
### 1. Upload script to VPS
|
### 1. Upload script to VPS
|
||||||
```bash
|
```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
|
```bash
|
||||||
chmod +x ~/wireguard.sh
|
chmod +x ~/wireguard.sh
|
||||||
sudo ~/wireguard.sh install
|
sudo ~/wireguard.sh install
|
||||||
@@ -127,6 +223,23 @@ Then run:
|
|||||||
sudo ~/wireguard.sh load-clients
|
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
|
## Client Setup
|
||||||
|
|
||||||
### Importing the config
|
### Importing the config
|
||||||
|
|||||||
385
cmd/wg-tui/main.go
Normal file
385
cmd/wg-tui/main.go
Normal file
@@ -0,0 +1,385 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
|
||||||
|
"github.com/calmcacil/wg-admin/internal/config"
|
||||||
|
"github.com/calmcacil/wg-admin/internal/tui/screens"
|
||||||
|
)
|
||||||
|
|
||||||
|
const version = "0.1.0"
|
||||||
|
|
||||||
|
// TickMsg is sent for transition animation frames
|
||||||
|
type TickMsg time.Time
|
||||||
|
|
||||||
|
// Transition settings
|
||||||
|
const (
|
||||||
|
transitionDuration = 200 * time.Millisecond
|
||||||
|
transitionFPS = 60
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
var (
|
||||||
|
styleTitle = lipgloss.NewStyle().Foreground(lipgloss.Color("62")).Bold(true)
|
||||||
|
styleSubtitle = lipgloss.NewStyle().Foreground(lipgloss.Color("241"))
|
||||||
|
styleSuccess = lipgloss.NewStyle().Foreground(lipgloss.Color("46"))
|
||||||
|
styleError = lipgloss.NewStyle().Foreground(lipgloss.Color("196"))
|
||||||
|
styleHelpKey = lipgloss.NewStyle().Foreground(lipgloss.Color("63")).Bold(true)
|
||||||
|
)
|
||||||
|
|
||||||
|
type model struct {
|
||||||
|
transitionFrom screens.Screen
|
||||||
|
transitionTo screens.Screen
|
||||||
|
transitionProgress float64
|
||||||
|
transitionAnimating bool
|
||||||
|
transitionStartTime time.Time
|
||||||
|
transitionType screens.TransitionType
|
||||||
|
transitionDuration time.Duration
|
||||||
|
currentScreen screens.Screen
|
||||||
|
previousScreen screens.Screen
|
||||||
|
quitting bool
|
||||||
|
initialized bool
|
||||||
|
errorScreen *screens.ErrorScreen
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m model) Init() tea.Cmd {
|
||||||
|
if os.Geteuid() != 0 {
|
||||||
|
fmt.Println(styleError.Render("ERROR: Must run as root"))
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
_, _ = config.LoadConfig()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
|
|
||||||
|
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
|
// Handle initialization on first update after Init
|
||||||
|
if !m.initialized {
|
||||||
|
m.initialized = true
|
||||||
|
m.currentScreen = screens.NewListScreen()
|
||||||
|
return m, m.currentScreen.Init()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle transition animation updates
|
||||||
|
if m.transitionAnimating {
|
||||||
|
switch msg.(type) {
|
||||||
|
case TickMsg:
|
||||||
|
// Update transition progress
|
||||||
|
elapsed := time.Since(m.transitionStartTime)
|
||||||
|
m.transitionProgress = float64(elapsed) / float64(m.transitionDuration)
|
||||||
|
|
||||||
|
// Ease-out cubic function for smoother animation
|
||||||
|
easeProgress := 1 - ((1 - m.transitionProgress) * (1 - m.transitionProgress) * (1 - m.transitionProgress))
|
||||||
|
|
||||||
|
if m.transitionProgress >= 1 || easeProgress >= 1 {
|
||||||
|
// Transition complete
|
||||||
|
m.transitionAnimating = false
|
||||||
|
m.currentScreen = m.transitionTo
|
||||||
|
m.previousScreen = m.transitionFrom
|
||||||
|
m.transitionFrom = nil
|
||||||
|
m.transitionTo = nil
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Continue animation
|
||||||
|
return m, tea.Tick(time.Second/time.Duration(transitionFPS), func(t time.Time) tea.Msg {
|
||||||
|
return TickMsg(t)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// During transition, don't process other messages
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
switch msg := msg.(type) {
|
||||||
|
case tea.KeyMsg:
|
||||||
|
// Only quit on 'q' or ctrl+c when on list screen
|
||||||
|
if msg.String() == "q" || msg.String() == "ctrl+c" {
|
||||||
|
if _, ok := m.currentScreen.(*screens.ListScreen); ok {
|
||||||
|
m.quitting = true
|
||||||
|
return m, tea.Quit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch msg.String() {
|
||||||
|
case "?":
|
||||||
|
cmd := m.startTransition(screens.NewHelpScreen(m.previousScreen), screens.TransitionFade)
|
||||||
|
return m, cmd
|
||||||
|
case "l":
|
||||||
|
cmd := m.startTransition(screens.NewListScreen(), screens.TransitionFade)
|
||||||
|
return m, cmd
|
||||||
|
case "a":
|
||||||
|
cmd := m.startTransition(screens.NewAddScreen(), screens.TransitionSlideLeft)
|
||||||
|
return m, cmd
|
||||||
|
}
|
||||||
|
case screens.ClientSelectedMsg:
|
||||||
|
cmd := m.startTransition(screens.NewDetailScreen(msg.Client.Client), screens.TransitionSlideLeft)
|
||||||
|
return m, cmd
|
||||||
|
case screens.ClientDeletedMsg:
|
||||||
|
cmd := m.startTransition(screens.NewListScreen(), screens.TransitionFade)
|
||||||
|
return m, cmd
|
||||||
|
case screens.ClientCreatedMsg:
|
||||||
|
cmd := m.startTransition(screens.NewListScreen(), screens.TransitionFade)
|
||||||
|
return m, cmd
|
||||||
|
case screens.RestoreCompletedMsg:
|
||||||
|
cmd := m.startTransition(screens.NewListScreen(), screens.TransitionFade)
|
||||||
|
return m, cmd
|
||||||
|
case screens.CloseDetailScreenMsg:
|
||||||
|
if m.previousScreen != nil {
|
||||||
|
cmd := m.startTransition(m.previousScreen, screens.TransitionSlideRight)
|
||||||
|
return m, cmd
|
||||||
|
}
|
||||||
|
cmd := m.startTransition(screens.NewListScreen(), screens.TransitionFade)
|
||||||
|
return m, cmd
|
||||||
|
case screens.ErrMsg:
|
||||||
|
m.previousScreen = m.currentScreen
|
||||||
|
m.errorScreen = screens.NewErrorScreen(msg.Err)
|
||||||
|
cmd := m.startTransition(m.errorScreen, screens.TransitionFade)
|
||||||
|
return m, cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pass messages to current screen
|
||||||
|
if m.currentScreen != nil {
|
||||||
|
newScreen, cmd := m.currentScreen.Update(msg)
|
||||||
|
if newScreen == nil {
|
||||||
|
if m.previousScreen != nil {
|
||||||
|
transitionCmd := m.startTransition(m.previousScreen, screens.TransitionSlideRight)
|
||||||
|
return m, tea.Batch(cmd, transitionCmd)
|
||||||
|
}
|
||||||
|
} else if newScreen != m.currentScreen {
|
||||||
|
m.previousScreen = m.currentScreen
|
||||||
|
transitionCmd := m.startTransition(newScreen, screens.TransitionSlideLeft)
|
||||||
|
return m, tea.Batch(cmd, transitionCmd)
|
||||||
|
}
|
||||||
|
return m, cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m model) View() string {
|
||||||
|
|
||||||
|
func (m model) View() string {
|
||||||
|
if m.quitting {
|
||||||
|
return "\nGoodbye!\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
// If transitioning, render the transition animation
|
||||||
|
if m.transitionAnimating {
|
||||||
|
return renderTransition(m)
|
||||||
|
}
|
||||||
|
|
||||||
|
if m.currentScreen != nil && m.initialized {
|
||||||
|
return m.currentScreen.View()
|
||||||
|
}
|
||||||
|
|
||||||
|
title := styleTitle.Render("WireGuard Client Manager")
|
||||||
|
subtitle := styleSubtitle.Render(fmt.Sprintf("v%s", version))
|
||||||
|
bar := fmt.Sprintf("Press %s for help, %s to quit", styleHelpKey.Render("?"), styleHelpKey.Render("q"))
|
||||||
|
|
||||||
|
return title + " " + subtitle + "\n\nInitializing...\n\n" + bar
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
p := tea.NewProgram(model{}, tea.WithAltScreen())
|
||||||
|
if _, err := p.Run(); err != nil {
|
||||||
|
fmt.Printf("Error: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// startTransition begins a screen transition animation
|
||||||
|
func (m *model) startTransition(toScreen screens.Screen, ttype screens.TransitionType) tea.Cmd {
|
||||||
|
// Initialize new screen
|
||||||
|
var initCmd tea.Cmd
|
||||||
|
if toScreen != nil {
|
||||||
|
initCmd = toScreen.Init()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up transition
|
||||||
|
m.transitionFrom = m.currentScreen
|
||||||
|
m.transitionTo = toScreen
|
||||||
|
m.transitionType = ttype
|
||||||
|
m.transitionDuration = transitionDuration
|
||||||
|
m.transitionProgress = 0
|
||||||
|
m.transitionAnimating = true
|
||||||
|
m.transitionStartTime = time.Now()
|
||||||
|
|
||||||
|
// Return initialization command and tick command
|
||||||
|
tickCmd := tea.Tick(time.Second/time.Duration(transitionFPS), func(t time.Time) tea.Msg {
|
||||||
|
return TickMsg(t)
|
||||||
|
})
|
||||||
|
|
||||||
|
if initCmd != nil {
|
||||||
|
return tea.Batch(initCmd, tickCmd)
|
||||||
|
}
|
||||||
|
return tickCmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderTransition(m model) string {
|
||||||
|
if m.transitionFrom == nil || m.transitionTo == nil {
|
||||||
|
return m.transitionTo.View()
|
||||||
|
}
|
||||||
|
|
||||||
|
fromView := m.transitionFrom.View()
|
||||||
|
toView := m.transitionTo.View()
|
||||||
|
|
||||||
|
// Apply easing to progress
|
||||||
|
easeProgress := 1 - ((1 - m.transitionProgress) * (1 - m.transitionProgress) * (1 - m.transitionProgress))
|
||||||
|
|
||||||
|
switch m.transitionType {
|
||||||
|
case screens.TransitionFade:
|
||||||
|
return renderFadeTransition(fromView, toView, easeProgress)
|
||||||
|
case screens.TransitionSlideLeft:
|
||||||
|
return renderSlideTransition(fromView, toView, easeProgress, true)
|
||||||
|
case screens.TransitionSlideRight:
|
||||||
|
return renderSlideTransition(fromView, toView, easeProgress, false)
|
||||||
|
default:
|
||||||
|
return toView
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderFadeTransition(fromView, toView string, progress float64) string {
|
||||||
|
fromLines := splitLines(fromView)
|
||||||
|
toLines := splitLines(toView)
|
||||||
|
|
||||||
|
maxLines := max(len(fromLines), len(toLines))
|
||||||
|
for len(fromLines) < maxLines {
|
||||||
|
fromLines = append(fromLines, "")
|
||||||
|
}
|
||||||
|
for len(toLines) < maxLines {
|
||||||
|
toLines = append(toLines, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
var result []string
|
||||||
|
for i := 0; i < maxLines; i++ {
|
||||||
|
threshold := progress * float64(maxLines)
|
||||||
|
if float64(i) < threshold {
|
||||||
|
result = append(result, toLines[i])
|
||||||
|
} else {
|
||||||
|
result = append(result, fromLines[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return joinLines(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderSlideTransition(fromView, toView string, progress float64, slideLeft bool) string {
|
||||||
|
fromLines := splitLines(fromView)
|
||||||
|
toLines := splitLines(toView)
|
||||||
|
|
||||||
|
width := 80
|
||||||
|
|
||||||
|
for i := range fromLines {
|
||||||
|
fromLines[i] = padLine(fromLines[i], width)
|
||||||
|
}
|
||||||
|
for i := range toLines {
|
||||||
|
toLines[i] = padLine(toLines[i], width)
|
||||||
|
}
|
||||||
|
|
||||||
|
offset := int(float64(width) * progress)
|
||||||
|
|
||||||
|
var result []string
|
||||||
|
maxLines := max(len(fromLines), len(toLines))
|
||||||
|
|
||||||
|
for i := 0; i < maxLines; i++ {
|
||||||
|
var fromLine, toLine string
|
||||||
|
if i < len(fromLines) {
|
||||||
|
fromLine = fromLines[i]
|
||||||
|
} else {
|
||||||
|
fromLine = ""
|
||||||
|
}
|
||||||
|
if i < len(toLines) {
|
||||||
|
toLine = toLines[i]
|
||||||
|
} else {
|
||||||
|
toLine = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if slideLeft {
|
||||||
|
fromOffset := -offset
|
||||||
|
toOffset := width - offset
|
||||||
|
combined := combineSlidingLines(fromLine, toLine, fromOffset, toOffset, width)
|
||||||
|
result = append(result, combined)
|
||||||
|
} else {
|
||||||
|
fromOffset := offset
|
||||||
|
toOffset := -width + offset
|
||||||
|
combined := combineSlidingLines(fromLine, toLine, fromOffset, toOffset, width)
|
||||||
|
result = append(result, combined)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return joinLines(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func combineSlidingLines(from, to string, fromOffset, toOffset, width int) string {
|
||||||
|
result := make([]byte, width)
|
||||||
|
for i := range result {
|
||||||
|
result[i] = ' '
|
||||||
|
}
|
||||||
|
|
||||||
|
if fromOffset >= -len(from) && fromOffset < width {
|
||||||
|
for i := 0; i < len(from) && i+fromOffset < width && i+fromOffset >= 0; i++ {
|
||||||
|
if i+fromOffset >= 0 && i+fromOffset < width {
|
||||||
|
result[i+fromOffset] = from[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if toOffset >= -len(to) && toOffset < width {
|
||||||
|
for i := 0; i < len(to) && i+toOffset < width && i+toOffset >= 0; i++ {
|
||||||
|
if i+toOffset >= 0 && i+toOffset < width {
|
||||||
|
result[i+toOffset] = to[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitLines(s string) []string {
|
||||||
|
lines := make([]string, 0)
|
||||||
|
current := ""
|
||||||
|
for _, c := range s {
|
||||||
|
if c == '\n' {
|
||||||
|
lines = append(lines, current)
|
||||||
|
current = ""
|
||||||
|
} else {
|
||||||
|
current += string(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if current != "" {
|
||||||
|
lines = append(lines, current)
|
||||||
|
}
|
||||||
|
return lines
|
||||||
|
}
|
||||||
|
|
||||||
|
func joinLines(lines []string) string {
|
||||||
|
result := ""
|
||||||
|
for i, line := range lines {
|
||||||
|
if i > 0 {
|
||||||
|
result += "\n"
|
||||||
|
}
|
||||||
|
result += line
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func padLine(line string, width int) string {
|
||||||
|
if len(line) >= width {
|
||||||
|
return line[:width]
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%-*s", width, line)
|
||||||
|
}
|
||||||
|
|
||||||
|
func max(a, b int) int {
|
||||||
|
if a > b {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
28
config.example
Normal file
28
config.example
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
# WireGuard VPN Configuration
|
||||||
|
# Copy this file to /etc/wg-admin/config.conf and customize for your environment
|
||||||
|
# All values are optional - script will use defaults if not set
|
||||||
|
|
||||||
|
# Server domain or IP address (required for client endpoints)
|
||||||
|
# Example: vpn.example.com or 203.0.113.10
|
||||||
|
#SERVER_DOMAIN=vpn.example.com
|
||||||
|
|
||||||
|
# WireGuard UDP port (default: 51820)
|
||||||
|
#WG_PORT=51820
|
||||||
|
|
||||||
|
# VPN IPv4 address range (default: 10.10.69.0/24)
|
||||||
|
#VPN_IPV4_RANGE=10.10.69.0/24
|
||||||
|
|
||||||
|
# VPN IPv6 address range (default: fd69:dead:beef:69::/64)
|
||||||
|
#VPN_IPV6_RANGE=fd69:dead:beef:69::/64
|
||||||
|
|
||||||
|
# WireGuard interface name (default: wg0)
|
||||||
|
#WG_INTERFACE=wg0
|
||||||
|
|
||||||
|
# DNS servers for clients (default: 8.8.8.8, 8.8.4.4)
|
||||||
|
#DNS_SERVERS=8.8.8.8, 8.8.4.4
|
||||||
|
|
||||||
|
# Log file location (default: /var/log/wireguard-admin.log)
|
||||||
|
#LOG_FILE=/var/log/wireguard-admin.log
|
||||||
|
|
||||||
|
# Minimum disk space required in MB (default: 100)
|
||||||
|
#MIN_DISK_SPACE_MB=100
|
||||||
36
go.mod
Normal file
36
go.mod
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
module github.com/calmcacil/wg-admin
|
||||||
|
|
||||||
|
go 1.24.4
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/atotto/clipboard v0.1.4 // indirect
|
||||||
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||||
|
github.com/catppuccin/go v0.3.0 // indirect
|
||||||
|
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect
|
||||||
|
github.com/charmbracelet/bubbletea v1.3.10 // indirect
|
||||||
|
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
|
||||||
|
github.com/charmbracelet/huh v0.8.0 // indirect
|
||||||
|
github.com/charmbracelet/lipgloss v1.1.0 // indirect
|
||||||
|
github.com/charmbracelet/x/ansi v0.10.1 // indirect
|
||||||
|
github.com/charmbracelet/x/cellbuf v0.0.13 // indirect
|
||||||
|
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
|
||||||
|
github.com/charmbracelet/x/term v0.2.1 // indirect
|
||||||
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||||
|
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||||
|
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||||
|
github.com/mdp/qrterminal/v3 v3.2.1 // indirect
|
||||||
|
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
|
||||||
|
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||||
|
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||||
|
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||||
|
github.com/muesli/termenv v0.16.0 // indirect
|
||||||
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||||
|
golang.org/x/sys v0.36.0 // indirect
|
||||||
|
golang.org/x/term v0.13.0 // indirect
|
||||||
|
golang.org/x/text v0.23.0 // indirect
|
||||||
|
rsc.io/qr v0.2.0 // indirect
|
||||||
|
)
|
||||||
63
go.sum
Normal file
63
go.sum
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||||
|
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
||||||
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||||
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||||
|
github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY=
|
||||||
|
github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
|
||||||
|
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws=
|
||||||
|
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7/go.mod h1:ISC1gtLcVilLOf23wvTfoQuYbW2q0JevFxPfUzZ9Ybw=
|
||||||
|
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
|
||||||
|
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
|
||||||
|
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
|
||||||
|
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
|
||||||
|
github.com/charmbracelet/huh v0.8.0 h1:Xz/Pm2h64cXQZn/Jvele4J3r7DDiqFCNIVteYukxDvY=
|
||||||
|
github.com/charmbracelet/huh v0.8.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4=
|
||||||
|
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
|
||||||
|
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
|
||||||
|
github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ=
|
||||||
|
github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
|
||||||
|
github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k=
|
||||||
|
github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
|
||||||
|
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4=
|
||||||
|
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ=
|
||||||
|
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
|
||||||
|
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
|
||||||
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||||
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
||||||
|
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
||||||
|
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
||||||
|
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
||||||
|
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||||
|
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||||
|
github.com/mdp/qrterminal/v3 v3.2.1 h1:6+yQjiiOsSuXT5n9/m60E54vdgFsw0zhADHhHLrFet4=
|
||||||
|
github.com/mdp/qrterminal/v3 v3.2.1/go.mod h1:jOTmXvnBsMy5xqLniO0R++Jmjs2sTm9dFSuQ5kpz/SU=
|
||||||
|
github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
|
||||||
|
github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
|
||||||
|
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
||||||
|
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||||
|
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
||||||
|
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
|
||||||
|
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||||
|
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||||
|
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||||
|
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||||
|
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||||
|
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||||
|
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||||
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||||
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||||
|
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
|
||||||
|
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
|
golang.org/x/term v0.13.0 h1:bb+I9cTfFazGW51MZqBVmZy7+JEJMouUHTUSKVQLBek=
|
||||||
|
golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U=
|
||||||
|
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
|
||||||
|
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
|
||||||
|
rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY=
|
||||||
|
rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs=
|
||||||
267
internal/backup/backup.go
Normal file
267
internal/backup/backup.go
Normal file
@@ -0,0 +1,267 @@
|
|||||||
|
package backup
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Backup represents a backup with metadata
|
||||||
|
type Backup struct {
|
||||||
|
Name string // Backup name (directory name)
|
||||||
|
Path string // Full path to backup directory
|
||||||
|
Operation string // Operation that triggered the backup
|
||||||
|
Timestamp time.Time // When the backup was created
|
||||||
|
Size int64 // Size in bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateBackup creates a new backup with the specified operation
|
||||||
|
func CreateBackup(operation string) error {
|
||||||
|
backupDir := "/etc/wg-admin/backups"
|
||||||
|
|
||||||
|
// Create backup directory if it doesn't exist
|
||||||
|
if err := os.MkdirAll(backupDir, 0700); err != nil {
|
||||||
|
return fmt.Errorf("failed to create backup directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create timestamped backup directory
|
||||||
|
timestamp := time.Now().Format("20060102-150405")
|
||||||
|
backupName := fmt.Sprintf("wg-backup-%s-%s", operation, timestamp)
|
||||||
|
backupPath := filepath.Join(backupDir, backupName)
|
||||||
|
|
||||||
|
if err := os.MkdirAll(backupPath, 0700); err != nil {
|
||||||
|
return fmt.Errorf("failed to create backup path: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backup entire wireguard directory to maintain structure expected by restore.go
|
||||||
|
wgConfigPath := "/etc/wireguard"
|
||||||
|
if _, err := os.Stat(wgConfigPath); err == nil {
|
||||||
|
backupWgPath := filepath.Join(backupPath, "wireguard")
|
||||||
|
if err := exec.Command("cp", "-a", wgConfigPath, backupWgPath).Run(); err != nil {
|
||||||
|
return fmt.Errorf("failed to backup wireguard config: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create backup metadata
|
||||||
|
metadataPath := filepath.Join(backupPath, "backup-info.txt")
|
||||||
|
metadata := fmt.Sprintf("Backup created: %s\nOperation: %s\nTimestamp: %s\n", time.Now().Format(time.RFC3339), operation, timestamp)
|
||||||
|
if err := os.WriteFile(metadataPath, []byte(metadata), 0600); err != nil {
|
||||||
|
return fmt.Errorf("failed to create backup metadata: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set restrictive permissions on backup directory
|
||||||
|
if err := os.Chmod(backupPath, 0700); err != nil {
|
||||||
|
return fmt.Errorf("failed to set backup directory permissions: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply retention policy (keep last 10 backups)
|
||||||
|
if err := applyRetentionPolicy(backupDir, 10); err != nil {
|
||||||
|
// Log but don't fail on retention errors
|
||||||
|
fmt.Fprintf(os.Stderr, "Warning: failed to apply retention policy: %v\n", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListBackups returns all available backups sorted by creation time (newest first)
|
||||||
|
func ListBackups() ([]Backup, error) {
|
||||||
|
backupDir := "/etc/wg-admin/backups"
|
||||||
|
|
||||||
|
// Check if backup directory exists
|
||||||
|
if _, err := os.Stat(backupDir); os.IsNotExist(err) {
|
||||||
|
return []Backup{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read all entries
|
||||||
|
entries, err := os.ReadDir(backupDir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read backup directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var backups []Backup
|
||||||
|
|
||||||
|
// Parse backup directories
|
||||||
|
for _, entry := range entries {
|
||||||
|
if !entry.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
name := entry.Name()
|
||||||
|
|
||||||
|
// Check if it's a valid backup directory (starts with "wg-backup-")
|
||||||
|
if !strings.HasPrefix(name, "wg-backup-") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse backup name to extract operation and timestamp
|
||||||
|
// Format: wg-backup-{operation}-{timestamp}
|
||||||
|
parts := strings.SplitN(name, "-", 4)
|
||||||
|
if len(parts) < 4 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
operation := parts[2]
|
||||||
|
timestampStr := parts[3]
|
||||||
|
|
||||||
|
// Parse timestamp
|
||||||
|
timestamp, err := time.Parse("20060102-150405", timestampStr)
|
||||||
|
if err != nil {
|
||||||
|
// If timestamp parsing fails, use directory modification time
|
||||||
|
info, err := entry.Info()
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
timestamp = info.ModTime()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get backup size
|
||||||
|
backupPath := filepath.Join(backupDir, name)
|
||||||
|
size, err := getBackupSize(backupPath)
|
||||||
|
if err != nil {
|
||||||
|
size = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
backup := Backup{
|
||||||
|
Name: name,
|
||||||
|
Path: backupPath,
|
||||||
|
Operation: operation,
|
||||||
|
Timestamp: timestamp,
|
||||||
|
Size: size,
|
||||||
|
}
|
||||||
|
|
||||||
|
backups = append(backups, backup)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by timestamp (newest first)
|
||||||
|
sort.Slice(backups, func(i, j int) bool {
|
||||||
|
return backups[i].Timestamp.After(backups[j].Timestamp)
|
||||||
|
})
|
||||||
|
|
||||||
|
return backups, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getBackupSize calculates the total size of a backup directory
|
||||||
|
func getBackupSize(backupPath string) (int64, error) {
|
||||||
|
var size int64
|
||||||
|
|
||||||
|
err := filepath.Walk(backupPath, func(_ string, info os.FileInfo, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !info.IsDir() {
|
||||||
|
size += info.Size()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
return size, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// RestoreBackup restores WireGuard configurations from a backup by name
|
||||||
|
func RestoreBackup(backupName string) error {
|
||||||
|
backupDir := "/etc/wg-admin/backups"
|
||||||
|
backupPath := filepath.Join(backupDir, backupName)
|
||||||
|
|
||||||
|
// Verify backup exists
|
||||||
|
if _, err := os.Stat(backupPath); os.IsNotExist(err) {
|
||||||
|
return fmt.Errorf("backup does not exist: %s", backupName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for wireguard subdirectory
|
||||||
|
wgSourcePath := filepath.Join(backupPath, "wireguard")
|
||||||
|
if _, err := os.Stat(wgSourcePath); os.IsNotExist(err) {
|
||||||
|
return fmt.Errorf("backup does not contain wireguard configuration")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore entire wireguard directory
|
||||||
|
wgDestPath := "/etc/wireguard"
|
||||||
|
if err := os.RemoveAll(wgDestPath); err != nil {
|
||||||
|
return fmt.Errorf("failed to remove existing wireguard config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := exec.Command("cp", "-a", wgSourcePath, wgDestPath).Run(); err != nil {
|
||||||
|
return fmt.Errorf("failed to restore wireguard config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set proper permissions on restored files
|
||||||
|
if err := setRestoredPermissions(wgDestPath); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Warning: failed to set permissions on restored files: %v\n", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// setRestoredPermissions sets appropriate permissions on restored WireGuard files
|
||||||
|
func setRestoredPermissions(wgPath string) error {
|
||||||
|
// Set 0600 on .conf files
|
||||||
|
return filepath.Walk(wgPath, func(path string, info os.FileInfo, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !info.IsDir() && strings.HasSuffix(path, ".conf") {
|
||||||
|
if err := os.Chmod(path, 0600); err != nil {
|
||||||
|
return fmt.Errorf("failed to chmod %s: %w", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyRetentionPolicy keeps only the last N backups
|
||||||
|
func applyRetentionPolicy(backupDir string, keepCount int) error {
|
||||||
|
// List all backup directories
|
||||||
|
entries, err := os.ReadDir(backupDir)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter backup directories and sort by modification time
|
||||||
|
var backups []os.FileInfo
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.IsDir() && len(entry.Name()) > 10 && entry.Name()[:10] == "wg-backup-" {
|
||||||
|
info, err := entry.Info()
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
backups = append(backups, info)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we have more backups than we want to keep, remove the oldest
|
||||||
|
if len(backups) > keepCount {
|
||||||
|
// Sort by modification time (oldest first)
|
||||||
|
for i := 0; i < len(backups); i++ {
|
||||||
|
for j := i + 1; j < len(backups); j++ {
|
||||||
|
if backups[i].ModTime().After(backups[j].ModTime()) {
|
||||||
|
backups[i], backups[j] = backups[j], backups[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove oldest backups
|
||||||
|
toRemove := len(backups) - keepCount
|
||||||
|
for i := 0; i < toRemove; i++ {
|
||||||
|
backupPath := filepath.Join(backupDir, backups[i].Name())
|
||||||
|
if err := os.RemoveAll(backupPath); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Warning: failed to remove old backup %s: %v\n", backupPath, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// BackupConfig is a compatibility wrapper that calls CreateBackup
|
||||||
|
func BackupConfig(operation string) (string, error) {
|
||||||
|
if err := CreateBackup(operation); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
// Return the backup path for compatibility
|
||||||
|
timestamp := time.Now().Format("20060102-150405")
|
||||||
|
backupName := fmt.Sprintf("wg-backup-%s-%s", operation, timestamp)
|
||||||
|
return filepath.Join("/etc/wg-admin/backups", backupName), nil
|
||||||
|
}
|
||||||
122
internal/backup/restore.go
Normal file
122
internal/backup/restore.go
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
package backup
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ValidateBackup checks if a backup exists and is valid
|
||||||
|
func ValidateBackup(backupName string) error {
|
||||||
|
backupDir := "/etc/wg-admin/backups"
|
||||||
|
backupPath := filepath.Join(backupDir, backupName)
|
||||||
|
|
||||||
|
// Check if backup directory exists
|
||||||
|
info, err := os.Stat(backupPath)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return fmt.Errorf("backup '%s' does not exist", backupName)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("failed to access backup '%s': %w", backupName, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if it's a directory
|
||||||
|
if !info.IsDir() {
|
||||||
|
return fmt.Errorf("'%s' is not a valid backup directory", backupName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for required files
|
||||||
|
metadataPath := filepath.Join(backupPath, "backup-info.txt")
|
||||||
|
if _, err := os.Stat(metadataPath); os.IsNotExist(err) {
|
||||||
|
return fmt.Errorf("backup '%s' is missing required metadata", backupName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for wireguard directory
|
||||||
|
wgBackupPath := filepath.Join(backupPath, "wireguard")
|
||||||
|
if _, err := os.Stat(wgBackupPath); os.IsNotExist(err) {
|
||||||
|
return fmt.Errorf("backup '%s' is missing wireguard configuration", backupName)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReloadWireGuard reloads the WireGuard interface to apply configuration changes
|
||||||
|
func ReloadWireGuard() error {
|
||||||
|
interfaceName := "wg0"
|
||||||
|
|
||||||
|
// Try to down the interface first
|
||||||
|
cmdDown := exec.Command("wg-quick", "down", interfaceName)
|
||||||
|
_ = cmdDown.Run() // Ignore errors if interface is not up
|
||||||
|
|
||||||
|
// Bring the interface up
|
||||||
|
cmdUp := exec.Command("wg-quick", "up", interfaceName)
|
||||||
|
output, err := cmdUp.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to reload wireguard interface: %w, output: %s", err, string(output))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetBackupSize calculates the total size of a backup directory
|
||||||
|
func GetBackupSize(backupName string) (int64, error) {
|
||||||
|
backupDir := "/etc/wg-admin/backups"
|
||||||
|
backupPath := filepath.Join(backupDir, backupName)
|
||||||
|
|
||||||
|
var size int64
|
||||||
|
|
||||||
|
err := filepath.Walk(backupPath, func(_ string, info os.FileInfo, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !info.IsDir() {
|
||||||
|
size += info.Size()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
return size, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetBackupPath returns the full path for a backup name
|
||||||
|
func GetBackupPath(backupName string) string {
|
||||||
|
return filepath.Join("/etc/wg-admin/backups", backupName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseBackupName extracts operation and timestamp from backup directory name
|
||||||
|
// Format: wg-backup-{operation}-{timestamp}
|
||||||
|
func ParseBackupName(backupName string) (operation, timestamp string, err error) {
|
||||||
|
if !strings.HasPrefix(backupName, "wg-backup-") {
|
||||||
|
return "", "", fmt.Errorf("invalid backup name format")
|
||||||
|
}
|
||||||
|
|
||||||
|
nameWithoutPrefix := strings.TrimPrefix(backupName, "wg-backup-")
|
||||||
|
|
||||||
|
// Timestamp format: 20060102-150405 (15 chars)
|
||||||
|
if len(nameWithoutPrefix) < 16 {
|
||||||
|
return "", "", fmt.Errorf("backup name too short")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract operation (everything before last timestamp)
|
||||||
|
timestampLen := 15
|
||||||
|
if len(nameWithoutPrefix) > timestampLen+1 {
|
||||||
|
operation = nameWithoutPrefix[:len(nameWithoutPrefix)-timestampLen-1]
|
||||||
|
// Remove trailing dash if present
|
||||||
|
if strings.HasSuffix(operation, "-") {
|
||||||
|
operation = operation[:len(operation)-1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract timestamp
|
||||||
|
timestamp = nameWithoutPrefix[len(nameWithoutPrefix)-timestampLen:]
|
||||||
|
|
||||||
|
// Validate timestamp format
|
||||||
|
if _, err := time.Parse("20060102-150405", timestamp); err != nil {
|
||||||
|
return "", "", fmt.Errorf("invalid timestamp format in backup name: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return operation, timestamp, nil
|
||||||
|
}
|
||||||
237
internal/config/config.go
Normal file
237
internal/config/config.go
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config holds application configuration
|
||||||
|
type Config struct {
|
||||||
|
ServerDomain string `mapstructure:"SERVER_DOMAIN"`
|
||||||
|
WGPort int `mapstructure:"WG_PORT"`
|
||||||
|
VPNIPv4Range string `mapstructure:"VPN_IPV4_RANGE"`
|
||||||
|
VPNIPv6Range string `mapstructure:"VPN_IPV6_RANGE"`
|
||||||
|
WGInterface string `mapstructure:"WG_INTERFACE"`
|
||||||
|
DNSServers string `mapstructure:"DNS_SERVERS"`
|
||||||
|
LogFile string `mapstructure:"LOG_FILE"`
|
||||||
|
Theme string `mapstructure:"THEME"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default values
|
||||||
|
const (
|
||||||
|
DefaultWGPort = 51820
|
||||||
|
DefaultVPNIPv4Range = "10.10.69.0/24"
|
||||||
|
DefaultVPNIPv6Range = "fd69:dead:beef:69::/64"
|
||||||
|
DefaultWGInterface = "wg0"
|
||||||
|
DefaultDNSServers = "8.8.8.8, 8.8.4.4"
|
||||||
|
DefaultLogFile = "/var/log/wireguard-admin.log"
|
||||||
|
DefaultTheme = "default"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LoadConfig loads configuration from file and environment variables
|
||||||
|
func LoadConfig() (*Config, error) {
|
||||||
|
cfg := &Config{}
|
||||||
|
|
||||||
|
// Load from config file if it exists
|
||||||
|
if err := loadFromFile(cfg); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to load config file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Override with environment variables
|
||||||
|
if err := loadFromEnv(cfg); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to load config from environment: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply defaults for empty values
|
||||||
|
applyDefaults(cfg)
|
||||||
|
|
||||||
|
// Validate required configuration
|
||||||
|
if err := validateConfig(cfg); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadFromFile reads configuration from /etc/wg-admin/config.conf
|
||||||
|
func loadFromFile(cfg *Config) error {
|
||||||
|
configPath := "/etc/wg-admin/config.conf"
|
||||||
|
|
||||||
|
// Check if config file exists
|
||||||
|
if _, err := os.Stat(configPath); os.IsNotExist(err) {
|
||||||
|
// Config file is optional, skip if not exists
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read config file
|
||||||
|
content, err := os.ReadFile(configPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse key=value pairs
|
||||||
|
lines := strings.Split(string(content), "\n")
|
||||||
|
for _, line := range lines {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line == "" || strings.HasPrefix(line, "#") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := strings.SplitN(line, "=", 2)
|
||||||
|
if len(parts) != 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
key := strings.TrimSpace(parts[0])
|
||||||
|
value := strings.TrimSpace(parts[1])
|
||||||
|
|
||||||
|
// Set value using mapstructure tags
|
||||||
|
switch key {
|
||||||
|
case "SERVER_DOMAIN":
|
||||||
|
cfg.ServerDomain = value
|
||||||
|
case "WG_PORT":
|
||||||
|
var port int
|
||||||
|
if _, err := fmt.Sscanf(value, "%d", &port); err == nil {
|
||||||
|
cfg.WGPort = port
|
||||||
|
}
|
||||||
|
case "VPN_IPV4_RANGE":
|
||||||
|
cfg.VPNIPv4Range = value
|
||||||
|
case "VPN_IPV6_RANGE":
|
||||||
|
cfg.VPNIPv6Range = value
|
||||||
|
case "WG_INTERFACE":
|
||||||
|
cfg.WGInterface = value
|
||||||
|
case "DNS_SERVERS":
|
||||||
|
cfg.DNSServers = value
|
||||||
|
case "LOG_FILE":
|
||||||
|
cfg.LogFile = value
|
||||||
|
case "THEME":
|
||||||
|
cfg.Theme = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadFromEnv loads configuration from environment variables
|
||||||
|
func loadFromEnv(cfg *Config) error {
|
||||||
|
// Read environment variables
|
||||||
|
if val := os.Getenv("SERVER_DOMAIN"); val != "" {
|
||||||
|
cfg.ServerDomain = val
|
||||||
|
}
|
||||||
|
if val := os.Getenv("WG_PORT"); val != "" {
|
||||||
|
var port int
|
||||||
|
if _, err := fmt.Sscanf(val, "%d", &port); err == nil {
|
||||||
|
cfg.WGPort = port
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if val := os.Getenv("VPN_IPV4_RANGE"); val != "" {
|
||||||
|
cfg.VPNIPv4Range = val
|
||||||
|
}
|
||||||
|
if val := os.Getenv("VPN_IPV6_RANGE"); val != "" {
|
||||||
|
cfg.VPNIPv6Range = val
|
||||||
|
}
|
||||||
|
if val := os.Getenv("WG_INTERFACE"); val != "" {
|
||||||
|
cfg.WGInterface = val
|
||||||
|
}
|
||||||
|
if val := os.Getenv("DNS_SERVERS"); val != "" {
|
||||||
|
cfg.DNSServers = val
|
||||||
|
}
|
||||||
|
if val := os.Getenv("LOG_FILE"); val != "" {
|
||||||
|
cfg.LogFile = val
|
||||||
|
}
|
||||||
|
if val := os.Getenv("THEME"); val != "" {
|
||||||
|
cfg.Theme = val
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyDefaults sets default values for empty configuration
|
||||||
|
func applyDefaults(cfg *Config) {
|
||||||
|
if cfg.WGPort == 0 {
|
||||||
|
cfg.WGPort = DefaultWGPort
|
||||||
|
}
|
||||||
|
if cfg.VPNIPv4Range == "" {
|
||||||
|
cfg.VPNIPv4Range = DefaultVPNIPv4Range
|
||||||
|
}
|
||||||
|
if cfg.VPNIPv6Range == "" {
|
||||||
|
cfg.VPNIPv6Range = DefaultVPNIPv6Range
|
||||||
|
}
|
||||||
|
if cfg.WGInterface == "" {
|
||||||
|
cfg.WGInterface = DefaultWGInterface
|
||||||
|
}
|
||||||
|
if cfg.DNSServers == "" {
|
||||||
|
cfg.DNSServers = DefaultDNSServers
|
||||||
|
}
|
||||||
|
if cfg.LogFile == "" {
|
||||||
|
cfg.LogFile = DefaultLogFile
|
||||||
|
}
|
||||||
|
if cfg.Theme == "" {
|
||||||
|
cfg.Theme = DefaultTheme
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateConfig checks that required configuration is present
|
||||||
|
func validateConfig(cfg *Config) error {
|
||||||
|
if cfg.ServerDomain == "" {
|
||||||
|
return fmt.Errorf("SERVER_DOMAIN is required. Set it in /etc/wg-admin/config.conf or via environment variable.")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate port range
|
||||||
|
if cfg.WGPort < 1 || cfg.WGPort > 65535 {
|
||||||
|
return fmt.Errorf("WG_PORT must be between 1 and 65535, got: %d", cfg.WGPort)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate CIDR format for IPv4 range
|
||||||
|
if !isValidCIDR(cfg.VPNIPv4Range, true) {
|
||||||
|
return fmt.Errorf("Invalid VPN_IPV4_RANGE format: %s", cfg.VPNIPv4Range)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate CIDR format for IPv6 range
|
||||||
|
if !isValidCIDR(cfg.VPNIPv6Range, false) {
|
||||||
|
return fmt.Errorf("Invalid VPN_IPV6_RANGE format: %s", cfg.VPNIPv6Range)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// isValidCIDR performs basic CIDR validation
|
||||||
|
func isValidCIDR(cidr string, isIPv4 bool) bool {
|
||||||
|
if cidr == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split address and prefix
|
||||||
|
parts := strings.Split(cidr, "/")
|
||||||
|
if len(parts) != 2 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Basic validation - more comprehensive validation could be added
|
||||||
|
if isIPv4 {
|
||||||
|
// IPv4 CIDR should have address like x.x.x.x
|
||||||
|
return true // Simplified validation
|
||||||
|
}
|
||||||
|
|
||||||
|
// IPv6 CIDR
|
||||||
|
return true // Simplified validation
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetVPNIPv4Network extracts the IPv4 network from CIDR (e.g., "10.10.69.0" from "10.10.69.0/24")
|
||||||
|
func (c *Config) GetVPNIPv4Network() string {
|
||||||
|
parts := strings.Split(c.VPNIPv4Range, "/")
|
||||||
|
if len(parts) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSuffix(parts[0], "0")
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetVPNIPv6Network extracts the IPv6 network from CIDR (e.g., "fd69:dead:beef:69::" from "fd69:dead:beef:69::/64")
|
||||||
|
func (c *Config) GetVPNIPv6Network() string {
|
||||||
|
parts := strings.Split(c.VPNIPv6Range, "/")
|
||||||
|
if len(parts) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSuffix(parts[0], "::")
|
||||||
|
}
|
||||||
54
internal/tui/components/breadcrumb.go
Normal file
54
internal/tui/components/breadcrumb.go
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
package components
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BreadcrumbItem represents a single item in the breadcrumb trail
|
||||||
|
type BreadcrumbItem struct {
|
||||||
|
Label string
|
||||||
|
ID string // Optional identifier for the screen
|
||||||
|
}
|
||||||
|
|
||||||
|
// BreadcrumbStyle defines the appearance of breadcrumbs
|
||||||
|
var (
|
||||||
|
breadcrumbStyle = lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color("241")).
|
||||||
|
MarginBottom(1)
|
||||||
|
breadcrumbSeparatorStyle = lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color("240"))
|
||||||
|
breadcrumbItemStyle = lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color("241"))
|
||||||
|
breadcrumbCurrentStyle = lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color("62")).
|
||||||
|
Bold(true)
|
||||||
|
)
|
||||||
|
|
||||||
|
// RenderBreadcrumb renders a breadcrumb trail from a slice of items
|
||||||
|
func RenderBreadcrumb(items []BreadcrumbItem) string {
|
||||||
|
if len(items) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
var parts []string
|
||||||
|
for i, item := range items {
|
||||||
|
var text string
|
||||||
|
if i == len(items)-1 {
|
||||||
|
// Last item - current page
|
||||||
|
text = breadcrumbCurrentStyle.Render(item.Label)
|
||||||
|
} else {
|
||||||
|
// Non-last items - clickable/previous pages
|
||||||
|
text = breadcrumbItemStyle.Render(item.Label)
|
||||||
|
}
|
||||||
|
parts = append(parts, text)
|
||||||
|
|
||||||
|
// Add separator if not last item
|
||||||
|
if i < len(items)-1 {
|
||||||
|
parts = append(parts, breadcrumbSeparatorStyle.Render(" > "))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return breadcrumbStyle.Render(strings.Join(parts, ""))
|
||||||
|
}
|
||||||
149
internal/tui/components/config-display.go
Normal file
149
internal/tui/components/config-display.go
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
package components
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/charmbracelet/bubbles/viewport"
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ConfigDisplayModel represents a configuration display modal
|
||||||
|
type ConfigDisplayModel struct {
|
||||||
|
config string
|
||||||
|
viewport viewport.Model
|
||||||
|
Visible bool
|
||||||
|
Width int
|
||||||
|
Height int
|
||||||
|
scrollPos int
|
||||||
|
}
|
||||||
|
|
||||||
|
// Styles
|
||||||
|
var (
|
||||||
|
configModalStyle = lipgloss.NewStyle().
|
||||||
|
Border(lipgloss.RoundedBorder()).
|
||||||
|
BorderForeground(lipgloss.Color("62")).
|
||||||
|
Padding(1, 2).
|
||||||
|
Background(lipgloss.Color("235"))
|
||||||
|
configTitleStyle = lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color("62")).
|
||||||
|
Bold(true).
|
||||||
|
MarginBottom(1)
|
||||||
|
configHelpStyle = lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color("241")).
|
||||||
|
MarginTop(1)
|
||||||
|
configContentStyle = lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color("255")).
|
||||||
|
Width(76)
|
||||||
|
)
|
||||||
|
|
||||||
|
// NewConfigDisplay creates a new configuration display modal
|
||||||
|
func NewConfigDisplay(config string, width, height int) *ConfigDisplayModel {
|
||||||
|
vp := viewport.New(76, 24)
|
||||||
|
vp.SetContent(config)
|
||||||
|
|
||||||
|
return &ConfigDisplayModel{
|
||||||
|
config: config,
|
||||||
|
viewport: vp,
|
||||||
|
Visible: true,
|
||||||
|
Width: width,
|
||||||
|
Height: height,
|
||||||
|
scrollPos: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Init initializes the configuration display modal
|
||||||
|
func (m *ConfigDisplayModel) Init() tea.Cmd {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update handles messages for the configuration display modal
|
||||||
|
func (m *ConfigDisplayModel) Update(msg tea.Msg) (*ConfigDisplayModel, tea.Cmd) {
|
||||||
|
switch msg := msg.(type) {
|
||||||
|
case tea.KeyMsg:
|
||||||
|
switch msg.String() {
|
||||||
|
case "esc", "q":
|
||||||
|
m.Visible = false
|
||||||
|
return m, nil
|
||||||
|
case "up", "k":
|
||||||
|
m.viewport.LineUp(1)
|
||||||
|
case "down", "j":
|
||||||
|
m.viewport.LineDown(1)
|
||||||
|
case "pgup", "b":
|
||||||
|
m.viewport.HalfViewUp()
|
||||||
|
case "pgdown", "f", " ":
|
||||||
|
m.viewport.HalfViewDown()
|
||||||
|
case "g", "home":
|
||||||
|
m.viewport.GotoTop()
|
||||||
|
case "G", "end":
|
||||||
|
m.viewport.GotoBottom()
|
||||||
|
}
|
||||||
|
case tea.WindowSizeMsg:
|
||||||
|
// Update modal dimensions on resize
|
||||||
|
m.Width = msg.Width
|
||||||
|
m.Height = msg.Height
|
||||||
|
}
|
||||||
|
|
||||||
|
var cmd tea.Cmd
|
||||||
|
m.viewport, cmd = m.viewport.Update(msg)
|
||||||
|
return m, cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
// View renders the configuration display modal
|
||||||
|
func (m *ConfigDisplayModel) View() string {
|
||||||
|
if !m.Visible {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build modal content
|
||||||
|
title := configTitleStyle.Render("📋 Client Configuration")
|
||||||
|
content := m.viewport.View()
|
||||||
|
help := configHelpStyle.Render("↑/j: down • ↓/k: up • pgup/pgdn: page • g/G: top/bottom • Esc: close")
|
||||||
|
|
||||||
|
fullContent := lipgloss.JoinVertical(
|
||||||
|
lipgloss.Left,
|
||||||
|
title,
|
||||||
|
"",
|
||||||
|
content,
|
||||||
|
help,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Apply modal style
|
||||||
|
modal := configModalStyle.Render(fullContent)
|
||||||
|
|
||||||
|
// Center modal on screen
|
||||||
|
modalWidth := lipgloss.Width(modal)
|
||||||
|
modalHeight := lipgloss.Height(modal)
|
||||||
|
|
||||||
|
x := (m.Width - modalWidth) / 2
|
||||||
|
if x < 0 {
|
||||||
|
x = 0
|
||||||
|
}
|
||||||
|
y := (m.Height - modalHeight) / 2
|
||||||
|
if y < 0 {
|
||||||
|
y = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return lipgloss.Place(m.Width, m.Height,
|
||||||
|
lipgloss.Left, lipgloss.Top,
|
||||||
|
modal,
|
||||||
|
lipgloss.WithWhitespaceChars(" "),
|
||||||
|
lipgloss.WithWhitespaceForeground(lipgloss.Color("235")),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsVisible returns true if the modal is visible
|
||||||
|
func (m *ConfigDisplayModel) IsVisible() bool {
|
||||||
|
return m.Visible
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide hides the modal
|
||||||
|
func (m *ConfigDisplayModel) Hide() {
|
||||||
|
m.Visible = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show shows the modal
|
||||||
|
func (m *ConfigDisplayModel) Show(config string) {
|
||||||
|
m.config = config
|
||||||
|
m.viewport.SetContent(config)
|
||||||
|
m.viewport.GotoTop()
|
||||||
|
m.Visible = true
|
||||||
|
}
|
||||||
143
internal/tui/components/confirm.go
Normal file
143
internal/tui/components/confirm.go
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
package components
|
||||||
|
|
||||||
|
import (
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ConfirmModel represents a confirmation modal
|
||||||
|
type ConfirmModel struct {
|
||||||
|
Message string
|
||||||
|
Yes bool // true = yes, false = no
|
||||||
|
Visible bool
|
||||||
|
Width int
|
||||||
|
Height int
|
||||||
|
}
|
||||||
|
|
||||||
|
// Styles
|
||||||
|
var (
|
||||||
|
confirmModalStyle = lipgloss.NewStyle().
|
||||||
|
Border(lipgloss.RoundedBorder()).
|
||||||
|
BorderForeground(lipgloss.Color("196")).
|
||||||
|
Padding(1, 2).
|
||||||
|
Background(lipgloss.Color("235"))
|
||||||
|
confirmTitleStyle = lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color("226")).
|
||||||
|
Bold(true)
|
||||||
|
confirmMessageStyle = lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color("255")).
|
||||||
|
Width(50)
|
||||||
|
confirmHelpStyle = lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color("241")).
|
||||||
|
MarginTop(1)
|
||||||
|
)
|
||||||
|
|
||||||
|
// NewConfirm creates a new confirmation modal
|
||||||
|
func NewConfirm(message string, width, height int) *ConfirmModel {
|
||||||
|
return &ConfirmModel{
|
||||||
|
Message: message,
|
||||||
|
Yes: false,
|
||||||
|
Visible: true,
|
||||||
|
Width: width,
|
||||||
|
Height: height,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Init initializes the confirmation modal
|
||||||
|
func (m *ConfirmModel) Init() tea.Cmd {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update handles messages for the confirmation modal
|
||||||
|
func (m *ConfirmModel) Update(msg tea.Msg) (*ConfirmModel, tea.Cmd) {
|
||||||
|
switch msg := msg.(type) {
|
||||||
|
case tea.KeyMsg:
|
||||||
|
switch msg.String() {
|
||||||
|
case "y", "Y", "left":
|
||||||
|
m.Yes = true
|
||||||
|
case "n", "N", "right":
|
||||||
|
m.Yes = false
|
||||||
|
case "enter":
|
||||||
|
// Confirmed - will be handled by parent
|
||||||
|
return m, nil
|
||||||
|
case "esc":
|
||||||
|
m.Visible = false
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// View renders the confirmation modal
|
||||||
|
func (m *ConfirmModel) View() string {
|
||||||
|
if !m.Visible {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build modal content
|
||||||
|
content := lipgloss.JoinVertical(
|
||||||
|
lipgloss.Left,
|
||||||
|
confirmTitleStyle.Render("⚠️ Confirm Action"),
|
||||||
|
"",
|
||||||
|
confirmMessageStyle.Render(m.Message),
|
||||||
|
"",
|
||||||
|
m.renderOptions(),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Apply modal style
|
||||||
|
modal := confirmModalStyle.Render(content)
|
||||||
|
|
||||||
|
// Center modal on screen
|
||||||
|
modalWidth := lipgloss.Width(modal)
|
||||||
|
modalHeight := lipgloss.Height(modal)
|
||||||
|
|
||||||
|
x := (m.Width - modalWidth) / 2
|
||||||
|
if x < 0 {
|
||||||
|
x = 0
|
||||||
|
}
|
||||||
|
y := (m.Height - modalHeight) / 2
|
||||||
|
if y < 0 {
|
||||||
|
y = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return lipgloss.Place(m.Width, m.Height,
|
||||||
|
lipgloss.Left, lipgloss.Top,
|
||||||
|
modal,
|
||||||
|
lipgloss.WithWhitespaceChars(" "),
|
||||||
|
lipgloss.WithWhitespaceForeground(lipgloss.Color("235")),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderOptions renders the yes/no options
|
||||||
|
func (m *ConfirmModel) renderOptions() string {
|
||||||
|
yesStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("241"))
|
||||||
|
noStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("241"))
|
||||||
|
|
||||||
|
selectedStyle := lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color("57")).
|
||||||
|
Bold(true).
|
||||||
|
Underline(true)
|
||||||
|
|
||||||
|
var yesText, noText string
|
||||||
|
if m.Yes {
|
||||||
|
yesText = selectedStyle.Render("[Yes]")
|
||||||
|
noText = noStyle.Render(" No ")
|
||||||
|
} else {
|
||||||
|
yesText = yesStyle.Render(" Yes ")
|
||||||
|
noText = selectedStyle.Render("[No]")
|
||||||
|
}
|
||||||
|
|
||||||
|
helpText := confirmHelpStyle.Render("←/→ to choose • Enter to confirm • Esc to cancel")
|
||||||
|
|
||||||
|
return lipgloss.JoinHorizontal(lipgloss.Left, yesText, " ", noText, "\n", helpText)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsConfirmed returns true if user confirmed with Yes
|
||||||
|
func (m *ConfirmModel) IsConfirmed() bool {
|
||||||
|
return m.Yes
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsCancelled returns true if user cancelled
|
||||||
|
func (m *ConfirmModel) IsCancelled() bool {
|
||||||
|
return !m.Visible
|
||||||
|
}
|
||||||
173
internal/tui/components/delete-confirm.go
Normal file
173
internal/tui/components/delete-confirm.go
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
package components
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/calmcacil/wg-admin/internal/tui/theme"
|
||||||
|
"github.com/charmbracelet/bubbles/textinput"
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DeleteConfirmModel represents a deletion confirmation modal with name verification
|
||||||
|
type DeleteConfirmModel struct {
|
||||||
|
clientName string
|
||||||
|
input textinput.Model
|
||||||
|
Visible bool
|
||||||
|
Width int
|
||||||
|
Height int
|
||||||
|
showErrorMessage bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Local styles for modal
|
||||||
|
var (
|
||||||
|
modalBaseStyle = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(lipgloss.Color("196")).Background(lipgloss.Color("235"))
|
||||||
|
modalTitleStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("226")).Bold(true)
|
||||||
|
modalMessageStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("255"))
|
||||||
|
modalHelpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241"))
|
||||||
|
)
|
||||||
|
|
||||||
|
// NewDeleteConfirm creates a new deletion confirmation modal
|
||||||
|
func NewDeleteConfirm(clientName string, width, height int) *DeleteConfirmModel {
|
||||||
|
ti := textinput.New()
|
||||||
|
ti.Placeholder = "Type client name to confirm"
|
||||||
|
ti.Focus()
|
||||||
|
ti.CharLimit = 100
|
||||||
|
ti.Width = 40
|
||||||
|
|
||||||
|
return &DeleteConfirmModel{
|
||||||
|
clientName: clientName,
|
||||||
|
input: ti,
|
||||||
|
Visible: true,
|
||||||
|
Width: width,
|
||||||
|
Height: height,
|
||||||
|
showErrorMessage: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Init initializes the deletion confirmation modal
|
||||||
|
func (m *DeleteConfirmModel) Init() tea.Cmd {
|
||||||
|
return textinput.Blink
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update handles messages for the deletion confirmation modal
|
||||||
|
func (m *DeleteConfirmModel) Update(msg tea.Msg) (*DeleteConfirmModel, tea.Cmd) {
|
||||||
|
var cmd tea.Cmd
|
||||||
|
|
||||||
|
switch msg := msg.(type) {
|
||||||
|
case tea.KeyMsg:
|
||||||
|
switch msg.String() {
|
||||||
|
case "esc":
|
||||||
|
m.Visible = false
|
||||||
|
return m, nil
|
||||||
|
case "enter":
|
||||||
|
if m.input.Value() == m.clientName {
|
||||||
|
m.Visible = false
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
m.showErrorMessage = true
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
m.input, cmd = m.input.Update(msg)
|
||||||
|
return m, cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
// View renders the deletion confirmation modal
|
||||||
|
func (m *DeleteConfirmModel) View() string {
|
||||||
|
if !m.Visible {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if input matches client name
|
||||||
|
matches := m.input.Value() == m.clientName
|
||||||
|
|
||||||
|
// Build warning section
|
||||||
|
warningText := theme.StyleError.Bold(true).MarginTop(1).Render(
|
||||||
|
fmt.Sprintf("⚠️ This will permanently delete client '%s'", m.clientName),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Build message section
|
||||||
|
messageText := modalMessageStyle.Width(60).Render(
|
||||||
|
fmt.Sprintf("This action cannot be undone. Please type the client name to confirm deletion."),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Build input section
|
||||||
|
inputSection := lipgloss.JoinVertical(
|
||||||
|
lipgloss.Left,
|
||||||
|
"",
|
||||||
|
theme.StyleValue.Width(60).Render("Client name:"),
|
||||||
|
m.input.View(),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Build status section
|
||||||
|
var statusText string
|
||||||
|
if matches {
|
||||||
|
statusText = theme.StyleSuccess.Bold(true).MarginTop(1).Render("✓ Client name matches. Press Enter to confirm deletion.")
|
||||||
|
} else if m.showErrorMessage {
|
||||||
|
statusText = theme.StyleError.Bold(true).MarginTop(1).Render("✗ Client name does not match. Please try again.")
|
||||||
|
} else if m.input.Value() != "" {
|
||||||
|
statusText = modalHelpStyle.Render("Client name does not match yet...")
|
||||||
|
} else {
|
||||||
|
statusText = modalHelpStyle.Render("Type the client name to enable confirmation.")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build help section
|
||||||
|
helpText := modalHelpStyle.Render("Esc to cancel • Enter to confirm (when name matches)")
|
||||||
|
|
||||||
|
// Build modal content
|
||||||
|
content := lipgloss.JoinVertical(
|
||||||
|
lipgloss.Left,
|
||||||
|
modalTitleStyle.Render("🗑️ Delete Client"),
|
||||||
|
"",
|
||||||
|
warningText,
|
||||||
|
"",
|
||||||
|
messageText,
|
||||||
|
inputSection,
|
||||||
|
statusText,
|
||||||
|
"",
|
||||||
|
helpText,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Apply modal style
|
||||||
|
modal := modalBaseStyle.Padding(1, 3).Render(content)
|
||||||
|
|
||||||
|
// Center modal on screen
|
||||||
|
modalWidth := lipgloss.Width(modal)
|
||||||
|
modalHeight := lipgloss.Height(modal)
|
||||||
|
|
||||||
|
x := (m.Width - modalWidth) / 2
|
||||||
|
if x < 0 {
|
||||||
|
x = 0
|
||||||
|
}
|
||||||
|
y := (m.Height - modalHeight) / 2
|
||||||
|
if y < 0 {
|
||||||
|
y = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return lipgloss.Place(m.Width, m.Height,
|
||||||
|
lipgloss.Left, lipgloss.Top,
|
||||||
|
modal,
|
||||||
|
lipgloss.WithWhitespaceChars(" "),
|
||||||
|
lipgloss.WithWhitespaceForeground(theme.StyleBackground),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsConfirmed returns true if user confirmed the deletion
|
||||||
|
func (m *DeleteConfirmModel) IsConfirmed() bool {
|
||||||
|
return !m.Visible && m.input.Value() == m.clientName
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsCancelled returns true if user cancelled
|
||||||
|
func (m *DeleteConfirmModel) IsCancelled() bool {
|
||||||
|
return !m.Visible && m.input.Value() != m.clientName
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset resets the modal for reuse
|
||||||
|
func (m *DeleteConfirmModel) Reset() {
|
||||||
|
m.input.SetValue("")
|
||||||
|
m.input.Reset()
|
||||||
|
m.Visible = true
|
||||||
|
m.showErrorMessage = false
|
||||||
|
}
|
||||||
311
internal/tui/components/search.go
Normal file
311
internal/tui/components/search.go
Normal file
@@ -0,0 +1,311 @@
|
|||||||
|
package components
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/calmcacil/wg-admin/internal/tui/theme"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/bubbles/textinput"
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SearchFilterType represents the type of filter
|
||||||
|
type SearchFilterType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
FilterByName SearchFilterType = "name"
|
||||||
|
FilterByIPv4 SearchFilterType = "ipv4"
|
||||||
|
FilterByIPv6 SearchFilterType = "ipv6"
|
||||||
|
FilterByStatus SearchFilterType = "status"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SearchModel represents the search component
|
||||||
|
type SearchModel struct {
|
||||||
|
input textinput.Model
|
||||||
|
active bool
|
||||||
|
filterType SearchFilterType
|
||||||
|
matchCount int
|
||||||
|
totalCount int
|
||||||
|
visible bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Styles
|
||||||
|
|
||||||
|
// Styles (using theme package)
|
||||||
|
var (
|
||||||
|
searchBarStyle = lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color("255")).
|
||||||
|
Background(theme.StyleBackground).
|
||||||
|
Padding(0, 1).
|
||||||
|
Border(lipgloss.RoundedBorder()).
|
||||||
|
BorderForeground(theme.StyleBorder)
|
||||||
|
searchPromptStyle = lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color("226")).
|
||||||
|
Bold(true)
|
||||||
|
searchFilterStyle = lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color("147"))
|
||||||
|
searchCountStyle = lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color("241"))
|
||||||
|
searchHelpStyle = lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color("243"))
|
||||||
|
)
|
||||||
|
|
||||||
|
// NewSearch creates a new search component
|
||||||
|
func NewSearch() *SearchModel {
|
||||||
|
ti := textinput.New()
|
||||||
|
ti.Placeholder = "Search clients..."
|
||||||
|
ti.Focus()
|
||||||
|
ti.CharLimit = 156
|
||||||
|
ti.Width = 40
|
||||||
|
ti.Prompt = ""
|
||||||
|
|
||||||
|
return &SearchModel{
|
||||||
|
input: ti,
|
||||||
|
active: false,
|
||||||
|
filterType: FilterByName,
|
||||||
|
matchCount: 0,
|
||||||
|
totalCount: 0,
|
||||||
|
visible: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Init initializes the search component
|
||||||
|
func (m *SearchModel) Init() tea.Cmd {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update handles messages for the search component
|
||||||
|
func (m *SearchModel) Update(msg tea.Msg) (*SearchModel, tea.Cmd) {
|
||||||
|
if !m.active {
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var cmd tea.Cmd
|
||||||
|
|
||||||
|
switch msg := msg.(type) {
|
||||||
|
case tea.KeyMsg:
|
||||||
|
switch msg.String() {
|
||||||
|
case "esc":
|
||||||
|
m.active = false
|
||||||
|
m.input.Reset()
|
||||||
|
m.matchCount = m.totalCount
|
||||||
|
return m, nil
|
||||||
|
case "tab":
|
||||||
|
m.cycleFilterType()
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
m.input, cmd = m.input.Update(msg)
|
||||||
|
|
||||||
|
return m, cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
// View renders the search component
|
||||||
|
func (m *SearchModel) View() string {
|
||||||
|
if !m.visible {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
var filterLabel string
|
||||||
|
switch m.filterType {
|
||||||
|
case FilterByName:
|
||||||
|
filterLabel = "Name"
|
||||||
|
case FilterByIPv4:
|
||||||
|
filterLabel = "IPv4"
|
||||||
|
case FilterByIPv6:
|
||||||
|
filterLabel = "IPv6"
|
||||||
|
case FilterByStatus:
|
||||||
|
filterLabel = "Status"
|
||||||
|
}
|
||||||
|
|
||||||
|
searchIndicator := ""
|
||||||
|
if m.active {
|
||||||
|
searchIndicator = searchPromptStyle.Render("🔍 ")
|
||||||
|
} else {
|
||||||
|
searchIndicator = searchPromptStyle.Render("⌕ ")
|
||||||
|
}
|
||||||
|
|
||||||
|
filterText := searchFilterStyle.Render("[" + filterLabel + "]")
|
||||||
|
countText := ""
|
||||||
|
if m.totalCount > 0 {
|
||||||
|
countText = searchCountStyle.Render(
|
||||||
|
lipgloss.JoinHorizontal(
|
||||||
|
lipgloss.Left,
|
||||||
|
strings.Repeat(" ", 4),
|
||||||
|
"Matched: ",
|
||||||
|
m.renderCount(m.matchCount),
|
||||||
|
"/",
|
||||||
|
m.renderCount(m.totalCount),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
helpText := ""
|
||||||
|
if m.active {
|
||||||
|
helpText = searchHelpStyle.Render(" | Tab: filter | Esc: clear")
|
||||||
|
} else {
|
||||||
|
helpText = searchHelpStyle.Render(" | /: search")
|
||||||
|
}
|
||||||
|
|
||||||
|
content := lipgloss.JoinHorizontal(
|
||||||
|
lipgloss.Left,
|
||||||
|
searchIndicator,
|
||||||
|
m.input.View(),
|
||||||
|
filterText,
|
||||||
|
countText,
|
||||||
|
helpText,
|
||||||
|
)
|
||||||
|
|
||||||
|
return searchBarStyle.Render(content)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsActive returns true if search is active
|
||||||
|
func (m *SearchModel) IsActive() bool {
|
||||||
|
return m.active
|
||||||
|
}
|
||||||
|
|
||||||
|
// Activate activates the search
|
||||||
|
func (m *SearchModel) Activate() {
|
||||||
|
m.active = true
|
||||||
|
m.input.Focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deactivate deactivates the search
|
||||||
|
func (m *SearchModel) Deactivate() {
|
||||||
|
m.active = false
|
||||||
|
m.input.Reset()
|
||||||
|
m.matchCount = m.totalCount
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear clears the search input and filter
|
||||||
|
func (m *SearchModel) Clear() {
|
||||||
|
m.input.Reset()
|
||||||
|
m.matchCount = m.totalCount
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetQuery returns the current search query
|
||||||
|
func (m *SearchModel) GetQuery() string {
|
||||||
|
return m.input.Value()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetFilterType returns the current filter type
|
||||||
|
func (m *SearchModel) GetFilterType() SearchFilterType {
|
||||||
|
return m.filterType
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetTotalCount sets the total number of items
|
||||||
|
func (m *SearchModel) SetTotalCount(count int) {
|
||||||
|
m.totalCount = count
|
||||||
|
if !m.active {
|
||||||
|
m.matchCount = count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetMatchCount sets the number of matching items
|
||||||
|
func (m *SearchModel) SetMatchCount(count int) {
|
||||||
|
m.matchCount = count
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter filters a list of client data based on the current search query
|
||||||
|
func (m *SearchModel) Filter(clients []ClientData) []ClientData {
|
||||||
|
query := strings.TrimSpace(m.input.Value())
|
||||||
|
if query == "" || !m.active {
|
||||||
|
m.matchCount = len(clients)
|
||||||
|
return clients
|
||||||
|
}
|
||||||
|
|
||||||
|
var filtered []ClientData
|
||||||
|
queryLower := strings.ToLower(query)
|
||||||
|
|
||||||
|
for _, client := range clients {
|
||||||
|
var matches bool
|
||||||
|
|
||||||
|
switch m.filterType {
|
||||||
|
case FilterByName:
|
||||||
|
matches = strings.Contains(strings.ToLower(client.Name), queryLower)
|
||||||
|
case FilterByIPv4:
|
||||||
|
matches = strings.Contains(strings.ToLower(client.IPv4), queryLower)
|
||||||
|
case FilterByIPv6:
|
||||||
|
matches = strings.Contains(strings.ToLower(client.IPv6), queryLower)
|
||||||
|
case FilterByStatus:
|
||||||
|
matches = strings.Contains(strings.ToLower(client.Status), queryLower)
|
||||||
|
}
|
||||||
|
|
||||||
|
if matches {
|
||||||
|
filtered = append(filtered, client)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
m.matchCount = len(filtered)
|
||||||
|
return filtered
|
||||||
|
}
|
||||||
|
|
||||||
|
// HighlightMatches highlights matching text in the given value
|
||||||
|
func (m *SearchModel) HighlightMatches(value string) string {
|
||||||
|
if !m.active {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
query := strings.TrimSpace(m.input.Value())
|
||||||
|
if query == "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
queryLower := strings.ToLower(query)
|
||||||
|
valueLower := strings.ToLower(value)
|
||||||
|
|
||||||
|
index := strings.Index(valueLower, queryLower)
|
||||||
|
if index == -1 {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
matchStyle := lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color("226")).
|
||||||
|
Background(lipgloss.Color("57")).
|
||||||
|
Bold(true)
|
||||||
|
|
||||||
|
before := value[:index]
|
||||||
|
match := value[index : index+len(query)]
|
||||||
|
after := value[index+len(query):]
|
||||||
|
|
||||||
|
return lipgloss.JoinHorizontal(
|
||||||
|
lipgloss.Left,
|
||||||
|
before,
|
||||||
|
matchStyle.Render(string(match)),
|
||||||
|
after,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// cycleFilterType cycles to the next filter type
|
||||||
|
func (m *SearchModel) cycleFilterType() {
|
||||||
|
switch m.filterType {
|
||||||
|
case FilterByName:
|
||||||
|
m.filterType = FilterByIPv4
|
||||||
|
case FilterByIPv4:
|
||||||
|
m.filterType = FilterByIPv6
|
||||||
|
case FilterByIPv6:
|
||||||
|
m.filterType = FilterByStatus
|
||||||
|
case FilterByStatus:
|
||||||
|
m.filterType = FilterByName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderCount renders a count number with proper styling
|
||||||
|
func (m *SearchModel) renderCount(count int) string {
|
||||||
|
if m.matchCount == 0 && m.active && m.input.Value() != "" {
|
||||||
|
return lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color("196")).
|
||||||
|
Render("No matches")
|
||||||
|
}
|
||||||
|
return searchCountStyle.Render(string(rune('0' + count)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClientData represents client data for filtering
|
||||||
|
type ClientData struct {
|
||||||
|
Name string
|
||||||
|
IPv4 string
|
||||||
|
IPv6 string
|
||||||
|
Status string
|
||||||
|
}
|
||||||
89
internal/tui/model.go
Normal file
89
internal/tui/model.go
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
package screens
|
||||||
|
|
||||||
|
import (
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Screen represents a UI screen (list, add, detail, etc.)
|
||||||
|
type Screen interface {
|
||||||
|
Init() tea.Cmd
|
||||||
|
Update(tea.Msg) (Screen, tea.Cmd)
|
||||||
|
View() string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Model is shared state across all screens
|
||||||
|
type Model struct {
|
||||||
|
err error
|
||||||
|
isQuitting bool
|
||||||
|
ready bool
|
||||||
|
statusMessage string
|
||||||
|
screen Screen
|
||||||
|
}
|
||||||
|
|
||||||
|
// View renders the model (implements Screen interface)
|
||||||
|
func (m *Model) View() string {
|
||||||
|
if m.err != nil {
|
||||||
|
return "\n" + lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color("196")).
|
||||||
|
Render(m.err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
if m.isQuitting {
|
||||||
|
return "\nGoodbye!\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
if m.screen != nil {
|
||||||
|
return m.screen.View()
|
||||||
|
}
|
||||||
|
|
||||||
|
return "Initializing..."
|
||||||
|
}
|
||||||
|
|
||||||
|
// Init initializes the model
|
||||||
|
func (m *Model) Init() tea.Cmd {
|
||||||
|
m.ready = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update handles incoming messages (implements Screen interface)
|
||||||
|
func (m *Model) Update(msg tea.Msg) (Screen, tea.Cmd) {
|
||||||
|
// If we have an error, let it persist for display
|
||||||
|
if m.err != nil {
|
||||||
|
switch msg := msg.(type) {
|
||||||
|
case tea.KeyMsg:
|
||||||
|
if msg.String() == "q" || msg.String() == "ctrl+c" {
|
||||||
|
m.isQuitting = true
|
||||||
|
return m, tea.Quit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// No error - handle normally
|
||||||
|
switch msg := msg.(type) {
|
||||||
|
case tea.KeyMsg:
|
||||||
|
switch msg.String() {
|
||||||
|
case "q", "ctrl+c":
|
||||||
|
m.isQuitting = true
|
||||||
|
return m, tea.Quit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetScreen changes the current screen
|
||||||
|
func (m *Model) SetScreen(screen Screen) {
|
||||||
|
m.screen = screen
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetError sets an error message
|
||||||
|
func (m *Model) SetError(err error) {
|
||||||
|
m.err = err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearError clears the error message
|
||||||
|
func (m *Model) ClearError() {
|
||||||
|
m.err = nil
|
||||||
|
}
|
||||||
200
internal/tui/screens/add.go
Normal file
200
internal/tui/screens/add.go
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
package screens
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/calmcacil/wg-admin/internal/config"
|
||||||
|
"github.com/calmcacil/wg-admin/internal/tui/components"
|
||||||
|
"github.com/calmcacil/wg-admin/internal/validation"
|
||||||
|
"github.com/calmcacil/wg-admin/internal/wireguard"
|
||||||
|
"github.com/charmbracelet/bubbles/spinner"
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/charmbracelet/huh"
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AddScreen is a form for adding new WireGuard clients
|
||||||
|
type AddScreen struct {
|
||||||
|
form *huh.Form
|
||||||
|
quitting bool
|
||||||
|
spinner spinner.Model
|
||||||
|
isCreating bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Styles
|
||||||
|
var (
|
||||||
|
addTitleStyle = lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color("62")).
|
||||||
|
Bold(true).
|
||||||
|
MarginBottom(1)
|
||||||
|
addHelpStyle = lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color("241")).
|
||||||
|
MarginTop(1)
|
||||||
|
addLoadingStyle = lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color("62")).
|
||||||
|
Bold(true).
|
||||||
|
MarginTop(1)
|
||||||
|
)
|
||||||
|
|
||||||
|
// NewAddScreen creates a new add screen
|
||||||
|
func NewAddScreen() *AddScreen {
|
||||||
|
// Get default DNS from config
|
||||||
|
cfg, err := config.LoadConfig()
|
||||||
|
defaultDNS := "8.8.8.8, 8.8.4.4"
|
||||||
|
if err == nil && cfg.DNSServers != "" {
|
||||||
|
defaultDNS = cfg.DNSServers
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the form
|
||||||
|
form := huh.NewForm(
|
||||||
|
huh.NewGroup(
|
||||||
|
huh.NewInput().
|
||||||
|
Key("name").
|
||||||
|
Title("Client Name").
|
||||||
|
Description("Name for the new client (alphanumeric, -, _)").
|
||||||
|
Placeholder("e.g., laptop-john").
|
||||||
|
Validate(func(s string) error {
|
||||||
|
return validation.ValidateClientName(s)
|
||||||
|
}),
|
||||||
|
|
||||||
|
huh.NewInput().
|
||||||
|
Key("dns").
|
||||||
|
Title("DNS Servers").
|
||||||
|
Description("Comma-separated IPv4 addresses").
|
||||||
|
Placeholder("e.g., 8.8.8.8, 8.8.4.4").
|
||||||
|
Value(&defaultDNS).
|
||||||
|
Validate(func(s string) error {
|
||||||
|
return validation.ValidateDNSServers(s)
|
||||||
|
}),
|
||||||
|
|
||||||
|
huh.NewConfirm().
|
||||||
|
Key("use_psk").
|
||||||
|
Title("Use Preshared Key").
|
||||||
|
Description("Enable additional security layer with a preshared key").
|
||||||
|
Affirmative("Yes").
|
||||||
|
Negative("No"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Create spinner for loading states
|
||||||
|
s := spinner.New()
|
||||||
|
s.Spinner = spinner.Dot
|
||||||
|
s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("62"))
|
||||||
|
|
||||||
|
return &AddScreen{
|
||||||
|
form: form,
|
||||||
|
quitting: false,
|
||||||
|
spinner: s,
|
||||||
|
isCreating: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Init initializes the add screen
|
||||||
|
func (s *AddScreen) Init() tea.Cmd {
|
||||||
|
if s.isCreating {
|
||||||
|
return s.spinner.Tick
|
||||||
|
}
|
||||||
|
return s.form.Init()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update handles messages for the add screen
|
||||||
|
func (s *AddScreen) Update(msg tea.Msg) (Screen, tea.Cmd) {
|
||||||
|
var cmds []tea.Cmd
|
||||||
|
|
||||||
|
switch msg := msg.(type) {
|
||||||
|
case tea.KeyMsg:
|
||||||
|
switch msg.String() {
|
||||||
|
case "q", "ctrl+c", "esc":
|
||||||
|
// Cancel and return to list (only if not creating)
|
||||||
|
if !s.isCreating {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If creating, update spinner instead of form
|
||||||
|
if s.isCreating {
|
||||||
|
var cmd tea.Cmd
|
||||||
|
s.spinner, cmd = s.spinner.Update(msg)
|
||||||
|
return s, cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the form
|
||||||
|
form, cmd := s.form.Update(msg)
|
||||||
|
if f, ok := form.(*huh.Form); ok {
|
||||||
|
s.form = f
|
||||||
|
}
|
||||||
|
cmds = append(cmds, cmd)
|
||||||
|
|
||||||
|
// Check if form is completed
|
||||||
|
if s.form.State == huh.StateCompleted {
|
||||||
|
name := s.form.GetString("name")
|
||||||
|
dns := s.form.GetString("dns")
|
||||||
|
usePSK := s.form.GetBool("use_psk")
|
||||||
|
|
||||||
|
// Set creating state and start spinner
|
||||||
|
s.isCreating = true
|
||||||
|
// Create the client
|
||||||
|
return s, s.createClient(name, dns, usePSK)
|
||||||
|
}
|
||||||
|
|
||||||
|
return s, tea.Batch(cmds...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// View renders the add screen
|
||||||
|
func (s *AddScreen) View() string {
|
||||||
|
// Breadcrumb: Clients > Add
|
||||||
|
breadcrumb := components.RenderBreadcrumb([]components.BreadcrumbItem{
|
||||||
|
{Label: "Clients", ID: "list"},
|
||||||
|
{Label: "Add", ID: "add"},
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
if s.quitting {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.isCreating {
|
||||||
|
return addLoadingStyle.Render(
|
||||||
|
lipgloss.JoinVertical(
|
||||||
|
lipgloss.Left,
|
||||||
|
addTitleStyle.Render("Add New WireGuard Client"),
|
||||||
|
s.spinner.View()+" Creating client configuration, please wait...",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
content := lipgloss.JoinVertical(
|
||||||
|
lipgloss.Left,
|
||||||
|
breadcrumb,
|
||||||
|
"",
|
||||||
|
addTitleStyle.Render("Add New WireGuard Client"),
|
||||||
|
s.form.View(),
|
||||||
|
addHelpStyle.Render("Press Enter to submit • Esc to cancel"),
|
||||||
|
)
|
||||||
|
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
|
||||||
|
// createClient creates a new WireGuard client
|
||||||
|
func (s *AddScreen) createClient(name, dns string, usePSK bool) tea.Cmd {
|
||||||
|
return func() tea.Msg {
|
||||||
|
// Create the client via wireguard package
|
||||||
|
err := wireguard.CreateClient(name, dns, usePSK)
|
||||||
|
if err != nil {
|
||||||
|
return ErrMsg{Err: fmt.Errorf("failed to create client: %w", err)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return success message
|
||||||
|
return ClientCreatedMsg{
|
||||||
|
Name: name,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Messages
|
||||||
|
|
||||||
|
// ClientCreatedMsg is sent when a client is successfully created
|
||||||
|
type ClientCreatedMsg struct {
|
||||||
|
Name string
|
||||||
|
}
|
||||||
291
internal/tui/screens/detail.go
Normal file
291
internal/tui/screens/detail.go
Normal file
@@ -0,0 +1,291 @@
|
|||||||
|
package screens
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/calmcacil/wg-admin/internal/tui/components"
|
||||||
|
"github.com/calmcacil/wg-admin/internal/tui/theme"
|
||||||
|
"github.com/calmcacil/wg-admin/internal/wireguard"
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DetailScreen displays detailed information about a single WireGuard client
|
||||||
|
type DetailScreen struct {
|
||||||
|
client wireguard.Client
|
||||||
|
status string
|
||||||
|
lastHandshake time.Time
|
||||||
|
transferRx string
|
||||||
|
transferTx string
|
||||||
|
confirmModal *components.DeleteConfirmModel
|
||||||
|
showConfirm bool
|
||||||
|
configDisplay *components.ConfigDisplayModel
|
||||||
|
showConfig bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Styles
|
||||||
|
var (
|
||||||
|
detailValueStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("255"))
|
||||||
|
dimmedContentStyle = theme.StyleMuted
|
||||||
|
)
|
||||||
|
|
||||||
|
// NewDetailScreen creates a new detail screen for a client
|
||||||
|
func NewDetailScreen(client wireguard.Client) *DetailScreen {
|
||||||
|
return &DetailScreen{
|
||||||
|
client: client,
|
||||||
|
showConfirm: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Init initializes the detail screen
|
||||||
|
func (s *DetailScreen) Init() tea.Cmd {
|
||||||
|
return s.loadClientStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update handles messages for the detail screen
|
||||||
|
func (s *DetailScreen) Update(msg tea.Msg) (Screen, tea.Cmd) {
|
||||||
|
var cmd tea.Cmd
|
||||||
|
|
||||||
|
// Handle confirmation modal
|
||||||
|
if s.showConfirm && s.confirmModal != nil {
|
||||||
|
_, cmd = s.confirmModal.Update(msg)
|
||||||
|
|
||||||
|
// Handle confirmation result
|
||||||
|
if !s.confirmModal.Visible {
|
||||||
|
if s.confirmModal.IsConfirmed() {
|
||||||
|
// User confirmed deletion
|
||||||
|
return s, tea.Batch(s.deleteClient(), func() tea.Msg {
|
||||||
|
return CloseDetailScreenMsg{}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// User cancelled - close modal
|
||||||
|
s.showConfirm = false
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return s, cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle config display modal
|
||||||
|
if s.showConfig && s.configDisplay != nil {
|
||||||
|
_, cmd = s.configDisplay.Update(msg)
|
||||||
|
|
||||||
|
// Handle modal close
|
||||||
|
if !s.configDisplay.IsVisible() {
|
||||||
|
s.showConfig = false
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return s, cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle normal screen messages
|
||||||
|
switch msg := msg.(type) {
|
||||||
|
case clientStatusLoadedMsg:
|
||||||
|
s.status = msg.status
|
||||||
|
s.lastHandshake = msg.lastHandshake
|
||||||
|
s.transferRx = msg.transferRx
|
||||||
|
s.transferTx = msg.transferTx
|
||||||
|
case tea.KeyMsg:
|
||||||
|
switch msg.String() {
|
||||||
|
case "q", "b", "esc":
|
||||||
|
// Return to list screen - signal parent to switch screens
|
||||||
|
return nil, nil
|
||||||
|
case "d":
|
||||||
|
// Show delete confirmation
|
||||||
|
s.confirmModal = components.NewDeleteConfirm(
|
||||||
|
s.client.Name,
|
||||||
|
80,
|
||||||
|
24,
|
||||||
|
)
|
||||||
|
s.showConfirm = true
|
||||||
|
case "c":
|
||||||
|
// Show client configuration
|
||||||
|
return s, s.loadConfig()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return s, cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
// View renders the detail screen
|
||||||
|
func (s *DetailScreen) View() string {
|
||||||
|
// Handle config display modal
|
||||||
|
if s.showConfig && s.configDisplay != nil {
|
||||||
|
// Render underlying content dimmed
|
||||||
|
content := s.renderContent()
|
||||||
|
dimmedContent := dimmedContentStyle.Render(content)
|
||||||
|
|
||||||
|
// Overlay config display modal
|
||||||
|
return lipgloss.JoinVertical(
|
||||||
|
lipgloss.Left,
|
||||||
|
dimmedContent,
|
||||||
|
s.configDisplay.View(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle confirmation modal
|
||||||
|
if s.showConfirm && s.confirmModal != nil {
|
||||||
|
// Render underlying content dimmed
|
||||||
|
content := s.renderContent()
|
||||||
|
dimmedContent := dimmedContentStyle.Render(content)
|
||||||
|
|
||||||
|
// Overlay confirmation modal
|
||||||
|
return lipgloss.JoinVertical(
|
||||||
|
lipgloss.Left,
|
||||||
|
dimmedContent,
|
||||||
|
s.confirmModal.View(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.renderContent()
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderContent renders the main detail screen content
|
||||||
|
func (s *DetailScreen) renderContent() string {
|
||||||
|
// Breadcrumb: Clients > Client Name
|
||||||
|
breadcrumb := components.RenderBreadcrumb([]components.BreadcrumbItem{
|
||||||
|
{Label: "Clients", ID: "list"},
|
||||||
|
{Label: s.client.Name, ID: "detail"},
|
||||||
|
})
|
||||||
|
|
||||||
|
statusText := s.status
|
||||||
|
if s.status == wireguard.StatusConnected {
|
||||||
|
duration := time.Since(s.lastHandshake)
|
||||||
|
quality := wireguard.CalculateQuality(duration)
|
||||||
|
statusText = theme.StyleSuccess.Bold(true).Render("● " + s.status + " (" + quality + ")")
|
||||||
|
} else {
|
||||||
|
statusText = theme.StyleError.Bold(true).Render("● " + s.status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build content
|
||||||
|
content := lipgloss.JoinVertical(
|
||||||
|
lipgloss.Left,
|
||||||
|
breadcrumb,
|
||||||
|
"",
|
||||||
|
theme.StyleTitle.Render(fmt.Sprintf("Client Details: %s", s.client.Name)),
|
||||||
|
"",
|
||||||
|
s.renderField("Status", statusText),
|
||||||
|
s.renderField("IPv4 Address", detailValueStyle.Render(s.client.IPv4)),
|
||||||
|
s.renderField("IPv6 Address", detailValueStyle.Render(s.client.IPv6)),
|
||||||
|
"",
|
||||||
|
theme.StyleSubtitle.Bold(true).MarginTop(1).Render("WireGuard Configuration"),
|
||||||
|
s.renderField("Public Key", detailValueStyle.Render(s.client.PublicKey)),
|
||||||
|
s.renderField("Preshared Key", detailValueStyle.Render(func() string {
|
||||||
|
if s.client.HasPSK {
|
||||||
|
return "✓ Configured"
|
||||||
|
}
|
||||||
|
return "Not configured"
|
||||||
|
}())),
|
||||||
|
"",
|
||||||
|
theme.StyleSubtitle.Bold(true).MarginTop(1).Render("Connection Info"),
|
||||||
|
s.renderField("Last Handshake", detailValueStyle.Render(s.formatHandshake())),
|
||||||
|
s.renderField("Transfer (Rx/Tx)", detailValueStyle.Render(fmt.Sprintf("%s / %s", s.transferRx, s.transferTx))),
|
||||||
|
s.renderField("Config Path", detailValueStyle.Render(s.client.ConfigPath)),
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
|
||||||
|
// Add help text with all keyboard shortcuts
|
||||||
|
helpText := theme.StyleHelpKey.MarginTop(1).Render("Shortcuts: [d] Delete • [c] View Config • [q/b/esc] Back")
|
||||||
|
content = lipgloss.JoinVertical(lipgloss.Left, content, helpText)
|
||||||
|
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderField renders a label-value pair
|
||||||
|
func (s *DetailScreen) renderField(label string, value string) string {
|
||||||
|
return lipgloss.JoinHorizontal(lipgloss.Left,
|
||||||
|
theme.StyleSubtitle.Width(18).Render(label),
|
||||||
|
value,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatHandshake formats the last handshake time
|
||||||
|
func (s *DetailScreen) formatHandshake() string {
|
||||||
|
if s.lastHandshake.IsZero() {
|
||||||
|
return "Never"
|
||||||
|
}
|
||||||
|
|
||||||
|
duration := time.Since(s.lastHandshake)
|
||||||
|
if duration < time.Minute {
|
||||||
|
return "Just now"
|
||||||
|
} else if duration < time.Hour {
|
||||||
|
return fmt.Sprintf("%d min ago", int(duration.Minutes()))
|
||||||
|
} else if duration < 24*time.Hour {
|
||||||
|
return fmt.Sprintf("%d hours ago", int(duration.Hours()))
|
||||||
|
} else if duration < 7*24*time.Hour {
|
||||||
|
return fmt.Sprintf("%d days ago", int(duration.Hours()/24))
|
||||||
|
}
|
||||||
|
return s.lastHandshake.Format("2006-01-02 15:04")
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadClientStatus loads the current status of the client
|
||||||
|
func (s *DetailScreen) loadClientStatus() tea.Msg {
|
||||||
|
peers, err := wireguard.GetAllPeers()
|
||||||
|
if err != nil {
|
||||||
|
return ErrMsg{Err: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find peer by public key
|
||||||
|
for _, peer := range peers {
|
||||||
|
if peer.PublicKey == s.client.PublicKey {
|
||||||
|
return clientStatusLoadedMsg{
|
||||||
|
status: peer.Status,
|
||||||
|
lastHandshake: peer.LatestHandshake,
|
||||||
|
transferRx: peer.TransferRx,
|
||||||
|
transferTx: peer.TransferTx,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Peer not found in active list
|
||||||
|
return clientStatusLoadedMsg{
|
||||||
|
status: wireguard.StatusDisconnected,
|
||||||
|
lastHandshake: time.Time{},
|
||||||
|
transferRx: "",
|
||||||
|
transferTx: "",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadConfig loads and displays the client configuration
|
||||||
|
func (s *DetailScreen) loadConfig() tea.Cmd {
|
||||||
|
return func() tea.Msg {
|
||||||
|
config, err := wireguard.GetClientConfigContent(s.client.Name)
|
||||||
|
if err != nil {
|
||||||
|
return ErrMsg{Err: fmt.Errorf("failed to load client config: %w", err)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create or update config display modal
|
||||||
|
if s.configDisplay == nil {
|
||||||
|
s.configDisplay = components.NewConfigDisplay(config, 80, 24)
|
||||||
|
} else {
|
||||||
|
s.configDisplay.Show(config)
|
||||||
|
}
|
||||||
|
s.showConfig = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// deleteClient deletes the client
|
||||||
|
func (s *DetailScreen) deleteClient() tea.Cmd {
|
||||||
|
return func() tea.Msg {
|
||||||
|
err := wireguard.DeleteClient(s.client.Name)
|
||||||
|
if err != nil {
|
||||||
|
return ErrMsg{fmt.Errorf("failed to delete client: %w", err)}
|
||||||
|
}
|
||||||
|
return ClientDeletedMsg{
|
||||||
|
Name: s.client.Name,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Messages
|
||||||
|
|
||||||
|
// clientStatusLoadedMsg is sent when client status is loaded
|
||||||
|
type clientStatusLoadedMsg struct {
|
||||||
|
status string
|
||||||
|
lastHandshake time.Time
|
||||||
|
transferRx string
|
||||||
|
transferTx string
|
||||||
|
}
|
||||||
242
internal/tui/screens/error.go
Normal file
242
internal/tui/screens/error.go
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
package screens
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/calmcacil/wg-admin/internal/tui/theme"
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrorScreen displays user-friendly error messages with recovery options
|
||||||
|
type ErrorScreen struct {
|
||||||
|
err error
|
||||||
|
friendly string
|
||||||
|
actions []ErrorAction
|
||||||
|
quitting bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrorAction represents a recovery action
|
||||||
|
type ErrorAction struct {
|
||||||
|
Key string
|
||||||
|
Label string
|
||||||
|
Description string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewErrorScreen creates a new error screen with mapped error information
|
||||||
|
func NewErrorScreen(err error) *ErrorScreen {
|
||||||
|
screen := &ErrorScreen{
|
||||||
|
err: err,
|
||||||
|
friendly: err.Error(), // Fallback to raw error
|
||||||
|
actions: []ErrorAction{{Key: "enter", Label: "OK", Description: "Dismiss and return"}},
|
||||||
|
quitting: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map error to user-friendly message and recovery options
|
||||||
|
screen.mapError(err)
|
||||||
|
|
||||||
|
return screen
|
||||||
|
}
|
||||||
|
|
||||||
|
// Init initializes the error screen
|
||||||
|
func (s *ErrorScreen) Init() tea.Cmd {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update handles messages for the error screen
|
||||||
|
func (s *ErrorScreen) Update(msg tea.Msg) (Screen, tea.Cmd) {
|
||||||
|
switch msg := msg.(type) {
|
||||||
|
case tea.KeyMsg:
|
||||||
|
switch msg.String() {
|
||||||
|
case "q", "ctrl+c":
|
||||||
|
// Quit application
|
||||||
|
s.quitting = true
|
||||||
|
return s, tea.Quit
|
||||||
|
case "enter", "esc":
|
||||||
|
// Dismiss error screen
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// View renders the error screen
|
||||||
|
func (s *ErrorScreen) View() string {
|
||||||
|
if s.quitting {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
titleStyle := theme.StyleTitle.Copy().MarginBottom(1)
|
||||||
|
errorStyle := theme.StyleError.Copy().Bold(true)
|
||||||
|
msgStyle := theme.StyleSubtitle.Copy().MarginTop(1).MarginBottom(2)
|
||||||
|
actionTitleStyle := theme.StylePrimary.Copy().Bold(true).MarginTop(1)
|
||||||
|
actionStyle := theme.StyleMuted.Copy().MarginLeft(2)
|
||||||
|
|
||||||
|
// Build the error display
|
||||||
|
content := lipgloss.JoinVertical(
|
||||||
|
lipgloss.Left,
|
||||||
|
titleStyle.Render("Error Occurred"),
|
||||||
|
errorStyle.Render("⚠ "+s.friendly),
|
||||||
|
msgStyle.Render("Technical Details: "+s.err.Error()),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Add recovery actions
|
||||||
|
if len(s.actions) > 1 {
|
||||||
|
content += "\n" + actionTitleStyle.Render("Recovery Options:")
|
||||||
|
for _, action := range s.actions {
|
||||||
|
key := theme.StyleHelpKey.Render("[" + action.Key + "]")
|
||||||
|
content += "\n" + actionStyle.Render(
|
||||||
|
lipgloss.JoinHorizontal(lipgloss.Left,
|
||||||
|
key+" ",
|
||||||
|
action.Label+" - "+action.Description,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
content += "\n\n" + theme.StyleMuted.Render("Press Enter to dismiss • Press q to quit")
|
||||||
|
|
||||||
|
return lipgloss.NewStyle().
|
||||||
|
Padding(1, 2).
|
||||||
|
Border(lipgloss.RoundedBorder()).
|
||||||
|
BorderForeground(theme.GetCurrentTheme().Scheme.Error).
|
||||||
|
Render(content)
|
||||||
|
}
|
||||||
|
|
||||||
|
// mapError converts technical errors to user-friendly messages and recovery options
|
||||||
|
func (s *ErrorScreen) mapError(err error) {
|
||||||
|
if err == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
errStr := strings.ToLower(err.Error())
|
||||||
|
|
||||||
|
// Permission errors
|
||||||
|
if strings.Contains(errStr, "permission") ||
|
||||||
|
strings.Contains(errStr, "denied") ||
|
||||||
|
strings.Contains(errStr, "operation not permitted") {
|
||||||
|
s.friendly = "Permission Denied"
|
||||||
|
s.actions = []ErrorAction{
|
||||||
|
{Key: "r", Label: "Run with sudo", Description: "Restart with elevated privileges"},
|
||||||
|
{Key: "enter", Label: "Dismiss", Description: "Return to previous screen"},
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// File not found errors
|
||||||
|
if strings.Contains(errStr, "no such file") ||
|
||||||
|
strings.Contains(errStr, "file not found") ||
|
||||||
|
strings.Contains(errStr, "does not exist") {
|
||||||
|
s.friendly = "Configuration File Missing"
|
||||||
|
s.actions = []ErrorAction{
|
||||||
|
{Key: "r", Label: "Restore", Description: "Restore from backup (if available)"},
|
||||||
|
{Key: "enter", Label: "Dismiss", Description: "Return to previous screen"},
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client already exists errors
|
||||||
|
if strings.Contains(errStr, "already exists") ||
|
||||||
|
strings.Contains(errStr, "duplicate") {
|
||||||
|
s.friendly = "Client Already Exists"
|
||||||
|
s.actions = []ErrorAction{
|
||||||
|
{Key: "n", Label: "New Name", Description: "Try a different client name"},
|
||||||
|
{Key: "enter", Label: "Dismiss", Description: "Return to previous screen"},
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// IP address exhaustion
|
||||||
|
if strings.Contains(errStr, "no available") ||
|
||||||
|
strings.Contains(errStr, "exhausted") ||
|
||||||
|
strings.Contains(errStr, "out of") {
|
||||||
|
s.friendly = "No Available IP Addresses"
|
||||||
|
s.actions = []ErrorAction{
|
||||||
|
{Key: "d", Label: "Delete Client", Description: "Remove an unused client to free IPs"},
|
||||||
|
{Key: "enter", Label: "Dismiss", Description: "Return to previous screen"},
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config file parsing errors
|
||||||
|
if strings.Contains(errStr, "parse") ||
|
||||||
|
strings.Contains(errStr, "invalid") ||
|
||||||
|
strings.Contains(errStr, "malformed") {
|
||||||
|
s.friendly = "Invalid Configuration"
|
||||||
|
s.actions = []ErrorAction{
|
||||||
|
{Key: "r", Label: "Restore", Description: "Restore from backup"},
|
||||||
|
{Key: "m", Label: "Manual Fix", Description: "Edit configuration manually"},
|
||||||
|
{Key: "enter", Label: "Dismiss", Description: "Return to previous screen"},
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// WireGuard command failures
|
||||||
|
if strings.Contains(errStr, "wg genkey") ||
|
||||||
|
strings.Contains(errStr, "wg pubkey") ||
|
||||||
|
strings.Contains(errStr, "wg genpsk") ||
|
||||||
|
strings.Contains(errStr, "wg set") {
|
||||||
|
s.friendly = "WireGuard Command Failed"
|
||||||
|
s.actions = []ErrorAction{
|
||||||
|
{Key: "i", Label: "Install WG", Description: "Ensure WireGuard is installed"},
|
||||||
|
{Key: "s", Label: "Check Service", Description: "Verify WireGuard service is running"},
|
||||||
|
{Key: "enter", Label: "Dismiss", Description: "Return to previous screen"},
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Network errors
|
||||||
|
if strings.Contains(errStr, "network") ||
|
||||||
|
strings.Contains(errStr, "connection") ||
|
||||||
|
strings.Contains(errStr, "timeout") {
|
||||||
|
s.friendly = "Network Error"
|
||||||
|
s.actions = []ErrorAction{
|
||||||
|
{Key: "r", Label: "Retry", Description: "Attempt the operation again"},
|
||||||
|
{Key: "c", Label: "Check Connection", Description: "Verify network connectivity"},
|
||||||
|
{Key: "enter", Label: "Dismiss", Description: "Return to previous screen"},
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config directory not found
|
||||||
|
if strings.Contains(errStr, "config directory") ||
|
||||||
|
strings.Contains(errStr, "wireguard") {
|
||||||
|
s.friendly = "WireGuard Not Configured"
|
||||||
|
s.actions = []ErrorAction{
|
||||||
|
{Key: "i", Label: "Install WireGuard", Description: "Set up WireGuard on this server"},
|
||||||
|
{Key: "enter", Label: "Dismiss", Description: "Return to previous screen"},
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// DNS validation errors
|
||||||
|
if strings.Contains(errStr, "dns") ||
|
||||||
|
strings.Contains(errStr, "invalid address") {
|
||||||
|
s.friendly = "Invalid DNS Configuration"
|
||||||
|
s.actions = []ErrorAction{
|
||||||
|
{Key: "e", Label: "Edit DNS", Description: "Update DNS server settings"},
|
||||||
|
{Key: "d", Label: "Use Default", Description: "Use default DNS (8.8.8.8, 8.8.4.4)"},
|
||||||
|
{Key: "enter", Label: "Dismiss", Description: "Return to previous screen"},
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backup/restore errors
|
||||||
|
if strings.Contains(errStr, "backup") ||
|
||||||
|
strings.Contains(errStr, "restore") {
|
||||||
|
s.friendly = "Backup Operation Failed"
|
||||||
|
s.actions = []ErrorAction{
|
||||||
|
{Key: "r", Label: "Retry", Description: "Attempt backup/restore again"},
|
||||||
|
{Key: "c", Label: "Check Space", Description: "Verify disk space is available"},
|
||||||
|
{Key: "enter", Label: "Dismiss", Description: "Return to previous screen"},
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default: generic error
|
||||||
|
s.friendly = "An Error Occurred"
|
||||||
|
s.actions = []ErrorAction{
|
||||||
|
{Key: "r", Label: "Retry", Description: "Attempt the operation again"},
|
||||||
|
{Key: "enter", Label: "Dismiss", Description: "Return to previous screen"},
|
||||||
|
}
|
||||||
|
}
|
||||||
106
internal/tui/screens/help.go
Normal file
106
internal/tui/screens/help.go
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
package screens
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/calmcacil/wg-admin/internal/tui/components"
|
||||||
|
"github.com/calmcacil/wg-admin/internal/tui/theme"
|
||||||
|
"github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HelpScreen displays keyboard shortcuts
|
||||||
|
type HelpScreen struct {
|
||||||
|
previousScreen Screen
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewHelpScreen creates a new help screen
|
||||||
|
func NewHelpScreen(previous Screen) *HelpScreen {
|
||||||
|
return &HelpScreen{
|
||||||
|
previousScreen: previous,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Init initializes the help screen
|
||||||
|
func (s *HelpScreen) Init() tea.Cmd {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update handles messages for the help screen
|
||||||
|
func (s *HelpScreen) Update(msg tea.Msg) (Screen, tea.Cmd) {
|
||||||
|
switch msg := msg.(type) {
|
||||||
|
case tea.KeyMsg:
|
||||||
|
switch msg.String() {
|
||||||
|
case "q", "esc":
|
||||||
|
// Return to previous screen
|
||||||
|
return s.previousScreen, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// View renders the help screen
|
||||||
|
func (s *HelpScreen) View() string {
|
||||||
|
// Breadcrumb: Help
|
||||||
|
breadcrumb := components.RenderBreadcrumb([]components.BreadcrumbItem{{Label: "Help", ID: "help"}})
|
||||||
|
|
||||||
|
// Styles using theme
|
||||||
|
borderStyle := lipgloss.NewStyle().
|
||||||
|
BorderStyle(lipgloss.RoundedBorder()).
|
||||||
|
BorderForeground(theme.StyleBorder).
|
||||||
|
Padding(1, 2)
|
||||||
|
|
||||||
|
keyStyle := theme.StyleHelpKey.Width(12)
|
||||||
|
|
||||||
|
descStyle := lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color("250"))
|
||||||
|
|
||||||
|
// Header
|
||||||
|
header := theme.StyleTitle.MarginBottom(1).Render("Keyboard Shortcuts")
|
||||||
|
|
||||||
|
// Shortcut groups
|
||||||
|
navigationGroup := theme.StyleWarning.Bold(true).MarginTop(1).MarginBottom(0).Render("Navigation") + "\n" +
|
||||||
|
keyStyle.Render("j / ↓") + descStyle.Render("Move down") + "\n" +
|
||||||
|
keyStyle.Render("k / ↑") + descStyle.Render("Move up") + "\n" +
|
||||||
|
keyStyle.Render("← →") + descStyle.Render("Move left/right") + "\n" +
|
||||||
|
keyStyle.Render("Enter") + descStyle.Render("Select") + "\n" +
|
||||||
|
keyStyle.Render("Esc") + descStyle.Render("Go back")
|
||||||
|
|
||||||
|
actionsGroup := theme.StyleWarning.Bold(true).MarginTop(1).MarginBottom(0).Render("Actions") + "\n" +
|
||||||
|
keyStyle.Render("a") + descStyle.Render("Add client") + "\n" +
|
||||||
|
keyStyle.Render("d") + descStyle.Render("Delete client") + "\n" +
|
||||||
|
keyStyle.Render("Q") + descStyle.Render("Show QR code") + "\n" +
|
||||||
|
keyStyle.Render("r") + descStyle.Render("Refresh list") + "\n" +
|
||||||
|
keyStyle.Render("l") + descStyle.Render("List view")
|
||||||
|
|
||||||
|
otherGroup := theme.StyleWarning.Bold(true).MarginTop(1).MarginBottom(0).Render("Other") + "\n" +
|
||||||
|
keyStyle.Render("?") + descStyle.Render("Show this help") + "\n" +
|
||||||
|
keyStyle.Render("/") + descStyle.Render("Search") + "\n" +
|
||||||
|
keyStyle.Render("q") + descStyle.Render("Quit")
|
||||||
|
|
||||||
|
copyGroup := theme.StyleWarning.Bold(true).MarginTop(1).MarginBottom(0).Render("Text Selection & Copy") + "\n" +
|
||||||
|
keyStyle.Render("SHIFT+drag") + descStyle.Render("Select text") + "\n" +
|
||||||
|
keyStyle.Render("Ctrl+Shift+C") + descStyle.Render("Copy (Linux)") + "\n" +
|
||||||
|
keyStyle.Render("Cmd+C") + descStyle.Render("Copy (macOS)")
|
||||||
|
|
||||||
|
// Two-column layout
|
||||||
|
leftColumn := lipgloss.JoinVertical(lipgloss.Left,
|
||||||
|
navigationGroup,
|
||||||
|
"",
|
||||||
|
actionsGroup,
|
||||||
|
)
|
||||||
|
|
||||||
|
rightColumn := lipgloss.JoinVertical(lipgloss.Left,
|
||||||
|
otherGroup,
|
||||||
|
"",
|
||||||
|
copyGroup,
|
||||||
|
)
|
||||||
|
|
||||||
|
content := lipgloss.JoinHorizontal(lipgloss.Top, leftColumn, " ", rightColumn)
|
||||||
|
|
||||||
|
// Footer
|
||||||
|
footer := theme.StyleMuted.MarginTop(1).Render("Press q or Esc to return")
|
||||||
|
|
||||||
|
// Combine all
|
||||||
|
return breadcrumb + "\n\n" + borderStyle.Render(
|
||||||
|
lipgloss.JoinVertical(lipgloss.Left, header, content, footer),
|
||||||
|
)
|
||||||
|
}
|
||||||
41
internal/tui/screens/interface.go
Normal file
41
internal/tui/screens/interface.go
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
package screens
|
||||||
|
|
||||||
|
import (
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TransitionType defines the type of transition animation
|
||||||
|
type TransitionType int
|
||||||
|
|
||||||
|
const (
|
||||||
|
TransitionNone TransitionType = iota
|
||||||
|
TransitionFade
|
||||||
|
TransitionSlideLeft
|
||||||
|
TransitionSlideRight
|
||||||
|
)
|
||||||
|
|
||||||
|
// Screen represents a UI screen (list, add, detail, etc.)
|
||||||
|
type Screen interface {
|
||||||
|
Init() tea.Cmd
|
||||||
|
Update(tea.Msg) (Screen, tea.Cmd)
|
||||||
|
View() string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClientSelectedMsg is sent when a client is selected from the list
|
||||||
|
type ClientSelectedMsg struct {
|
||||||
|
Client ClientWithStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClientDeletedMsg is sent when a client is successfully deleted
|
||||||
|
type ClientDeletedMsg struct {
|
||||||
|
Name string
|
||||||
|
}
|
||||||
|
|
||||||
|
// CloseDetailScreenMsg signals to close detail screen
|
||||||
|
type CloseDetailScreenMsg struct{}
|
||||||
|
|
||||||
|
// RestoreCompletedMsg is sent when a restore operation completes
|
||||||
|
type RestoreCompletedMsg struct {
|
||||||
|
Err error
|
||||||
|
SafetyBackupPath string
|
||||||
|
}
|
||||||
462
internal/tui/screens/list.go
Normal file
462
internal/tui/screens/list.go
Normal file
@@ -0,0 +1,462 @@
|
|||||||
|
package screens
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/calmcacil/wg-admin/internal/tui/components"
|
||||||
|
"github.com/calmcacil/wg-admin/internal/wireguard"
|
||||||
|
"github.com/charmbracelet/bubbles/table"
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
)
|
||||||
|
|
||||||
|
const statusRefreshInterval = 3 // seconds
|
||||||
|
|
||||||
|
// ListScreen displays a table of WireGuard clients
|
||||||
|
type ListScreen struct {
|
||||||
|
table table.Model
|
||||||
|
search *components.SearchModel
|
||||||
|
clients []ClientWithStatus
|
||||||
|
filtered []ClientWithStatus
|
||||||
|
sortedBy string // Column name being sorted by
|
||||||
|
ascending bool // Sort direction
|
||||||
|
lastUpdated time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClientWithStatus wraps a client with its connection status
|
||||||
|
type ClientWithStatus struct {
|
||||||
|
Client wireguard.Client
|
||||||
|
Status string
|
||||||
|
Quality string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewListScreen creates a new list screen
|
||||||
|
func NewListScreen() *ListScreen {
|
||||||
|
return &ListScreen{
|
||||||
|
search: components.NewSearch(),
|
||||||
|
sortedBy: "Name",
|
||||||
|
ascending: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Init initializes the list screen
|
||||||
|
func (s *ListScreen) Init() tea.Cmd {
|
||||||
|
return tea.Batch(
|
||||||
|
s.loadClients,
|
||||||
|
wireguard.Tick(statusRefreshInterval),
|
||||||
|
ticker(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ticker sends a message every second to update the time display
|
||||||
|
func ticker() tea.Cmd {
|
||||||
|
return tea.Tick(time.Second, func(t time.Time) tea.Msg {
|
||||||
|
return timeTickMsg(t)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// timeTickMsg is sent every second to update the time display
|
||||||
|
type timeTickMsg time.Time
|
||||||
|
|
||||||
|
// Update handles messages for the list screen
|
||||||
|
func (s *ListScreen) Update(msg tea.Msg) (Screen, tea.Cmd) {
|
||||||
|
var cmd tea.Cmd
|
||||||
|
|
||||||
|
switch msg := msg.(type) {
|
||||||
|
case tea.KeyMsg:
|
||||||
|
// Handle search activation
|
||||||
|
if msg.String() == "/" && !s.search.IsActive() {
|
||||||
|
s.search.Activate()
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// If search is active, pass input to search
|
||||||
|
if s.search.IsActive() {
|
||||||
|
s.search, cmd = s.search.Update(msg)
|
||||||
|
// Apply filter to clients
|
||||||
|
s.applyFilter()
|
||||||
|
return s, cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normal key handling when search is not active
|
||||||
|
switch msg.String() {
|
||||||
|
case "q", "ctrl+c":
|
||||||
|
// Handle quit in parent model
|
||||||
|
return s, nil
|
||||||
|
case "r":
|
||||||
|
// Refresh clients
|
||||||
|
return s, s.loadClients
|
||||||
|
case "R":
|
||||||
|
// Show restore screen
|
||||||
|
return NewRestoreScreen(), nil
|
||||||
|
case "Q":
|
||||||
|
// Show QR code for selected client
|
||||||
|
if len(s.table.Rows()) > 0 {
|
||||||
|
selected := s.table.SelectedRow()
|
||||||
|
clientName := selected[0] // First column is Name
|
||||||
|
return NewQRScreen(clientName), nil
|
||||||
|
}
|
||||||
|
case "enter":
|
||||||
|
// Open detail view for selected client
|
||||||
|
if len(s.table.Rows()) > 0 {
|
||||||
|
selectedRow := s.table.SelectedRow()
|
||||||
|
selectedName := selectedRow[0] // First column is Name
|
||||||
|
// Find the client with this name
|
||||||
|
for _, cws := range s.clients {
|
||||||
|
if cws.Client.Name == selectedName {
|
||||||
|
return s, func() tea.Msg {
|
||||||
|
return ClientSelectedMsg{Client: cws}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "1", "2", "3", "4":
|
||||||
|
// Sort by column number (Name, IPv4, IPv6, Status)
|
||||||
|
s.sortByColumn(msg.String())
|
||||||
|
}
|
||||||
|
case clientsLoadedMsg:
|
||||||
|
s.clients = msg.clients
|
||||||
|
s.lastUpdated = time.Now()
|
||||||
|
s.search.SetTotalCount(len(s.clients))
|
||||||
|
s.applyFilter()
|
||||||
|
case timeTickMsg:
|
||||||
|
// Trigger a re-render to update "Last updated" display
|
||||||
|
case wireguard.StatusTickMsg:
|
||||||
|
// Refresh status on periodic tick
|
||||||
|
return s, s.loadClients
|
||||||
|
case wireguard.RefreshStatusMsg:
|
||||||
|
// Refresh status on manual refresh
|
||||||
|
return s, s.loadClients
|
||||||
|
}
|
||||||
|
|
||||||
|
s.table, cmd = s.table.Update(msg)
|
||||||
|
return s, cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
// View renders the list screen
|
||||||
|
func (s *ListScreen) View() string {
|
||||||
|
// Breadcrumb: Home
|
||||||
|
breadcrumb := components.RenderBreadcrumb([]components.BreadcrumbItem{
|
||||||
|
{Label: "Clients", ID: "list"},
|
||||||
|
})
|
||||||
|
if len(s.clients) == 0 {
|
||||||
|
// Empty state with helpful guidance
|
||||||
|
return breadcrumb + "\n" + s.search.View() + "\n\n" +
|
||||||
|
lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color("226")).
|
||||||
|
Bold(true).
|
||||||
|
Render("No clients yet") + "\n" +
|
||||||
|
lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color("241")).
|
||||||
|
Render("Let's get started! Here are your options:") + "\n" +
|
||||||
|
lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color("109")).
|
||||||
|
Render(" • Press [a] to add your first client") + "\n" +
|
||||||
|
lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color("109")).
|
||||||
|
Render(" • Press [R] to restore from backup") + "\n" +
|
||||||
|
lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color("109")).
|
||||||
|
Render(" • Press [r] to refresh the client list") + "\n" +
|
||||||
|
lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color("109")).
|
||||||
|
Render(" • Press [q] to quit")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if there are no matches
|
||||||
|
if s.search.IsActive() && len(s.filtered) == 0 && s.search.GetQuery() != "" {
|
||||||
|
// Empty search results with helpful tips
|
||||||
|
return breadcrumb + "\n" + s.search.View() + "\n\n" +
|
||||||
|
lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color("226")).
|
||||||
|
Bold(true).
|
||||||
|
Render("No matching clients found") + "\n" +
|
||||||
|
lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color("241")).
|
||||||
|
Render("Search tips:") + "\n" +
|
||||||
|
lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color("109")).
|
||||||
|
Render(" • Check your spelling") + "\n" +
|
||||||
|
lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color("109")).
|
||||||
|
Render(" • Try a shorter search term") + "\n" +
|
||||||
|
lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color("109")).
|
||||||
|
Render(" • Search by name, IP, or status") + "\n" +
|
||||||
|
lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color("109")).
|
||||||
|
Render(" • Press [esc] to clear search") + "\n" +
|
||||||
|
lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color("109")).
|
||||||
|
Render(" • Press [r] to refresh client list")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate time since last update
|
||||||
|
timeAgo := "never"
|
||||||
|
if !s.lastUpdated.IsZero() {
|
||||||
|
duration := time.Since(s.lastUpdated)
|
||||||
|
timeAgo = formatDuration(duration)
|
||||||
|
}
|
||||||
|
|
||||||
|
lastUpdatedText := lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color("241")).
|
||||||
|
Render("Last updated: " + timeAgo)
|
||||||
|
|
||||||
|
// Add keyboard shortcuts help
|
||||||
|
helpText := lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color("63")).
|
||||||
|
MarginTop(1).
|
||||||
|
Render("Shortcuts: [?/h] Help • [a] Add • [R] Restore • [r] Refresh • [Q] QR Code • [/] Search • [1-4] Sort • [Enter] Details • [q] Quit")
|
||||||
|
|
||||||
|
return breadcrumb + "\n" + s.search.View() + "\n" + s.table.View() + "\n" + lastUpdatedText + "\n" + helpText
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatDuration returns a human-readable string for the duration
|
||||||
|
func formatDuration(d time.Duration) string {
|
||||||
|
if d < time.Minute {
|
||||||
|
return "just now"
|
||||||
|
}
|
||||||
|
if d < time.Hour {
|
||||||
|
return fmt.Sprintf("%d min ago", int(d.Minutes()))
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%d hr ago", int(d.Hours()))
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadClients loads clients from wireguard config
|
||||||
|
|
||||||
|
// loadClients loads clients from wireguard config
|
||||||
|
func (s *ListScreen) loadClients() tea.Msg {
|
||||||
|
clients, err := wireguard.ListClients()
|
||||||
|
if err != nil {
|
||||||
|
return ErrMsg{Err: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all peer statuses to retrieve quality information
|
||||||
|
peerStatuses, err := wireguard.GetAllPeers()
|
||||||
|
if err != nil {
|
||||||
|
return ErrMsg{Err: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match clients with their peer status
|
||||||
|
clientsWithStatus := make([]ClientWithStatus, len(clients))
|
||||||
|
for i, client := range clients {
|
||||||
|
status := wireguard.StatusDisconnected
|
||||||
|
quality := ""
|
||||||
|
|
||||||
|
// Find matching peer status
|
||||||
|
for _, peerStatus := range peerStatuses {
|
||||||
|
if peerStatus.PublicKey == client.PublicKey {
|
||||||
|
status = peerStatus.Status
|
||||||
|
quality = peerStatus.Quality
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
clientsWithStatus[i] = ClientWithStatus{
|
||||||
|
Client: client,
|
||||||
|
Status: status,
|
||||||
|
Quality: quality,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return clientsLoadedMsg{clients: clientsWithStatus}
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyFilter applies the current search filter to clients
|
||||||
|
func (s *ListScreen) applyFilter() {
|
||||||
|
// Convert clients to ClientData for filtering
|
||||||
|
clientData := make([]components.ClientData, len(s.clients))
|
||||||
|
for i, cws := range s.clients {
|
||||||
|
clientData[i] = components.ClientData{
|
||||||
|
Name: cws.Client.Name,
|
||||||
|
IPv4: cws.Client.IPv4,
|
||||||
|
IPv6: cws.Client.IPv6,
|
||||||
|
Status: cws.Status,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter clients
|
||||||
|
filteredData := s.search.Filter(clientData)
|
||||||
|
|
||||||
|
// Convert back to ClientWithStatus
|
||||||
|
s.filtered = make([]ClientWithStatus, len(filteredData))
|
||||||
|
for i, cd := range filteredData {
|
||||||
|
// Find the matching client
|
||||||
|
for _, cws := range s.clients {
|
||||||
|
if cws.Client.Name == cd.Name {
|
||||||
|
s.filtered[i] = cws
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rebuild table with filtered clients
|
||||||
|
s.buildTable()
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatStatusWithIcon formats the status with a colored circle icon
|
||||||
|
func (s *ListScreen) formatStatusWithIcon(status string, quality string) string {
|
||||||
|
if status == wireguard.StatusConnected {
|
||||||
|
if quality != "" {
|
||||||
|
return lipgloss.NewStyle().Foreground(lipgloss.Color("46")).Render("●") + " " + status + " (" + quality + ")"
|
||||||
|
}
|
||||||
|
return lipgloss.NewStyle().Foreground(lipgloss.Color("46")).Render("●") + " " + status
|
||||||
|
}
|
||||||
|
return lipgloss.NewStyle().Foreground(lipgloss.Color("196")).Render("●") + " " + status
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildTable creates and configures the table
|
||||||
|
func (s *ListScreen) buildTable() {
|
||||||
|
columns := []table.Column{
|
||||||
|
{Title: "Name", Width: 20},
|
||||||
|
{Title: "IPv4", Width: 15},
|
||||||
|
{Title: "IPv6", Width: 35},
|
||||||
|
{Title: "Status", Width: 14},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use filtered clients if search is active, otherwise use all clients
|
||||||
|
displayClients := s.filtered
|
||||||
|
if !s.search.IsActive() {
|
||||||
|
displayClients = s.clients
|
||||||
|
}
|
||||||
|
|
||||||
|
var rows []table.Row
|
||||||
|
for _, cws := range displayClients {
|
||||||
|
// Apply highlighting based on filter type
|
||||||
|
name := cws.Client.Name
|
||||||
|
ipv4 := cws.Client.IPv4
|
||||||
|
ipv6 := cws.Client.IPv6
|
||||||
|
status := cws.Status
|
||||||
|
|
||||||
|
if s.search.IsActive() {
|
||||||
|
switch s.search.GetFilterType() {
|
||||||
|
case components.FilterByName:
|
||||||
|
name = s.search.HighlightMatches(name)
|
||||||
|
case components.FilterByIPv4:
|
||||||
|
ipv4 = s.search.HighlightMatches(ipv4)
|
||||||
|
case components.FilterByIPv6:
|
||||||
|
ipv6 = s.search.HighlightMatches(ipv6)
|
||||||
|
case components.FilterByStatus:
|
||||||
|
status = s.search.HighlightMatches(status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
statusText := s.formatStatusWithIcon(status, 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
|
||||||
|
}
|
||||||
220
internal/tui/screens/list_go_append.txt
Normal file
220
internal/tui/screens/list_go_append.txt
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
lastUpdatedText := lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color("241")).
|
||||||
|
Render("Last updated: " + timeAgo)
|
||||||
|
|
||||||
|
// Add keyboard shortcuts help
|
||||||
|
helpText := lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color("63")).
|
||||||
|
MarginTop(1).
|
||||||
|
Render("Shortcuts: [?/h] Help • [a] Add • [R] Restore • [r] Refresh • [Q] QR Code • [/] Search • [1-4] Sort • [Enter] Details • [q] Quit")
|
||||||
|
|
||||||
|
return breadcrumb + "\n" + s.search.View() + "\n" + s.table.View() + "\n" + lastUpdatedText + "\n" + helpText
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatDuration returns a human-readable string for the duration
|
||||||
|
func formatDuration(d time.Duration) string {
|
||||||
|
if d < time.Minute {
|
||||||
|
return "just now"
|
||||||
|
}
|
||||||
|
if d < time.Hour {
|
||||||
|
return fmt.Sprintf("%d min ago", int(d.Minutes()))
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%d hr ago", int(d.Hours()))
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadClients loads clients from wireguard config
|
||||||
|
func (s *ListScreen) loadClients() tea.Msg {
|
||||||
|
clients, err := wireguard.ListClients()
|
||||||
|
if err != nil {
|
||||||
|
return ErrMsg{Err: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get status for each client
|
||||||
|
clientsWithStatus := make([]ClientWithStatus, len(clients))
|
||||||
|
for i, client := range clients {
|
||||||
|
status, err := wireguard.GetClientStatus(client.PublicKey)
|
||||||
|
if err != nil {
|
||||||
|
status = wireguard.StatusDisconnected
|
||||||
|
}
|
||||||
|
clientsWithStatus[i] = ClientWithStatus{
|
||||||
|
Client: client,
|
||||||
|
Status: status,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return clientsLoadedMsg{clients: clientsWithStatus}
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyFilter applies the current search filter to clients
|
||||||
|
func (s *ListScreen) applyFilter() {
|
||||||
|
// Convert clients to ClientData for filtering
|
||||||
|
clientData := make([]components.ClientData, len(s.clients))
|
||||||
|
for i, cws := range s.clients {
|
||||||
|
clientData[i] = components.ClientData{
|
||||||
|
Name: cws.Client.Name,
|
||||||
|
IPv4: cws.Client.IPv4,
|
||||||
|
IPv6: cws.Client.IPv6,
|
||||||
|
Status: cws.Status,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter clients
|
||||||
|
filteredData := s.search.Filter(clientData)
|
||||||
|
|
||||||
|
// Convert back to ClientWithStatus
|
||||||
|
s.filtered = make([]ClientWithStatus, len(filteredData))
|
||||||
|
for i, cd := range filteredData {
|
||||||
|
// Find the matching client
|
||||||
|
for _, cws := range s.clients {
|
||||||
|
if cws.Client.Name == cd.Name {
|
||||||
|
s.filtered[i] = cws
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rebuild table with filtered clients
|
||||||
|
s.buildTable()
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatStatusWithIcon formats the status with a colored circle icon
|
||||||
|
func (s *ListScreen) formatStatusWithIcon(status string) string {
|
||||||
|
if status == wireguard.StatusConnected {
|
||||||
|
return lipgloss.NewStyle().Foreground(lipgloss.Color("46")).Render("●") + " " + status
|
||||||
|
}
|
||||||
|
return lipgloss.NewStyle().Foreground(lipgloss.Color("196")).Render("●") + " " + status
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildTable creates and configures the table
|
||||||
|
func (s *ListScreen) buildTable() {
|
||||||
|
columns := []table.Column{
|
||||||
|
{Title: "Name", Width: 20},
|
||||||
|
{Title: "IPv4", Width: 15},
|
||||||
|
{Title: "IPv6", Width: 35},
|
||||||
|
{Title: "Status", Width: 14},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use filtered clients if search is active, otherwise use all clients
|
||||||
|
displayClients := s.filtered
|
||||||
|
if !s.search.IsActive() {
|
||||||
|
displayClients = s.clients
|
||||||
|
}
|
||||||
|
|
||||||
|
var rows []table.Row
|
||||||
|
for _, cws := range displayClients {
|
||||||
|
statusText := s.formatStatusWithIcon(cws.Status)
|
||||||
|
row := table.Row{
|
||||||
|
cws.Client.Name,
|
||||||
|
cws.Client.IPv4,
|
||||||
|
cws.Client.IPv6,
|
||||||
|
statusText,
|
||||||
|
}
|
||||||
|
rows = append(rows, row)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort rows based on current sort settings
|
||||||
|
s.sortRows(rows)
|
||||||
|
|
||||||
|
// Determine table height
|
||||||
|
tableHeight := len(rows) + 2 // Header + rows
|
||||||
|
if tableHeight < 5 {
|
||||||
|
tableHeight = 5
|
||||||
|
}
|
||||||
|
|
||||||
|
s.table = table.New(
|
||||||
|
table.WithColumns(columns),
|
||||||
|
table.WithRows(rows),
|
||||||
|
table.WithFocused(true),
|
||||||
|
table.WithHeight(tableHeight),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Apply styles
|
||||||
|
s.setTableStyles()
|
||||||
|
}
|
||||||
|
|
||||||
|
// setTableStyles applies styling to the table
|
||||||
|
func (s *ListScreen) setTableStyles() {
|
||||||
|
styles := table.DefaultStyles()
|
||||||
|
styles.Header = styles.Header.
|
||||||
|
BorderStyle(lipgloss.NormalBorder()).
|
||||||
|
BorderForeground(lipgloss.Color("240")).
|
||||||
|
BorderBottom(true).
|
||||||
|
Bold(true)
|
||||||
|
styles.Selected = styles.Selected.
|
||||||
|
Foreground(lipgloss.Color("229")).
|
||||||
|
Background(lipgloss.Color("57")).
|
||||||
|
Bold(false)
|
||||||
|
s.table.SetStyles(styles)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sortRows sorts the rows based on the current sort settings
|
||||||
|
func (s *ListScreen) sortRows(rows []table.Row) {
|
||||||
|
colIndex := s.getColumnIndex(s.sortedBy)
|
||||||
|
|
||||||
|
sort.Slice(rows, func(i, j int) bool {
|
||||||
|
var valI, valJ string
|
||||||
|
if colIndex < len(rows[i]) {
|
||||||
|
valI = rows[i][colIndex]
|
||||||
|
}
|
||||||
|
if colIndex < len(rows[j]) {
|
||||||
|
valJ = rows[j][colIndex]
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.ascending {
|
||||||
|
return strings.ToLower(valI) < strings.ToLower(valJ)
|
||||||
|
}
|
||||||
|
return strings.ToLower(valI) > strings.ToLower(valJ)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// sortByColumn changes the sort column
|
||||||
|
func (s *ListScreen) sortByColumn(col string) {
|
||||||
|
sortedBy := "Name"
|
||||||
|
switch col {
|
||||||
|
case "1":
|
||||||
|
sortedBy = "Name"
|
||||||
|
case "2":
|
||||||
|
sortedBy = "IPv4"
|
||||||
|
case "3":
|
||||||
|
sortedBy = "IPv6"
|
||||||
|
case "4":
|
||||||
|
sortedBy = "Status"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle direction if clicking same column
|
||||||
|
if s.sortedBy == sortedBy {
|
||||||
|
s.ascending = !s.ascending
|
||||||
|
} else {
|
||||||
|
s.sortedBy = sortedBy
|
||||||
|
s.ascending = true
|
||||||
|
}
|
||||||
|
|
||||||
|
s.buildTable()
|
||||||
|
}
|
||||||
|
|
||||||
|
// getColumnIndex returns the index of a column by name
|
||||||
|
func (s *ListScreen) getColumnIndex(name string) int {
|
||||||
|
switch name {
|
||||||
|
case "Name":
|
||||||
|
return 0
|
||||||
|
case "IPv4":
|
||||||
|
return 1
|
||||||
|
case "IPv6":
|
||||||
|
return 2
|
||||||
|
case "Status":
|
||||||
|
return 3
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Messages
|
||||||
|
|
||||||
|
// clientsLoadedMsg is sent when clients are loaded
|
||||||
|
type clientsLoadedMsg struct {
|
||||||
|
clients []ClientWithStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrMsg is sent when an error occurs
|
||||||
|
type ErrMsg struct {
|
||||||
|
Err error
|
||||||
|
}
|
||||||
129
internal/tui/screens/qr.go
Normal file
129
internal/tui/screens/qr.go
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
package screens
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/calmcacil/wg-admin/internal/tui/components"
|
||||||
|
"github.com/calmcacil/wg-admin/internal/tui/theme"
|
||||||
|
"github.com/calmcacil/wg-admin/internal/wireguard"
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
"github.com/mdp/qrterminal/v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// QRScreen displays a QR code for a WireGuard client configuration
|
||||||
|
type QRScreen struct {
|
||||||
|
clientName string
|
||||||
|
configContent string
|
||||||
|
qrCode string
|
||||||
|
inlineMode bool
|
||||||
|
width, height int
|
||||||
|
errorMsg string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewQRScreen creates a new QR screen for displaying client config QR codes
|
||||||
|
func NewQRScreen(clientName string) *QRScreen {
|
||||||
|
return &QRScreen{
|
||||||
|
clientName: clientName,
|
||||||
|
inlineMode: true, // Start in inline mode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Init initializes the QR screen
|
||||||
|
func (s *QRScreen) Init() tea.Cmd {
|
||||||
|
return s.loadConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update handles messages for the QR screen
|
||||||
|
func (s *QRScreen) Update(msg tea.Msg) (Screen, tea.Cmd) {
|
||||||
|
switch msg := msg.(type) {
|
||||||
|
case tea.KeyMsg:
|
||||||
|
switch msg.String() {
|
||||||
|
case "q", "escape":
|
||||||
|
// Return to list screen (parent should handle this)
|
||||||
|
return nil, nil
|
||||||
|
case "f":
|
||||||
|
// Toggle between inline and fullscreen mode
|
||||||
|
s.inlineMode = !s.inlineMode
|
||||||
|
s.generateQRCode()
|
||||||
|
}
|
||||||
|
case tea.WindowSizeMsg:
|
||||||
|
// Handle terminal resize
|
||||||
|
s.width = msg.Width
|
||||||
|
s.height = msg.Height
|
||||||
|
s.generateQRCode()
|
||||||
|
case configLoadedMsg:
|
||||||
|
s.configContent = msg.content
|
||||||
|
s.generateQRCode()
|
||||||
|
case ErrMsg:
|
||||||
|
s.errorMsg = msg.Err.Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// View renders the QR screen
|
||||||
|
func (s *QRScreen) View() string {
|
||||||
|
// Breadcrumb: Clients > Client > QR Code
|
||||||
|
breadcrumb := components.RenderBreadcrumb([]components.BreadcrumbItem{{Label: "Clients", ID: "list"}, {Label: s.clientName, ID: "detail"}, {Label: "QR Code", ID: "qr"}})
|
||||||
|
|
||||||
|
if s.errorMsg != "" {
|
||||||
|
return s.renderError()
|
||||||
|
}
|
||||||
|
if s.qrCode == "" {
|
||||||
|
return "Loading QR code..."
|
||||||
|
}
|
||||||
|
return breadcrumb + "\n" + s.renderQR()
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadConfig loads the client configuration
|
||||||
|
func (s *QRScreen) loadConfig() tea.Msg {
|
||||||
|
content, err := wireguard.GetClientConfigContent(s.clientName)
|
||||||
|
if err != nil {
|
||||||
|
return ErrMsg{Err: err}
|
||||||
|
}
|
||||||
|
return configLoadedMsg{content: content}
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateQRCode generates the QR code based on current mode and terminal size
|
||||||
|
func (s *QRScreen) generateQRCode() {
|
||||||
|
if s.configContent == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate QR code and capture output
|
||||||
|
var builder strings.Builder
|
||||||
|
|
||||||
|
// Generate ANSI QR code using half-block characters
|
||||||
|
qrterminal.GenerateHalfBlock(s.configContent, qrterminal.L, &builder)
|
||||||
|
|
||||||
|
s.qrCode = builder.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderQR renders the QR code with styling
|
||||||
|
func (s *QRScreen) renderQR() string {
|
||||||
|
styleQR := lipgloss.NewStyle().
|
||||||
|
MarginLeft(2)
|
||||||
|
|
||||||
|
title := theme.StyleTitle.MarginBottom(1).Render(fmt.Sprintf("QR Code: %s", s.clientName))
|
||||||
|
help := "Press [f] to toggle fullscreen • Press [q/Escape] to return"
|
||||||
|
|
||||||
|
return title + "\n\n" + styleQR.Render(s.qrCode) + "\n" + theme.StyleMuted.MarginTop(1).Render(help)
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderError renders an error message
|
||||||
|
func (s *QRScreen) renderError() string {
|
||||||
|
title := theme.StyleError.Bold(true).Render("Error")
|
||||||
|
message := s.errorMsg
|
||||||
|
help := "Press [q/Escape] to return"
|
||||||
|
|
||||||
|
return title + "\n\n" + message + "\n" + theme.StyleMuted.MarginTop(1).Render(help)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Messages
|
||||||
|
|
||||||
|
// configLoadedMsg is sent when the client configuration is loaded
|
||||||
|
type configLoadedMsg struct {
|
||||||
|
content string
|
||||||
|
}
|
||||||
329
internal/tui/screens/restore.go
Normal file
329
internal/tui/screens/restore.go
Normal file
@@ -0,0 +1,329 @@
|
|||||||
|
package screens
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/calmcacil/wg-admin/internal/backup"
|
||||||
|
"github.com/calmcacil/wg-admin/internal/tui/components"
|
||||||
|
"github.com/calmcacil/wg-admin/internal/tui/theme"
|
||||||
|
"github.com/charmbracelet/bubbles/spinner"
|
||||||
|
"github.com/charmbracelet/bubbles/table"
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RestoreScreen displays a list of available backups for restoration
|
||||||
|
type RestoreScreen struct {
|
||||||
|
table table.Model
|
||||||
|
backups []backup.Backup
|
||||||
|
selectedBackup *backup.Backup
|
||||||
|
confirmModal *components.ConfirmModel
|
||||||
|
showConfirm bool
|
||||||
|
isRestoring bool
|
||||||
|
restoreError error
|
||||||
|
restoreSuccess bool
|
||||||
|
message string
|
||||||
|
spinner spinner.Model
|
||||||
|
}
|
||||||
|
|
||||||
|
// No local styles - all use theme package
|
||||||
|
|
||||||
|
// NewRestoreScreen creates a new restore screen
|
||||||
|
func NewRestoreScreen() *RestoreScreen {
|
||||||
|
// Create spinner for loading states
|
||||||
|
s := spinner.New()
|
||||||
|
s.Spinner = spinner.Dot
|
||||||
|
s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("62"))
|
||||||
|
|
||||||
|
return &RestoreScreen{
|
||||||
|
showConfirm: false,
|
||||||
|
spinner: s,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Init initializes the restore screen
|
||||||
|
func (s *RestoreScreen) Init() tea.Cmd {
|
||||||
|
return s.loadBackups
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update handles messages for the restore screen
|
||||||
|
func (s *RestoreScreen) Update(msg tea.Msg) (Screen, tea.Cmd) {
|
||||||
|
var cmd tea.Cmd
|
||||||
|
|
||||||
|
// If restoring, only update spinner
|
||||||
|
if s.isRestoring && !s.showConfirm {
|
||||||
|
s.spinner, cmd = s.spinner.Update(msg)
|
||||||
|
return s, cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle confirmation modal
|
||||||
|
if s.showConfirm && s.confirmModal != nil {
|
||||||
|
_, cmd = s.confirmModal.Update(msg)
|
||||||
|
|
||||||
|
// Handle confirmation result
|
||||||
|
if !s.confirmModal.Visible {
|
||||||
|
if s.confirmModal.IsConfirmed() && s.selectedBackup != nil {
|
||||||
|
// User confirmed restore
|
||||||
|
s.isRestoring = true
|
||||||
|
s.showConfirm = false
|
||||||
|
return s, tea.Sequence(s.spinner.Tick, s.performRestore())
|
||||||
|
}
|
||||||
|
// User cancelled - close modal
|
||||||
|
s.showConfirm = false
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle Enter key to confirm
|
||||||
|
switch msg := msg.(type) {
|
||||||
|
case tea.KeyMsg:
|
||||||
|
if msg.String() == "enter" && s.confirmModal.IsConfirmed() && s.selectedBackup != nil {
|
||||||
|
s.isRestoring = true
|
||||||
|
s.showConfirm = false
|
||||||
|
return s, tea.Sequence(s.spinner.Tick, s.performRestore())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return s, cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle normal screen messages
|
||||||
|
switch msg := msg.(type) {
|
||||||
|
case backupsLoadedMsg:
|
||||||
|
s.backups = msg.backups
|
||||||
|
s.buildTable()
|
||||||
|
case tea.KeyMsg:
|
||||||
|
switch msg.String() {
|
||||||
|
case "q", "esc":
|
||||||
|
// Return to list screen - signal parent to switch screens
|
||||||
|
return nil, nil
|
||||||
|
case "enter":
|
||||||
|
// Show confirmation for selected backup
|
||||||
|
if len(s.table.Rows()) > 0 {
|
||||||
|
selected := s.table.SelectedRow()
|
||||||
|
if len(selected) > 0 {
|
||||||
|
// Find the backup by name
|
||||||
|
for _, b := range s.backups {
|
||||||
|
if b.Name == selected[0] {
|
||||||
|
s.selectedBackup = &b
|
||||||
|
s.confirmModal = components.NewConfirm(
|
||||||
|
fmt.Sprintf(
|
||||||
|
"Are you sure you want to restore from backup '%s'?\n\nOperation: %s\nDate: %s\n\nThis will replace current WireGuard configuration.\nA safety backup will be created first.",
|
||||||
|
b.Name,
|
||||||
|
b.Operation,
|
||||||
|
b.Timestamp.Format("2006-01-02 15:04:05"),
|
||||||
|
),
|
||||||
|
80,
|
||||||
|
24,
|
||||||
|
)
|
||||||
|
s.showConfirm = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case RestoreCompletedMsg:
|
||||||
|
s.isRestoring = false
|
||||||
|
if msg.Err != nil {
|
||||||
|
s.restoreError = msg.Err
|
||||||
|
s.message = fmt.Sprintf("Restore failed: %v", msg.Err)
|
||||||
|
} else {
|
||||||
|
s.restoreSuccess = true
|
||||||
|
s.message = fmt.Sprintf("Restore successful! Safety backup created at: %s", msg.SafetyBackupPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !s.showConfirm && s.confirmModal != nil {
|
||||||
|
s.table, cmd = s.table.Update(msg)
|
||||||
|
}
|
||||||
|
return s, cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
// View renders the restore screen
|
||||||
|
func (s *RestoreScreen) View() string {
|
||||||
|
if s.showConfirm && s.confirmModal != nil {
|
||||||
|
// Render underlying content dimmed
|
||||||
|
content := s.renderContent()
|
||||||
|
dimmedContent := lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color("244")).
|
||||||
|
Render(content)
|
||||||
|
|
||||||
|
// Overlay confirmation modal
|
||||||
|
return lipgloss.JoinVertical(
|
||||||
|
lipgloss.Left,
|
||||||
|
dimmedContent,
|
||||||
|
s.confirmModal.View(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.renderContent()
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderContent renders the main restore screen content
|
||||||
|
func (s *RestoreScreen) renderContent() string {
|
||||||
|
// Breadcrumb: Clients > Restore
|
||||||
|
breadcrumb := components.RenderBreadcrumb([]components.BreadcrumbItem{{Label: "Clients", ID: "list"}, {Label: "Restore", ID: "restore"}})
|
||||||
|
|
||||||
|
var content strings.Builder
|
||||||
|
|
||||||
|
content.WriteString(breadcrumb)
|
||||||
|
content.WriteString("\n")
|
||||||
|
content.WriteString(theme.StyleTitle.Render("Restore WireGuard Configuration"))
|
||||||
|
content.WriteString("\n\n")
|
||||||
|
|
||||||
|
if len(s.backups) == 0 && !s.isRestoring && s.message == "" {
|
||||||
|
content.WriteString("No backups found. Press 'q' to return.")
|
||||||
|
return content.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.isRestoring {
|
||||||
|
content.WriteString(theme.StyleTitle.Render(s.spinner.View() + " Restoring from backup, please wait..."))
|
||||||
|
return content.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.restoreSuccess {
|
||||||
|
content.WriteString(theme.StyleSuccess.Render("✓ " + s.message))
|
||||||
|
content.WriteString("\n\n")
|
||||||
|
content.WriteString(theme.StyleMuted.Render("Press 'q' to return to client list."))
|
||||||
|
return content.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.restoreError != nil {
|
||||||
|
content.WriteString(theme.StyleError.Render("✗ " + s.message))
|
||||||
|
content.WriteString("\n\n")
|
||||||
|
content.WriteString(s.table.View())
|
||||||
|
content.WriteString("\n\n")
|
||||||
|
content.WriteString(theme.StyleHelpKey.Render("Actions: [Enter] Restore Selected • [↑/↓] Navigate • [q] Back"))
|
||||||
|
return content.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show backup list
|
||||||
|
content.WriteString(s.table.View())
|
||||||
|
content.WriteString("\n\n")
|
||||||
|
|
||||||
|
// Show selected backup details
|
||||||
|
if len(s.table.Rows()) > 0 && s.selectedBackup != nil {
|
||||||
|
content.WriteString(theme.StyleMuted.Render(
|
||||||
|
fmt.Sprintf(
|
||||||
|
"Selected: %s (%s) - %s\nSize: %s",
|
||||||
|
s.selectedBackup.Operation,
|
||||||
|
s.selectedBackup.Timestamp.Format("2006-01-02 15:04:05"),
|
||||||
|
s.selectedBackup.Name,
|
||||||
|
formatBytes(s.selectedBackup.Size),
|
||||||
|
),
|
||||||
|
))
|
||||||
|
content.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
content.WriteString(theme.StyleHelpKey.Render("Actions: [Enter] Restore Selected • [↑/↓] Navigate • [q] Back"))
|
||||||
|
|
||||||
|
return content.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadBackups loads the list of available backups
|
||||||
|
func (s *RestoreScreen) loadBackups() tea.Msg {
|
||||||
|
backups, err := backup.ListBackups()
|
||||||
|
if err != nil {
|
||||||
|
return ErrMsg{Err: err}
|
||||||
|
}
|
||||||
|
return backupsLoadedMsg{backups: backups}
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildTable creates and configures the backup list table
|
||||||
|
func (s *RestoreScreen) buildTable() {
|
||||||
|
columns := []table.Column{
|
||||||
|
{Title: "Name", Width: 40},
|
||||||
|
{Title: "Operation", Width: 15},
|
||||||
|
{Title: "Date", Width: 20},
|
||||||
|
{Title: "Size", Width: 12},
|
||||||
|
}
|
||||||
|
|
||||||
|
var rows []table.Row
|
||||||
|
for _, b := range s.backups {
|
||||||
|
row := table.Row{
|
||||||
|
b.Name,
|
||||||
|
b.Operation,
|
||||||
|
b.Timestamp.Format("2006-01-02 15:04"),
|
||||||
|
formatBytes(b.Size),
|
||||||
|
}
|
||||||
|
rows = append(rows, row)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.table = table.New(
|
||||||
|
table.WithColumns(columns),
|
||||||
|
table.WithRows(rows),
|
||||||
|
table.WithFocused(true),
|
||||||
|
table.WithHeight(len(rows)+2), // Header + rows
|
||||||
|
)
|
||||||
|
|
||||||
|
// Apply styles
|
||||||
|
s.setTableStyles()
|
||||||
|
}
|
||||||
|
|
||||||
|
// setTableStyles applies styling to the table
|
||||||
|
func (s *RestoreScreen) setTableStyles() {
|
||||||
|
styles := table.DefaultStyles()
|
||||||
|
styles.Header = theme.StyleTableHeader
|
||||||
|
styles.Selected = theme.StyleTableSelected
|
||||||
|
s.table.SetStyles(styles)
|
||||||
|
}
|
||||||
|
|
||||||
|
// performRestore performs the restore operation
|
||||||
|
func (s *RestoreScreen) performRestore() tea.Cmd {
|
||||||
|
return func() tea.Msg {
|
||||||
|
if s.selectedBackup == nil {
|
||||||
|
return restoreCompletedMsg{
|
||||||
|
err: fmt.Errorf("no backup selected"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get safety backup path from backup.BackupConfig
|
||||||
|
safetyBackupPath, err := backup.BackupConfig(fmt.Sprintf("pre-restore-from-%s", s.selectedBackup.Name))
|
||||||
|
if err != nil {
|
||||||
|
return restoreCompletedMsg{
|
||||||
|
err: fmt.Errorf("failed to create safety backup: %w", err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Perform restore
|
||||||
|
if err := backup.RestoreBackup(s.selectedBackup.Name); err != nil {
|
||||||
|
return restoreCompletedMsg{
|
||||||
|
err: err,
|
||||||
|
safetyBackupPath: safetyBackupPath,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore succeeded - trigger client list refresh
|
||||||
|
return restoreCompletedMsg{
|
||||||
|
safetyBackupPath: safetyBackupPath,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatBytes formats a byte count into human-readable format
|
||||||
|
func formatBytes(bytes int64) string {
|
||||||
|
const unit = 1024
|
||||||
|
if bytes < unit {
|
||||||
|
return fmt.Sprintf("%d B", bytes)
|
||||||
|
}
|
||||||
|
div, exp := int64(unit), 0
|
||||||
|
for n := bytes / unit; n >= unit; n /= unit {
|
||||||
|
div *= unit
|
||||||
|
exp++
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%.1f %ciB", float64(bytes)/float64(div), "KMGTPE"[exp])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Messages
|
||||||
|
|
||||||
|
// backupsLoadedMsg is sent when backups are loaded
|
||||||
|
type backupsLoadedMsg struct {
|
||||||
|
backups []backup.Backup
|
||||||
|
}
|
||||||
|
|
||||||
|
// restoreCompletedMsg is sent when a restore operation completes
|
||||||
|
type restoreCompletedMsg struct {
|
||||||
|
err error
|
||||||
|
safetyBackupPath string
|
||||||
|
}
|
||||||
294
internal/tui/theme/theme.go
Normal file
294
internal/tui/theme/theme.go
Normal file
@@ -0,0 +1,294 @@
|
|||||||
|
package theme
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ColorScheme defines the color palette for the theme
|
||||||
|
type ColorScheme struct {
|
||||||
|
Primary lipgloss.Color
|
||||||
|
Success lipgloss.Color
|
||||||
|
Warning lipgloss.Color
|
||||||
|
Error lipgloss.Color
|
||||||
|
Muted lipgloss.Color
|
||||||
|
Background lipgloss.Color
|
||||||
|
}
|
||||||
|
|
||||||
|
// Theme represents a color theme with its name and color scheme
|
||||||
|
type Theme struct {
|
||||||
|
Name string
|
||||||
|
Scheme ColorScheme
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global variables for current theme and styles
|
||||||
|
var (
|
||||||
|
currentTheme *Theme
|
||||||
|
once sync.Once
|
||||||
|
|
||||||
|
// Global styles that can be used throughout the application
|
||||||
|
StylePrimary lipgloss.Style
|
||||||
|
StyleSuccess lipgloss.Style
|
||||||
|
StyleWarning lipgloss.Style
|
||||||
|
StyleError lipgloss.Style
|
||||||
|
StyleMuted lipgloss.Style
|
||||||
|
StyleTitle lipgloss.Style
|
||||||
|
StyleSubtitle lipgloss.Style
|
||||||
|
StyleHelpKey lipgloss.Style
|
||||||
|
StyleValue lipgloss.Style
|
||||||
|
StyleDimmed lipgloss.Style
|
||||||
|
StyleTableHeader lipgloss.Style
|
||||||
|
StyleTableSelected lipgloss.Style
|
||||||
|
StyleBorder lipgloss.Color
|
||||||
|
StyleBackground lipgloss.Color
|
||||||
|
)
|
||||||
|
|
||||||
|
// DefaultTheme is the standard blue-based theme
|
||||||
|
var DefaultTheme = &Theme{
|
||||||
|
Name: "default",
|
||||||
|
Scheme: ColorScheme{
|
||||||
|
Primary: lipgloss.Color("62"), // Blue
|
||||||
|
Success: lipgloss.Color("46"), // Green
|
||||||
|
Warning: lipgloss.Color("208"), // Orange
|
||||||
|
Error: lipgloss.Color("196"), // Red
|
||||||
|
Muted: lipgloss.Color("241"), // Gray
|
||||||
|
Background: lipgloss.Color(""), // Default terminal background
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// DarkTheme is a purple-based dark theme
|
||||||
|
var DarkTheme = &Theme{
|
||||||
|
Name: "dark",
|
||||||
|
Scheme: ColorScheme{
|
||||||
|
Primary: lipgloss.Color("141"), // Purple
|
||||||
|
Success: lipgloss.Color("51"), // Cyan
|
||||||
|
Warning: lipgloss.Color("226"), // Yellow
|
||||||
|
Error: lipgloss.Color("196"), // Red
|
||||||
|
Muted: lipgloss.Color("245"), // Light gray
|
||||||
|
Background: lipgloss.Color(""), // Default terminal background
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// LightTheme is a green-based light theme
|
||||||
|
var LightTheme = &Theme{
|
||||||
|
Name: "light",
|
||||||
|
Scheme: ColorScheme{
|
||||||
|
Primary: lipgloss.Color("34"), // Green
|
||||||
|
Success: lipgloss.Color("36"), // Teal
|
||||||
|
Warning: lipgloss.Color("214"), // Amber
|
||||||
|
Error: lipgloss.Color("196"), // Red
|
||||||
|
Muted: lipgloss.Color("244"), // Gray
|
||||||
|
Background: lipgloss.Color(""), // Default terminal background
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// DraculaTheme is the popular Dracula dark color scheme
|
||||||
|
var DraculaTheme = &Theme{
|
||||||
|
Name: "dracula",
|
||||||
|
Scheme: ColorScheme{
|
||||||
|
Primary: lipgloss.Color("#BD93F9"), // Purple
|
||||||
|
Success: lipgloss.Color("#50FA7B"), // Green
|
||||||
|
Warning: lipgloss.Color("#FFB86C"), // Orange
|
||||||
|
Error: lipgloss.Color("#FF5555"), // Red
|
||||||
|
Muted: lipgloss.Color("#6272A4"), // Light purple-gray
|
||||||
|
Background: lipgloss.Color("#282A36"), // Dark background
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// EverforestTheme is the natural Everforest dark color scheme
|
||||||
|
var EverforestTheme = &Theme{
|
||||||
|
Name: "everforest",
|
||||||
|
Scheme: ColorScheme{
|
||||||
|
Primary: lipgloss.Color("#7fbbb3"), // Blue
|
||||||
|
Success: lipgloss.Color("#a7c080"), // Green
|
||||||
|
Warning: lipgloss.Color("#dbbc7f"), // Yellow
|
||||||
|
Error: lipgloss.Color("#e67e80"), // Red
|
||||||
|
Muted: lipgloss.Color("#414b50"), // Gray
|
||||||
|
Background: lipgloss.Color("#272e33"), // Dark background
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// ThemeRegistry holds all available themes
|
||||||
|
var ThemeRegistry = map[string]*Theme{
|
||||||
|
"default": DefaultTheme,
|
||||||
|
"dark": DarkTheme,
|
||||||
|
"light": LightTheme,
|
||||||
|
"dracula": DraculaTheme,
|
||||||
|
"everforest": EverforestTheme,
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTheme loads the theme from config or environment variable
|
||||||
|
// Returns the default theme if no theme is specified
|
||||||
|
func GetTheme() (*Theme, error) {
|
||||||
|
once.Do(func() {
|
||||||
|
// Try to get theme from environment variable first
|
||||||
|
themeName := os.Getenv("THEME")
|
||||||
|
if themeName == "" {
|
||||||
|
themeName = "everforest"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look up the theme in the registry
|
||||||
|
if theme, ok := ThemeRegistry[themeName]; ok {
|
||||||
|
currentTheme = theme
|
||||||
|
} else {
|
||||||
|
// If theme not found, use everforest as default
|
||||||
|
currentTheme = EverforestTheme
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply the theme to initialize styles
|
||||||
|
ApplyTheme(currentTheme)
|
||||||
|
})
|
||||||
|
|
||||||
|
return currentTheme, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ApplyTheme applies the given theme to all global styles
|
||||||
|
func ApplyTheme(theme *Theme) {
|
||||||
|
currentTheme = theme
|
||||||
|
|
||||||
|
// Primary style
|
||||||
|
StylePrimary = lipgloss.NewStyle().
|
||||||
|
Foreground(theme.Scheme.Primary)
|
||||||
|
|
||||||
|
// Success style
|
||||||
|
StyleSuccess = lipgloss.NewStyle().
|
||||||
|
Foreground(theme.Scheme.Success)
|
||||||
|
|
||||||
|
// Warning style
|
||||||
|
StyleWarning = lipgloss.NewStyle().
|
||||||
|
Foreground(theme.Scheme.Warning)
|
||||||
|
|
||||||
|
// Error style
|
||||||
|
StyleError = lipgloss.NewStyle().
|
||||||
|
Foreground(theme.Scheme.Error)
|
||||||
|
|
||||||
|
// Muted style
|
||||||
|
StyleMuted = lipgloss.NewStyle().
|
||||||
|
Foreground(theme.Scheme.Muted)
|
||||||
|
|
||||||
|
// Title style (bold primary)
|
||||||
|
StyleTitle = lipgloss.NewStyle().
|
||||||
|
Foreground(theme.Scheme.Primary).
|
||||||
|
Bold(true)
|
||||||
|
|
||||||
|
// Subtitle style (muted)
|
||||||
|
StyleSubtitle = lipgloss.NewStyle().
|
||||||
|
Foreground(theme.Scheme.Muted)
|
||||||
|
|
||||||
|
// Help key style (bold primary, slightly different shade)
|
||||||
|
StyleHelpKey = lipgloss.NewStyle().
|
||||||
|
Foreground(theme.Scheme.Primary).
|
||||||
|
Bold(true)
|
||||||
|
|
||||||
|
// Value style for content values
|
||||||
|
StyleValue = lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color("255"))
|
||||||
|
|
||||||
|
// Dimmed style for overlay content
|
||||||
|
StyleDimmed = lipgloss.NewStyle().
|
||||||
|
Foreground(theme.Scheme.Muted)
|
||||||
|
|
||||||
|
// Table header style
|
||||||
|
StyleTableHeader = lipgloss.NewStyle().
|
||||||
|
BorderStyle(lipgloss.NormalBorder()).
|
||||||
|
BorderForeground(lipgloss.Color("240")).
|
||||||
|
BorderBottom(true).
|
||||||
|
Bold(true)
|
||||||
|
|
||||||
|
// Table selected style
|
||||||
|
StyleTableSelected = lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color("229")).
|
||||||
|
Background(lipgloss.Color("57")).
|
||||||
|
Bold(false)
|
||||||
|
|
||||||
|
// Border color
|
||||||
|
StyleBorder = lipgloss.Color("240")
|
||||||
|
|
||||||
|
// Background color for modals
|
||||||
|
StyleBackground = lipgloss.Color("235")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Modal styles
|
||||||
|
var (
|
||||||
|
// ModalBaseStyle is the base style for all modals
|
||||||
|
ModalBaseStyle = func() lipgloss.Style {
|
||||||
|
return lipgloss.NewStyle().
|
||||||
|
Border(lipgloss.RoundedBorder()).
|
||||||
|
BorderForeground(lipgloss.Color("196")).
|
||||||
|
Padding(1, 2).
|
||||||
|
Background(StyleBackground)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ModalTitleStyle is the style for modal titles
|
||||||
|
ModalTitleStyle = lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color("226")).
|
||||||
|
Bold(true)
|
||||||
|
|
||||||
|
// ModalMessageStyle is the style for modal messages
|
||||||
|
ModalMessageStyle = lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color("255")).
|
||||||
|
Width(50)
|
||||||
|
|
||||||
|
// ModalHelpStyle is the style for modal help text
|
||||||
|
ModalHelpStyle = lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color("241")).
|
||||||
|
MarginTop(1)
|
||||||
|
|
||||||
|
// ModalSelectedStyle is the style for selected modal options
|
||||||
|
ModalSelectedStyle = lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color("57")).
|
||||||
|
Bold(true).
|
||||||
|
Underline(true)
|
||||||
|
|
||||||
|
// ModalUnselectedStyle is the style for unselected modal options
|
||||||
|
ModalUnselectedStyle = lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color("241"))
|
||||||
|
)
|
||||||
|
|
||||||
|
// Status icon styles
|
||||||
|
var (
|
||||||
|
// StatusConnectedStyle is the style for connected status icons
|
||||||
|
StatusConnectedStyle = lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color("46"))
|
||||||
|
|
||||||
|
// StatusDisconnectedStyle is the style for disconnected status icons
|
||||||
|
StatusDisconnectedStyle = lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color("196"))
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetThemeNames returns a list of available theme names
|
||||||
|
func GetThemeNames() []string {
|
||||||
|
names := make([]string, 0, len(ThemeRegistry))
|
||||||
|
for name := range ThemeRegistry {
|
||||||
|
names = append(names, name)
|
||||||
|
}
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetTheme changes the current theme by name
|
||||||
|
func SetTheme(name string) error {
|
||||||
|
theme, ok := ThemeRegistry[name]
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("theme '%s' not found. Available themes: %v", name, GetThemeNames())
|
||||||
|
}
|
||||||
|
|
||||||
|
ApplyTheme(theme)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCurrentTheme returns the currently active theme
|
||||||
|
func GetCurrentTheme() *Theme {
|
||||||
|
if currentTheme == nil {
|
||||||
|
currentTheme = DefaultTheme
|
||||||
|
ApplyTheme(currentTheme)
|
||||||
|
}
|
||||||
|
return currentTheme
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetColorScheme returns the color scheme of the current theme
|
||||||
|
func GetColorScheme() ColorScheme {
|
||||||
|
return GetCurrentTheme().Scheme
|
||||||
|
}
|
||||||
86
internal/validation/client.go
Normal file
86
internal/validation/client.go
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
package validation
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ValidateClientName validates the client name format
|
||||||
|
// Requirements: alphanumeric, hyphens, underscores only, max 64 characters
|
||||||
|
func ValidateClientName(name string) error {
|
||||||
|
if name == "" {
|
||||||
|
return fmt.Errorf("client name cannot be empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(name) > 64 {
|
||||||
|
return fmt.Errorf("client name must be 64 characters or less (got %d)", len(name))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for valid characters: a-z, A-Z, 0-9, hyphen, underscore
|
||||||
|
matched, err := regexp.MatchString(`^[a-zA-Z0-9_-]+$`, name)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to validate client name: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !matched {
|
||||||
|
return fmt.Errorf("client name contains invalid characters (allowed: a-z, A-Z, 0-9, -, _)")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateIPAvailability checks if an IP address is already assigned to a client
|
||||||
|
// This function is meant to be called after getting next available IPs
|
||||||
|
func ValidateIPAvailability(ipv4, ipv6 string, existingIPs map[string]bool) error {
|
||||||
|
if ipv4 != "" && existingIPs[ipv4] {
|
||||||
|
return fmt.Errorf("IPv4 address %s is already assigned", ipv4)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ipv6 != "" && existingIPs[ipv6] {
|
||||||
|
return fmt.Errorf("IPv6 address %s is already assigned", ipv6)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateDNSServers validates DNS server addresses
|
||||||
|
// Requirements: comma-separated list of IPv4 addresses
|
||||||
|
func ValidateDNSServers(dns string) error {
|
||||||
|
if dns == "" {
|
||||||
|
return fmt.Errorf("DNS servers cannot be empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split by comma and trim whitespace
|
||||||
|
servers := strings.Split(dns, ",")
|
||||||
|
for _, server := range servers {
|
||||||
|
server = strings.TrimSpace(server)
|
||||||
|
if server == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Basic IPv4 validation (4 octets, each 0-255)
|
||||||
|
matched, err := regexp.MatchString(`^(\d{1,3}\.){3}\d{1,3}$`, server)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to validate DNS server: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !matched {
|
||||||
|
return fmt.Errorf("invalid DNS server format: %s (expected IPv4 address)", server)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate each octet is in range 0-255
|
||||||
|
parts := strings.Split(server, ".")
|
||||||
|
for _, part := range parts {
|
||||||
|
var num int
|
||||||
|
if _, err := fmt.Sscanf(part, "%d", &num); err != nil {
|
||||||
|
return fmt.Errorf("invalid DNS server: %s", server)
|
||||||
|
}
|
||||||
|
if num < 0 || num > 255 {
|
||||||
|
return fmt.Errorf("DNS server octet out of range (0-255) in: %s", server)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
585
internal/wireguard/client.go
Normal file
585
internal/wireguard/client.go
Normal file
@@ -0,0 +1,585 @@
|
|||||||
|
package wireguard
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/calmcacil/wg-admin/internal/backup"
|
||||||
|
"github.com/calmcacil/wg-admin/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Client represents a WireGuard peer configuration
|
||||||
|
type Client struct {
|
||||||
|
Name string // Client name extracted from filename
|
||||||
|
IPv4 string // IPv4 address from AllowedIPs
|
||||||
|
IPv6 string // IPv6 address from AllowedIPs
|
||||||
|
PublicKey string // WireGuard public key
|
||||||
|
HasPSK bool // Whether PresharedKey is configured
|
||||||
|
ConfigPath string // Path to the client config file
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseClientConfig parses a single WireGuard client configuration file
|
||||||
|
func ParseClientConfig(path string) (*Client, error) {
|
||||||
|
// Read the file
|
||||||
|
content, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read config file %s: %w", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract client name from filename
|
||||||
|
base := filepath.Base(path)
|
||||||
|
name := strings.TrimPrefix(base, "client-")
|
||||||
|
name = strings.TrimSuffix(name, ".conf")
|
||||||
|
|
||||||
|
if name == "" || name == "client-" {
|
||||||
|
return nil, fmt.Errorf("invalid client filename: %s", base)
|
||||||
|
}
|
||||||
|
|
||||||
|
client := &Client{
|
||||||
|
Name: name,
|
||||||
|
ConfigPath: path,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the INI-style config
|
||||||
|
inPeerSection := false
|
||||||
|
hasPublicKey := false
|
||||||
|
|
||||||
|
for i, line := range strings.Split(string(content), "\n") {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
|
||||||
|
// Skip empty lines and comments
|
||||||
|
if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, ";") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for Peer section
|
||||||
|
if line == "[Peer]" {
|
||||||
|
inPeerSection = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse key-value pairs within Peer section
|
||||||
|
if inPeerSection {
|
||||||
|
parts := strings.SplitN(line, "=", 2)
|
||||||
|
if len(parts) != 2 {
|
||||||
|
log.Printf("Warning: malformed line %d in %s: %s", i+1, path, line)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
key := strings.TrimSpace(parts[0])
|
||||||
|
value := strings.TrimSpace(parts[1])
|
||||||
|
|
||||||
|
switch key {
|
||||||
|
case "PublicKey":
|
||||||
|
client.PublicKey = value
|
||||||
|
hasPublicKey = true
|
||||||
|
case "PresharedKey":
|
||||||
|
client.HasPSK = true
|
||||||
|
case "AllowedIPs":
|
||||||
|
if err := parseAllowedIPs(client, value); err != nil {
|
||||||
|
log.Printf("Warning: %v (file: %s, line: %d)", err, path, i+1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
if !hasPublicKey {
|
||||||
|
return nil, fmt.Errorf("missing required PublicKey in %s", path)
|
||||||
|
}
|
||||||
|
|
||||||
|
if client.IPv4 == "" && client.IPv6 == "" {
|
||||||
|
return nil, fmt.Errorf("no valid IP addresses found in AllowedIPs in %s", path)
|
||||||
|
}
|
||||||
|
|
||||||
|
return client, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseAllowedIPs extracts IPv4 and IPv6 addresses from AllowedIPs value
|
||||||
|
func parseAllowedIPs(client *Client, allowedIPs string) error {
|
||||||
|
// AllowedIPs format: "ipv4/32, ipv6/128"
|
||||||
|
addresses := strings.Split(allowedIPs, ",")
|
||||||
|
|
||||||
|
for _, addr := range addresses {
|
||||||
|
addr = strings.TrimSpace(addr)
|
||||||
|
if addr == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split IP from CIDR suffix
|
||||||
|
parts := strings.Split(addr, "/")
|
||||||
|
if len(parts) != 2 {
|
||||||
|
return fmt.Errorf("invalid AllowedIP format: %s", addr)
|
||||||
|
}
|
||||||
|
|
||||||
|
ip := strings.TrimSpace(parts[0])
|
||||||
|
|
||||||
|
// Detect if IPv4 or IPv6 based on presence of colon
|
||||||
|
if strings.Contains(ip, ":") {
|
||||||
|
client.IPv6 = ip
|
||||||
|
} else {
|
||||||
|
client.IPv4 = ip
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListClients finds and parses all client configurations from /etc/wireguard/conf.d/
|
||||||
|
func ListClients() ([]Client, error) {
|
||||||
|
configDir := "/etc/wireguard/conf.d"
|
||||||
|
|
||||||
|
// Check if directory exists
|
||||||
|
if _, err := os.Stat(configDir); os.IsNotExist(err) {
|
||||||
|
return nil, fmt.Errorf("wireguard config directory does not exist: %s", configDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find all client-*.conf files
|
||||||
|
pattern := filepath.Join(configDir, "client-*.conf")
|
||||||
|
matches, err := filepath.Glob(pattern)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to find client config files: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(matches) == 0 {
|
||||||
|
return []Client{}, nil // No clients found, return empty slice
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse each config file
|
||||||
|
var clients []Client
|
||||||
|
var parseErrors []string
|
||||||
|
|
||||||
|
for _, match := range matches {
|
||||||
|
client, err := ParseClientConfig(match)
|
||||||
|
if err != nil {
|
||||||
|
parseErrors = append(parseErrors, err.Error())
|
||||||
|
log.Printf("Warning: failed to parse %s: %v", match, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
clients = append(clients, *client)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If all files failed to parse, return an error
|
||||||
|
if len(clients) == 0 && len(parseErrors) > 0 {
|
||||||
|
return nil, fmt.Errorf("failed to parse any client configs: %s", strings.Join(parseErrors, "; "))
|
||||||
|
}
|
||||||
|
|
||||||
|
return clients, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetClientConfigContent reads the raw configuration content for a client
|
||||||
|
func GetClientConfigContent(name string) (string, error) {
|
||||||
|
configDir := "/etc/wireguard/clients"
|
||||||
|
configPath := filepath.Join(configDir, fmt.Sprintf("%s.conf", name))
|
||||||
|
|
||||||
|
content, err := os.ReadFile(configPath)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return "", fmt.Errorf("client config not found: %s", configPath)
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("failed to read client config %s: %w", configPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(content), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteClient removes a WireGuard client configuration and associated files
|
||||||
|
func DeleteClient(name string) error {
|
||||||
|
// First, find the client config to get public key for removal from interface
|
||||||
|
configDir := "/etc/wireguard/conf.d"
|
||||||
|
configPath := filepath.Join(configDir, fmt.Sprintf("client-%s.conf", name))
|
||||||
|
|
||||||
|
client, err := ParseClientConfig(configPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to parse client config for deletion: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Deleting client: %s (public key: %s)", name, client.PublicKey)
|
||||||
|
|
||||||
|
// Create backup before deletion
|
||||||
|
backupPath, err := backup.BackupConfig(fmt.Sprintf("delete-%s", name))
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Warning: failed to create backup before deletion: %v", err)
|
||||||
|
} else {
|
||||||
|
log.Printf("Created backup: %s", backupPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove peer from WireGuard interface using wg command
|
||||||
|
if err := removePeerFromInterface(client.PublicKey); err != nil {
|
||||||
|
log.Printf("Warning: failed to remove peer from interface: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove client config from /etc/wireguard/conf.d/
|
||||||
|
if err := os.Remove(configPath); err != nil && !os.IsNotExist(err) {
|
||||||
|
return fmt.Errorf("failed to remove client config %s: %w", configPath, err)
|
||||||
|
}
|
||||||
|
log.Printf("Removed client config: %s", configPath)
|
||||||
|
|
||||||
|
// Remove client files from /etc/wireguard/clients/
|
||||||
|
clientsDir := "/etc/wireguard/clients"
|
||||||
|
clientFile := filepath.Join(clientsDir, fmt.Sprintf("%s.conf", name))
|
||||||
|
if err := os.Remove(clientFile); err != nil && !os.IsNotExist(err) {
|
||||||
|
log.Printf("Warning: failed to remove client file %s: %v", clientFile, err)
|
||||||
|
} else {
|
||||||
|
log.Printf("Removed client file: %s", clientFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove QR code PNG if it exists
|
||||||
|
qrFile := filepath.Join(clientsDir, fmt.Sprintf("%s.png", name))
|
||||||
|
if err := os.Remove(qrFile); err != nil && !os.IsNotExist(err) {
|
||||||
|
log.Printf("Warning: failed to remove QR code %s: %v", qrFile, err)
|
||||||
|
} else {
|
||||||
|
log.Printf("Removed QR code: %s", qrFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Successfully deleted client: %s", name)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// removePeerFromInterface removes a peer from the WireGuard interface
|
||||||
|
func removePeerFromInterface(publicKey string) error {
|
||||||
|
// Use wg command to remove peer
|
||||||
|
cmd := exec.Command("wg", "set", "wg0", "peer", publicKey, "remove")
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("wg set peer remove failed: %w, output: %s", err, string(output))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateClient creates a new WireGuard client configuration
|
||||||
|
func CreateClient(name, dns string, usePSK bool) error {
|
||||||
|
log.Printf("Creating client: %s (PSK: %v)", name, usePSK)
|
||||||
|
|
||||||
|
// Create backup before creating client
|
||||||
|
backupPath, err := backup.BackupConfig(fmt.Sprintf("create-%s", name))
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Warning: failed to create backup before creating client: %v", err)
|
||||||
|
} else {
|
||||||
|
log.Printf("Created backup: %s", backupPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate keys
|
||||||
|
privateKey, publicKey, err := generateKeyPair()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to generate key pair: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var psk string
|
||||||
|
if usePSK {
|
||||||
|
psk, err = generatePSK()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to generate PSK: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get next available IP addresses
|
||||||
|
cfg, err := config.LoadConfig()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to load config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
clients, err := ListClients()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to list existing clients: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ipv4, err := getNextAvailableIP(cfg.VPNIPv4Range, clients)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get IPv4 address: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ipv6, err := getNextAvailableIP(cfg.VPNIPv6Range, clients)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get IPv6 address: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create server config
|
||||||
|
serverConfigPath := fmt.Sprintf("/etc/wireguard/conf.d/client-%s.conf", name)
|
||||||
|
serverConfig, err := generateServerConfig(name, publicKey, ipv4, ipv6, psk, cfg)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to generate server config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.WriteFile(serverConfigPath, []byte(serverConfig), 0600); err != nil {
|
||||||
|
return fmt.Errorf("failed to write server config: %w", err)
|
||||||
|
}
|
||||||
|
log.Printf("Created server config: %s", serverConfigPath)
|
||||||
|
|
||||||
|
// Create client config
|
||||||
|
clientsDir := "/etc/wireguard/clients"
|
||||||
|
if err := os.MkdirAll(clientsDir, 0700); err != nil {
|
||||||
|
return fmt.Errorf("failed to create clients directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
clientConfigPath := filepath.Join(clientsDir, fmt.Sprintf("%s.conf", name))
|
||||||
|
clientConfig, err := generateClientConfig(name, privateKey, ipv4, ipv6, dns, cfg)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to generate client config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.WriteFile(clientConfigPath, []byte(clientConfig), 0600); err != nil {
|
||||||
|
return fmt.Errorf("failed to write client config: %w", err)
|
||||||
|
}
|
||||||
|
log.Printf("Created client config: %s", clientConfigPath)
|
||||||
|
|
||||||
|
// Add peer to WireGuard interface
|
||||||
|
if err := addPeerToInterface(publicKey, ipv4, ipv6, psk); err != nil {
|
||||||
|
log.Printf("Warning: failed to add peer to interface: %v", err)
|
||||||
|
} else {
|
||||||
|
log.Printf("Added peer to WireGuard interface")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate QR code
|
||||||
|
qrPath := filepath.Join(clientsDir, fmt.Sprintf("%s.png", name))
|
||||||
|
if err := generateQRCode(clientConfigPath, qrPath); err != nil {
|
||||||
|
log.Printf("Warning: failed to generate QR code: %v", err)
|
||||||
|
} else {
|
||||||
|
log.Printf("Generated QR code: %s", qrPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Successfully created client: %s", name)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateKeyPair generates a WireGuard private and public key pair
|
||||||
|
func generateKeyPair() (privateKey, publicKey string, err error) {
|
||||||
|
// Generate private key
|
||||||
|
privateKeyBytes, err := exec.Command("wg", "genkey").Output()
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("wg genkey failed: %w", err)
|
||||||
|
}
|
||||||
|
privateKey = strings.TrimSpace(string(privateKeyBytes))
|
||||||
|
|
||||||
|
// Derive public key
|
||||||
|
pubKeyCmd := exec.Command("wg", "pubkey")
|
||||||
|
pubKeyCmd.Stdin = strings.NewReader(privateKey)
|
||||||
|
publicKeyBytes, err := pubKeyCmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("wg pubkey failed: %w", err)
|
||||||
|
}
|
||||||
|
publicKey = strings.TrimSpace(string(publicKeyBytes))
|
||||||
|
|
||||||
|
return privateKey, publicKey, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// generatePSK generates a WireGuard preshared key
|
||||||
|
func generatePSK() (string, error) {
|
||||||
|
psk, err := exec.Command("wg", "genpsk").Output()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("wg genpsk failed: %w", err)
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(psk)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getNextAvailableIP finds the next available IP address in the given CIDR range
|
||||||
|
func getNextAvailableIP(cidr string, existingClients []Client) (string, error) {
|
||||||
|
// Parse CIDR to get network
|
||||||
|
parts := strings.Split(cidr, "/")
|
||||||
|
if len(parts) != 2 {
|
||||||
|
return "", fmt.Errorf("invalid CIDR format: %s", cidr)
|
||||||
|
}
|
||||||
|
|
||||||
|
network := strings.TrimSpace(parts[0])
|
||||||
|
|
||||||
|
// For IPv4, extract base network and assign next available host
|
||||||
|
if !strings.Contains(network, ":") {
|
||||||
|
// IPv4: Simple implementation - use .1, .2, etc.
|
||||||
|
// In production, this would parse the CIDR properly
|
||||||
|
usedHosts := make(map[string]bool)
|
||||||
|
for _, client := range existingClients {
|
||||||
|
if client.IPv4 != "" {
|
||||||
|
ipParts := strings.Split(client.IPv4, ".")
|
||||||
|
if len(ipParts) == 4 {
|
||||||
|
usedHosts[ipParts[3]] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find next available host (skip 0 and 1 as they may be reserved)
|
||||||
|
for i := 2; i < 255; i++ {
|
||||||
|
host := fmt.Sprintf("%d", i)
|
||||||
|
if !usedHosts[host] {
|
||||||
|
return fmt.Sprintf("%s.%s", network, host), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", fmt.Errorf("no available IPv4 addresses in range: %s", cidr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IPv6: Similar simplified approach
|
||||||
|
usedHosts := make(map[string]bool)
|
||||||
|
for _, client := range existingClients {
|
||||||
|
if client.IPv6 != "" {
|
||||||
|
// Extract last segment for IPv6
|
||||||
|
lastColon := strings.LastIndex(client.IPv6, ":")
|
||||||
|
if lastColon > 0 {
|
||||||
|
host := client.IPv6[lastColon+1:]
|
||||||
|
usedHosts[host] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find next available host
|
||||||
|
for i := 1; i < 65536; i++ {
|
||||||
|
host := fmt.Sprintf("%x", i)
|
||||||
|
if !usedHosts[host] {
|
||||||
|
return fmt.Sprintf("%s:%s", network, host), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", fmt.Errorf("no available IPv6 addresses in range: %s", cidr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateServerConfig generates the server-side configuration for a client
|
||||||
|
func generateServerConfig(name, publicKey, ipv4, ipv6, psk string, cfg *config.Config) (string, error) {
|
||||||
|
var builder strings.Builder
|
||||||
|
|
||||||
|
builder.WriteString(fmt.Sprintf("# Client: %s\n", name))
|
||||||
|
builder.WriteString(fmt.Sprintf("[Peer]\n"))
|
||||||
|
builder.WriteString(fmt.Sprintf("PublicKey = %s\n", publicKey))
|
||||||
|
|
||||||
|
allowedIPs := ""
|
||||||
|
if ipv4 != "" {
|
||||||
|
allowedIPs = ipv4 + "/32"
|
||||||
|
}
|
||||||
|
if ipv6 != "" {
|
||||||
|
if allowedIPs != "" {
|
||||||
|
allowedIPs += ", "
|
||||||
|
}
|
||||||
|
allowedIPs += ipv6 + "/128"
|
||||||
|
}
|
||||||
|
builder.WriteString(fmt.Sprintf("AllowedIPs = %s\n", allowedIPs))
|
||||||
|
|
||||||
|
if psk != "" {
|
||||||
|
builder.WriteString(fmt.Sprintf("PresharedKey = %s\n", psk))
|
||||||
|
}
|
||||||
|
|
||||||
|
return builder.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateClientConfig generates the client-side configuration
|
||||||
|
func generateClientConfig(name, privateKey, ipv4, ipv6, dns string, cfg *config.Config) (string, error) {
|
||||||
|
// Get server's public key from the main config
|
||||||
|
serverConfigPath := "/etc/wireguard/wg0.conf"
|
||||||
|
serverPublicKey, serverEndpoint, err := getServerConfig(serverConfigPath)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to read server config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var builder strings.Builder
|
||||||
|
|
||||||
|
builder.WriteString(fmt.Sprintf("# WireGuard client configuration for %s\n", name))
|
||||||
|
builder.WriteString("[Interface]\n")
|
||||||
|
builder.WriteString(fmt.Sprintf("PrivateKey = %s\n", privateKey))
|
||||||
|
builder.WriteString(fmt.Sprintf("Address = %s/32", ipv4))
|
||||||
|
if ipv6 != "" {
|
||||||
|
builder.WriteString(fmt.Sprintf(", %s/128", ipv6))
|
||||||
|
}
|
||||||
|
builder.WriteString("\n")
|
||||||
|
builder.WriteString(fmt.Sprintf("DNS = %s\n", dns))
|
||||||
|
|
||||||
|
builder.WriteString("\n")
|
||||||
|
builder.WriteString("[Peer]\n")
|
||||||
|
builder.WriteString(fmt.Sprintf("PublicKey = %s\n", serverPublicKey))
|
||||||
|
builder.WriteString(fmt.Sprintf("Endpoint = %s:%d\n", serverEndpoint, cfg.WGPort))
|
||||||
|
builder.WriteString("AllowedIPs = 0.0.0.0/0, ::/0\n")
|
||||||
|
|
||||||
|
return builder.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getServerConfig reads the server's public key and endpoint from the main config
|
||||||
|
func getServerConfig(path string) (publicKey, endpoint string, err error) {
|
||||||
|
content, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("failed to read server config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
inInterfaceSection := false
|
||||||
|
for _, line := range strings.Split(string(content), "\n") {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
|
||||||
|
if line == "[Interface]" {
|
||||||
|
inInterfaceSection = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if line == "[Peer]" {
|
||||||
|
inInterfaceSection = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if inInterfaceSection {
|
||||||
|
if strings.HasPrefix(line, "PublicKey") {
|
||||||
|
parts := strings.SplitN(line, "=", 2)
|
||||||
|
if len(parts) == 2 {
|
||||||
|
publicKey = strings.TrimSpace(parts[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use SERVER_DOMAIN as endpoint if available, otherwise fallback
|
||||||
|
cfg, err := config.LoadConfig()
|
||||||
|
if err == nil && cfg.ServerDomain != "" {
|
||||||
|
endpoint = cfg.ServerDomain
|
||||||
|
} else {
|
||||||
|
endpoint = "0.0.0.0"
|
||||||
|
}
|
||||||
|
|
||||||
|
return publicKey, endpoint, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// addPeerToInterface adds a peer to the WireGuard interface
|
||||||
|
func addPeerToInterface(publicKey, ipv4, ipv6, psk string) error {
|
||||||
|
args := []string{"set", "wg0", "peer", publicKey}
|
||||||
|
|
||||||
|
if ipv4 != "" {
|
||||||
|
args = append(args, "allowed-ips", ipv4+"/32")
|
||||||
|
}
|
||||||
|
if ipv6 != "" {
|
||||||
|
args = append(args, ipv6+"/128")
|
||||||
|
}
|
||||||
|
|
||||||
|
if psk != "" {
|
||||||
|
args = append(args, "preshared-key", "/dev/stdin")
|
||||||
|
cmd := exec.Command("wg", args...)
|
||||||
|
cmd.Stdin = strings.NewReader(psk)
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("wg set peer failed: %w, output: %s", err, string(output))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
cmd := exec.Command("wg", args...)
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("wg set peer failed: %w, output: %s", err, string(output))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateQRCode generates a QR code from the client config
|
||||||
|
func generateQRCode(configPath, qrPath string) error {
|
||||||
|
// Read config file
|
||||||
|
content, err := os.ReadFile(configPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read config file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate QR code using qrencode or similar
|
||||||
|
// For now, use a simple approach with qrencode if available
|
||||||
|
cmd := exec.Command("qrencode", "-o", qrPath, "-t", "PNG")
|
||||||
|
cmd.Stdin = bytes.NewReader(content)
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
// qrencode not available, try alternative method
|
||||||
|
return fmt.Errorf("qrencode not available: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
106
internal/wireguard/config.go
Normal file
106
internal/wireguard/config.go
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
package wireguard
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GenerateServerConfig generates a server-side WireGuard [Peer] configuration
|
||||||
|
// and writes it to /etc/wireguard/conf.d/client-<name>.conf
|
||||||
|
func GenerateServerConfig(name, publicKey string, hasPSK bool, ipv4, ipv6, psk string) (string, error) {
|
||||||
|
var builder strings.Builder
|
||||||
|
|
||||||
|
builder.WriteString(fmt.Sprintf("[Peer]\n"))
|
||||||
|
builder.WriteString(fmt.Sprintf("# %s\n", name))
|
||||||
|
builder.WriteString(fmt.Sprintf("PublicKey = %s\n", publicKey))
|
||||||
|
|
||||||
|
if hasPSK {
|
||||||
|
builder.WriteString(fmt.Sprintf("PresharedKey = %s\n", psk))
|
||||||
|
}
|
||||||
|
|
||||||
|
builder.WriteString(fmt.Sprintf("AllowedIPs = %s/32", ipv4))
|
||||||
|
if ipv6 != "" {
|
||||||
|
builder.WriteString(fmt.Sprintf(", %s/128", ipv6))
|
||||||
|
}
|
||||||
|
builder.WriteString("\n")
|
||||||
|
|
||||||
|
configContent := builder.String()
|
||||||
|
|
||||||
|
configDir := "/etc/wireguard/conf.d"
|
||||||
|
configPath := filepath.Join(configDir, fmt.Sprintf("client-%s.conf", name))
|
||||||
|
|
||||||
|
if err := atomicWrite(configPath, configContent); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to write server config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Generated server config: %s", configPath)
|
||||||
|
return configPath, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateClientConfig generates a client-side WireGuard configuration
|
||||||
|
// with [Interface] and [Peer] sections and writes it to /etc/wireguard/clients/<name>.conf
|
||||||
|
func GenerateClientConfig(name, privateKey, ipv4, ipv6, dns, serverPublicKey, endpoint string, port int, hasPSK bool, psk string) (string, error) {
|
||||||
|
var builder strings.Builder
|
||||||
|
|
||||||
|
// [Interface] section
|
||||||
|
builder.WriteString("[Interface]\n")
|
||||||
|
builder.WriteString(fmt.Sprintf("PrivateKey = %s\n", privateKey))
|
||||||
|
builder.WriteString(fmt.Sprintf("Address = %s/24", ipv4))
|
||||||
|
if ipv6 != "" {
|
||||||
|
builder.WriteString(fmt.Sprintf(", %s/64", ipv6))
|
||||||
|
}
|
||||||
|
builder.WriteString("\n")
|
||||||
|
builder.WriteString(fmt.Sprintf("DNS = %s\n", dns))
|
||||||
|
|
||||||
|
// [Peer] section
|
||||||
|
builder.WriteString("\n[Peer]\n")
|
||||||
|
builder.WriteString(fmt.Sprintf("PublicKey = %s\n", serverPublicKey))
|
||||||
|
|
||||||
|
if hasPSK {
|
||||||
|
builder.WriteString(fmt.Sprintf("PresharedKey = %s\n", psk))
|
||||||
|
}
|
||||||
|
|
||||||
|
builder.WriteString(fmt.Sprintf("Endpoint = %s:%d\n", endpoint, port))
|
||||||
|
builder.WriteString("AllowedIPs = 0.0.0.0/0, ::/0\n")
|
||||||
|
builder.WriteString("PersistentKeepalive = 25\n")
|
||||||
|
|
||||||
|
configContent := builder.String()
|
||||||
|
|
||||||
|
clientsDir := "/etc/wireguard/clients"
|
||||||
|
configPath := filepath.Join(clientsDir, fmt.Sprintf("%s.conf", name))
|
||||||
|
|
||||||
|
if err := atomicWrite(configPath, configContent); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to write client config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Generated client config: %s", configPath)
|
||||||
|
return configPath, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// atomicWrite writes content to a file atomically
|
||||||
|
// Uses temp file + rename pattern for atomicity and sets permissions to 0600
|
||||||
|
func atomicWrite(path, content string) error {
|
||||||
|
// Ensure parent directory exists
|
||||||
|
dir := filepath.Dir(path)
|
||||||
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||||
|
return fmt.Errorf("failed to create directory %s: %w", dir, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write to temp file first
|
||||||
|
tempPath := path + ".tmp"
|
||||||
|
if err := os.WriteFile(tempPath, []byte(content), 0600); err != nil {
|
||||||
|
return fmt.Errorf("failed to write temp file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Atomic rename
|
||||||
|
if err := os.Rename(tempPath, path); err != nil {
|
||||||
|
// Clean up temp file if rename fails
|
||||||
|
_ = os.Remove(tempPath)
|
||||||
|
return fmt.Errorf("failed to rename temp file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
243
internal/wireguard/keys.go
Normal file
243
internal/wireguard/keys.go
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
package wireguard
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// KeyLength is the standard WireGuard key length in bytes (32 bytes = 256 bits)
|
||||||
|
KeyLength = 32
|
||||||
|
// Base64KeyLength is the length of a base64-encoded WireGuard key (44 characters)
|
||||||
|
Base64KeyLength = 44
|
||||||
|
// TempDir is the directory for temporary key storage
|
||||||
|
TempDir = "/tmp/wg-admin"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
tempKeys = make(map[string]bool)
|
||||||
|
tempKeysMutex sync.Mutex
|
||||||
|
)
|
||||||
|
|
||||||
|
// KeyPair represents a WireGuard key pair
|
||||||
|
type KeyPair struct {
|
||||||
|
PrivateKey string // Base64-encoded private key
|
||||||
|
PublicKey string // Base64-encoded public key
|
||||||
|
}
|
||||||
|
|
||||||
|
// GeneratePrivateKey generates a new WireGuard private key using wg genkey
|
||||||
|
func GeneratePrivateKey() (string, error) {
|
||||||
|
cmd := exec.Command("wg", "genkey")
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("wg genkey failed: %w, output: %s", err, string(output))
|
||||||
|
}
|
||||||
|
|
||||||
|
key := string(output)
|
||||||
|
if err := ValidateKey(key); err != nil {
|
||||||
|
return "", fmt.Errorf("generated invalid private key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store as temporary key
|
||||||
|
tempKeyPath := filepath.Join(TempDir, "private.key")
|
||||||
|
if err := storeTempKey(tempKeyPath, key); err != nil {
|
||||||
|
log.Printf("Warning: failed to store temporary private key: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return key, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GeneratePublicKey generates a public key from a private key using wg pubkey
|
||||||
|
func GeneratePublicKey(privateKey string) (string, error) {
|
||||||
|
if err := ValidateKey(privateKey); err != nil {
|
||||||
|
return "", fmt.Errorf("invalid private key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.Command("wg", "pubkey")
|
||||||
|
cmd.Stdin = strings.NewReader(privateKey)
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("wg pubkey failed: %w, output: %s", err, string(output))
|
||||||
|
}
|
||||||
|
|
||||||
|
key := string(output)
|
||||||
|
if err := ValidateKey(key); err != nil {
|
||||||
|
return "", fmt.Errorf("generated invalid public key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store as temporary key
|
||||||
|
tempKeyPath := filepath.Join(TempDir, "public.key")
|
||||||
|
if err := storeTempKey(tempKeyPath, key); err != nil {
|
||||||
|
log.Printf("Warning: failed to store temporary public key: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return key, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GeneratePSK generates a new pre-shared key using wg genpsk
|
||||||
|
func GeneratePSK() (string, error) {
|
||||||
|
cmd := exec.Command("wg", "genpsk")
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("wg genpsk failed: %w, output: %s", err, string(output))
|
||||||
|
}
|
||||||
|
|
||||||
|
key := string(output)
|
||||||
|
if err := ValidateKey(key); err != nil {
|
||||||
|
return "", fmt.Errorf("generated invalid PSK: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store as temporary key
|
||||||
|
tempKeyPath := filepath.Join(TempDir, "psk.key")
|
||||||
|
if err := storeTempKey(tempKeyPath, key); err != nil {
|
||||||
|
log.Printf("Warning: failed to store temporary PSK: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return key, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateKeyPair generates a complete WireGuard key pair (private + public)
|
||||||
|
func GenerateKeyPair() (*KeyPair, error) {
|
||||||
|
privateKey, err := GeneratePrivateKey()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
publicKey, err := GeneratePublicKey(privateKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to generate public key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &KeyPair{
|
||||||
|
PrivateKey: privateKey,
|
||||||
|
PublicKey: publicKey,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateKey validates that a key is properly formatted (44 base64 characters)
|
||||||
|
func ValidateKey(key string) error {
|
||||||
|
// Trim whitespace
|
||||||
|
key = strings.TrimSpace(key)
|
||||||
|
|
||||||
|
// Check length (44 base64 characters for 32 bytes)
|
||||||
|
if len(key) != Base64KeyLength {
|
||||||
|
return fmt.Errorf("invalid key length: expected %d characters, got %d", Base64KeyLength, len(key))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify it's valid base64
|
||||||
|
decoded, err := base64.StdEncoding.DecodeString(key)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid base64 encoding: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify decoded length is 32 bytes
|
||||||
|
if len(decoded) != KeyLength {
|
||||||
|
return fmt.Errorf("invalid decoded key length: expected %d bytes, got %d", KeyLength, len(decoded))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// StoreKey atomically writes a key to a file with 0600 permissions
|
||||||
|
func StoreKey(path string, key string) error {
|
||||||
|
// Validate key before storing
|
||||||
|
if err := ValidateKey(key); err != nil {
|
||||||
|
return fmt.Errorf("invalid key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trim whitespace
|
||||||
|
key = strings.TrimSpace(key)
|
||||||
|
|
||||||
|
// Create parent directories if needed
|
||||||
|
dir := filepath.Dir(path)
|
||||||
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||||
|
return fmt.Errorf("failed to create directory %s: %w", dir, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write to temporary file
|
||||||
|
tempPath := path + ".tmp"
|
||||||
|
if err := os.WriteFile(tempPath, []byte(key), 0600); err != nil {
|
||||||
|
return fmt.Errorf("failed to write temp file %s: %w", tempPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Atomic rename
|
||||||
|
if err := os.Rename(tempPath, path); err != nil {
|
||||||
|
os.Remove(tempPath) // Clean up temp file on failure
|
||||||
|
return fmt.Errorf("failed to rename temp file to %s: %w", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadKey reads a key from a file and validates it
|
||||||
|
func LoadKey(path string) (string, error) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to read key file %s: %w", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
key := strings.TrimSpace(string(data))
|
||||||
|
if err := ValidateKey(key); err != nil {
|
||||||
|
return "", fmt.Errorf("invalid key in file %s: %w", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return key, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// storeTempKey stores a temporary key and tracks it for cleanup
|
||||||
|
func storeTempKey(path string, key string) error {
|
||||||
|
// Create temp directory if needed
|
||||||
|
if err := os.MkdirAll(TempDir, 0700); err != nil {
|
||||||
|
return fmt.Errorf("failed to create temp directory %s: %w", TempDir, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trim whitespace
|
||||||
|
key = strings.TrimSpace(key)
|
||||||
|
|
||||||
|
// Write to file with 0600 permissions
|
||||||
|
if err := os.WriteFile(path, []byte(key), 0600); err != nil {
|
||||||
|
return fmt.Errorf("failed to write temp key to %s: %w", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track for cleanup
|
||||||
|
tempKeysMutex.Lock()
|
||||||
|
tempKeys[path] = true
|
||||||
|
tempKeysMutex.Unlock()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CleanupTempKeys removes all temporary keys
|
||||||
|
func CleanupTempKeys() error {
|
||||||
|
tempKeysMutex.Lock()
|
||||||
|
defer tempKeysMutex.Unlock()
|
||||||
|
|
||||||
|
var cleanupErrors []string
|
||||||
|
|
||||||
|
for path := range tempKeys {
|
||||||
|
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
||||||
|
cleanupErrors = append(cleanupErrors, fmt.Sprintf("%s: %v", path, err))
|
||||||
|
log.Printf("Warning: failed to remove temp key %s: %v", path, err)
|
||||||
|
}
|
||||||
|
delete(tempKeys, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also attempt to clean up the temp directory if empty
|
||||||
|
if _, err := os.ReadDir(TempDir); err == nil {
|
||||||
|
if err := os.Remove(TempDir); err != nil && !os.IsNotExist(err) {
|
||||||
|
log.Printf("Warning: failed to remove temp directory %s: %v", TempDir, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(cleanupErrors) > 0 {
|
||||||
|
return fmt.Errorf("cleanup errors: %s", strings.Join(cleanupErrors, "; "))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
246
internal/wireguard/status.go
Normal file
246
internal/wireguard/status.go
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
package wireguard
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"os/exec"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// StatusConnected indicates a peer has an active connection
|
||||||
|
StatusConnected = "Connected"
|
||||||
|
// StatusDisconnected indicates a peer is not connected
|
||||||
|
StatusDisconnected = "Disconnected"
|
||||||
|
|
||||||
|
// QualityExcellent indicates handshake was very recent (< 30s)
|
||||||
|
QualityExcellent = "Excellent"
|
||||||
|
// QualityGood indicates handshake was recent (< 2m)
|
||||||
|
QualityGood = "Good"
|
||||||
|
// QualityFair indicates handshake was acceptable (< 5m)
|
||||||
|
QualityFair = "Fair"
|
||||||
|
// QualityPoor indicates handshake was old (> 5m)
|
||||||
|
QualityPoor = "Poor"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PeerStatus represents the status of a WireGuard peer
|
||||||
|
type PeerStatus struct {
|
||||||
|
PublicKey string `json:"public_key"`
|
||||||
|
Endpoint string `json:"endpoint"`
|
||||||
|
AllowedIPs string `json:"allowed_ips"`
|
||||||
|
LatestHandshake time.Time `json:"latest_handshake"`
|
||||||
|
TransferRx string `json:"transfer_rx"`
|
||||||
|
TransferTx string `json:"transfer_tx"`
|
||||||
|
Status string `json:"status"` // "Connected" or "Disconnected"
|
||||||
|
Quality string `json:"quality,omitempty"` // "Excellent", "Good", "Fair", "Poor" (if connected)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetClientStatus checks if a specific client is connected
|
||||||
|
// Returns "Connected" if the peer appears in the active peers list, "Disconnected" otherwise
|
||||||
|
func GetClientStatus(publicKey string) (string, error) {
|
||||||
|
peers, err := GetAllPeers()
|
||||||
|
if err != nil {
|
||||||
|
return StatusDisconnected, fmt.Errorf("failed to get peer status: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, peer := range peers {
|
||||||
|
if peer.PublicKey == publicKey {
|
||||||
|
return peer.Status, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return StatusDisconnected, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAllPeers retrieves all peers with their current status from WireGuard
|
||||||
|
func GetAllPeers() ([]PeerStatus, error) {
|
||||||
|
output, err := exec.Command("wg", "show", "wg0").Output()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to execute wg show: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsePeersOutput(string(output)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parsePeersOutput parses the output of 'wg show wg0' command
|
||||||
|
func parsePeersOutput(output string) []PeerStatus {
|
||||||
|
var peers []PeerStatus
|
||||||
|
var currentPeer *PeerStatus
|
||||||
|
var handshake string
|
||||||
|
var transfer string
|
||||||
|
|
||||||
|
lines := strings.Split(output, "\n")
|
||||||
|
peerLineRegex := regexp.MustCompile(`^peer:\s*(.+)$`)
|
||||||
|
handshakeRegex := regexp.MustCompile(`^latest handshake:\s*(.+)\s+ago$`)
|
||||||
|
transferRegex := regexp.MustCompile(`^transfer:\s*(.+),\s+(.+)$`)
|
||||||
|
|
||||||
|
for _, line := range lines {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
|
||||||
|
// Check for new peer
|
||||||
|
if match := peerLineRegex.FindStringSubmatch(line); match != nil {
|
||||||
|
// Save previous peer if exists
|
||||||
|
if currentPeer != nil {
|
||||||
|
peers = append(peers, finalizePeerStatus(currentPeer, handshake, transfer))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start new peer
|
||||||
|
currentPeer = &PeerStatus{
|
||||||
|
PublicKey: match[1],
|
||||||
|
}
|
||||||
|
handshake = ""
|
||||||
|
transfer = ""
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if currentPeer == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse endpoint
|
||||||
|
if strings.HasPrefix(line, "endpoint:") {
|
||||||
|
currentPeer.Endpoint = strings.TrimSpace(strings.TrimPrefix(line, "endpoint:"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse allowed ips
|
||||||
|
if strings.HasPrefix(line, "allowed ips:") {
|
||||||
|
currentPeer.AllowedIPs = strings.TrimSpace(strings.TrimPrefix(line, "allowed ips:"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse latest handshake
|
||||||
|
if match := handshakeRegex.FindStringSubmatch(line); match != nil {
|
||||||
|
handshake = match[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse transfer
|
||||||
|
if match := transferRegex.FindStringSubmatch(line); match != nil {
|
||||||
|
transfer = fmt.Sprintf("%s, %s", match[1], match[2])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't forget the last peer
|
||||||
|
if currentPeer != nil {
|
||||||
|
peers = append(peers, finalizePeerStatus(currentPeer, handshake, transfer))
|
||||||
|
}
|
||||||
|
|
||||||
|
return peers
|
||||||
|
}
|
||||||
|
|
||||||
|
// CalculateQuality returns the connection quality based on handshake time
|
||||||
|
func CalculateQuality(timeSinceHandshake time.Duration) string {
|
||||||
|
if timeSinceHandshake < 30*time.Second {
|
||||||
|
return QualityExcellent
|
||||||
|
}
|
||||||
|
if timeSinceHandshake < 2*time.Minute {
|
||||||
|
return QualityGood
|
||||||
|
}
|
||||||
|
if timeSinceHandshake < 5*time.Minute {
|
||||||
|
return QualityFair
|
||||||
|
}
|
||||||
|
return QualityPoor
|
||||||
|
}
|
||||||
|
|
||||||
|
// finalizePeerStatus determines the peer's status based on handshake time
|
||||||
|
func finalizePeerStatus(peer *PeerStatus, handshake string, transfer string) PeerStatus {
|
||||||
|
peer.TransferRx = ""
|
||||||
|
peer.TransferTx = ""
|
||||||
|
|
||||||
|
// Parse transfer
|
||||||
|
if transfer != "" {
|
||||||
|
parts := strings.Split(transfer, ", ")
|
||||||
|
if len(parts) == 2 {
|
||||||
|
// Extract received and sent values
|
||||||
|
rxParts := strings.Fields(parts[0])
|
||||||
|
if len(rxParts) >= 2 {
|
||||||
|
peer.TransferRx = strings.Join(rxParts[:2], " ")
|
||||||
|
}
|
||||||
|
txParts := strings.Fields(parts[1])
|
||||||
|
if len(txParts) >= 2 {
|
||||||
|
peer.TransferTx = strings.Join(txParts[:2], " ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine status and quality based on handshake
|
||||||
|
if handshake != "" {
|
||||||
|
peer.LatestHandshake = parseHandshake(handshake)
|
||||||
|
timeSinceHandshake := time.Since(peer.LatestHandshake)
|
||||||
|
|
||||||
|
// Peer is considered connected if handshake is recent (within 5 minutes)
|
||||||
|
// This allows for ~12 missed keepalive intervals (at 25 seconds each)
|
||||||
|
if timeSinceHandshake < 5*time.Minute {
|
||||||
|
peer.Status = StatusConnected
|
||||||
|
peer.Quality = CalculateQuality(timeSinceHandshake)
|
||||||
|
} else {
|
||||||
|
peer.Status = StatusDisconnected
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
peer.Status = StatusDisconnected
|
||||||
|
}
|
||||||
|
|
||||||
|
return *peer
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseHandshake converts handshake string to time.Time
|
||||||
|
func parseHandshake(handshake string) time.Time {
|
||||||
|
now := time.Now()
|
||||||
|
var totalDuration time.Duration
|
||||||
|
parts := strings.Fields(handshake)
|
||||||
|
|
||||||
|
for i, part := range parts {
|
||||||
|
// Clean up commas
|
||||||
|
cleanPart := strings.TrimSuffix(part, ",")
|
||||||
|
|
||||||
|
// Check if this part is a time unit
|
||||||
|
if strings.HasSuffix(cleanPart, "second") || strings.HasSuffix(cleanPart, "seconds") {
|
||||||
|
// The number is the previous part
|
||||||
|
if i > 0 {
|
||||||
|
if val, err := strconv.Atoi(parts[i-1]); err == nil {
|
||||||
|
totalDuration += time.Duration(val) * time.Second
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.HasSuffix(cleanPart, "minute") || strings.HasSuffix(cleanPart, "minutes") {
|
||||||
|
if i > 0 {
|
||||||
|
if val, err := strconv.Atoi(parts[i-1]); err == nil {
|
||||||
|
totalDuration += time.Duration(val) * time.Minute
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.HasSuffix(cleanPart, "hour") || strings.HasSuffix(cleanPart, "hours") {
|
||||||
|
if i > 0 {
|
||||||
|
if val, err := strconv.Atoi(parts[i-1]); err == nil {
|
||||||
|
totalDuration += time.Duration(val) * time.Hour
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.HasSuffix(cleanPart, "day") || strings.HasSuffix(cleanPart, "days") {
|
||||||
|
if i > 0 {
|
||||||
|
if val, err := strconv.Atoi(parts[i-1]); err == nil {
|
||||||
|
totalDuration += time.Duration(val) * 24 * time.Hour
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if totalDuration == 0 {
|
||||||
|
return now.Add(-time.Hour) // Default to 1 hour ago if parsing fails
|
||||||
|
}
|
||||||
|
|
||||||
|
return now.Add(-totalDuration)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckInterface verifies if the WireGuard interface exists and is accessible
|
||||||
|
func CheckInterface(interfaceName string) error {
|
||||||
|
cmd := exec.Command("wg", "show", interfaceName)
|
||||||
|
var out bytes.Buffer
|
||||||
|
cmd.Stdout = &out
|
||||||
|
err := cmd.Run()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("wireguard interface '%s' not accessible: %w", interfaceName, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
27
internal/wireguard/tea_messages.go
Normal file
27
internal/wireguard/tea_messages.go
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
package wireguard
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
)
|
||||||
|
|
||||||
|
// StatusTickMsg is sent when it's time to refresh the status
|
||||||
|
type StatusTickMsg struct{}
|
||||||
|
|
||||||
|
// RefreshStatusMsg is sent when manual refresh is triggered
|
||||||
|
type RefreshStatusMsg struct{}
|
||||||
|
|
||||||
|
// Tick returns a tea.Cmd that will send StatusTickMsg at the specified interval
|
||||||
|
func Tick(interval int) tea.Cmd {
|
||||||
|
return tea.Tick(time.Duration(interval)*time.Second, func(t time.Time) tea.Msg {
|
||||||
|
return StatusTickMsg{}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ManualRefresh returns a tea.Cmd to trigger immediate status refresh
|
||||||
|
func ManualRefresh() tea.Cmd {
|
||||||
|
return func() tea.Msg {
|
||||||
|
return RefreshStatusMsg{}
|
||||||
|
}
|
||||||
|
}
|
||||||
124
test-wg-install.sh
Executable file
124
test-wg-install.sh
Executable file
@@ -0,0 +1,124 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Test script for wg-install.sh validation functions
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Override log location for tests
|
||||||
|
export WGI_LOG_FILE="/tmp/wg-admin-install-test.log"
|
||||||
|
|
||||||
|
# Source the main script to get functions
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
source "${SCRIPT_DIR}/wg-install.sh"
|
||||||
|
|
||||||
|
# Test counter
|
||||||
|
TESTS_RUN=0
|
||||||
|
TESTS_PASSED=0
|
||||||
|
TESTS_FAILED=0
|
||||||
|
|
||||||
|
# Test function
|
||||||
|
run_test() {
|
||||||
|
local test_name="$1"
|
||||||
|
local expected="$2"
|
||||||
|
shift 2
|
||||||
|
local result
|
||||||
|
|
||||||
|
TESTS_RUN=$((TESTS_RUN + 1))
|
||||||
|
|
||||||
|
# Run function and capture output and exit code
|
||||||
|
if result=$("$@" 2>&1); then
|
||||||
|
local exit_code=$?
|
||||||
|
else
|
||||||
|
local exit_code=$?
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$expected" == "success" && "$exit_code" -eq 0 ]]; then
|
||||||
|
TESTS_PASSED=$((TESTS_PASSED + 1))
|
||||||
|
echo "✓ PASS: $test_name"
|
||||||
|
return 0
|
||||||
|
elif [[ "$expected" == "failure" && "$exit_code" -ne 0 ]]; then
|
||||||
|
TESTS_PASSED=$((TESTS_PASSED + 1))
|
||||||
|
echo "✓ PASS: $test_name"
|
||||||
|
return 0
|
||||||
|
else
|
||||||
|
TESTS_FAILED=$((TESTS_FAILED + 1))
|
||||||
|
echo "✗ FAIL: $test_name"
|
||||||
|
echo " Expected: $expected"
|
||||||
|
echo " Exit code: $exit_code"
|
||||||
|
echo " Output: $result"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "=== Running wg-install.sh Tests ==="
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Test validate_server_domain
|
||||||
|
echo "Testing validate_server_domain..."
|
||||||
|
run_test "Valid domain" success validate_server_domain "vpn.example.com"
|
||||||
|
run_test "Valid subdomain" success validate_server_domain "wg.vpn.example.com"
|
||||||
|
run_test "Valid domain with hyphen" success validate_server_domain "my-vpn.example.com"
|
||||||
|
run_test "Empty domain" failure validate_server_domain ""
|
||||||
|
run_test "Invalid domain - spaces" failure validate_server_domain "vpn example.com"
|
||||||
|
run_test "Invalid domain - special chars" failure validate_server_domain "vpn@example.com"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Test validate_port_range
|
||||||
|
echo "Testing validate_port_range..."
|
||||||
|
run_test "Valid default port" success validate_port_range "51820"
|
||||||
|
run_test "Valid port 80" success validate_port_range "80"
|
||||||
|
run_test "Valid port 443" success validate_port_range "443"
|
||||||
|
run_test "Valid max port" success validate_port_range "65535"
|
||||||
|
run_test "Empty port" failure validate_port_range ""
|
||||||
|
run_test "Invalid port - negative" failure validate_port_range "-1"
|
||||||
|
run_test "Invalid port - zero" failure validate_port_range "0"
|
||||||
|
run_test "Invalid port - too high" failure validate_port_range "65536"
|
||||||
|
run_test "Invalid port - non-numeric" failure validate_port_range "abc"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Test validate_cidr (IPv4)
|
||||||
|
echo "Testing validate_cidr (IPv4)..."
|
||||||
|
run_test "Valid IPv4 /24" success validate_cidr "10.10.69.0/24" false
|
||||||
|
run_test "Valid IPv4 /32" success validate_cidr "192.168.1.1/32" false
|
||||||
|
run_test "Valid IPv4 /16" success validate_cidr "172.16.0.0/16" false
|
||||||
|
run_test "Empty IPv4 CIDR" failure validate_cidr "" false
|
||||||
|
run_test "Invalid IPv4 - no prefix" failure validate_cidr "10.10.69.0" false
|
||||||
|
run_test "Invalid IPv4 - prefix too large" failure validate_cidr "10.10.69.0/33" false
|
||||||
|
run_test "Invalid IPv4 - negative prefix" failure validate_cidr "10.10.69.0/-1" false
|
||||||
|
run_test "Invalid IPv4 - bad IP" failure validate_cidr "256.1.1.1/24" false
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Test validate_cidr (IPv6)
|
||||||
|
echo "Testing validate_cidr (IPv6)..."
|
||||||
|
run_test "Valid IPv6 /64" success validate_cidr "fd69:dead:beef:69::/64" true
|
||||||
|
run_test "Valid IPv6 /128" success validate_cidr "fd69:dead:beef:69::1/128" true
|
||||||
|
run_test "Valid IPv6 /48" success validate_cidr "fd69:dead::/48" true
|
||||||
|
run_test "Empty IPv6 CIDR" failure validate_cidr "" true
|
||||||
|
run_test "Invalid IPv6 - no prefix" failure validate_cidr "fd69:dead:beef:69::" true
|
||||||
|
run_test "Invalid IPv6 - prefix too large" failure validate_cidr "fd69:dead:beef:69::/129" true
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Test validate_dns_servers
|
||||||
|
echo "Testing validate_dns_servers..."
|
||||||
|
run_test "Valid single DNS" success validate_dns_servers "8.8.8.8"
|
||||||
|
run_test "Valid multiple DNS" success validate_dns_servers "8.8.8.8, 8.8.4.4"
|
||||||
|
run_test "Valid DNS with spaces" success validate_dns_servers "8.8.8.8, 1.1.1.1"
|
||||||
|
run_test "Empty DNS" success validate_dns_servers ""
|
||||||
|
run_test "Invalid DNS - bad format" failure validate_dns_servers "8.8.8"
|
||||||
|
run_test "Invalid DNS - special chars" failure validate_dns_servers "dns.example.com"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Summary
|
||||||
|
echo ""
|
||||||
|
echo "=== Test Summary ==="
|
||||||
|
echo "Tests run: $TESTS_RUN"
|
||||||
|
echo "Tests passed: $TESTS_PASSED"
|
||||||
|
echo "Tests failed: $TESTS_FAILED"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
if [[ $TESTS_FAILED -eq 0 ]]; then
|
||||||
|
echo "All tests passed! ✓"
|
||||||
|
exit 0
|
||||||
|
else
|
||||||
|
echo "Some tests failed! ✗"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
921
wg-install.sh
Executable file
921
wg-install.sh
Executable file
@@ -0,0 +1,921 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# wg-install.sh - WireGuard VPN Server Installation Script
|
||||||
|
#
|
||||||
|
# This script handles the complete installation of a WireGuard VPN server on Debian 13.
|
||||||
|
# It includes dependency checks, package installation, firewall setup (nftables),
|
||||||
|
# server key generation, interface initialization, and systemd service setup.
|
||||||
|
#
|
||||||
|
# Settings can be provided via interactive prompts or environment variables prefixed with WGI_
|
||||||
|
# (e.g., WGI_SERVER_DOMAIN, WGI_WG_PORT, WGI_VPN_IPV4_RANGE, etc.)
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Default Configuration (can be overridden by environment variables with WGI_ prefix)
|
||||||
|
WGI_SERVER_DOMAIN="${WGI_SERVER_DOMAIN:-}"
|
||||||
|
WGI_WG_PORT="${WGI_WG_PORT:-51820}"
|
||||||
|
WGI_VPN_IPV4_RANGE="${WGI_VPN_IPV4_RANGE:-10.10.69.0/24}"
|
||||||
|
WGI_VPN_IPV6_RANGE="${WGI_VPN_IPV6_RANGE:-fd69:dead:beef:69::/64}"
|
||||||
|
WGI_WG_INTERFACE="${WGI_WG_INTERFACE:-wg0}"
|
||||||
|
WGI_DNS_SERVERS="${WGI_DNS_SERVERS:-8.8.8.8, 8.8.4.4}"
|
||||||
|
WGI_LOG_FILE="${WGI_LOG_FILE:-/var/log/wg-admin-install.log}"
|
||||||
|
WGI_MIN_DISK_SPACE_MB=100
|
||||||
|
|
||||||
|
# Derived paths
|
||||||
|
CONF_D_DIR="/etc/wireguard/conf.d"
|
||||||
|
SERVER_CONF="${CONF_D_DIR}/server.conf"
|
||||||
|
CLIENT_OUTPUT_DIR="/etc/wireguard/clients"
|
||||||
|
WG_CONFIG="/etc/wireguard/${WGI_WG_INTERFACE}.conf"
|
||||||
|
BACKUP_DIR="/etc/wg-admin/backups"
|
||||||
|
|
||||||
|
# Global variables for cleanup and rollback
|
||||||
|
TEMP_DIR=""
|
||||||
|
ROLLBACK_BACKUP_DIR=""
|
||||||
|
ROLLBACK_NEEDED=false
|
||||||
|
PUBLIC_INTERFACE=""
|
||||||
|
SERVER_PRIVATE_KEY=""
|
||||||
|
SERVER_PUBLIC_KEY=""
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Logging Functions
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
log() {
|
||||||
|
local level="$1"
|
||||||
|
shift
|
||||||
|
local message="$*"
|
||||||
|
local timestamp=$(date '+%Y-%m-%d %H:%M:%S')
|
||||||
|
echo "[${timestamp}] [${level}] ${message}" | tee -a "${WGI_LOG_FILE}"
|
||||||
|
}
|
||||||
|
|
||||||
|
log_info() {
|
||||||
|
log "INFO" "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
log_error() {
|
||||||
|
log "ERROR" "$@" >&2
|
||||||
|
}
|
||||||
|
|
||||||
|
log_warn() {
|
||||||
|
log "WARN" "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Cleanup and Error Handling
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
cleanup_handler() {
|
||||||
|
local exit_code=$?
|
||||||
|
|
||||||
|
# Remove temporary directories
|
||||||
|
if [[ -n "${TEMP_DIR}" ]] && [[ -d "${TEMP_DIR}" ]]; then
|
||||||
|
log_info "Cleaning up temporary directory: ${TEMP_DIR}"
|
||||||
|
rm -rf "${TEMP_DIR}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Rollback on failure if needed
|
||||||
|
if [[ ${ROLLBACK_NEEDED} == true ]] && [[ ${exit_code} -ne 0 ]]; then
|
||||||
|
log_error "Installation failed, attempting rollback..."
|
||||||
|
rollback_installation
|
||||||
|
fi
|
||||||
|
|
||||||
|
exit ${exit_code}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Set up traps for cleanup
|
||||||
|
trap cleanup_handler EXIT INT TERM HUP
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Input Validation Functions
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
validate_dns_servers() {
|
||||||
|
local dns="$1"
|
||||||
|
if [[ -z "$dns" ]]; then
|
||||||
|
return 0 # Empty DNS is allowed
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Split by comma and validate each DNS server
|
||||||
|
IFS=',' read -ra dns_array <<< "$dns"
|
||||||
|
for dns_server in "${dns_array[@]}"; do
|
||||||
|
dns_server=$(echo "$dns_server" | xargs) # Trim whitespace
|
||||||
|
if [[ ! "$dns_server" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||||
|
log_error "Invalid DNS server format: ${dns_server}"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
validate_port_range() {
|
||||||
|
local port="$1"
|
||||||
|
if [[ -z "$port" ]]; then
|
||||||
|
log_error "Port cannot be empty"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
if [[ ! "$port" =~ ^[0-9]+$ ]]; then
|
||||||
|
log_error "Port must be a number"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
if [[ "$port" -lt 1 ]] || [[ "$port" -gt 65535 ]]; then
|
||||||
|
log_error "Port must be between 1 and 65535"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
validate_cidr() {
|
||||||
|
local cidr="$1"
|
||||||
|
local is_ipv6="$2"
|
||||||
|
|
||||||
|
if [[ -z "$cidr" ]]; then
|
||||||
|
log_error "CIDR cannot be empty"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$is_ipv6" == "true" ]]; then
|
||||||
|
# Basic IPv6 CIDR validation
|
||||||
|
if [[ ! "$cidr" =~ ^[0-9a-fA-F:]+/[0-9]+$ ]]; then
|
||||||
|
log_error "Invalid IPv6 CIDR format: ${cidr}"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
local prefix="${cidr#*/}"
|
||||||
|
if [[ "$prefix" -lt 0 ]] || [[ "$prefix" -gt 128 ]]; then
|
||||||
|
log_error "Invalid IPv6 prefix length: ${prefix}"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
# IPv4 CIDR validation
|
||||||
|
if [[ ! "$cidr" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+/[0-9]+$ ]]; then
|
||||||
|
log_error "Invalid IPv4 CIDR format: ${cidr}"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
local ip="${cidr%/*}"
|
||||||
|
local prefix="${cidr#*/}"
|
||||||
|
|
||||||
|
# Validate each octet
|
||||||
|
IFS='.' read -ra octets <<< "$ip"
|
||||||
|
for octet in "${octets[@]}"; do
|
||||||
|
if [[ "$octet" -lt 0 ]] || [[ "$octet" -gt 255 ]]; then
|
||||||
|
log_error "Invalid IPv4 octet: ${octet} in ${cidr}"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ "$prefix" -lt 0 ]] || [[ "$prefix" -gt 32 ]]; then
|
||||||
|
log_error "Invalid IPv4 prefix length: ${prefix}"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
validate_server_domain() {
|
||||||
|
local domain="$1"
|
||||||
|
|
||||||
|
if [[ -z "$domain" ]]; then
|
||||||
|
log_error "Server domain cannot be empty"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Basic domain validation (alphanumeric, hyphens, dots)
|
||||||
|
if [[ ! "$domain" =~ ^[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?)*$ ]]; then
|
||||||
|
log_error "Invalid server domain format: ${domain}"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Pre-installation Validation
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
pre_install_validation() {
|
||||||
|
log_info "Running pre-installation validation..."
|
||||||
|
|
||||||
|
# Check root privileges
|
||||||
|
if [[ $EUID -ne 0 ]]; then
|
||||||
|
log_error "This script must be run as root"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check disk space (at least MIN_DISK_SPACE_MB free)
|
||||||
|
local free_space_kb=$(df / | awk 'NR==2 {print $4}')
|
||||||
|
local free_space_mb=$((free_space_kb / 1024))
|
||||||
|
if [[ ${free_space_mb} -lt ${WGI_MIN_DISK_SPACE_MB} ]]; then
|
||||||
|
log_error "Insufficient disk space (${free_space_mb}MB free, ${WGI_MIN_DISK_SPACE_MB}MB required)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
log_info "Disk space validation passed (${free_space_mb}MB free)"
|
||||||
|
|
||||||
|
# Check port availability
|
||||||
|
if ss -ulnp 2>/dev/null | grep -q ":${WGI_WG_PORT}"; then
|
||||||
|
log_error "Port ${WGI_WG_PORT} is already in use"
|
||||||
|
echo "ERROR: WireGuard port ${WGI_WG_PORT} is already in use."
|
||||||
|
echo "Action: Stop the service using port ${WGI_WG_PORT} or change the port."
|
||||||
|
echo "To find what's using the port: 'sudo ss -tulnp | grep ${WGI_WG_PORT}'"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
log_info "Port ${WGI_WG_PORT} is available"
|
||||||
|
|
||||||
|
log_info "Pre-installation validation passed"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Backup Functions
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
backup_config() {
|
||||||
|
local operation="${1:-manual}"
|
||||||
|
local timestamp=$(date +"%Y%m%d_%H%M%S")
|
||||||
|
local backup_name="wg-backup-${operation}-${timestamp}"
|
||||||
|
local backup_path="${BACKUP_DIR}/${backup_name}"
|
||||||
|
|
||||||
|
log_info "Creating configuration backup: ${backup_name}"
|
||||||
|
|
||||||
|
# Create backup directory if it doesn't exist
|
||||||
|
mkdir -p "${BACKUP_DIR}"
|
||||||
|
chmod 700 "${BACKUP_DIR}"
|
||||||
|
|
||||||
|
# Create backup directory
|
||||||
|
mkdir -p "${backup_path}"
|
||||||
|
|
||||||
|
# Backup WireGuard configurations
|
||||||
|
if [[ -d "/etc/wireguard" ]]; then
|
||||||
|
cp -a /etc/wireguard "${backup_path}/" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Backup nftables configurations
|
||||||
|
if [[ -f "/etc/nftables.conf" ]]; then
|
||||||
|
cp -a /etc/nftables.conf "${backup_path}/" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -d "/etc/nftables.d" ]]; then
|
||||||
|
cp -a /etc/nftables.d "${backup_path}/" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Backup sysctl configuration
|
||||||
|
if [[ -f "/etc/sysctl.d/99-wireguard.conf" ]]; then
|
||||||
|
cp -a /etc/sysctl.d/99-wireguard.conf "${backup_path}/" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create backup metadata
|
||||||
|
cat > "${backup_path}/backup-info.txt" <<EOF
|
||||||
|
Backup created: ${timestamp}
|
||||||
|
Operation: ${operation}
|
||||||
|
Hostname: $(hostname)
|
||||||
|
WireGuard interface: ${WGI_WG_INTERFACE}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Set restrictive permissions
|
||||||
|
chmod -R 600 "${backup_path}"
|
||||||
|
|
||||||
|
# Apply retention policy - keep only last 10 backups
|
||||||
|
apply_retention_policy
|
||||||
|
|
||||||
|
log_info "Backup created successfully: ${backup_path}"
|
||||||
|
echo "${backup_path}"
|
||||||
|
}
|
||||||
|
|
||||||
|
apply_retention_policy() {
|
||||||
|
if [[ ! -d "${BACKUP_DIR}" ]]; then
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
# List all backups sorted by modification time (oldest first)
|
||||||
|
local backups=($(ls -t "${BACKUP_DIR}" 2>/dev/null | grep -E '^wg-backup-'))
|
||||||
|
|
||||||
|
# If we have more than 10 backups, remove oldest backups
|
||||||
|
if [[ ${#backups[@]} -gt 10 ]]; then
|
||||||
|
log_info "Applying retention policy (keeping last 10 backups)..."
|
||||||
|
local to_remove=(${backups[@]:10})
|
||||||
|
|
||||||
|
for backup in "${to_remove[@]}"; do
|
||||||
|
log_info "Removing old backup: ${backup}"
|
||||||
|
rm -rf "${BACKUP_DIR}/${backup}"
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Rollback Functions
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
rollback_installation() {
|
||||||
|
log_warn "Rolling back installation..."
|
||||||
|
|
||||||
|
# Stop services
|
||||||
|
systemctl stop wg-quick@${WGI_WG_INTERFACE}.service 2>/dev/null || true
|
||||||
|
systemctl disable wg-quick@${WGI_WG_INTERFACE}.service 2>/dev/null || true
|
||||||
|
|
||||||
|
# Restore from backup if exists
|
||||||
|
if [[ -d "${ROLLBACK_BACKUP_DIR}" ]]; then
|
||||||
|
log_info "Restoring from backup: ${ROLLBACK_BACKUP_DIR}"
|
||||||
|
|
||||||
|
if [[ -f "${ROLLBACK_BACKUP_DIR}/wireguard.conf" ]]; then
|
||||||
|
cp "${ROLLBACK_BACKUP_DIR}/wireguard.conf" "${WG_CONFIG}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -f "${ROLLBACK_BACKUP_DIR}/nftables.conf" ]]; then
|
||||||
|
cp "${ROLLBACK_BACKUP_DIR}/nftables.conf" /etc/nftables.conf
|
||||||
|
nft -f /etc/nftables.conf 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -d "${ROLLBACK_BACKUP_DIR}/conf.d" ]]; then
|
||||||
|
rm -rf "${CONF_D_DIR}"
|
||||||
|
cp -r "${ROLLBACK_BACKUP_DIR}/conf.d" "${CONF_D_DIR}"
|
||||||
|
chmod 700 "${CONF_D_DIR}"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Bring down interface
|
||||||
|
wg-quick down ${WGI_WG_INTERFACE} 2>/dev/null || true
|
||||||
|
|
||||||
|
log_warn "Rollback complete. Please review the logs and try again."
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Interactive Configuration
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
prompt_configuration() {
|
||||||
|
echo ""
|
||||||
|
echo "=== WireGuard VPN Server Configuration ==="
|
||||||
|
echo ""
|
||||||
|
echo "Press Enter to accept the default value (shown in brackets)"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Server Domain
|
||||||
|
if [[ -z "${WGI_SERVER_DOMAIN}" ]]; then
|
||||||
|
read -p "Server Domain [e.g., vpn.example.com]: " input_domain
|
||||||
|
WGI_SERVER_DOMAIN="${input_domain}"
|
||||||
|
else
|
||||||
|
echo "Server Domain: ${WGI_SERVER_DOMAIN}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Port
|
||||||
|
if [[ -z "${WGI_WG_PORT}" ]] || [[ "${WGI_WG_PORT}" == "51820" ]]; then
|
||||||
|
read -p "WireGuard Port [${WGI_WG_PORT}]: " input_port
|
||||||
|
if [[ -n "$input_port" ]]; then
|
||||||
|
WGI_WG_PORT="$input_port"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "WireGuard Port: ${WGI_WG_PORT}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# IPv4 Range
|
||||||
|
if [[ -z "${WGI_VPN_IPV4_RANGE}" ]] || [[ "${WGI_VPN_IPV4_RANGE}" == "10.10.69.0/24" ]]; then
|
||||||
|
read -p "VPN IPv4 Range [${WGI_VPN_IPV4_RANGE}]: " input_ipv4
|
||||||
|
if [[ -n "$input_ipv4" ]]; then
|
||||||
|
WGI_VPN_IPV4_RANGE="$input_ipv4"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "VPN IPv4 Range: ${WGI_VPN_IPV4_RANGE}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# IPv6 Range
|
||||||
|
if [[ -z "${WGI_VPN_IPV6_RANGE}" ]] || [[ "${WGI_VPN_IPV6_RANGE}" == "fd69:dead:beef:69::/64" ]]; then
|
||||||
|
read -p "VPN IPv6 Range [${WGI_VPN_IPV6_RANGE}]: " input_ipv6
|
||||||
|
if [[ -n "$input_ipv6" ]]; then
|
||||||
|
WGI_VPN_IPV6_RANGE="$input_ipv6"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "VPN IPv6 Range: ${WGI_VPN_IPV6_RANGE}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# DNS Servers
|
||||||
|
if [[ -z "${WGI_DNS_SERVERS}" ]] || [[ "${WGI_DNS_SERVERS}" == "8.8.8.8, 8.8.4.4" ]]; then
|
||||||
|
read -p "DNS Servers [${WGI_DNS_SERVERS}]: " input_dns
|
||||||
|
if [[ -n "$input_dns" ]]; then
|
||||||
|
WGI_DNS_SERVERS="$input_dns"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "DNS Servers: ${WGI_DNS_SERVERS}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Interface Name
|
||||||
|
if [[ -z "${WGI_WG_INTERFACE}" ]] || [[ "${WGI_WG_INTERFACE}" == "wg0" ]]; then
|
||||||
|
read -p "WireGuard Interface [${WGI_WG_INTERFACE}]: " input_interface
|
||||||
|
if [[ -n "$input_interface" ]]; then
|
||||||
|
WGI_WG_INTERFACE="$input_interface"
|
||||||
|
# Update derived paths
|
||||||
|
WG_CONFIG="/etc/wireguard/${WGI_WG_INTERFACE}.conf"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "WireGuard Interface: ${WGI_WG_INTERFACE}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Configuration Summary ==="
|
||||||
|
echo "Server Domain: ${WGI_SERVER_DOMAIN}"
|
||||||
|
echo "WireGuard Port: ${WGI_WG_PORT}"
|
||||||
|
echo "VPN IPv4 Range: ${WGI_VPN_IPV4_RANGE}"
|
||||||
|
echo "VPN IPv6 Range: ${WGI_VPN_IPV6_RANGE}"
|
||||||
|
echo "DNS Servers: ${WGI_DNS_SERVERS}"
|
||||||
|
echo "WireGuard Interface: ${WGI_WG_INTERFACE}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
read -p "Proceed with installation? (yes/no): " confirm
|
||||||
|
if [[ "${confirm}" != "yes" ]]; then
|
||||||
|
log_info "Installation cancelled by user"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Package Installation
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
install_packages() {
|
||||||
|
log_info "Installing packages..."
|
||||||
|
echo "Updating package lists..."
|
||||||
|
apt-get update -qq
|
||||||
|
|
||||||
|
echo "Installing WireGuard and dependencies..."
|
||||||
|
apt-get install -y wireguard wireguard-tools qrencode nftables
|
||||||
|
|
||||||
|
log_info "Package installation complete"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Cleanup Existing Installation
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
cleanup_existing_installation() {
|
||||||
|
log_info "Checking for existing WireGuard installation..."
|
||||||
|
|
||||||
|
# Stop and disable WireGuard service
|
||||||
|
if systemctl is-enabled --quiet wg-quick@${WGI_WG_INTERFACE}.service 2>/dev/null || systemctl list-unit-files | grep -q wg-quick@${WGI_WG_INTERFACE}.service; then
|
||||||
|
log_info "Stopping WireGuard service..."
|
||||||
|
echo "Stopping WireGuard service..."
|
||||||
|
systemctl stop wg-quick@${WGI_WG_INTERFACE}.service 2>/dev/null || true
|
||||||
|
echo "Disabling WireGuard service..."
|
||||||
|
systemctl disable wg-quick@${WGI_WG_INTERFACE}.service 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Stop and disable old client loader service
|
||||||
|
if systemctl is-enabled --quiet wg-load-clients.service 2>/dev/null || systemctl list-unit-files | grep -q wg-load-clients.service; then
|
||||||
|
echo "Removing old WireGuard client loader service..."
|
||||||
|
systemctl disable wg-load-clients.service 2>/dev/null || true
|
||||||
|
systemctl stop wg-load-clients.service 2>/dev/null || true
|
||||||
|
rm -f /etc/systemd/system/wg-load-clients.service
|
||||||
|
systemctl daemon-reload 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Bring down interface if running
|
||||||
|
if wg show ${WGI_WG_INTERFACE} &>/dev/null; then
|
||||||
|
echo "WireGuard interface is active, bringing down..."
|
||||||
|
wg-quick down ${WGI_WG_INTERFACE} 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Remove existing configuration directories
|
||||||
|
if [[ -d "/etc/wireguard" ]]; then
|
||||||
|
echo "Removing existing WireGuard configuration..."
|
||||||
|
rm -rf /etc/wireguard
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -d "/etc/clients.d" ]]; then
|
||||||
|
echo "Removing old client directory..."
|
||||||
|
rm -rf /etc/clients.d
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -d "/etc/wireguard/conf.d" ]]; then
|
||||||
|
echo "Removing existing config.d directory..."
|
||||||
|
rm -rf /etc/wireguard/conf.d
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -d "/etc/wireguard/peer.d" ]]; then
|
||||||
|
echo "Removing old peer.d directory..."
|
||||||
|
rm -rf /etc/wireguard/peer.d
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -d "/root/wireguard-clients" ]]; then
|
||||||
|
echo "Removing old client config directory..."
|
||||||
|
rm -rf /root/wireguard-clients
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Flush nftables rules
|
||||||
|
if command -v nft &> /dev/null; then
|
||||||
|
echo "Flushing nftables rules..."
|
||||||
|
nft flush ruleset 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Cleanup complete."
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Network Configuration
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
detect_public_interface() {
|
||||||
|
PUBLIC_INTERFACE=$(ip route get 8.8.8.8 | grep -oP 'dev \K\S+' | head -1)
|
||||||
|
log_info "Public interface detected: ${PUBLIC_INTERFACE}"
|
||||||
|
echo "Public interface: ${PUBLIC_INTERFACE}"
|
||||||
|
}
|
||||||
|
|
||||||
|
enable_ip_forwarding() {
|
||||||
|
log_info "Enabling IP forwarding..."
|
||||||
|
echo "Enabling IP forwarding..."
|
||||||
|
|
||||||
|
cat > /etc/sysctl.d/99-wireguard.conf <<EOF
|
||||||
|
net.ipv4.ip_forward = 1
|
||||||
|
net.ipv6.conf.all.forwarding = 1
|
||||||
|
EOF
|
||||||
|
|
||||||
|
sysctl -p /etc/sysctl.d/99-wireguard.conf
|
||||||
|
log_info "IP forwarding enabled"
|
||||||
|
}
|
||||||
|
|
||||||
|
configure_nftables() {
|
||||||
|
log_info "Configuring nftables firewall..."
|
||||||
|
echo "Configuring nftables firewall..."
|
||||||
|
|
||||||
|
mkdir -p /etc/nftables.d
|
||||||
|
|
||||||
|
# Create wireguard config in /etc/nftables.d
|
||||||
|
cat > /etc/nftables.d/wireguard.conf <<EOF
|
||||||
|
#!/usr/sbin/nft -f
|
||||||
|
# nftables configuration for WireGuard VPN
|
||||||
|
|
||||||
|
flush ruleset
|
||||||
|
|
||||||
|
table inet wireguard {
|
||||||
|
chain prerouting {
|
||||||
|
type filter hook prerouting priority -150;
|
||||||
|
|
||||||
|
# Rate limiting for SSH (3 connections per minute, burst 5)
|
||||||
|
tcp dport 22 ct state new limit rate 3/minute burst 5 packets accept
|
||||||
|
tcp dport 22 ct state new limit rate 3/minute burst 5 packets drop
|
||||||
|
|
||||||
|
# Rate limiting for WireGuard (${WGI_WG_PORT})
|
||||||
|
udp dport ${WGI_WG_PORT} limit rate 10/second burst 20 packets accept
|
||||||
|
udp dport ${WGI_WG_PORT} limit rate 10/second burst 20 packets drop
|
||||||
|
}
|
||||||
|
|
||||||
|
chain input {
|
||||||
|
type filter hook input priority 0; policy drop;
|
||||||
|
|
||||||
|
iifname lo accept
|
||||||
|
|
||||||
|
# Connection tracking bypass for WireGuard UDP traffic
|
||||||
|
iifname "${PUBLIC_INTERFACE}" udp dport ${WGI_WG_PORT} notrack
|
||||||
|
|
||||||
|
ct state established,related accept
|
||||||
|
ct state invalid drop
|
||||||
|
|
||||||
|
# Allow SSH
|
||||||
|
tcp dport 22 accept
|
||||||
|
|
||||||
|
# Allow WireGuard UDP traffic (already tracked via notrack)
|
||||||
|
udp dport ${WGI_WG_PORT} accept
|
||||||
|
|
||||||
|
# ICMPv4
|
||||||
|
icmp type { echo-request, echo-reply } accept
|
||||||
|
|
||||||
|
# ICMPv6 - ensure neighbor discovery is allowed
|
||||||
|
icmpv6 type { echo-request, echo-reply, nd-neighbor-solicit, nd-neighbor-advert, nd-router-solicit, nd-router-advert } accept
|
||||||
|
}
|
||||||
|
|
||||||
|
chain forward {
|
||||||
|
type filter hook forward priority 0; policy drop;
|
||||||
|
|
||||||
|
ct state established,related accept
|
||||||
|
iifname ${WGI_WG_INTERFACE} accept
|
||||||
|
oifname ${WGI_WG_INTERFACE} accept
|
||||||
|
}
|
||||||
|
|
||||||
|
chain output {
|
||||||
|
type filter hook output priority 0; policy accept;
|
||||||
|
|
||||||
|
# Connection tracking bypass for WireGuard UDP traffic
|
||||||
|
oifname "${PUBLIC_INTERFACE}" udp dport ${WGI_WG_PORT} notrack
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
table ip nat {
|
||||||
|
chain postrouting {
|
||||||
|
type nat hook postrouting priority 100; policy accept;
|
||||||
|
|
||||||
|
# TCP MSS clamping for MTU issues (clamp to 1360)
|
||||||
|
oifname "${PUBLIC_INTERFACE}" tcp flags syn tcp option maxseg size set 1360
|
||||||
|
|
||||||
|
oifname "${PUBLIC_INTERFACE}" ip saddr ${WGI_VPN_IPV4_RANGE} masquerade
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
table ip6 nat {
|
||||||
|
chain postrouting {
|
||||||
|
type nat hook postrouting priority 100; policy accept;
|
||||||
|
|
||||||
|
# TCP MSS clamping for IPv6 MTU issues
|
||||||
|
oifname "${PUBLIC_INTERFACE}" tcp flags syn tcp option maxseg size set 1360
|
||||||
|
|
||||||
|
oifname "${PUBLIC_INTERFACE}" ip6 saddr ${WGI_VPN_IPV6_RANGE} masquerade
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Create /etc/nftables.conf that includes wireguard.conf
|
||||||
|
cat > /etc/nftables.conf <<EOF
|
||||||
|
#!/usr/sbin/nft -f
|
||||||
|
# nftables configuration automatically generated by wg-install.sh
|
||||||
|
flush ruleset
|
||||||
|
include "/etc/nftables.d/wireguard.conf"
|
||||||
|
EOF
|
||||||
|
|
||||||
|
chmod 600 /etc/nftables.conf
|
||||||
|
chmod 600 /etc/nftables.d/wireguard.conf
|
||||||
|
|
||||||
|
# Validate nftables configuration before applying
|
||||||
|
echo "Validating nftables configuration..."
|
||||||
|
if ! nft check -f /etc/nftables.conf; then
|
||||||
|
log_error "nftables configuration validation failed"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "nftables configuration is valid"
|
||||||
|
|
||||||
|
# Apply nftables rules
|
||||||
|
nft -f /etc/nftables.conf
|
||||||
|
|
||||||
|
# Enable and start nftables service
|
||||||
|
echo "Enabling nftables service..."
|
||||||
|
systemctl enable nftables.service
|
||||||
|
systemctl start nftables.service
|
||||||
|
|
||||||
|
# Verify nftables is running
|
||||||
|
if ! systemctl is-active --quiet nftables.service; then
|
||||||
|
log_error "nftables service failed to start"
|
||||||
|
echo "=== Service Status ==="
|
||||||
|
systemctl status nftables.service
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "nftables service started successfully"
|
||||||
|
log_info "nftables configuration complete"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Server Key Generation
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
generate_server_keys() {
|
||||||
|
log_info "Generating server keys..."
|
||||||
|
echo "Generating server keys..."
|
||||||
|
|
||||||
|
mkdir -p /etc/wireguard
|
||||||
|
cd /etc/wireguard
|
||||||
|
|
||||||
|
# Generate keys with atomic write
|
||||||
|
local temp_private=$(mktemp)
|
||||||
|
local temp_public=$(mktemp)
|
||||||
|
wg genkey > "$temp_private"
|
||||||
|
wg pubkey < "$temp_private" > "$temp_public"
|
||||||
|
SERVER_PRIVATE_KEY=$(cat "$temp_private")
|
||||||
|
SERVER_PUBLIC_KEY=$(cat "$temp_public")
|
||||||
|
|
||||||
|
# Move to final location with proper permissions
|
||||||
|
mv "$temp_private" server_private.key
|
||||||
|
mv "$temp_public" server_public.key
|
||||||
|
chmod 600 server_private.key
|
||||||
|
chmod 644 server_public.key
|
||||||
|
|
||||||
|
log_info "Server keys generated and secured"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# WireGuard Configuration
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
configure_wireguard() {
|
||||||
|
log_info "Configuring WireGuard interface..."
|
||||||
|
echo "Configuring WireGuard interface..."
|
||||||
|
|
||||||
|
# Create config.d directory
|
||||||
|
mkdir -p "${CONF_D_DIR}"
|
||||||
|
|
||||||
|
# Create server.conf in conf.d directory
|
||||||
|
cat > "${SERVER_CONF}" <<EOF
|
||||||
|
[Interface]
|
||||||
|
PrivateKey = ${SERVER_PRIVATE_KEY}
|
||||||
|
Address = ${WGI_VPN_IPV4_RANGE%.*}.1/${WGI_VPN_IPV4_RANGE#*/}, ${WGI_VPN_IPV6_RANGE%:*}:1/${WGI_VPN_IPV6_RANGE#*/}
|
||||||
|
ListenPort = ${WGI_WG_PORT}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Set permissions
|
||||||
|
chmod 600 "${SERVER_CONF}"
|
||||||
|
chmod 700 "${CONF_D_DIR}"
|
||||||
|
chmod 600 /etc/wireguard/server_private.key
|
||||||
|
|
||||||
|
log_info "WireGuard configuration created"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Systemd Service Setup
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
setup_systemd_service() {
|
||||||
|
log_info "Setting up systemd service..."
|
||||||
|
echo "Setting up WireGuard systemd service..."
|
||||||
|
|
||||||
|
# Create main config file (will be populated by load-clients)
|
||||||
|
cat > "${WG_CONFIG}" <<EOF
|
||||||
|
[Interface]
|
||||||
|
PrivateKey = ${SERVER_PRIVATE_KEY}
|
||||||
|
Address = ${WGI_VPN_IPV4_RANGE%.*}.1/${WGI_VPN_IPV4_RANGE#*/}, ${WGI_VPN_IPV6_RANGE%:*}:1/${WGI_VPN_IPV6_RANGE#*/}
|
||||||
|
ListenPort = ${WGI_WG_PORT}
|
||||||
|
EOF
|
||||||
|
chmod 600 "${WG_CONFIG}"
|
||||||
|
|
||||||
|
# Enable WireGuard service
|
||||||
|
systemctl enable wg-quick@${WGI_WG_INTERFACE}.service
|
||||||
|
|
||||||
|
# Start WireGuard service
|
||||||
|
echo ""
|
||||||
|
echo "Starting WireGuard..."
|
||||||
|
|
||||||
|
# Ensure interface is down before starting
|
||||||
|
if wg show ${WGI_WG_INTERFACE} &>/dev/null; then
|
||||||
|
wg-quick down ${WGI_WG_INTERFACE} 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
systemctl start wg-quick@${WGI_WG_INTERFACE}.service
|
||||||
|
|
||||||
|
# Wait for WireGuard to initialize
|
||||||
|
sleep 2
|
||||||
|
|
||||||
|
# Verify WireGuard is running
|
||||||
|
if ! systemctl is-active --quiet wg-quick@${WGI_WG_INTERFACE}.service; then
|
||||||
|
log_error "WireGuard service failed to start"
|
||||||
|
echo "=== Service Status ==="
|
||||||
|
systemctl status wg-quick@${WGI_WG_INTERFACE}.service
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Verify correct listening port
|
||||||
|
ACTUAL_PORT=$(wg show ${WGI_WG_INTERFACE} listen-port)
|
||||||
|
if [[ "$ACTUAL_PORT" != "$WGI_WG_PORT" ]]; then
|
||||||
|
log_error "WireGuard listening on port $ACTUAL_PORT instead of $WGI_WG_PORT"
|
||||||
|
echo "=== Config File ==="
|
||||||
|
grep ListenPort "${WG_CONFIG}"
|
||||||
|
echo "=== Running Interface ==="
|
||||||
|
wg show ${WGI_WG_INTERFACE}
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "WireGuard started successfully on port $ACTUAL_PORT"
|
||||||
|
log_info "WireGuard service started successfully"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Main Installation Function
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
main() {
|
||||||
|
log_info "=== WireGuard VPN Installation for Debian 13 ==="
|
||||||
|
|
||||||
|
# Validate configuration
|
||||||
|
validate_server_domain "${WGI_SERVER_DOMAIN}"
|
||||||
|
validate_port_range "${WGI_WG_PORT}"
|
||||||
|
validate_cidr "${WGI_VPN_IPV4_RANGE}" false
|
||||||
|
validate_cidr "${WGI_VPN_IPV6_RANGE}" true
|
||||||
|
validate_dns_servers "${WGI_DNS_SERVERS}"
|
||||||
|
|
||||||
|
# Interactive configuration if not all values set
|
||||||
|
if [[ -z "${WGI_SERVER_DOMAIN}" ]] || \
|
||||||
|
[[ -z "${WGI_WG_PORT}" ]] || \
|
||||||
|
[[ -z "${WGI_VPN_IPV4_RANGE}" ]] || \
|
||||||
|
[[ -z "${WGI_VPN_IPV6_RANGE}" ]]; then
|
||||||
|
prompt_configuration
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Print configuration
|
||||||
|
echo ""
|
||||||
|
echo "=== WireGuard VPN Installation for Debian 13 ==="
|
||||||
|
echo "Server: ${WGI_SERVER_DOMAIN}"
|
||||||
|
echo "IPv4 VPN range: ${WGI_VPN_IPV4_RANGE}"
|
||||||
|
echo "IPv6 VPN range: ${WGI_VPN_IPV6_RANGE}"
|
||||||
|
echo "Port: ${WGI_WG_PORT}"
|
||||||
|
echo "Interface: ${WGI_WG_INTERFACE}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Pre-installation validation
|
||||||
|
pre_install_validation
|
||||||
|
|
||||||
|
# Auto-backup before install (only if config exists)
|
||||||
|
if [[ -f "${WG_CONFIG}" ]] || [[ -f "/etc/nftables.conf" ]]; then
|
||||||
|
backup_config "install-pre"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Enable rollback flag
|
||||||
|
ROLLBACK_NEEDED=true
|
||||||
|
|
||||||
|
# Create backup directory for potential rollback
|
||||||
|
ROLLBACK_BACKUP_DIR=$(mktemp -d)
|
||||||
|
log_info "Created rollback backup directory: ${ROLLBACK_BACKUP_DIR}"
|
||||||
|
|
||||||
|
# Backup existing configs if they exist
|
||||||
|
if [[ -f "${WG_CONFIG}" ]]; then
|
||||||
|
cp "${WG_CONFIG}" "${ROLLBACK_BACKUP_DIR}/wireguard.conf"
|
||||||
|
log_info "Backed up existing WireGuard config"
|
||||||
|
fi
|
||||||
|
if [[ -f "/etc/nftables.conf" ]]; then
|
||||||
|
cp /etc/nftables.conf "${ROLLBACK_BACKUP_DIR}/nftables.conf"
|
||||||
|
log_info "Backed up existing nftables config"
|
||||||
|
fi
|
||||||
|
if [[ -d "${CONF_D_DIR}" ]]; then
|
||||||
|
cp -r "${CONF_D_DIR}" "${ROLLBACK_BACKUP_DIR}/conf.d"
|
||||||
|
log_info "Backed up existing client configs"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Cleanup existing installation
|
||||||
|
cleanup_existing_installation
|
||||||
|
|
||||||
|
# Install packages
|
||||||
|
install_packages
|
||||||
|
|
||||||
|
# Detect public interface
|
||||||
|
detect_public_interface
|
||||||
|
|
||||||
|
# Enable IP forwarding
|
||||||
|
enable_ip_forwarding
|
||||||
|
|
||||||
|
# Configure nftables firewall
|
||||||
|
configure_nftables
|
||||||
|
|
||||||
|
# Generate server keys
|
||||||
|
generate_server_keys
|
||||||
|
|
||||||
|
# Configure WireGuard
|
||||||
|
configure_wireguard
|
||||||
|
|
||||||
|
# Setup systemd service
|
||||||
|
setup_systemd_service
|
||||||
|
|
||||||
|
# Disable rollback flag - installation successful
|
||||||
|
ROLLBACK_NEEDED=false
|
||||||
|
|
||||||
|
# Clean up rollback backup directory
|
||||||
|
if [[ -d "${ROLLBACK_BACKUP_DIR}" ]]; then
|
||||||
|
rm -rf "${ROLLBACK_BACKUP_DIR}"
|
||||||
|
log_info "Cleaned up rollback backup directory"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Installation complete
|
||||||
|
echo ""
|
||||||
|
log_info "=== Installation Complete ==="
|
||||||
|
echo "=== Installation Complete ==="
|
||||||
|
echo ""
|
||||||
|
echo "Server Public Key: ${SERVER_PUBLIC_KEY}"
|
||||||
|
echo "Endpoint: ${WGI_SERVER_DOMAIN}:${WGI_WG_PORT}"
|
||||||
|
echo "VPN IPv4 Range: ${WGI_VPN_IPV4_RANGE}"
|
||||||
|
echo "VPN IPv6 Range: ${WGI_VPN_IPV6_RANGE}"
|
||||||
|
echo ""
|
||||||
|
echo "Use 'wireguard.sh add <name> [--psk]' to add clients"
|
||||||
|
echo "Use 'wireguard.sh list' to list clients"
|
||||||
|
echo "Configs will be merged from: ${CONF_D_DIR}"
|
||||||
|
echo " - ${SERVER_CONF} (server interface)"
|
||||||
|
echo " - ${CONF_D_DIR}/client-*.conf (client peers)"
|
||||||
|
echo ""
|
||||||
|
echo "Check status: wg show"
|
||||||
|
echo "System status: systemctl status wg-quick@${WGI_WG_INTERFACE}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
log_info "Installation completed successfully"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Entry Point
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
# Only run main if script is executed directly (not sourced)
|
||||||
|
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||||
|
# Show usage if help requested
|
||||||
|
if [[ "${1:-}" == "-h" ]] || [[ "${1:-}" == "--help" ]] || [[ "${1:-}" == "help" ]]; then
|
||||||
|
echo "Usage: $0 [options]"
|
||||||
|
echo ""
|
||||||
|
echo "Environment Variables (prefix with WGI_):"
|
||||||
|
echo " WGI_SERVER_DOMAIN Server domain (e.g., vpn.example.com)"
|
||||||
|
echo " WGI_WG_PORT WireGuard UDP port (default: 51820)"
|
||||||
|
echo " WGI_VPN_IPV4_RANGE IPv4 VPN range (default: 10.10.69.0/24)"
|
||||||
|
echo " WGI_VPN_IPV6_RANGE IPv6 VPN range (default: fd69:dead:beef:69::/64)"
|
||||||
|
echo " WGI_DNS_SERVERS DNS servers (default: 8.8.8.8, 8.8.4.4)"
|
||||||
|
echo " WGI_WG_INTERFACE WireGuard interface name (default: wg0)"
|
||||||
|
echo ""
|
||||||
|
echo "Examples:"
|
||||||
|
echo " # Interactive installation"
|
||||||
|
echo " sudo $0"
|
||||||
|
echo ""
|
||||||
|
echo " # Non-interactive with environment variables"
|
||||||
|
echo " sudo WGI_SERVER_DOMAIN=vpn.example.com $0"
|
||||||
|
echo ""
|
||||||
|
echo " # Custom port and VPN range"
|
||||||
|
echo " sudo WGI_SERVER_DOMAIN=vpn.example.com WGI_WG_PORT=443 $0"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Run main installation
|
||||||
|
main
|
||||||
|
fi
|
||||||
843
wireguard.sh
843
wireguard.sh
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user