package awg import ( "encoding/base64" "encoding/hex" "fmt" "net" "net/netip" "strconv" "strings" ) // Conf is a parsed AmneziaWG / WireGuard client configuration (AWG 2.0 fields included). type Conf struct { PrivateKey string Address []string DNS []string MTU int Jc int Jmin int Jmax int S1, S2, S3, S4 int H1, H2, H3, H4 string I1, I2, I3, I4, I5 string PublicKey string PresharedKey string Endpoint string AllowedIPs []string Keepalive int Name string } // Detect reports whether raw looks like an AWG/WireGuard config or share link. func Detect(raw string) bool { raw = strings.TrimSpace(raw) if raw == "" { return false } lower := strings.ToLower(raw) if strings.HasPrefix(lower, "awg://") || strings.HasPrefix(lower, "amneziawg://") || strings.HasPrefix(lower, "wireguard://") { return true } if strings.Contains(lower, "[interface]") && strings.Contains(lower, "privatekey") { return true } return false } // Parse accepts a WireGuard/AWG .conf body or awg:// URI and returns Conf. func Parse(raw string) (Conf, error) { raw = strings.TrimSpace(raw) if raw == "" { return Conf{}, fmt.Errorf("пустой AWG конфиг") } lower := strings.ToLower(raw) switch { case strings.HasPrefix(lower, "awg://"), strings.HasPrefix(lower, "amneziawg://"), strings.HasPrefix(lower, "wireguard://"): return parseURI(raw) default: return parseINI(raw) } } func parseINI(raw string) (Conf, error) { var c Conf section := "" for _, line := range strings.Split(raw, "\n") { line = strings.TrimSpace(line) if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, ";") { continue } if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") { section = strings.ToLower(strings.TrimSpace(line[1 : len(line)-1])) continue } key, val, ok := splitKV(line) if !ok { continue } k := strings.ToLower(key) switch section { case "interface": switch k { case "privatekey": c.PrivateKey = val case "address": c.Address = appendCSV(c.Address, val) case "dns": c.DNS = appendCSV(c.DNS, val) case "mtu": c.MTU, _ = strconv.Atoi(val) case "jc": c.Jc, _ = strconv.Atoi(val) case "jmin": c.Jmin, _ = strconv.Atoi(val) case "jmax": c.Jmax, _ = strconv.Atoi(val) case "s1": c.S1, _ = strconv.Atoi(val) case "s2": c.S2, _ = strconv.Atoi(val) case "s3": c.S3, _ = strconv.Atoi(val) case "s4": c.S4, _ = strconv.Atoi(val) case "h1": c.H1 = val case "h2": c.H2 = val case "h3": c.H3 = val case "h4": c.H4 = val case "i1": c.I1 = val case "i2": c.I2 = val case "i3": c.I3 = val case "i4": c.I4 = val case "i5": c.I5 = val } case "peer": switch k { case "publickey": c.PublicKey = val case "presharedkey": c.PresharedKey = val case "endpoint": c.Endpoint = val case "allowedips": c.AllowedIPs = appendCSV(c.AllowedIPs, val) case "persistentkeepalive": c.Keepalive, _ = strconv.Atoi(val) } } } if err := c.validate(); err != nil { return Conf{}, err } return c, nil } func parseURI(raw string) (Conf, error) { // awg://host:port/?public_key=...&private_key=...&address=10.8.0.2/32&jc=4&... u := raw schemeIdx := strings.Index(u, "://") if schemeIdx < 0 { return Conf{}, fmt.Errorf("bad awg uri") } rest := u[schemeIdx+3:] hostPort := rest query := "" if i := strings.Index(rest, "?"); i >= 0 { hostPort = rest[:i] query = rest[i+1:] } hostPort = strings.TrimSuffix(hostPort, "/") c := Conf{Endpoint: hostPort} for _, part := range strings.Split(query, "&") { if part == "" { continue } k, v, ok := strings.Cut(part, "=") if !ok { continue } k = strings.ToLower(strings.TrimSpace(k)) v = strings.TrimSpace(v) v, _ = urlDecode(v) switch k { case "private_key", "privatekey": c.PrivateKey = v case "public_key", "publickey": c.PublicKey = v case "preshared_key", "presharedkey", "psk": c.PresharedKey = v case "address", "addr": c.Address = appendCSV(c.Address, v) case "dns": c.DNS = appendCSV(c.DNS, v) case "allowed_ips", "allowedips": c.AllowedIPs = appendCSV(c.AllowedIPs, v) case "mtu": c.MTU, _ = strconv.Atoi(v) case "keepalive", "persistentkeepalive": c.Keepalive, _ = strconv.Atoi(v) case "jc": c.Jc, _ = strconv.Atoi(v) case "jmin": c.Jmin, _ = strconv.Atoi(v) case "jmax": c.Jmax, _ = strconv.Atoi(v) case "s1": c.S1, _ = strconv.Atoi(v) case "s2": c.S2, _ = strconv.Atoi(v) case "s3": c.S3, _ = strconv.Atoi(v) case "s4": c.S4, _ = strconv.Atoi(v) case "h1": c.H1 = v case "h2": c.H2 = v case "h3": c.H3 = v case "h4": c.H4 = v case "i1": c.I1 = v case "i2": c.I2 = v case "i3": c.I3 = v case "i4": c.I4 = v case "i5": c.I5 = v case "name", "remark": c.Name = v case "endpoint": c.Endpoint = v } } if err := c.validate(); err != nil { return Conf{}, err } return c, nil } func (c *Conf) validate() error { if c.PrivateKey == "" { return fmt.Errorf("awg: нет PrivateKey") } if c.PublicKey == "" { return fmt.Errorf("awg: нет PublicKey") } if c.Endpoint == "" { return fmt.Errorf("awg: нет Endpoint") } if len(c.Address) == 0 { return fmt.Errorf("awg: нет Address") } if len(c.AllowedIPs) == 0 { c.AllowedIPs = []string{"0.0.0.0/0", "::/0"} } return nil } // HostPort returns UDP endpoint host and port for ping. func HostPort(raw string) (host, port string, err error) { c, err := Parse(raw) if err != nil { return "", "", err } h, p, err := net.SplitHostPort(c.Endpoint) if err != nil { // bare host without port return c.Endpoint, "51820", nil } return h, p, nil } // ToIPC builds the amneziawg-go UAPI configuration string. func (c Conf) ToIPC() (string, error) { priv, err := decodeKey(c.PrivateKey) if err != nil { return "", fmt.Errorf("private key: %w", err) } pub, err := decodeKey(c.PublicKey) if err != nil { return "", fmt.Errorf("public key: %w", err) } var b strings.Builder b.WriteString("private_key=") b.WriteString(hex.EncodeToString(priv)) writeInt(&b, "jc", c.Jc) writeInt(&b, "jmin", c.Jmin) writeInt(&b, "jmax", c.Jmax) writeInt(&b, "s1", c.S1) writeInt(&b, "s2", c.S2) writeInt(&b, "s3", c.S3) writeInt(&b, "s4", c.S4) writeStr(&b, "h1", c.H1) writeStr(&b, "h2", c.H2) writeStr(&b, "h3", c.H3) writeStr(&b, "h4", c.H4) writeStr(&b, "i1", c.I1) writeStr(&b, "i2", c.I2) writeStr(&b, "i3", c.I3) writeStr(&b, "i4", c.I4) writeStr(&b, "i5", c.I5) b.WriteString("\npublic_key=") b.WriteString(hex.EncodeToString(pub)) b.WriteString("\nendpoint=") b.WriteString(c.Endpoint) for _, ip := range c.AllowedIPs { b.WriteString("\nallowed_ip=") b.WriteString(strings.TrimSpace(ip)) } if c.PresharedKey != "" { psk, err := decodeKey(c.PresharedKey) if err != nil { return "", fmt.Errorf("preshared key: %w", err) } b.WriteString("\npreshared_key=") b.WriteString(hex.EncodeToString(psk)) } if c.Keepalive > 0 { writeInt(&b, "persistent_keepalive_interval", c.Keepalive) } return b.String(), nil } // LocalAddrs parses Interface Address values. func (c Conf) LocalAddrs() ([]netip.Addr, error) { var out []netip.Addr for _, a := range c.Address { a = strings.TrimSpace(a) if a == "" { continue } if p, err := netip.ParsePrefix(a); err == nil { out = append(out, p.Addr()) continue } addr, err := netip.ParseAddr(a) if err != nil { return nil, fmt.Errorf("address %q: %w", a, err) } out = append(out, addr) } if len(out) == 0 { return nil, fmt.Errorf("no local addresses") } return out, nil } // DNSAddrs parses DNS servers (defaults to 1.1.1.1). func (c Conf) DNSAddrs() []netip.Addr { var out []netip.Addr for _, d := range c.DNS { d = strings.TrimSpace(d) if d == "" { continue } if addr, err := netip.ParseAddr(d); err == nil { out = append(out, addr) } } if len(out) == 0 { out = append(out, netip.MustParseAddr("1.1.1.1")) } return out } func (c Conf) EffectiveMTU() int { if c.MTU > 0 { return c.MTU } return 1420 } func decodeKey(s string) ([]byte, error) { s = strings.TrimSpace(s) if b, err := base64.StdEncoding.DecodeString(s); err == nil && len(b) == 32 { return b, nil } if b, err := hex.DecodeString(s); err == nil && len(b) == 32 { return b, nil } return nil, fmt.Errorf("expected 32-byte base64/hex key") } func writeInt(b *strings.Builder, key string, v int) { if v == 0 { return } b.WriteByte('\n') b.WriteString(key) b.WriteByte('=') b.WriteString(strconv.Itoa(v)) } func writeStr(b *strings.Builder, key, v string) { v = strings.TrimSpace(v) if v == "" { return } b.WriteByte('\n') b.WriteString(key) b.WriteByte('=') b.WriteString(v) } func splitKV(line string) (key, val string, ok bool) { i := strings.IndexAny(line, "=:") if i < 0 { return "", "", false } return strings.TrimSpace(line[:i]), strings.TrimSpace(line[i+1:]), true } func appendCSV(dst []string, raw string) []string { for _, p := range strings.Split(raw, ",") { p = strings.TrimSpace(p) if p != "" { dst = append(dst, p) } } return dst } func urlDecode(s string) (string, error) { var b strings.Builder for i := 0; i < len(s); { switch s[i] { case '+': b.WriteByte(' ') i++ case '%': if i+2 >= len(s) { return s, nil } var v byte for _, c := range []byte{s[i+1], s[i+2]} { v <<= 4 switch { case c >= '0' && c <= '9': v |= c - '0' case c >= 'a' && c <= 'f': v |= c - 'a' + 10 case c >= 'A' && c <= 'F': v |= c - 'A' + 10 default: return s, nil } } b.WriteByte(v) i += 3 default: b.WriteByte(s[i]) i++ } } return b.String(), nil } // NormalizeShareLink stores a canonical conf body (or passes through awg URI). func NormalizeShareLink(raw string) (normalized string, remark string, err error) { c, err := Parse(raw) if err != nil { return "", "", err } if c.Name != "" { remark = c.Name } else if h, _, e := net.SplitHostPort(c.Endpoint); e == nil { remark = h } else { remark = c.Endpoint } // Prefer keeping original INI when pasted; re-serialize only for URI imports. lower := strings.ToLower(strings.TrimSpace(raw)) if strings.HasPrefix(lower, "awg://") || strings.HasPrefix(lower, "amneziawg://") || strings.HasPrefix(lower, "wireguard://") { return serializeINI(c), remark, nil } return strings.TrimSpace(raw), remark, nil } func serializeINI(c Conf) string { var b strings.Builder b.WriteString("[Interface]\n") b.WriteString("PrivateKey = " + c.PrivateKey + "\n") if len(c.Address) > 0 { b.WriteString("Address = " + strings.Join(c.Address, ", ") + "\n") } if len(c.DNS) > 0 { b.WriteString("DNS = " + strings.Join(c.DNS, ", ") + "\n") } if c.MTU > 0 { b.WriteString("MTU = " + strconv.Itoa(c.MTU) + "\n") } if c.Jc != 0 { b.WriteString("Jc = " + strconv.Itoa(c.Jc) + "\n") } if c.Jmin != 0 { b.WriteString("Jmin = " + strconv.Itoa(c.Jmin) + "\n") } if c.Jmax != 0 { b.WriteString("Jmax = " + strconv.Itoa(c.Jmax) + "\n") } if c.S1 != 0 { b.WriteString("S1 = " + strconv.Itoa(c.S1) + "\n") } if c.S2 != 0 { b.WriteString("S2 = " + strconv.Itoa(c.S2) + "\n") } if c.S3 != 0 { b.WriteString("S3 = " + strconv.Itoa(c.S3) + "\n") } if c.S4 != 0 { b.WriteString("S4 = " + strconv.Itoa(c.S4) + "\n") } if c.H1 != "" { b.WriteString("H1 = " + c.H1 + "\n") } if c.H2 != "" { b.WriteString("H2 = " + c.H2 + "\n") } if c.H3 != "" { b.WriteString("H3 = " + c.H3 + "\n") } if c.H4 != "" { b.WriteString("H4 = " + c.H4 + "\n") } if c.I1 != "" { b.WriteString("I1 = " + c.I1 + "\n") } if c.I2 != "" { b.WriteString("I2 = " + c.I2 + "\n") } if c.I3 != "" { b.WriteString("I3 = " + c.I3 + "\n") } if c.I4 != "" { b.WriteString("I4 = " + c.I4 + "\n") } if c.I5 != "" { b.WriteString("I5 = " + c.I5 + "\n") } b.WriteString("\n[Peer]\n") b.WriteString("PublicKey = " + c.PublicKey + "\n") if c.PresharedKey != "" { b.WriteString("PresharedKey = " + c.PresharedKey + "\n") } b.WriteString("Endpoint = " + c.Endpoint + "\n") if len(c.AllowedIPs) > 0 { b.WriteString("AllowedIPs = " + strings.Join(c.AllowedIPs, ", ") + "\n") } if c.Keepalive > 0 { b.WriteString("PersistentKeepalive = " + strconv.Itoa(c.Keepalive) + "\n") } return b.String() }