diff --git a/Meta-Docs b/Meta-Docs index d31369a..eedcf07 160000 --- a/Meta-Docs +++ b/Meta-Docs @@ -1 +1 @@ -Subproject commit d31369ab4517a5fcbb4b5d3ec81b3178bca502ca +Subproject commit eedcf074cd71fbe018a7902352dd25cce55f9e66 diff --git a/alpine-router b/alpine-router index fdf7cbd..996fdb1 100755 Binary files a/alpine-router and b/alpine-router differ diff --git a/handlers/api.go b/handlers/api.go index 29edb14..af21326 100644 --- a/handlers/api.go +++ b/handlers/api.go @@ -45,9 +45,12 @@ func HandleInterfaces(w http.ResponseWriter, r *http.Request) { type iface struct { *network.InterfaceStats - Pending bool `json:"pending"` + Pending bool `json:"pending"` + Label string `json:"label,omitempty"` } + appCfg, _ := config.Load() + result := make([]iface, 0, len(names)) existingNames := map[string]bool{} for _, name := range names { @@ -59,8 +62,14 @@ func HandleInterfaces(w http.ResponseWriter, r *http.Request) { if cfg, ok := fileCfg[name]; ok { s.Mode = cfg.Mode } - _, hasPending := network.GetPendingConfig(name), network.GetPendingConfig(name) != nil - result = append(result, iface{s, hasPending}) + hasPending := network.GetPendingConfig(name) != nil + label := "" + if appCfg != nil && appCfg.Interfaces != nil { + if ic, ok := appCfg.Interfaces[name]; ok { + label = ic.Label + } + } + result = append(result, iface{s, hasPending, label}) } // Also include pending VLAN configs not yet present in the system. @@ -79,7 +88,13 @@ func HandleInterfaces(w http.ResponseWriter, r *http.Request) { s.IPv4Mask = cfg.Netmask s.Gateway = cfg.Gateway } - result = append(result, iface{s, true}) + label := cfg.Label + if label == "" && appCfg != nil && appCfg.Interfaces != nil { + if ic, ok := appCfg.Interfaces[name]; ok { + label = ic.Label + } + } + result = append(result, iface{s, true, label}) } ok(w, result) @@ -149,8 +164,18 @@ func HandleConfig(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: + appCfg, _ := config.Load() + label := "" + if appCfg != nil && appCfg.Interfaces != nil { + if ic, ok2 := appCfg.Interfaces[name]; ok2 { + label = ic.Label + } + } if cfg := network.GetPendingConfig(name); cfg != nil { - ok(w, map[string]interface{}{"config": cfg, "pending": true}) + if cfg.Label != "" { + label = cfg.Label + } + ok(w, map[string]interface{}{"config": cfg, "pending": true, "label": label}) return } fileCfg, err := network.ParseConfig() @@ -159,11 +184,12 @@ func HandleConfig(w http.ResponseWriter, r *http.Request) { return } if cfg, exists := fileCfg[name]; exists { - ok(w, map[string]interface{}{"config": cfg, "pending": false}) + ok(w, map[string]interface{}{"config": cfg, "pending": false, "label": label}) } else { ok(w, map[string]interface{}{ "config": &network.InterfaceConfig{Name: name, Auto: true, Mode: "dhcp", Extra: map[string]string{}}, "pending": false, + "label": label, }) } @@ -188,6 +214,7 @@ func HandleConfig(w http.ResponseWriter, r *http.Request) { appCfg.Interfaces = map[string]*config.InterfaceConfig{} } appCfg.Interfaces[name] = &config.InterfaceConfig{ + Label: cfg.Label, Auto: cfg.Auto, Mode: cfg.Mode, Address: cfg.Address, diff --git a/handlers/mihomo_proxy.go b/handlers/mihomo_proxy.go new file mode 100644 index 0000000..8a76bd8 --- /dev/null +++ b/handlers/mihomo_proxy.go @@ -0,0 +1,217 @@ +package handlers + +import ( + "io" + "log" + "net" + "net/http" + "net/url" + "strings" + + "alpine-router/mihomo" +) + +func getMihomoAPIBase() string { + cfg, err := mihomo.LoadConfig() + if err != nil { + return "http://127.0.0.1:9090" + } + ec, _ := cfg["external-controller"].(string) + if ec == "" { + ec = "0.0.0.0:9090" + } + if strings.HasPrefix(ec, "0.0.0.0:") || strings.HasPrefix(ec, ":") { + ec = "127.0.0.1" + strings.TrimPrefix(ec, "0.0.0.0") + } + if !strings.HasPrefix(ec, "http") { + ec = "http://" + ec + } + return ec +} + +func getMihomoSecret() string { + cfg, err := mihomo.LoadConfig() + if err != nil { + return "" + } + s, _ := cfg["secret"].(string) + return s +} + +func getMihomoHostPort() (string, string) { + u, err := url.Parse(getMihomoAPIBase()) + if err != nil { + return "127.0.0.1", "9090" + } + host := u.Hostname() + if host == "0.0.0.0" || host == "" { + host = "127.0.0.1" + } + port := u.Port() + if port == "" { + if u.Scheme == "https" { + port = "443" + } else { + port = "80" + } + } + return host, port +} + +func HandleMihomoAPIProxy(w http.ResponseWriter, r *http.Request) { + base := getMihomoAPIBase() + secret := getMihomoSecret() + + suffix := strings.TrimPrefix(r.URL.Path, "/api/mihomo/api/") + target, err := url.Parse(base + "/" + suffix) + if err != nil { + fail(w, http.StatusInternalServerError, "parse mihomo url: "+err.Error()) + return + } + + q := r.URL.Query() + if r.Method == http.MethodGet { + q.Set("nonce", "1") + } + target.RawQuery = q.Encode() + + proxyReq, err := http.NewRequest(r.Method, target.String(), r.Body) + if err != nil { + fail(w, http.StatusInternalServerError, "create proxy request: "+err.Error()) + return + } + + for k, vv := range r.Header { + if strings.EqualFold(k, "Authorization") { + continue + } + for _, v := range vv { + proxyReq.Header.Add(k, v) + } + } + if secret != "" { + proxyReq.Header.Set("Authorization", "Bearer "+secret) + } + + client := &http.Client{} + resp, err := client.Do(proxyReq) + if err != nil { + fail(w, http.StatusBadGateway, "mihomo api unreachable: "+err.Error()) + return + } + defer resp.Body.Close() + + for k, vv := range resp.Header { + if strings.EqualFold(k, "Content-Length") { + continue + } + for _, v := range vv { + w.Header().Add(k, v) + } + } + + w.WriteHeader(resp.StatusCode) + _, err = io.Copy(w, resp.Body) + if err != nil { + log.Printf("proxy copy error: %v", err) + } +} + +func HandleMihomoWSProxy(w http.ResponseWriter, r *http.Request) { + secret := getMihomoSecret() + host, port := getMihomoHostPort() + + suffix := strings.TrimPrefix(r.URL.Path, "/api/mihomo/ws/") + path := "/" + suffix + if r.URL.RawQuery != "" { + path += "?" + r.URL.RawQuery + } + + hj, ok := w.(http.Hijacker) + if !ok { + fail(w, http.StatusInternalServerError, "websocket hijack not supported") + return + } + + clientConn, _, err := hj.Hijack() + if err != nil { + fail(w, http.StatusInternalServerError, "hijack failed: "+err.Error()) + return + } + + remoteConn, err := net.Dial("tcp", net.JoinHostPort(host, port)) + if err != nil { + fail(w, http.StatusBadGateway, "mihomo ws connect failed: "+err.Error()) + clientConn.Close() + return + } + + upgradeReq := "GET " + path + " HTTP/1.1\r\n" + upgradeReq += "Host: " + host + ":" + port + "\r\n" + upgradeReq += "Upgrade: websocket\r\n" + upgradeReq += "Connection: Upgrade\r\n" + upgradeReq += "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n" + upgradeReq += "Sec-WebSocket-Version: 13\r\n" + if secret != "" { + upgradeReq += "Authorization: Bearer " + secret + "\r\n" + } + for k, vv := range r.Header { + if strings.EqualFold(k, "Upgrade") || strings.EqualFold(k, "Connection") || + strings.EqualFold(k, "Sec-Websocket-Key") || strings.EqualFold(k, "Sec-Websocket-Version") || + strings.EqualFold(k, "Sec-Websocket-Extensions") || strings.EqualFold(k, "Sec-Websocket-Protocol") || + strings.EqualFold(k, "Authorization") { + continue + } + for _, v := range vv { + upgradeReq += k + ": " + v + "\r\n" + } + } + for k, v := range r.Header { + if strings.EqualFold(k, "Sec-WebSocket-Protocol") { + upgradeReq += "Sec-WebSocket-Protocol: " + strings.Join(v, ", ") + "\r\n" + } + if strings.EqualFold(k, "Sec-WebSocket-Extensions") { + upgradeReq += "Sec-WebSocket-Extensions: " + strings.Join(v, ", ") + "\r\n" + } + } + upgradeReq += "\r\n" + + _, err = remoteConn.Write([]byte(upgradeReq)) + if err != nil { + clientConn.Close() + remoteConn.Close() + return + } + + buf := make([]byte, 4096) + n, err := remoteConn.Read(buf) + if err != nil { + clientConn.Close() + remoteConn.Close() + return + } + + respStr := string(buf[:n]) + if !strings.Contains(respStr, "101") { + clientConn.Write(buf[:n]) + clientConn.Close() + remoteConn.Close() + return + } + + headerEnd := strings.Index(respStr, "\r\n\r\n") + if headerEnd >= 0 { + clientConn.Write(buf[:n]) + } else { + clientConn.Write(buf[:n]) + } + + go func() { + io.Copy(remoteConn, clientConn) + remoteConn.Close() + }() + go func() { + io.Copy(clientConn, remoteConn) + clientConn.Close() + }() +} \ No newline at end of file diff --git a/main.go b/main.go index aac88c7..a64feee 100644 --- a/main.go +++ b/main.go @@ -104,6 +104,8 @@ func main() { mux.HandleFunc("/api/mihomo/config.yaml", handlers.HandleMihomoConfigYAML) mux.HandleFunc("/api/mihomo/logs", handlers.HandleMihomoLogs) mux.HandleFunc("/api/mihomo/upload-core", handlers.HandleMihomoUploadCore) + mux.HandleFunc("/api/mihomo/api/", handlers.HandleMihomoAPIProxy) + mux.HandleFunc("/api/mihomo/ws/", handlers.HandleMihomoWSProxy) sub, err := fs.Sub(publicFS, "public") if err != nil { diff --git a/public/app.js b/public/app.js index e2de8d3..5408864 100644 --- a/public/app.js +++ b/public/app.js @@ -64,6 +64,25 @@ function modeLabel(m) { return { dhcp: 'DHCP', static: 'Static', loopback: 'Loopback', manual: 'Manual' }[m] || (m || '?'); } +// ── SVG icons ──────────────────────────────────────────────────────────────── + +const ICON = { + pencil: ``, + restart: ``, + trash: ``, + plus: ``, +}; + // ── Render ─────────────────────────────────────────────────────────────────── function renderAll() { @@ -99,6 +118,8 @@ function buildCard(iface, vlans) { const hasPending = state.pending.includes(iface.name); const sc = stateClass(iface.state); const isLo = iface.name === 'lo' || iface.mode === 'loopback'; + const isUp = iface.state === 'up'; + const label = iface.label || ''; const card = document.createElement('div'); card.className = 'iface-card' + (hasPending ? ' has-pending' : ''); @@ -108,23 +129,24 @@ function buildCard(iface, vlans) { `
| Хост | +Тип | +Цепочка | +↑ | +↓ | ++ |
|---|