Files
OpenGFW/web/api.go
T

318 lines
7.5 KiB
Go
Raw Normal View History

2026-07-27 08:09:11 +08:00
package web
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"strconv"
"time"
)
type apiError struct {
Error string `json:"error"`
}
func writeJSON(w http.ResponseWriter, code int, v interface{}) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(v)
}
func writeError(w http.ResponseWriter, code int, msg string) {
writeJSON(w, code, apiError{Error: msg})
}
func methodAllowed(w http.ResponseWriter, r *http.Request, methods ...string) bool {
for _, m := range methods {
if r.Method == m {
return true
}
}
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
return false
}
func decodeBody(w http.ResponseWriter, r *http.Request, v interface{}) bool {
r.Body = http.MaxBytesReader(w, r.Body, 4<<20) // 4 MiB is plenty for a rule file
if err := json.NewDecoder(r.Body).Decode(v); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body: "+err.Error())
return false
}
return true
}
// POST /api/v1/login
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
if !methodAllowed(w, r, http.MethodPost) {
return
}
var req struct {
Password string `json:"password"`
}
if !decodeBody(w, r, &req) {
return
}
token, expiry, err := s.auth.login(r.RemoteAddr, req.Password)
if err != nil {
code := http.StatusUnauthorized
if errors.Is(err, errTooManyAttempts) {
code = http.StatusTooManyRequests
}
writeError(w, code, err.Error())
return
}
http.SetCookie(w, &http.Cookie{
Name: sessionCookieName,
Value: token,
Path: "/",
Expires: expiry,
HttpOnly: true,
Secure: s.TLS(),
SameSite: http.SameSiteLaxMode,
})
writeJSON(w, http.StatusOK, map[string]interface{}{
"token": token,
"expiresAt": expiry.UnixMilli(),
})
}
// POST /api/v1/logout
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
if !methodAllowed(w, r, http.MethodPost) {
return
}
s.auth.logout(bearerToken(r))
http.SetCookie(w, &http.Cookie{
Name: sessionCookieName,
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
Secure: s.TLS(),
SameSite: http.SameSiteLaxMode,
})
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
// GET /api/v1/info
func (s *Server) handleInfo(w http.ResponseWriter, r *http.Request) {
if !methodAllowed(w, r, http.MethodGet) {
return
}
var info Info
if s.config.Info != nil {
info = s.config.Info()
}
writeJSON(w, http.StatusOK, info)
}
// GET /api/v1/meta
func (s *Server) handleMeta(w http.ResponseWriter, r *http.Request) {
if !methodAllowed(w, r, http.MethodGet) {
return
}
writeJSON(w, http.StatusOK, s.config.Meta)
}
// GET /api/v1/metrics
func (s *Server) handleMetrics(w http.ResponseWriter, r *http.Request) {
if !methodAllowed(w, r, http.MethodGet) {
return
}
writeJSON(w, http.StatusOK, s.config.Hub.Metrics())
}
// GET /api/v1/events?limit=200
func (s *Server) handleEvents(w http.ResponseWriter, r *http.Request) {
if !methodAllowed(w, r, http.MethodGet) {
return
}
limit := 200
if v := r.URL.Query().Get("limit"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
limit = n
}
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"events": s.config.Hub.Events(limit),
})
}
// GET /api/v1/live - server-sent events carrying live events and metrics.
func (s *Server) handleLive(w http.ResponseWriter, r *http.Request) {
if !methodAllowed(w, r, http.MethodGet) {
return
}
flusher, ok := w.(http.Flusher)
if !ok {
writeError(w, http.StatusInternalServerError, "streaming unsupported")
return
}
h := w.Header()
h.Set("Content-Type", "text/event-stream")
h.Set("Cache-Control", "no-cache")
h.Set("Connection", "keep-alive")
h.Set("X-Accel-Buffering", "no")
w.WriteHeader(http.StatusOK)
flusher.Flush()
events, unsubscribe := s.config.Hub.Subscribe()
defer unsubscribe()
metricsTicker := time.NewTicker(2 * time.Second)
defer metricsTicker.Stop()
keepAlive := time.NewTicker(20 * time.Second)
defer keepAlive.Stop()
send := func(event string, v interface{}) bool {
data, err := json.Marshal(v)
if err != nil {
return true
}
if _, err := fmt.Fprintf(w, "event: %s\ndata: %s\n\n", event, data); err != nil {
return false
}
flusher.Flush()
return true
}
if !send("metrics", s.config.Hub.Metrics()) {
return
}
for {
select {
case <-r.Context().Done():
return
case ev, ok := <-events:
if !ok {
return
}
if !send("event", ev) {
return
}
case <-metricsTicker.C:
if !send("metrics", s.config.Hub.Metrics()) {
return
}
case <-keepAlive.C:
if _, err := fmt.Fprint(w, ": ping\n\n"); err != nil {
return
}
flusher.Flush()
}
}
}
// GET /api/v1/geo - the entries of the configured geo databases.
func (s *Server) handleGeo(w http.ResponseWriter, r *http.Request) {
if !methodAllowed(w, r, http.MethodGet) {
return
}
if s.config.Geo == nil {
writeJSON(w, http.StatusOK, GeoData{
IPError: "geo databases are unavailable",
SiteError: "geo databases are unavailable",
})
return
}
s.geoMu.Lock()
defer s.geoMu.Unlock()
fresh := s.geoCache != nil && time.Since(s.geoLoaded) < geoCacheTTL
if !fresh || r.URL.Query().Get("reload") == "1" {
data := s.config.Geo()
s.geoCache = &data
s.geoLoaded = time.Now()
}
writeJSON(w, http.StatusOK, s.geoCache)
}
2026-07-27 08:09:11 +08:00
type rulesRequest struct {
Raw string `json:"raw"`
Rules []Rule `json:"rules"`
}
type rulesResponse struct {
Path string `json:"path"`
Raw string `json:"raw"`
Rules []Rule `json:"rules"`
}
// resolveRaw turns a request into rule file content.
func (s *Server) resolveRaw(req rulesRequest) (string, error) {
if req.Rules != nil {
return s.config.Rules.Marshal(req.Rules)
}
return req.Raw, nil
}
// GET/PUT /api/v1/rules
func (s *Server) handleRules(w http.ResponseWriter, r *http.Request) {
if !methodAllowed(w, r, http.MethodGet, http.MethodPut) {
return
}
if s.config.Rules == nil {
writeError(w, http.StatusNotImplemented, "rule management is unavailable")
return
}
if r.Method == http.MethodGet {
raw, rules, err := s.config.Rules.Load()
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, rulesResponse{Path: s.config.Rules.Path(), Raw: raw, Rules: rules})
return
}
var req rulesRequest
if !decodeBody(w, r, &req) {
return
}
raw, err := s.resolveRaw(req)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
rules, err := s.config.Rules.Apply(raw)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
s.config.Logf("ruleset updated from web UI (%d rules)", len(rules))
writeJSON(w, http.StatusOK, rulesResponse{Path: s.config.Rules.Path(), Raw: raw, Rules: rules})
}
// POST /api/v1/rules/validate
func (s *Server) handleRulesValidate(w http.ResponseWriter, r *http.Request) {
if !methodAllowed(w, r, http.MethodPost) {
return
}
if s.config.Rules == nil {
writeError(w, http.StatusNotImplemented, "rule management is unavailable")
return
}
var req rulesRequest
if !decodeBody(w, r, &req) {
return
}
raw, err := s.resolveRaw(req)
if err != nil {
writeJSON(w, http.StatusOK, map[string]interface{}{"valid": false, "error": err.Error()})
return
}
rules, err := s.config.Rules.Validate(raw)
if err != nil {
writeJSON(w, http.StatusOK, map[string]interface{}{
"valid": false,
"error": err.Error(),
"raw": raw,
})
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"valid": true,
"rules": rules,
"raw": raw,
})
}