Implement Mihomo external-controller support in configuration and gateway
Publish telemt-api gateway Docker image / test (push) Successful in 34s
Publish telemt-api gateway Docker image / build-and-push (push) Successful in 2m55s

- 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:
Denozordec
2026-03-31 00:32:19 +07:00
parent bbd9619290
commit 2d7b06260e
17 changed files with 1093 additions and 9 deletions
+74 -5
View File
@@ -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
}
+73
View File
@@ -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},
})
}
+37
View File
@@ -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)
}
+43 -3
View File
@@ -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) {