package api import ( "fmt" "net/http" "strings" "text/template" "github.com/google/uuid" "go.bigb.es/shroud/internal/awgserver" "go.bigb.es/shroud/internal/config" "go.bigb.es/shroud/internal/store" ) func (h *Handler) getServer(w http.ResponseWriter, r *http.Request) { srv := h.store.GetServer() resp := ServerResponse{ Name: srv.Name, ServerID: srv.ID, MetricsEnabled: srv.MetricsEnabled, CreatedTimestampMs: srv.CreatedTimestampMs, Version: h.version, HostnameForAccessKeys: srv.Hostname, PortForNewAccessKeys: srv.PortForNewAccessKeys, } if srv.AccessKeyDataLimit != nil { resp.AccessKeyDataLimit = &DataLimitJSON{Bytes: srv.AccessKeyDataLimit.Bytes} } writeJSON(w, http.StatusOK, resp) } func (h *Handler) renameServer(w http.ResponseWriter, r *http.Request) { var req SetNameRequest if err := readJSON(r, &req); err != nil { writeError(w, http.StatusBadRequest, "invalid request body") return } if err := h.store.UpdateServer(func(s *store.ServerState) { s.Name = req.Name }); err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } w.WriteHeader(http.StatusNoContent) } func (h *Handler) listAccessKeys(w http.ResponseWriter, r *http.Request) { keys := h.store.ListKeys() resp := make([]AccessKeyResponse, len(keys)) for i, k := range keys { resp[i] = h.keyToResponse(k) } writeJSON(w, http.StatusOK, map[string]any{"accessKeys": resp}) } func (h *Handler) getAccessKey(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") key, ok := h.store.GetKey(id) if !ok { writeError(w, http.StatusNotFound, "key not found") return } writeJSON(w, http.StatusOK, h.keyToResponse(key)) } func (h *Handler) createAccessKey(w http.ResponseWriter, r *http.Request) { var req CreateAccessKeyRequest if r.ContentLength > 0 { if err := readJSON(r, &req); err != nil { writeError(w, http.StatusBadRequest, "invalid request body") return } } h.doCreateKey(w, req) } func (h *Handler) createAccessKeyWithID(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") var req CreateAccessKeyRequest if r.ContentLength > 0 { if err := readJSON(r, &req); err != nil { writeError(w, http.StatusBadRequest, "invalid request body") return } } req.ID = id h.doCreateKey(w, req) } func (h *Handler) doCreateKey(w http.ResponseWriter, req CreateAccessKeyRequest) { srv := h.store.GetServer() password := req.Password if password == "" { var err error password, err = store.GeneratePassword() if err != nil { writeError(w, http.StatusInternalServerError, "failed to generate password") return } } id := req.ID if id == "" { id = fmt.Sprintf("%d", srv.NextID) if err := h.store.UpdateServer(func(s *store.ServerState) { s.NextID++ }); err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } } method := req.Method if method == "" { method = srv.DefaultCipher } port := req.Port if port == 0 { port = srv.PortForNewAccessKeys } ak := store.AccessKey{ ID: id, Name: req.Name, Password: password, Port: port, Method: method, } if req.DataLimit != nil { ak.DataLimit = &store.DataLimit{Bytes: req.DataLimit.Bytes} } // Generate AWG credentials if AWG is enabled. if h.awgServer != nil && h.awgConfig != nil { awgData, err := h.generateAWGKey() if err != nil { h.logger.Error("Failed to generate AWG key.", "err", err) writeError(w, http.StatusInternalServerError, "failed to generate AWG credentials") return } ak.AWG = awgData } // Generate VLESS credentials if VLESS is enabled. if h.vlessServer != nil { ak.VLESS = &store.VLESSKeyData{ UUID: uuid.New().String(), } } if err := h.store.CreateKey(ak); err != nil { writeError(w, http.StatusConflict, err.Error()) return } if err := h.syncKeys(); err != nil { h.logger.Error("Failed to sync keys after create.", "err", err) writeError(w, http.StatusInternalServerError, "failed to apply key configuration") return } writeJSON(w, http.StatusCreated, h.keyToResponse(ak)) } func (h *Handler) deleteAccessKey(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") if err := h.store.DeleteKey(id); err != nil { writeError(w, http.StatusNotFound, "key not found") return } if err := h.syncKeys(); err != nil { h.logger.Error("Failed to sync keys after delete.", "err", err) } w.WriteHeader(http.StatusNoContent) } func (h *Handler) renameAccessKey(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") var req SetNameRequest if err := readJSON(r, &req); err != nil { writeError(w, http.StatusBadRequest, "invalid request body") return } if err := h.store.UpdateKey(id, func(k *store.AccessKey) { k.Name = req.Name }); err != nil { writeError(w, http.StatusNotFound, "key not found") return } w.WriteHeader(http.StatusNoContent) } func (h *Handler) setKeyDataLimit(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") var req SetDataLimitRequest if err := readJSON(r, &req); err != nil { writeError(w, http.StatusBadRequest, "invalid request body") return } if err := h.store.UpdateKey(id, func(k *store.AccessKey) { k.DataLimit = &store.DataLimit{Bytes: req.Limit.Bytes} }); err != nil { writeError(w, http.StatusNotFound, "key not found") return } w.WriteHeader(http.StatusNoContent) } func (h *Handler) removeKeyDataLimit(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") if err := h.store.UpdateKey(id, func(k *store.AccessKey) { k.DataLimit = nil }); err != nil { writeError(w, http.StatusNotFound, "key not found") return } w.WriteHeader(http.StatusNoContent) } func (h *Handler) setDefaultDataLimit(w http.ResponseWriter, r *http.Request) { var req SetDataLimitRequest if err := readJSON(r, &req); err != nil { writeError(w, http.StatusBadRequest, "invalid request body") return } if err := h.store.UpdateServer(func(s *store.ServerState) { s.AccessKeyDataLimit = &store.DataLimit{Bytes: req.Limit.Bytes} }); err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } w.WriteHeader(http.StatusNoContent) } func (h *Handler) removeDefaultDataLimit(w http.ResponseWriter, r *http.Request) { if err := h.store.UpdateServer(func(s *store.ServerState) { s.AccessKeyDataLimit = nil }); err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } w.WriteHeader(http.StatusNoContent) } func (h *Handler) setDefaultPort(w http.ResponseWriter, r *http.Request) { var req SetPortRequest if err := readJSON(r, &req); err != nil { writeError(w, http.StatusBadRequest, "invalid request body") return } if req.Port < 1 || req.Port > 65535 { writeError(w, http.StatusBadRequest, "port must be between 1 and 65535") return } if err := h.store.UpdateServer(func(s *store.ServerState) { s.PortForNewAccessKeys = req.Port }); err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } w.WriteHeader(http.StatusNoContent) } func (h *Handler) setHostname(w http.ResponseWriter, r *http.Request) { var req SetHostnameRequest if err := readJSON(r, &req); err != nil { writeError(w, http.StatusBadRequest, "invalid request body") return } if err := h.store.UpdateServer(func(s *store.ServerState) { s.Hostname = req.Hostname }); err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } w.WriteHeader(http.StatusNoContent) } func (h *Handler) getMetricsEnabled(w http.ResponseWriter, r *http.Request) { srv := h.store.GetServer() writeJSON(w, http.StatusOK, MetricsEnabledResponse{MetricsEnabled: srv.MetricsEnabled}) } func (h *Handler) setMetricsEnabled(w http.ResponseWriter, r *http.Request) { var req MetricsEnabledResponse if err := readJSON(r, &req); err != nil { writeError(w, http.StatusBadRequest, "invalid request body") return } if err := h.store.UpdateServer(func(s *store.ServerState) { s.MetricsEnabled = req.MetricsEnabled }); err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } w.WriteHeader(http.StatusNoContent) } func (h *Handler) getTransferMetrics(w http.ResponseWriter, r *http.Request) { transfer := h.tracker.GetTransferByKey() writeJSON(w, http.StatusOK, TransferMetricsResponse{BytesTransferredByUserId: transfer}) } func (h *Handler) keyToResponse(k store.AccessKey) AccessKeyResponse { resp := AccessKeyResponse{ ID: k.ID, Name: k.Name, Password: k.Password, Port: k.Port, Method: k.Method, AccessURL: h.accessURL(k), } if k.DataLimit != nil { resp.DataLimit = &DataLimitJSON{Bytes: k.DataLimit.Bytes} } if k.VLESS != nil && h.vlessServer != nil { resp.VLESS = &VLESSKeyResponse{ UUID: k.VLESS.UUID, } } if k.AWG != nil && h.awgConfig != nil { srv := h.store.GetServer() hostname := h.awgHostname(srv.Hostname) resp.AWG = &AWGKeyResponse{ PublicKey: k.AWG.PublicKey, AllowedIP: k.AWG.AllowedIP, Endpoint: fmt.Sprintf("%s:%d", hostname, h.awgConfig.ListenPort), } } return resp } func (h *Handler) generateAWGKey() (*store.AWGKeyData, error) { privKey, err := awgserver.GeneratePrivateKey() if err != nil { return nil, fmt.Errorf("generating private key: %w", err) } pubKey, err := awgserver.PublicKeyFromPrivate(privKey) if err != nil { return nil, fmt.Errorf("deriving public key: %w", err) } // Collect used IPs from existing keys. keys := h.store.ListKeys() used := make([]string, 0, len(keys)) for _, k := range keys { if k.AWG != nil { used = append(used, k.AWG.AllowedIP) } } alloc, err := awgserver.NewIPAllocator(h.awgConfig.Address) if err != nil { return nil, fmt.Errorf("creating IP allocator: %w", err) } ip, err := alloc.Allocate(used) if err != nil { return nil, fmt.Errorf("allocating IP: %w", err) } return &store.AWGKeyData{ PrivateKey: privKey, PublicKey: pubKey, AllowedIP: ip, }, nil } func (h *Handler) getAWGConfig(w http.ResponseWriter, r *http.Request) { if h.awgServer == nil || h.awgConfig == nil { writeError(w, http.StatusNotFound, "AmneziaWG is not enabled") return } id := r.PathValue("id") key, ok := h.store.GetKey(id) if !ok { writeError(w, http.StatusNotFound, "key not found") return } if key.AWG == nil { writeError(w, http.StatusNotFound, "key has no AWG credentials") return } srv := h.store.GetServer() hostname := h.awgHostname(srv.Hostname) var b strings.Builder fmt.Fprintf(&b, "[Interface]\n") fmt.Fprintf(&b, "PrivateKey = %s\n", key.AWG.PrivateKey) fmt.Fprintf(&b, "Address = %s\n", key.AWG.AllowedIP) if h.awgConfig.DNS != "" { fmt.Fprintf(&b, "DNS = %s\n", h.awgConfig.DNS) } fmt.Fprintf(&b, "MTU = %d\n", h.awgConfig.MTU) // Obfuscation parameters. if h.awgConfig.Jc > 0 { fmt.Fprintf(&b, "Jc = %d\n", h.awgConfig.Jc) fmt.Fprintf(&b, "Jmin = %d\n", h.awgConfig.Jmin) fmt.Fprintf(&b, "Jmax = %d\n", h.awgConfig.Jmax) } fmt.Fprintf(&b, "S1 = %d\n", h.awgConfig.S1) fmt.Fprintf(&b, "S2 = %d\n", h.awgConfig.S2) fmt.Fprintf(&b, "S3 = %d\n", h.awgConfig.S3) fmt.Fprintf(&b, "S4 = %d\n", h.awgConfig.S4) fmt.Fprintf(&b, "H1 = %s\n", h.awgConfig.H1) fmt.Fprintf(&b, "H2 = %s\n", h.awgConfig.H2) fmt.Fprintf(&b, "H3 = %s\n", h.awgConfig.H3) fmt.Fprintf(&b, "H4 = %s\n", h.awgConfig.H4) fmt.Fprintf(&b, "\n[Peer]\n") fmt.Fprintf(&b, "PublicKey = %s\n", srv.AWGPublicKey) fmt.Fprintf(&b, "Endpoint = %s:%d\n", hostname, h.awgConfig.ListenPort) fmt.Fprintf(&b, "AllowedIPs = 0.0.0.0/0, ::/0\n") fmt.Fprintf(&b, "PersistentKeepalive = 25\n") w.Header().Set("Content-Type", "text/plain; charset=utf-8") w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s-awg.conf"`, key.Name)) w.WriteHeader(http.StatusOK) w.Write([]byte(b.String())) } var outlineConfigTmpl = template.Must(template.New("outline").Parse(`dns: - {system: {}} tls: - "" fallback: - awg: address: [{{.AllowedIP}}] dns: [{{.DNS}}] private_key: {{.PrivateKey}} mtu: {{.MTU}} {{- if gt .Jc 0}} jc: {{.Jc}} jmin: {{.Jmin}} jmax: {{.Jmax}} {{- end}} {{- if ne .S1 0}} s1: {{.S1}} {{- end}} {{- if ne .S2 0}} s2: {{.S2}} {{- end}} {{- if ne .S3 0}} s3: {{.S3}} {{- end}} {{- if ne .S4 0}} s4: {{.S4}} {{- end}} {{- if .H1}} h1: {{.H1}} {{- end}} {{- if .H2}} h2: {{.H2}} {{- end}} {{- if .H3}} h3: {{.H3}} {{- end}} {{- if .H4}} h4: {{.H4}} {{- end}} peers: - public_key: {{.ServerPublicKey}} endpoint: {{.Endpoint}} allowed_ips: [0.0.0.0/0, "::/0"] persistent_keepalive_interval: 25 `)) type outlineConfigData struct { config.AmneziaWGConfig AllowedIP string PrivateKey string ServerPublicKey string Endpoint string } // getOutlineConfig returns a Smart Dialer YAML config for the Outline SDK client. // The config includes AWG as a fallback transport, compatible with the outline glue // in github.com/amnezia-vpn/amneziawg-go/outline. func (h *Handler) getOutlineConfig(w http.ResponseWriter, r *http.Request) { if h.awgServer == nil || h.awgConfig == nil { writeError(w, http.StatusNotFound, "AmneziaWG is not enabled") return } id := r.PathValue("id") key, ok := h.store.GetKey(id) if !ok { writeError(w, http.StatusNotFound, "key not found") return } if key.AWG == nil { writeError(w, http.StatusNotFound, "key has no AWG credentials") return } srv := h.store.GetServer() hostname := h.awgHostname(srv.Hostname) dns := h.awgConfig.DNS if dns == "" { dns = "8.8.8.8, 8.8.4.4" } data := outlineConfigData{ AmneziaWGConfig: *h.awgConfig, AllowedIP: key.AWG.AllowedIP, PrivateKey: key.AWG.PrivateKey, ServerPublicKey: srv.AWGPublicKey, Endpoint: fmt.Sprintf("%s:%d", hostname, h.awgConfig.ListenPort), } data.AmneziaWGConfig.DNS = dns var b strings.Builder if err := outlineConfigTmpl.Execute(&b, data); err != nil { h.logger.Error("Failed to render outline config.", "err", err) writeError(w, http.StatusInternalServerError, "failed to render config") return } w.Header().Set("Content-Type", "text/yaml; charset=utf-8") w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s-outline.yaml"`, key.Name)) w.WriteHeader(http.StatusOK) w.Write([]byte(b.String())) }