package reverseproxy import ( "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "strconv" "strings" "time" ) type TokenSource interface { Token(context.Context) (string, error) } type NPMClient struct { BaseURL string Token TokenSource HTTPClient *http.Client MaxBody int64 Now func() time.Time } func (c NPMClient) ListRoutes(ctx context.Context) ([]RawRoute, error) { if ctx == nil { return nil, errors.New("reverse proxy context is nil") } if err := ctx.Err(); err != nil { return nil, err } base, err := url.Parse(strings.TrimSpace(c.BaseURL)) if err != nil || (base.Scheme != "http" && base.Scheme != "https") || base.Host == "" || base.User != nil || base.RawQuery != "" || base.Fragment != "" { return nil, errors.New("nginx proxy manager base URL is invalid") } token := "" if c.Token != nil { token, err = c.Token.Token(ctx) if err != nil { if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { return nil, err } return nil, errors.New("nginx proxy manager credential unavailable") } } client := c.HTTPClient if client == nil { client = http.DefaultClient } requestURL := strings.TrimRight(base.String(), "/") + "/api/nginx/proxy-hosts" request, err := http.NewRequestWithContext(ctx, http.MethodGet, requestURL, nil) if err != nil { return nil, errors.New("create nginx proxy manager request") } request.Header.Set("Accept", "application/json") if strings.TrimSpace(token) != "" { request.Header.Set("Authorization", "Bearer "+token) } response, err := client.Do(request) if err != nil { if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { return nil, err } return nil, errors.New("nginx proxy manager request failed") } defer response.Body.Close() maxBody := c.MaxBody if maxBody == 0 { maxBody = 2 << 20 } if maxBody < 1024 || maxBody > 8<<20 { return nil, errors.New("nginx proxy manager body limit is outside bounds") } if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices { return nil, fmt.Errorf("nginx proxy manager returned status %d", response.StatusCode) } body, err := io.ReadAll(io.LimitReader(response.Body, maxBody+1)) if err != nil { return nil, errors.New("read nginx proxy manager response") } if int64(len(body)) > maxBody { return nil, errors.New("nginx proxy manager response exceeds bounds") } var hosts []npmHost if err := json.Unmarshal(body, &hosts); err != nil { return nil, errors.New("decode nginx proxy manager response") } now := time.Now().UTC() if c.Now != nil { now = c.Now().UTC() } routes := make([]RawRoute, 0, len(hosts)) for _, host := range hosts { if len(host.DomainNames) > 32 { return nil, errors.New("nginx proxy manager hostnames exceed bounds") } id := string(host.ID) if decoded, decodeErr := strconv.Unquote(id); decodeErr == nil { id = decoded } if strings.TrimSpace(id) == "" || len(id) > 128 { return nil, errors.New("nginx proxy manager route id is invalid") } scheme := strings.ToLower(strings.TrimSpace(host.ForwardScheme)) if scheme == "" { scheme = "http" } targetServiceID := "" if value, ok := host.Meta["pulse_service_id"].(string); ok { targetServiceID = value } for _, domain := range host.DomainNames { routes = append(routes, RawRoute{ID: id + ":" + domain, SourceID: "npm", Hostname: domain, Scheme: scheme, Port: host.Port, TargetServiceID: targetServiceID, TargetHost: host.ForwardHost, Enabled: host.Enabled, Description: "Nginx Proxy Manager", ObservedAt: now}) } } if len(routes) > 1000 { return nil, errors.New("nginx proxy manager route count exceeds bounds") } return routes, nil } type npmHost struct { ID json.RawMessage `json:"id"` DomainNames []string `json:"domain_names"` ForwardScheme string `json:"forward_scheme"` ForwardHost string `json:"forward_host"` Port int `json:"forward_port"` Enabled bool `json:"enabled"` Meta map[string]any `json:"meta"` }