package admin import ( "context" "errors" "fmt" "net/http" "strings" "github.com/proxy-pool/proxy-pool/internal/platform/httpapi" "github.com/proxy-pool/proxy-pool/internal/platform/httpsecurity" ) const ( statusPath = "/api/v1/status" reloadPath = "/api/v1/config/reload" upstreamPrefix = "/api/v1/upstreams/" routingPrefix = "/api/v1/routing/" maxResourceNameBytes = 128 ) var ( ErrInvalidHandler = errors.New("invalid admin HTTP handler") ErrNotFound = errors.New("admin resource not found") ErrConflict = errors.New("admin mutation conflict") ErrInvalidConfiguration = errors.New("invalid configuration") ErrUnavailable = errors.New("admin service unavailable") ) type Service interface { Status(context.Context) (Status, error) SetUpstreamEnabled(context.Context, SetUpstreamCommand) (MutationResult, error) SwitchRouting(context.Context, SwitchCommand) (MutationResult, error) ReloadConfiguration(context.Context, ReloadCommand) (MutationResult, error) } type Authorizer interface { Check(context.Context, *http.Request) error } var _ Authorizer = (*httpsecurity.Protection)(nil) type Options struct { MaxBodyBytes int64 } type Status struct { ConfigVersion string `json:"configVersion"` SnapshotVersion uint64 `json:"snapshotVersion"` Upstreams []UpstreamStatus `json:"upstreams"` Workers []WorkerStatus `json:"workers"` } type UpstreamStatus struct { Name string `json:"name"` Enabled bool `json:"enabled"` Available int64 `json:"available"` Checking int64 `json:"checking"` Suspect int64 `json:"suspect"` Draining int64 `json:"draining"` Extracted int64 `json:"extracted"` ConsecutiveEmptyFetch int64 `json:"consecutiveEmptyFetch,omitempty"` FetchErrorCount int64 `json:"fetchErrorCount,omitempty"` } type WorkerStatus struct { ID string `json:"id"` Zone string `json:"zone"` Connected bool `json:"connected"` SnapshotVersion uint64 `json:"snapshotVersion"` StaleSeconds int64 `json:"staleSeconds,omitempty"` } type MutationResult struct { RequestID string `json:"requestId"` Changed bool `json:"changed"` Version uint64 `json:"version"` Message string `json:"message,omitempty"` } type SetUpstreamCommand struct { RequestID string Name string Enabled bool } type SwitchCommand struct { RequestID string `json:"-"` Name string `json:"-"` ExpectedCurrent string `json:"expectedCurrent"` Target string `json:"target"` Reason string `json:"reason,omitempty"` } type ReloadCommand struct { RequestID string } type Handler struct { service Service authorizer Authorizer maxBodyBytes int64 } func NewHandler(service Service, authorizer Authorizer, options Options) (*Handler, error) { if service == nil || authorizer == nil || options.MaxBodyBytes <= 0 { return nil, ErrInvalidHandler } return &Handler{service: service, authorizer: authorizer, maxBodyBytes: options.MaxBodyBytes}, nil } func (handler *Handler) ServeHTTP(writer http.ResponseWriter, request *http.Request) { requestID, err := httpapi.ResolveRequestID(request) if err != nil { writeTransportProblem(writer, http.StatusBadRequest, "INVALID_REQUEST_ID", "Invalid request ID", "X-Request-ID is invalid", requestID) return } if err := handler.authorizer.Check(request.Context(), request); err != nil { if !httpsecurity.WriteProblem(writer, requestID, err) { writeTransportProblem(writer, http.StatusInternalServerError, "INTERNAL_ERROR", "Internal server error", "the request could not be completed", requestID) } return } switch request.URL.Path { case statusPath: if !requireMethod(writer, request, http.MethodGet, requestID) { return } handler.getStatus(writer, request, requestID) return case reloadPath: if !requireMethod(writer, request, http.MethodPost, requestID) { return } handler.reload(writer, request, requestID) return } if name, action, ok := matchNamedAction(request.URL.Path, upstreamPrefix, "enable", "disable"); ok { if !requireMethod(writer, request, http.MethodPost, requestID) { return } handler.setUpstreamEnabled(writer, request, name, action == "enable", requestID) return } if name, _, ok := matchNamedAction(request.URL.Path, routingPrefix, "switch"); ok { if !requireMethod(writer, request, http.MethodPost, requestID) { return } handler.switchRouting(writer, request, name, requestID) return } writeTransportProblem(writer, http.StatusNotFound, "NOT_FOUND", "Not found", "the requested endpoint does not exist", requestID) } func (handler *Handler) getStatus(writer http.ResponseWriter, request *http.Request, requestID string) { status, err := handler.service.Status(request.Context()) if err != nil { writeServiceProblem(writer, err, requestID) return } if status.Upstreams == nil { status.Upstreams = []UpstreamStatus{} } if status.Workers == nil { status.Workers = []WorkerStatus{} } writer.Header().Set(httpapi.HeaderRequestID, requestID) _ = httpapi.WriteJSON(writer, http.StatusOK, status) } func (handler *Handler) setUpstreamEnabled(writer http.ResponseWriter, request *http.Request, name string, enabled bool, requestID string) { result, err := handler.service.SetUpstreamEnabled(request.Context(), SetUpstreamCommand{ RequestID: requestID, Name: name, Enabled: enabled, }) if err != nil { writeServiceProblem(writer, err, requestID) return } writeMutation(writer, result, requestID) } func (handler *Handler) switchRouting(writer http.ResponseWriter, request *http.Request, name, requestID string) { var command SwitchCommand if err := httpapi.DecodeJSON(writer, request, handler.maxBodyBytes, &command); err != nil { writeDecodeProblem(writer, err, requestID) return } if command.ExpectedCurrent == "" || command.Target == "" || len(command.ExpectedCurrent) > maxResourceNameBytes || len(command.Target) > maxResourceNameBytes || len(command.Reason) > 512 { writeTransportProblem(writer, http.StatusUnprocessableEntity, "INVALID_SWITCH", "Invalid routing switch", "routing switch fields violate the API contract", requestID) return } command.RequestID = requestID command.Name = name result, err := handler.service.SwitchRouting(request.Context(), command) if err != nil { writeServiceProblem(writer, err, requestID) return } writeMutation(writer, result, requestID) } func (handler *Handler) reload(writer http.ResponseWriter, request *http.Request, requestID string) { result, err := handler.service.ReloadConfiguration(request.Context(), ReloadCommand{RequestID: requestID}) if err != nil { writeServiceProblem(writer, err, requestID) return } writeMutation(writer, result, requestID) } func writeMutation(writer http.ResponseWriter, result MutationResult, requestID string) { result.RequestID = requestID writer.Header().Set(httpapi.HeaderRequestID, requestID) _ = httpapi.WriteJSON(writer, http.StatusOK, result) } func matchNamedAction(path, prefix string, actions ...string) (string, string, bool) { if !strings.HasPrefix(path, prefix) { return "", "", false } remainder := strings.TrimPrefix(path, prefix) name, action, ok := strings.Cut(remainder, "/") if !ok || name == "" || len(name) > maxResourceNameBytes || strings.Contains(action, "/") { return "", "", false } for _, allowed := range actions { if action == allowed { return name, action, true } } return "", "", false } func requireMethod(writer http.ResponseWriter, request *http.Request, allowed, requestID string) bool { if request.Method == allowed { return true } writer.Header().Set("Allow", allowed) writeTransportProblem(writer, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "Method not allowed", fmt.Sprintf("use %s for this endpoint", allowed), requestID) return false } func writeDecodeProblem(writer http.ResponseWriter, err error, requestID string) { if errors.Is(err, httpapi.ErrUnsupportedMediaType) { writeTransportProblem(writer, http.StatusUnsupportedMediaType, "UNSUPPORTED_MEDIA_TYPE", "Unsupported media type", "Content-Type must be application/json", requestID) return } if errors.Is(err, httpapi.ErrBodyTooLarge) { writeTransportProblem(writer, http.StatusRequestEntityTooLarge, "REQUEST_BODY_TOO_LARGE", "Request body too large", "request body exceeds the configured limit", requestID) return } writeTransportProblem(writer, http.StatusBadRequest, "INVALID_JSON", "Invalid JSON", "request body must be one valid JSON document with no unknown fields", requestID) } func writeServiceProblem(writer http.ResponseWriter, err error, requestID string) { switch { case errors.Is(err, ErrNotFound): writeTransportProblem(writer, http.StatusNotFound, "NOT_FOUND", "Not found", "the requested resource does not exist", requestID) case errors.Is(err, ErrConflict): writeTransportProblem(writer, http.StatusConflict, "CONFLICT", "Mutation conflict", "the authoritative state changed before the mutation committed", requestID) case errors.Is(err, ErrInvalidConfiguration): writeTransportProblem(writer, http.StatusUnprocessableEntity, "INVALID_CONFIGURATION", "Invalid configuration", "the new configuration did not pass validation", requestID) case errors.Is(err, ErrUnavailable): writeTransportProblem(writer, http.StatusServiceUnavailable, "SERVICE_UNAVAILABLE", "Service unavailable", "the authoritative service is temporarily unavailable", requestID) default: writeTransportProblem(writer, http.StatusInternalServerError, "INTERNAL_ERROR", "Internal server error", "the request could not be completed", requestID) } } func writeTransportProblem(writer http.ResponseWriter, status int, code, title, detail, requestID string) { httpapi.WriteProblem(writer, httpapi.NewProblem(status, code, title, detail, requestID)) }