Implement Mihomo external-controller support in configuration and gateway
- Added optional Mihomo configuration fields in `config.compose.yaml` and `config.example.yaml` for enhanced integration with the Mihomo external-controller. - Updated the `Gateway` to handle Mihomo API requests, including proxying and error handling for Mihomo-specific endpoints. - Enhanced the documentation in `GATEWAY_RUN.md` to guide users on configuring Mihomo integration. - Introduced new utility functions in the web client for interacting with Mihomo API endpoints, improving the overall user experience. - Updated the sidebar in the Svelte components to include a link to the Mihomo section, enhancing navigation.
This commit is contained in:
@@ -49,6 +49,16 @@ type Server struct {
|
||||
BaseURL string `yaml:"base_url"`
|
||||
PathPrefix string `yaml:"path_prefix"`
|
||||
AuthorizationEnv string `yaml:"authorization_env"`
|
||||
// Mihomo external-controller (optional): REST + WebSocket at controller root.
|
||||
MihomoBaseURL string `yaml:"mihomo_base_url"`
|
||||
MihomoBaseURLEnv string `yaml:"mihomo_base_url_env"`
|
||||
MihomoAuthorizationEnv string `yaml:"mihomo_authorization_env"`
|
||||
}
|
||||
|
||||
// Mihomo holds resolved external-controller upstream for a server alias.
|
||||
type Mihomo struct {
|
||||
Base *url.URL
|
||||
Auth string // full Authorization header value (e.g. Bearer <secret>)
|
||||
}
|
||||
|
||||
// Load reads and validates configuration from path.
|
||||
@@ -79,6 +89,9 @@ func (c *Config) Validate() error {
|
||||
s.BaseURL = strings.TrimSpace(s.BaseURL)
|
||||
s.PathPrefix = strings.TrimSpace(s.PathPrefix)
|
||||
s.AuthorizationEnv = strings.TrimSpace(s.AuthorizationEnv)
|
||||
s.MihomoBaseURL = strings.TrimSpace(s.MihomoBaseURL)
|
||||
s.MihomoBaseURLEnv = strings.TrimSpace(s.MihomoBaseURLEnv)
|
||||
s.MihomoAuthorizationEnv = strings.TrimSpace(s.MihomoAuthorizationEnv)
|
||||
if s.Alias == "" {
|
||||
return fmt.Errorf("servers[%d]: alias is required", i)
|
||||
}
|
||||
@@ -106,6 +119,19 @@ func (c *Config) Validate() error {
|
||||
if !strings.HasPrefix(s.PathPrefix, "/") {
|
||||
s.PathPrefix = "/" + s.PathPrefix
|
||||
}
|
||||
hasMihomoURL := s.MihomoBaseURL != "" || s.MihomoBaseURLEnv != ""
|
||||
if hasMihomoURL && s.MihomoAuthorizationEnv == "" {
|
||||
return fmt.Errorf("servers[%d]: mihomo_authorization_env is required when mihomo url is set", i)
|
||||
}
|
||||
if !hasMihomoURL && s.MihomoAuthorizationEnv != "" {
|
||||
return fmt.Errorf("servers[%d]: mihomo_base_url or mihomo_base_url_env is required when mihomo_authorization_env is set", i)
|
||||
}
|
||||
if s.MihomoBaseURL != "" {
|
||||
mu, err := url.Parse(s.MihomoBaseURL)
|
||||
if err != nil || mu.Scheme == "" || mu.Host == "" {
|
||||
return fmt.Errorf("servers[%d]: invalid mihomo_base_url %q", i, s.MihomoBaseURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(c.Servers) == 0 {
|
||||
return fmt.Errorf("at least one server entry is required")
|
||||
@@ -160,6 +186,7 @@ type Parsed struct {
|
||||
Trusted []netip.Prefix
|
||||
ByAlias map[string]*Server
|
||||
AuthByAlias map[string]string // non-empty Authorization value per alias
|
||||
MihomoByAlias map[string]*Mihomo
|
||||
}
|
||||
|
||||
// Parse compiles CIDRs and resolves authorization from environment.
|
||||
@@ -182,6 +209,7 @@ func (c *Config) Parse() (*Parsed, error) {
|
||||
}
|
||||
by := make(map[string]*Server, len(c.Servers))
|
||||
auth := make(map[string]string)
|
||||
mihomo := make(map[string]*Mihomo)
|
||||
for i := range c.Servers {
|
||||
s := &c.Servers[i]
|
||||
by[s.Alias] = s
|
||||
@@ -192,12 +220,53 @@ func (c *Config) Parse() (*Parsed, error) {
|
||||
}
|
||||
auth[s.Alias] = v
|
||||
}
|
||||
mu, err := resolveMihomo(s)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if mu != nil {
|
||||
mihomo[s.Alias] = mu
|
||||
}
|
||||
}
|
||||
return &Parsed{
|
||||
Config: c,
|
||||
Whitelist: wl,
|
||||
Trusted: tr,
|
||||
ByAlias: by,
|
||||
AuthByAlias: auth,
|
||||
Config: c,
|
||||
Whitelist: wl,
|
||||
Trusted: tr,
|
||||
ByAlias: by,
|
||||
AuthByAlias: auth,
|
||||
MihomoByAlias: mihomo,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// resolveMihomo returns non-nil only when Mihomo is configured for this server.
|
||||
func resolveMihomo(s *Server) (*Mihomo, error) {
|
||||
hasURL := s.MihomoBaseURL != "" || s.MihomoBaseURLEnv != ""
|
||||
if !hasURL {
|
||||
return nil, nil
|
||||
}
|
||||
if s.MihomoAuthorizationEnv == "" {
|
||||
return nil, fmt.Errorf("server %q: mihomo_authorization_env is required when mihomo url is set", s.Alias)
|
||||
}
|
||||
var urlStr string
|
||||
if s.MihomoBaseURLEnv != "" {
|
||||
v := strings.TrimSpace(os.Getenv(s.MihomoBaseURLEnv))
|
||||
if v != "" {
|
||||
urlStr = v
|
||||
}
|
||||
}
|
||||
if urlStr == "" && s.MihomoBaseURL != "" {
|
||||
urlStr = s.MihomoBaseURL
|
||||
}
|
||||
if urlStr == "" {
|
||||
return nil, fmt.Errorf("server %q: mihomo controller url is empty (set mihomo_base_url or env %q)", s.Alias, s.MihomoBaseURLEnv)
|
||||
}
|
||||
u, err := url.Parse(urlStr)
|
||||
if err != nil || u.Scheme == "" || u.Host == "" {
|
||||
return nil, fmt.Errorf("server %q: invalid mihomo controller url %q", s.Alias, urlStr)
|
||||
}
|
||||
authVal := os.Getenv(s.MihomoAuthorizationEnv)
|
||||
if strings.TrimSpace(authVal) == "" {
|
||||
return nil, fmt.Errorf("server %q: env %q is empty or unset", s.Alias, s.MihomoAuthorizationEnv)
|
||||
}
|
||||
return &Mihomo{Base: u, Auth: authVal}, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// NewMihomoForward proxies /api/{alias}/mihomo/... to Mihomo external-controller (REST + WebSocket).
|
||||
func NewMihomoForward(
|
||||
target *url.URL,
|
||||
stripPrefix string,
|
||||
auth string,
|
||||
rt http.RoundTripper,
|
||||
errHandler func(http.ResponseWriter, *http.Request, error),
|
||||
) http.Handler {
|
||||
if errHandler == nil {
|
||||
errHandler = defaultForwardErrorHandler
|
||||
}
|
||||
if rt == nil {
|
||||
rt = http.DefaultTransport
|
||||
}
|
||||
rp := &httputil.ReverseProxy{
|
||||
Director: func(req *http.Request) {
|
||||
NormalizeRequestURLPath(req)
|
||||
p := req.URL.Path
|
||||
if !strings.HasPrefix(p, stripPrefix) {
|
||||
return
|
||||
}
|
||||
rest := strings.TrimPrefix(p, stripPrefix)
|
||||
rest = strings.TrimPrefix(rest, "/")
|
||||
q := req.URL.RawQuery
|
||||
dest := JoinPathPrefix(target, "/", rest)
|
||||
req.URL.Scheme = dest.Scheme
|
||||
req.URL.Host = dest.Host
|
||||
req.URL.Path = dest.Path
|
||||
req.URL.RawQuery = q
|
||||
req.Host = dest.Host
|
||||
req.Header.Del("Authorization")
|
||||
if auth != "" {
|
||||
req.Header.Set("Authorization", auth)
|
||||
}
|
||||
},
|
||||
Transport: rt,
|
||||
ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) {
|
||||
errHandler(w, r, err)
|
||||
},
|
||||
}
|
||||
return rp
|
||||
}
|
||||
|
||||
// MihomoMetaJSON returns a JSON body for GET .../mihomo/meta (display URL without credentials).
|
||||
func MihomoMetaJSON(target *url.URL) []byte {
|
||||
display := strings.TrimSuffix(target.String(), "/")
|
||||
b, _ := json.Marshal(map[string]any{
|
||||
"ok": true,
|
||||
"controller_base": display,
|
||||
})
|
||||
return b
|
||||
}
|
||||
|
||||
// MihomoJSONError writes a JSON error for Mihomo routes when proxy is not configured.
|
||||
func MihomoJSONError(w http.ResponseWriter, code, msg string) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"ok": false,
|
||||
"error": map[string]string{"code": code, "message": msg},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMihomoForwardRewritesPathAndAuth(t *testing.T) {
|
||||
target, err := url.Parse("http://127.0.0.1:9090")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cap := &captureTransport{}
|
||||
h := NewMihomoForward(target, "/api/mtg/mihomo", "Bearer testsecret", cap, nil)
|
||||
|
||||
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, "http://gw/api/mtg/mihomo/proxies", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer client-should-not-forward")
|
||||
h.ServeHTTP(httptest.NewRecorder(), req)
|
||||
|
||||
if cap.got == nil {
|
||||
t.Fatal("no outgoing request captured")
|
||||
}
|
||||
if got := cap.got.Header.Get("Authorization"); got != "Bearer testsecret" {
|
||||
t.Fatalf("Authorization: got %q want Bearer testsecret", got)
|
||||
}
|
||||
want, err := url.Parse("http://127.0.0.1:9090/proxies")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertSameURL(t, cap.got.URL, want)
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import (
|
||||
type Gateway struct {
|
||||
parsed *config.Parsed
|
||||
proxies map[string]http.Handler
|
||||
mihomo map[string]http.Handler
|
||||
agg *aggregate.Handler
|
||||
geo *geoip.Service
|
||||
log *slog.Logger
|
||||
@@ -45,6 +46,7 @@ func NewGateway(p *config.Parsed, log *slog.Logger, geo *geoip.Service) (*Gatewa
|
||||
g := &Gateway{
|
||||
parsed: p,
|
||||
proxies: make(map[string]http.Handler),
|
||||
mihomo: make(map[string]http.Handler),
|
||||
geo: geo,
|
||||
log: log,
|
||||
transport: t,
|
||||
@@ -68,6 +70,19 @@ func NewGateway(p *config.Parsed, log *slog.Logger, geo *geoip.Service) (*Gatewa
|
||||
})
|
||||
})
|
||||
}
|
||||
for alias, m := range p.MihomoByAlias {
|
||||
a := alias
|
||||
strip := "/api/" + a + "/mihomo"
|
||||
g.mihomo[a] = proxy.NewMihomoForward(m.Base, strip, m.Auth, t, func(w http.ResponseWriter, r *http.Request, err error) {
|
||||
log.Error("mihomo upstream error", "alias", a, "err", err)
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"ok": false,
|
||||
"error": map[string]string{"code": "bad_gateway", "message": "mihomo upstream unreachable"},
|
||||
})
|
||||
})
|
||||
}
|
||||
var aggCacheTTL time.Duration
|
||||
if p.Config.Aggregate != nil && p.Config.Aggregate.CacheTTLMs > 0 {
|
||||
aggCacheTTL = time.Duration(p.Config.Aggregate.CacheTTLMs) * time.Millisecond
|
||||
@@ -230,7 +245,11 @@ func routeEndpoint(path string) string {
|
||||
if i < 0 {
|
||||
return "proxy_root"
|
||||
}
|
||||
return "proxy_" + rest[i+1:]
|
||||
sub := rest[i+1:]
|
||||
if strings.HasPrefix(sub, "mihomo/") {
|
||||
return "mihomo_" + sub[len("mihomo/"):]
|
||||
}
|
||||
return "proxy_" + sub
|
||||
}
|
||||
return "ui"
|
||||
}
|
||||
@@ -285,7 +304,7 @@ func (g *Gateway) serve(w http.ResponseWriter, r *http.Request) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
rp, ok := g.proxies[alias]
|
||||
_, ok := g.proxies[alias]
|
||||
if !ok {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
@@ -295,7 +314,28 @@ func (g *Gateway) serve(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
return
|
||||
}
|
||||
rp.ServeHTTP(w, r)
|
||||
mihomoPfx := "/api/" + alias + "/mihomo"
|
||||
if strings.HasPrefix(r.URL.Path, mihomoPfx) {
|
||||
mh, have := g.mihomo[alias]
|
||||
if !have {
|
||||
proxy.MihomoJSONError(w, "mihomo_not_configured", "mihomo is not configured for this server")
|
||||
return
|
||||
}
|
||||
if r.URL.Path == mihomoPfx+"/meta" && r.Method == http.MethodGet {
|
||||
mu := g.parsed.MihomoByAlias[alias]
|
||||
if mu == nil {
|
||||
proxy.MihomoJSONError(w, "mihomo_not_configured", "mihomo is not configured for this server")
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(proxy.MihomoMetaJSON(mu.Base))
|
||||
return
|
||||
}
|
||||
mh.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
g.proxies[alias].ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
func (g *Gateway) serveLiveEvents(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
Reference in New Issue
Block a user