package main import ( "fmt" "net/http" "net/netip" "net/url" "regexp" "strings" ) // An imported wg-quick config (or `wg showconf` output): the tunnel it // describes, and the options the router does not take over. type wgImport struct { Tunnel WGTunnel Ignored []string } var slugRe = regexp.MustCompile(`[^a-z0-9]+`) // parseWGQuick reads a wg-quick style config. The tunnel gets name; peers are // named after the comment above their [Peer] line, else peer1, peer2, ... func parseWGQuick(name, text string) (wgImport, error) { imp := wgImport{Tunnel: WGTunnel{Name: name, KillSwitch: true}} t := &imp.Tunnel var section string var peer *WGPeer comment := "" ignored := map[string]bool{} flush := func() { if peer != nil { t.Peers = append(t.Peers, *peer) peer = nil } } for n, raw := range strings.Split(text, "\n") { line := strings.TrimSpace(raw) switch { case line == "": continue case strings.HasPrefix(line, "#"): comment = strings.TrimSpace(strings.TrimLeft(line, "# ")) comment = strings.TrimSpace(strings.TrimPrefix(strings.TrimPrefix(comment, "Name ="), "name =")) continue case strings.EqualFold(line, "[Interface]"): flush() section = "interface" comment = "" continue case strings.EqualFold(line, "[Peer]"): flush() section = "peer" peer = &WGPeer{} if slug := strings.Trim(slugRe.ReplaceAllString(strings.ToLower(comment), "-"), "-"); nameRe.MatchString(slug) { peer.Name = slug } comment = "" continue } key, value, ok := strings.Cut(line, "=") if !ok { return imp, fmt.Errorf("line %d is not \"key = value\"", n+1) } key, value = strings.ToLower(strings.TrimSpace(key)), strings.TrimSpace(value) comment = "" switch section { case "interface": switch key { case "privatekey": t.PrivateKey = value case "address": for _, a := range splitList(value) { if !strings.Contains(a, "/") { if strings.Contains(a, ":") { a += "/128" } else { a += "/32" } } t.Addresses += a + ", " } case "listenport": t.ListenPort = value case "mtu": t.MTU = value default: // DNS, Table, FwMark, PreUp, PostUp, ... ignored[key] = true } case "peer": switch key { case "publickey": peer.PublicKey = value case "presharedkey": peer.PSK = value case "endpoint": peer.Endpoint = value case "allowedips": peer.Allowed += value + ", " case "persistentkeepalive": if value != "off" { peer.Keepalive = value } default: ignored[key] = true } default: return imp, fmt.Errorf("line %d is outside [Interface] and [Peer]", n+1) } } flush() t.Addresses = strings.TrimSuffix(t.Addresses, ", ") if t.PrivateKey == "" { return imp, fmt.Errorf("the config has no [Interface] PrivateKey") } // Peers without a comment get numbered names. used := map[string]bool{} for _, p := range t.Peers { used[p.Name] = true } next := 1 for i := range t.Peers { p := &t.Peers[i] p.Allowed = strings.TrimSuffix(p.Allowed, ", ") if p.Name == "" || (used[p.Name] && countName(t.Peers, p.Name) > 1 && firstIndex(t.Peers, p.Name) != i) { for { p.Name = fmt.Sprintf("peer%d", next) next++ if !used[p.Name] { used[p.Name] = true break } } } for _, a := range splitList(p.Allowed) { if pfx, err := netip.ParsePrefix(a); err == nil && pfx.Bits() == 0 { return imp, fmt.Errorf("peer %s sends everything (%s) through the tunnel; this router routes by destination only, so list the networks you want through it instead", p.Name, a) } } } for k := range ignored { imp.Ignored = append(imp.Ignored, k) } return imp, nil } func countName(ps []WGPeer, name string) int { n := 0 for _, p := range ps { if p.Name == name { n++ } } return n } func firstIndex(ps []WGPeer, name string) int { for i, p := range ps { if p.Name == name { return i } } return -1 } var secretLine = regexp.MustCompile(`(?im)^(\s*(?:PrivateKey|PresharedKey)\s*=).*$`) // redact keeps secrets out of a page that is shown again after an error. func redact(text string) string { return secretLine.ReplaceAllString(text, "$1 (paste again)") } func (a *app) wireguardImport(w http.ResponseWriter, r *http.Request, sess session) { p := page{Title: "WireGuard", CSRF: sess.csrf, LoggedIn: true} s, err := loadSettings(a.sys.settingsPath()) if err != nil { p.Error = err.Error() a.render(w, "wireguard", p) return } name := strings.TrimSpace(r.PostFormValue("importName")) text := r.PostFormValue("importText") fail := func(msg string, status int) { p.Error = msg p.Data = a.wgView(s, func(d *wgData) { d.ImportName, d.ImportText = name, redact(text) }) w.WriteHeader(status) a.render(w, "wireguard", p) } if !nameRe.MatchString(name) { fail("Give the tunnel a name: lowercase letters, digits and dashes, starting with a letter.", http.StatusUnprocessableEntity) return } for _, t := range s.tunnels(a.sys) { if t.Name == name { fail("A tunnel called "+name+" exists already.", http.StatusUnprocessableEntity) return } } imp, err := parseWGQuick(name, text) if err != nil { fail("The config cannot be imported: "+err.Error()+".", http.StatusUnprocessableEntity) return } files, err := s.applyTunnel(a.sys, imp.Tunnel) if err == nil { if err = writeSecrets(a.sys.wgDir(), files); err == nil { err = s.save(a.sys.settingsPath()) } } if err != nil { msg := "Saving: " + err.Error() if fe, ok := err.(FormErrors); ok { msg = "The config has a problem: " + fe.Error() + "." } fail(msg, http.StatusUnprocessableEntity) return } msg := fmt.Sprintf("Imported %s with %d peer(s). Check its options below (internet, DNS, masquerade), then apply.", name, len(imp.Tunnel.Peers)) if len(imp.Ignored) > 0 { msg += " Not taken over: " + strings.Join(imp.Ignored, ", ") + "." } http.Redirect(w, r, "/wireguard?msg="+url.QueryEscape(msg), http.StatusSeeOther) }