Files
navi/internal/protocols/awg/conf.go
T

552 lines
12 KiB
Go

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()
}