initial commit
This commit is contained in:
148
protonvpn-wg-confgen/pkg/timeutil/duration.go
Normal file
148
protonvpn-wg-confgen/pkg/timeutil/duration.go
Normal file
@@ -0,0 +1,148 @@
|
||||
// Package timeutil provides time-related utility functions.
|
||||
package timeutil
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// pluralize returns singular or plural form based on count
|
||||
func pluralize(count int, singular, plural string) string {
|
||||
if count == 1 {
|
||||
return fmt.Sprintf("1 %s", singular)
|
||||
}
|
||||
return fmt.Sprintf("%d %s", count, plural)
|
||||
}
|
||||
|
||||
// formatTimeComponents formats non-zero time components into a string
|
||||
func formatTimeComponents(parts []string) string {
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
// formatWeeksAndDays formats weeks and remaining days
|
||||
func formatWeeksAndDays(days int) []string {
|
||||
var parts []string
|
||||
weeks := days / 7
|
||||
remainingDays := days % 7
|
||||
|
||||
if weeks > 0 {
|
||||
parts = append(parts, pluralize(weeks, "week", "weeks"))
|
||||
}
|
||||
if remainingDays > 0 {
|
||||
parts = append(parts, pluralize(remainingDays, "day", "days"))
|
||||
}
|
||||
return parts
|
||||
}
|
||||
|
||||
// formatHoursMinutes formats hours and minutes
|
||||
func formatHoursMinutes(hours, minutes int) string {
|
||||
switch {
|
||||
case hours > 0 && minutes > 0:
|
||||
return fmt.Sprintf("%d hours %d minutes", hours, minutes)
|
||||
case hours > 0:
|
||||
return fmt.Sprintf("%d hours", hours)
|
||||
default:
|
||||
return fmt.Sprintf("%d minutes", minutes)
|
||||
}
|
||||
}
|
||||
|
||||
// formatDaysHoursMinutes formats days, hours, and minutes into a string
|
||||
func formatDaysHoursMinutes(days, hours, minutes int) string {
|
||||
// Simple days format (less than a week)
|
||||
if days < 7 {
|
||||
return formatSimpleDays(days, hours, minutes)
|
||||
}
|
||||
|
||||
// Weeks format
|
||||
parts := formatWeeksAndDays(days)
|
||||
if hours > 0 || minutes > 0 {
|
||||
parts = append(parts, formatHoursMinutes(hours, minutes))
|
||||
}
|
||||
return formatTimeComponents(parts)
|
||||
}
|
||||
|
||||
// formatSimpleDays formats days less than a week with hours and minutes
|
||||
func formatSimpleDays(days, hours, minutes int) string {
|
||||
if hours == 0 && minutes == 0 {
|
||||
return pluralize(days, "day", "days")
|
||||
}
|
||||
if minutes == 0 {
|
||||
return fmt.Sprintf("%d days %d hours", days, hours)
|
||||
}
|
||||
return fmt.Sprintf("%d days %d hours %d minutes", days, hours, minutes)
|
||||
}
|
||||
|
||||
// HumanizeDuration converts a duration to a human-readable format with high precision.
|
||||
// It shows progressively less detail for longer durations:
|
||||
// - Less than a minute: "less than a minute"
|
||||
// - Less than an hour: "X minutes"
|
||||
// - Less than a day: "X hours Y minutes"
|
||||
// - Less than a week: "X days Y hours Z minutes"
|
||||
// - Less than a month: "X weeks Y days Z hours W minutes"
|
||||
// - Longer periods: months and years with appropriate detail
|
||||
func HumanizeDuration(d time.Duration) string {
|
||||
if d < 0 {
|
||||
return "expired"
|
||||
}
|
||||
|
||||
if d < time.Minute {
|
||||
return "less than a minute"
|
||||
}
|
||||
|
||||
if d < time.Hour {
|
||||
return pluralize(int(d.Minutes()), "minute", "minutes")
|
||||
}
|
||||
|
||||
if d < 24*time.Hour {
|
||||
return formatHoursDuration(d)
|
||||
}
|
||||
|
||||
days := int(d.Hours() / 24)
|
||||
hours := int(d.Hours()) % 24
|
||||
minutes := int(d.Minutes()) % 60
|
||||
|
||||
if days < 30 {
|
||||
return formatDaysHoursMinutes(days, hours, minutes)
|
||||
}
|
||||
|
||||
if days < 365 {
|
||||
return formatMonthsDuration(days)
|
||||
}
|
||||
|
||||
return formatYearsDuration(days)
|
||||
}
|
||||
|
||||
// formatHoursDuration formats durations less than a day
|
||||
func formatHoursDuration(d time.Duration) string {
|
||||
hours := int(d.Hours())
|
||||
minutes := int(d.Minutes()) % 60
|
||||
if minutes == 0 {
|
||||
return pluralize(hours, "hour", "hours")
|
||||
}
|
||||
return fmt.Sprintf("%d hours %d minutes", hours, minutes)
|
||||
}
|
||||
|
||||
// formatMonthsDuration formats durations between 30 and 365 days
|
||||
func formatMonthsDuration(days int) string {
|
||||
months := days / 30
|
||||
remainingDays := days % 30
|
||||
if remainingDays == 0 {
|
||||
return pluralize(months, "month", "months")
|
||||
}
|
||||
return fmt.Sprintf("%d months %d days", months, remainingDays)
|
||||
}
|
||||
|
||||
// formatYearsDuration formats durations of 365 days or more
|
||||
func formatYearsDuration(days int) string {
|
||||
years := days / 365
|
||||
remainingDays := days % 365
|
||||
if remainingDays == 0 {
|
||||
return pluralize(years, "year", "years")
|
||||
}
|
||||
if remainingDays < 30 {
|
||||
return fmt.Sprintf("%d years %d days", years, remainingDays)
|
||||
}
|
||||
months := remainingDays / 30
|
||||
return fmt.Sprintf("%d years %d months", years, months)
|
||||
}
|
||||
81
protonvpn-wg-confgen/pkg/timeutil/parser.go
Normal file
81
protonvpn-wg-confgen/pkg/timeutil/parser.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package timeutil
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// parseDaysDuration handles duration strings like "7d", "30d", "365d"
|
||||
func parseDaysDuration(durationStr string) (time.Duration, error) {
|
||||
if !strings.HasSuffix(durationStr, "d") {
|
||||
return 0, fmt.Errorf("not a day duration format")
|
||||
}
|
||||
|
||||
daysStr := strings.TrimSuffix(durationStr, "d")
|
||||
days, err := strconv.Atoi(daysStr)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("invalid day value: %s", daysStr)
|
||||
}
|
||||
|
||||
return time.Duration(days) * 24 * time.Hour, nil
|
||||
}
|
||||
|
||||
// ParseDuration parses a duration string that can be either Go duration or "Xd" format
|
||||
func ParseDuration(durationStr string) (time.Duration, error) {
|
||||
// Try days format first
|
||||
if duration, err := parseDaysDuration(durationStr); err == nil {
|
||||
return duration, nil
|
||||
}
|
||||
|
||||
// Fall back to standard Go duration
|
||||
return time.ParseDuration(durationStr)
|
||||
}
|
||||
|
||||
// ParseToMinutes parses a duration string and converts it to minutes for the ProtonVPN API.
|
||||
// Accepts formats like "7d", "30d", "365d", "30m", "24h", "1h30m".
|
||||
// Returns the duration in "XXX min" format as expected by the API.
|
||||
func ParseToMinutes(durationStr string) (string, error) {
|
||||
duration, err := ParseDuration(durationStr)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid duration format: %s", durationStr)
|
||||
}
|
||||
|
||||
// Convert to minutes
|
||||
minutes := int(duration.Minutes())
|
||||
if minutes < 1 {
|
||||
return "", fmt.Errorf("duration must be at least 1 minute")
|
||||
}
|
||||
|
||||
// Max 365 days = 525600 minutes
|
||||
maxMinutes := 365 * 24 * 60
|
||||
if minutes > maxMinutes {
|
||||
return "", fmt.Errorf("duration cannot exceed 365 days")
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%d min", minutes), nil
|
||||
}
|
||||
|
||||
// ParseSessionDuration parses a session duration string for local caching.
|
||||
// Accepts formats like "7d", "30d", or standard Go duration strings.
|
||||
// Returns 0 to indicate "use API default".
|
||||
func ParseSessionDuration(durationStr string) (time.Duration, error) {
|
||||
// Handle "0" as use API default
|
||||
if durationStr == "0" {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
duration, err := ParseDuration(durationStr)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("invalid session duration format: %s", durationStr)
|
||||
}
|
||||
|
||||
// Validate max duration (30 days)
|
||||
maxDuration := 30 * 24 * time.Hour
|
||||
if duration > maxDuration {
|
||||
return 0, fmt.Errorf("session duration cannot exceed 30 days (got: %s)", duration)
|
||||
}
|
||||
|
||||
return duration, nil
|
||||
}
|
||||
31
protonvpn-wg-confgen/pkg/validation/username.go
Normal file
31
protonvpn-wg-confgen/pkg/validation/username.go
Normal file
@@ -0,0 +1,31 @@
|
||||
// Package validation provides input validation utilities.
|
||||
package validation
|
||||
|
||||
import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// CleanUsername removes email domain suffixes from username.
|
||||
// ProtonVPN usernames don't include the email domain.
|
||||
func CleanUsername(username string) string {
|
||||
username = strings.TrimSpace(username)
|
||||
username = strings.TrimSuffix(username, "@protonmail.com")
|
||||
username = strings.TrimSuffix(username, "@proton.me")
|
||||
username = strings.TrimSuffix(username, "@pm.me")
|
||||
return username
|
||||
}
|
||||
|
||||
// IsValidCountryCode checks if a country code is valid (2 letters).
|
||||
func IsValidCountryCode(code string) bool {
|
||||
return len(code) == 2 && isAlpha(code)
|
||||
}
|
||||
|
||||
// isAlpha checks if a string contains only alphabetic characters.
|
||||
func isAlpha(s string) bool {
|
||||
for _, r := range s {
|
||||
if (r < 'A' || r > 'Z') && (r < 'a' || r > 'z') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
140
protonvpn-wg-confgen/pkg/wireguard/config.go
Normal file
140
protonvpn-wg-confgen/pkg/wireguard/config.go
Normal file
@@ -0,0 +1,140 @@
|
||||
// Package wireguard generates WireGuard configuration files.
|
||||
package wireguard
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"text/template"
|
||||
"time"
|
||||
|
||||
"protonvpn-wg-confgen/internal/api"
|
||||
"protonvpn-wg-confgen/internal/config"
|
||||
"protonvpn-wg-confgen/internal/constants"
|
||||
)
|
||||
|
||||
// wireguardConfigTemplate is the template for generating WireGuard configuration
|
||||
const wireguardConfigTemplate = `[Interface]
|
||||
PrivateKey = {{.PrivateKey}}
|
||||
{{.AddressLine}}
|
||||
DNS = {{.DNS}}
|
||||
|
||||
[Peer]
|
||||
PublicKey = {{.PublicKey}}
|
||||
AllowedIPs = {{.AllowedIPs}}
|
||||
Endpoint = {{.Endpoint}}:{{.Port}}
|
||||
`
|
||||
|
||||
// configData holds the data for the WireGuard config template
|
||||
type configData struct {
|
||||
PrivateKey string
|
||||
AddressLine string
|
||||
DNS string
|
||||
PublicKey string
|
||||
AllowedIPs string
|
||||
Endpoint string
|
||||
Port int
|
||||
}
|
||||
|
||||
// ConfigGenerator generates WireGuard configuration files
|
||||
type ConfigGenerator struct {
|
||||
config *config.Config
|
||||
template *template.Template
|
||||
}
|
||||
|
||||
// NewConfigGenerator creates a new configuration generator
|
||||
func NewConfigGenerator(cfg *config.Config) *ConfigGenerator {
|
||||
tmpl := template.Must(template.New("wireguard").Parse(wireguardConfigTemplate))
|
||||
return &ConfigGenerator{
|
||||
config: cfg,
|
||||
template: tmpl,
|
||||
}
|
||||
}
|
||||
|
||||
// Generate creates a WireGuard configuration file
|
||||
func (g *ConfigGenerator) Generate(server *api.LogicalServer, physicalServer *api.PhysicalServer, privateKey string) error {
|
||||
content, err := g.buildConfig(server, physicalServer, privateKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := os.WriteFile(g.config.OutputFile, []byte(content), 0o600); err != nil {
|
||||
return fmt.Errorf("failed to write config file: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g *ConfigGenerator) buildConfig(server *api.LogicalServer, physicalServer *api.PhysicalServer, privateKey string) (string, error) {
|
||||
// Build metadata header
|
||||
metadata := g.buildMetadata(server, physicalServer)
|
||||
|
||||
data := configData{
|
||||
PrivateKey: privateKey,
|
||||
AddressLine: g.buildAddressLine(),
|
||||
DNS: strings.Join(g.config.DNSServers, ", "),
|
||||
PublicKey: physicalServer.X25519PublicKey,
|
||||
AllowedIPs: strings.Join(g.config.AllowedIPs, ", "),
|
||||
Endpoint: physicalServer.EntryIP,
|
||||
Port: constants.WireGuardPort,
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := g.template.Execute(&buf, data); err != nil {
|
||||
return "", fmt.Errorf("failed to execute template: %w", err)
|
||||
}
|
||||
|
||||
return metadata + buf.String(), nil
|
||||
}
|
||||
|
||||
func (g *ConfigGenerator) buildAddressLine() string {
|
||||
if g.config.EnableIPv6 {
|
||||
return fmt.Sprintf("Address = %s, %s", constants.WireGuardIPv4, constants.WireGuardIPv6)
|
||||
}
|
||||
return fmt.Sprintf("Address = %s", constants.WireGuardIPv4)
|
||||
}
|
||||
|
||||
func (g *ConfigGenerator) buildMetadata(server *api.LogicalServer, physicalServer *api.PhysicalServer) string {
|
||||
var metadata strings.Builder
|
||||
|
||||
metadata.WriteString("# ProtonVPN WireGuard Configuration\n")
|
||||
metadata.WriteString(fmt.Sprintf("# Generated: %s\n", time.Now().Format("2006-01-02 15:04:05 MST")))
|
||||
if g.config.DeviceName != "" {
|
||||
metadata.WriteString(fmt.Sprintf("# Device: %s\n", g.config.DeviceName))
|
||||
}
|
||||
metadata.WriteString("#\n")
|
||||
metadata.WriteString("# Server Information:\n")
|
||||
metadata.WriteString(fmt.Sprintf("# - Name: %s\n", server.Name))
|
||||
metadata.WriteString(fmt.Sprintf("# - Country: %s\n", server.ExitCountry))
|
||||
metadata.WriteString(fmt.Sprintf("# - City: %s\n", server.City))
|
||||
metadata.WriteString(fmt.Sprintf("# - Tier: %s\n", api.GetTierName(server.Tier)))
|
||||
metadata.WriteString(fmt.Sprintf("# - Load: %d%%\n", server.Load))
|
||||
metadata.WriteString(fmt.Sprintf("# - Score: %.2f\n", server.Score))
|
||||
|
||||
// Add features if any
|
||||
features := api.GetFeatureNames(server.Features)
|
||||
if len(features) > 0 {
|
||||
metadata.WriteString(fmt.Sprintf("# - Features: %s\n", strings.Join(features, ", ")))
|
||||
}
|
||||
|
||||
// Add physical server info
|
||||
metadata.WriteString("#\n")
|
||||
metadata.WriteString("# Physical Server:\n")
|
||||
metadata.WriteString(fmt.Sprintf("# - ID: %s\n", physicalServer.ID))
|
||||
metadata.WriteString(fmt.Sprintf("# - Entry IP: %s\n", physicalServer.EntryIP))
|
||||
if physicalServer.ExitIP != physicalServer.EntryIP {
|
||||
metadata.WriteString(fmt.Sprintf("# - Exit IP: %s\n", physicalServer.ExitIP))
|
||||
}
|
||||
|
||||
// Add secure core routing info if applicable
|
||||
if server.EntryCountry != server.ExitCountry && server.EntryCountry != "" {
|
||||
metadata.WriteString("#\n")
|
||||
metadata.WriteString(fmt.Sprintf("# Secure Core Routing: %s → %s\n",
|
||||
server.EntryCountry, server.ExitCountry))
|
||||
}
|
||||
|
||||
metadata.WriteString("#\n\n")
|
||||
|
||||
return metadata.String()
|
||||
}
|
||||
119
protonvpn-wg-confgen/pkg/wireguard/config_test.go
Normal file
119
protonvpn-wg-confgen/pkg/wireguard/config_test.go
Normal file
@@ -0,0 +1,119 @@
|
||||
package wireguard
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"protonvpn-wg-confgen/internal/api"
|
||||
"protonvpn-wg-confgen/internal/config"
|
||||
)
|
||||
|
||||
func TestConfigGeneration(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
DNSServers: []string{"10.2.0.1"},
|
||||
AllowedIPs: []string{"0.0.0.0/0"},
|
||||
OutputFile: "test.conf",
|
||||
}
|
||||
|
||||
generator := NewConfigGenerator(cfg)
|
||||
|
||||
server := &api.LogicalServer{
|
||||
Name: "Test-Server",
|
||||
}
|
||||
|
||||
physicalServer := &api.PhysicalServer{
|
||||
EntryIP: "192.168.1.1",
|
||||
X25519PublicKey: "testPublicKey123=",
|
||||
}
|
||||
|
||||
privateKey := "testPrivateKey456="
|
||||
|
||||
result, err := generator.buildConfig(server, physicalServer, privateKey)
|
||||
if err != nil {
|
||||
t.Fatalf("buildConfig failed: %v", err)
|
||||
}
|
||||
|
||||
// Check that config starts with comment header
|
||||
if !strings.HasPrefix(result, "# ProtonVPN WireGuard Configuration") {
|
||||
t.Errorf("Expected config to start with header comment, got:\n%s", result[:100])
|
||||
}
|
||||
|
||||
// Check that metadata is present
|
||||
if !strings.Contains(result, "# - Name: Test-Server") {
|
||||
t.Errorf("Expected server name in metadata")
|
||||
}
|
||||
|
||||
// Check for proper WireGuard sections
|
||||
if !strings.Contains(result, "[Interface]") {
|
||||
t.Error("Expected [Interface] section")
|
||||
}
|
||||
|
||||
if !strings.Contains(result, "[Peer]") {
|
||||
t.Error("Expected [Peer] section")
|
||||
}
|
||||
|
||||
// Verify key content
|
||||
expectedContent := []string{
|
||||
"PrivateKey = testPrivateKey456=",
|
||||
"Address = 10.2.0.2/32",
|
||||
"DNS = 10.2.0.1",
|
||||
"PublicKey = testPublicKey123=",
|
||||
"AllowedIPs = 0.0.0.0/0",
|
||||
"Endpoint = 192.168.1.1:51820",
|
||||
}
|
||||
|
||||
for _, expected := range expectedContent {
|
||||
if !strings.Contains(result, expected) {
|
||||
t.Errorf("Expected config to contain '%s'\nGot:\n%s", expected, result)
|
||||
}
|
||||
}
|
||||
|
||||
// Check section order: [Interface] should come before [Peer]
|
||||
interfaceIdx := strings.Index(result, "[Interface]")
|
||||
peerIdx := strings.Index(result, "[Peer]")
|
||||
if interfaceIdx >= peerIdx {
|
||||
t.Error("[Interface] section should come before [Peer] section")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigGenerationWithIPv6(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
DNSServers: []string{"10.2.0.1", "2a07:b944::2:1"},
|
||||
AllowedIPs: []string{"0.0.0.0/0", "::/0"},
|
||||
OutputFile: "test.conf",
|
||||
EnableIPv6: true,
|
||||
}
|
||||
|
||||
generator := NewConfigGenerator(cfg)
|
||||
|
||||
server := &api.LogicalServer{
|
||||
Name: "Test-Server",
|
||||
}
|
||||
|
||||
physicalServer := &api.PhysicalServer{
|
||||
EntryIP: "192.168.1.1",
|
||||
X25519PublicKey: "testPublicKey123=",
|
||||
}
|
||||
|
||||
privateKey := "testPrivateKey456="
|
||||
|
||||
result, err := generator.buildConfig(server, physicalServer, privateKey)
|
||||
if err != nil {
|
||||
t.Fatalf("buildConfig failed: %v", err)
|
||||
}
|
||||
|
||||
// Check that IPv6 address is included
|
||||
if !strings.Contains(result, "Address = 10.2.0.2/32, 2a07:b944::2:2/128") {
|
||||
t.Errorf("Expected IPv6 address in config, got:\n%s", result)
|
||||
}
|
||||
|
||||
// Check DNS servers
|
||||
if !strings.Contains(result, "DNS = 10.2.0.1, 2a07:b944::2:1") {
|
||||
t.Errorf("Expected both DNS servers in config, got:\n%s", result)
|
||||
}
|
||||
|
||||
// Check AllowedIPs
|
||||
if !strings.Contains(result, "AllowedIPs = 0.0.0.0/0, ::/0") {
|
||||
t.Errorf("Expected both IPv4 and IPv6 in AllowedIPs, got:\n%s", result)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user