summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJames O'Doherty <james@theodohertyfamily.com>2026-05-29 19:30:26 -0400
committerJames O'Doherty <james@theodohertyfamily.com>2026-05-29 19:30:26 -0400
commitb1b68a4aa441d9ce39d05f85338e371a704dd601 (patch)
tree63491b88a18522eafddbd4b7525bb89bc2a04732
parent70096b533d42b684ab13651aaae884047e01e43d (diff)
feat(cli,parser): support custom profile names and overhaul WireGuard .conf parser for robustness
- CLI: - Add optional `[name]` argument to `wg-wrap profile import <path> [name]` to allow overriding the imported profile name. If not provided, it falls back to the derived filename. - Update `README.md` command documentation to reflect custom profile names and list the `wg-wrap profile stop <name>` subcommand. - Expand `internal/cli/profile_test.go` to cover derived vs custom-named profile imports. - WG Configuration Parser: - Overhaul `pkg/wgconf/wgconf.go` to support case-insensitivity on section headers (e.g. `[peer]`, `[interface]`) and key names (e.g. `privatekey`, `allowedips`). - Implement robust trailing comment stripping (both `#` and `;`) while preserving inline comment-like characters in cryptographic keys (e.g. `key-with-hash-inside#123`) using whitespace-padded match logic. - Clean up and normalize leading/trailing spaces/tabs on parsed keys, values, and list elements (e.g. `AllowedIPs` and `DNS` fields). - Gracefully ignore unrecognized keys (e.g. `MTU`, `ListenPort`, `PresharedKey`) without returning errors. - Add comprehensive tests in `pkg/wgconf/wgconf_test.go` covering inline/block comments, formatting variations, unrecognized keys, and case-insensitivity.
-rw-r--r--README.md3
-rw-r--r--internal/cli/cli.go18
-rw-r--r--internal/cli/profile_test.go18
-rw-r--r--pkg/wgconf/wgconf.go55
-rw-r--r--pkg/wgconf/wgconf_test.go145
5 files changed, 218 insertions, 21 deletions
diff --git a/README.md b/README.md
index c6d572f..6de14d1 100644
--- a/README.md
+++ b/README.md
@@ -43,9 +43,10 @@ To simplify usage, `wg-wrap` implements a profile system for managing WireGuard
### Profile Management Commands
Beyond wrapping commands, `wg-wrap` provides management sub-commands to handle profiles:
- `wg-wrap profile list`: Lists all available profiles in the config directory.
-- `wg-wrap profile import <path>`: Imports a `.conf` file into the profiles directory, prompting for a profile name.
+- `wg-wrap profile import <path> [name]`: Imports a `.conf` file into the profiles directory. If `[name]` is not provided, the profile name is derived from the `.conf` filename. Otherwise, the specified custom name is used.
- `wg-wrap profile configure <name>`: Opens the selected profile in the system's default editor.
- `wg-wrap profile delete <name>`: Removes the specified profile.
+- `wg-wrap profile stop <name>`: Stops the tunnel/namespace associated with the specified profile and unpins it.
### Diagnostics
For debugging and environment verification, `wg-wrap` provides diagnostic tools:
diff --git a/internal/cli/cli.go b/internal/cli/cli.go
index 11914b1..af408c5 100644
--- a/internal/cli/cli.go
+++ b/internal/cli/cli.go
@@ -228,9 +228,13 @@ func (a *App) handleProfileCmd() error {
return a.handleProfileList()
case "import":
if len(a.Args) < 4 {
- return fmt.Errorf("usage: wg-wrap profile import <path>")
+ return fmt.Errorf("usage: wg-wrap profile import <path> [name]")
}
- return a.handleProfileImport(a.Args[3])
+ var name string
+ if len(a.Args) > 4 {
+ name = a.Args[4]
+ }
+ return a.handleProfileImport(a.Args[3], name)
case "configure":
if len(a.Args) < 4 {
return fmt.Errorf("usage: wg-wrap profile configure <name>")
@@ -307,7 +311,7 @@ func (a *App) handleProfileList() error {
return nil
}
-func (a *App) handleProfileImport(srcPath string) error {
+func (a *App) handleProfileImport(srcPath string, name string) error {
profilesDir := a.getPathManager().ConfigDir()
if err := os.MkdirAll(profilesDir, 0755); err != nil {
return fmt.Errorf("failed to create profiles directory: %w", err)
@@ -317,10 +321,12 @@ func (a *App) handleProfileImport(srcPath string) error {
return fmt.Errorf("invalid WireGuard configuration at %s: %w", srcPath, err)
}
- baseName := filepath.Base(srcPath)
- name := strings.TrimSuffix(baseName, filepath.Ext(baseName))
if name == "" {
- return fmt.Errorf("invalid source filename")
+ baseName := filepath.Base(srcPath)
+ name = strings.TrimSuffix(baseName, filepath.Ext(baseName))
+ if name == "" {
+ return fmt.Errorf("invalid source filename")
+ }
}
destPath := filepath.Join(profilesDir, name+".conf")
diff --git a/internal/cli/profile_test.go b/internal/cli/profile_test.go
index 17a5bc6..c9b1274 100644
--- a/internal/cli/profile_test.go
+++ b/internal/cli/profile_test.go
@@ -41,6 +41,7 @@ func TestProfileImport(t *testing.T) {
t.Fatalf("failed to create source conf: %v", err)
}
+ // 1. Test importing with derived name (no explicit name argument)
app := NewApp([]string{"wg-wrap", "profile", "import", srcFile})
app.ConfigDir = profilesDir
@@ -49,11 +50,26 @@ func TestProfileImport(t *testing.T) {
t.Errorf("expected no error, got %v", err)
}
- // Verify the file was actually copied
+ // Verify the file was actually copied to derived name
destFile := filepath.Join(profilesDir, "source.conf")
if _, err := os.Stat(destFile); os.IsNotExist(err) {
t.Errorf("expected profile to be imported to %s", destFile)
}
+
+ // 2. Test importing with explicit name argument
+ customName := "custom-vpn"
+ appCustom := NewApp([]string{"wg-wrap", "profile", "import", srcFile, customName})
+ appCustom.ConfigDir = profilesDir
+
+ err = appCustom.Route()
+ if err != nil {
+ t.Errorf("expected no error for custom name import, got %v", err)
+ }
+
+ destCustomFile := filepath.Join(profilesDir, customName+".conf")
+ if _, err := os.Stat(destCustomFile); os.IsNotExist(err) {
+ t.Errorf("expected profile to be imported to %s", destCustomFile)
+ }
}
func TestProfileDelete(t *testing.T) {
diff --git a/pkg/wgconf/wgconf.go b/pkg/wgconf/wgconf.go
index 2615892..36434ba 100644
--- a/pkg/wgconf/wgconf.go
+++ b/pkg/wgconf/wgconf.go
@@ -22,6 +22,23 @@ type Peer struct {
AllowedIPs []string
}
+// stripComment removes inline and block comments from a line, keeping spaces intact otherwise.
+func stripComment(line string) string {
+ line = strings.TrimSpace(line)
+ if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, ";") {
+ return ""
+ }
+
+ // Find the first occurrence of '#' or ';' preceded by a whitespace character.
+ // This protects characters like '#' if they are part of a key/value with no leading whitespace.
+ for i := 1; i < len(line); i++ {
+ if (line[i] == '#' || line[i] == ';') && (line[i-1] == ' ' || line[i-1] == '\t') {
+ return strings.TrimSpace(line[:i])
+ }
+ }
+ return line
+}
+
// Parse reads a WireGuard .conf file and returns a Config struct.
func Parse(path string) (*Config, error) {
file, err := os.Open(path)
@@ -40,18 +57,23 @@ func Parse(path string) (*Config, error) {
scanner := bufio.NewScanner(file)
for scanner.Scan() {
- line := strings.TrimSpace(scanner.Text())
- if line == "" || strings.HasPrefix(line, "#") {
+ line := stripComment(scanner.Text())
+ if line == "" {
continue
}
- if strings.HasPrefix(line, "[") {
- section := strings.Trim(line, "[]")
- if section == "Peer" {
+ if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") {
+ section := strings.ToLower(strings.Trim(line, "[] \t"))
+ if section == "peer" {
if currentPeer != nil {
cfg.Peers = append(cfg.Peers, *currentPeer)
}
currentPeer = &Peer{}
+ } else {
+ if currentPeer != nil {
+ cfg.Peers = append(cfg.Peers, *currentPeer)
+ currentPeer = nil
+ }
}
continue
}
@@ -61,25 +83,32 @@ func Parse(path string) (*Config, error) {
return nil, fmt.Errorf("invalid line format: %s", line)
}
- key := strings.TrimSpace(parts[0])
+ key := strings.ToLower(strings.TrimSpace(parts[0]))
val := strings.TrimSpace(parts[1])
if currentPeer != nil {
switch key {
- case "PublicKey":
+ case "publickey":
currentPeer.PublicKey = val
- case "Endpoint":
+ case "endpoint":
currentPeer.Endpoint = val
- case "AllowedIPs":
- currentPeer.AllowedIPs = strings.Split(val, ",")
+ case "allowedips":
+ var ips []string
+ for _, ip := range strings.Split(val, ",") {
+ trimmed := strings.TrimSpace(ip)
+ if trimmed != "" {
+ ips = append(ips, trimmed)
+ }
+ }
+ currentPeer.AllowedIPs = ips
}
} else {
switch key {
- case "PrivateKey":
+ case "privatekey":
cfg.PrivateKey = val
- case "Address":
+ case "address":
cfg.Address = val
- case "DNS":
+ case "dns":
cfg.DNS = val
}
}
diff --git a/pkg/wgconf/wgconf_test.go b/pkg/wgconf/wgconf_test.go
index 805aeaa..92583a5 100644
--- a/pkg/wgconf/wgconf_test.go
+++ b/pkg/wgconf/wgconf_test.go
@@ -3,6 +3,7 @@ package wgconf
import (
"os"
"path/filepath"
+ "reflect"
"testing"
)
@@ -69,3 +70,147 @@ InvalidLineWithoutEquals`
t.Error("expected error for invalid line format, got nil")
}
}
+
+func TestParseConfigInTheWildEdgeCases(t *testing.T) {
+ tests := []struct {
+ name string
+ content string
+ want *Config
+ wantErr bool
+ }{
+ {
+ name: "Case insensitivity for sections and keys",
+ content: `
+[interface]
+privatekey = my-private-key
+address = 10.0.1.2/24
+dns = 8.8.8.8
+
+[peer]
+publickey = peer-public-key
+endpoint = 5.5.5.5:51820
+allowedips = 10.0.1.0/24, fd00::1/64
+`,
+ want: &Config{
+ PrivateKey: "my-private-key",
+ Address: "10.0.1.2/24",
+ DNS: "8.8.8.8",
+ Peers: []Peer{
+ {
+ PublicKey: "peer-public-key",
+ Endpoint: "5.5.5.5:51820",
+ AllowedIPs: []string{"10.0.1.0/24", "fd00::1/64"},
+ },
+ },
+ },
+ },
+ {
+ name: "Inline and block comments",
+ content: `
+# This is a whole-line comment
+; This is another whole-line comment starting with semicolon
+
+[Interface]
+PrivateKey = key-with-hash-inside#123 # Comment at end of line
+Address = 10.0.0.1/24 ; inline semicolon comment
+DNS = 1.1.1.1 # DNS fallback
+
+[Peer]
+PublicKey = peerkey ; comment here
+# This is a comment between fields
+Endpoint = 1.1.1.1:1111
+AllowedIPs = 10.0.0.0/24, 10.0.1.0/24 # comment at the end of allowed ips
+`,
+ want: &Config{
+ PrivateKey: "key-with-hash-inside#123",
+ Address: "10.0.0.1/24",
+ DNS: "1.1.1.1",
+ Peers: []Peer{
+ {
+ PublicKey: "peerkey",
+ Endpoint: "1.1.1.1:1111",
+ AllowedIPs: []string{"10.0.0.0/24", "10.0.1.0/24"},
+ },
+ },
+ },
+ },
+ {
+ name: "Crazy whitespaces and tabs",
+ content: `
+ [Interface]
+ PrivateKey = key123
+ Address = 10.0.0.2/24
+ DNS = 9.9.9.9
+
+ [Peer]
+PublicKey = key456
+Endpoint = 2.2.2.2:2222
+AllowedIPs = 192.168.1.1/32 , 192.168.1.2/32
+`,
+ want: &Config{
+ PrivateKey: "key123",
+ Address: "10.0.0.2/24",
+ DNS: "9.9.9.9",
+ Peers: []Peer{
+ {
+ PublicKey: "key456",
+ Endpoint: "2.2.2.2:2222",
+ AllowedIPs: []string{"192.168.1.1/32", "192.168.1.2/32"},
+ },
+ },
+ },
+ },
+ {
+ name: "Ignore unrecognized keys under Interface and Peer",
+ content: `
+[Interface]
+PrivateKey = pk
+Address = 10.0.0.1/24
+ListenPort = 51820
+FwMark = 1234
+MTU = 1420
+PostUp = iptables -A FORWARD -i %i -j ACCEPT; iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE
+PostDown = iptables -D FORWARD -i %i -j ACCEPT; iptables -t nat -D POSTROUTING -o eth0 -j MASQUERADE
+
+[Peer]
+PublicKey = pubk
+PresharedKey = preshared_key_abc
+Endpoint = 3.3.3.3:3333
+AllowedIPs = 0.0.0.0/0
+PersistentKeepalive = 25
+`,
+ want: &Config{
+ PrivateKey: "pk",
+ Address: "10.0.0.1/24",
+ DNS: "",
+ Peers: []Peer{
+ {
+ PublicKey: "pubk",
+ Endpoint: "3.3.3.3:3333",
+ AllowedIPs: []string{"0.0.0.0/0"},
+ },
+ },
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ tmpFile := filepath.Join(t.TempDir(), "test_wild.conf")
+ if err := os.WriteFile(tmpFile, []byte(tt.content), 0644); err != nil {
+ t.Fatal(err)
+ }
+
+ cfg, err := Parse(tmpFile)
+ if (err != nil) != tt.wantErr {
+ t.Fatalf("Parse() error = %v, wantErr %v", err, tt.wantErr)
+ }
+
+ if err == nil {
+ if !reflect.DeepEqual(cfg, tt.want) {
+ t.Errorf("Parse() got = %+v, want = %+v", cfg, tt.want)
+ }
+ }
+ })
+ }
+}