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) { `
IPv6${a}
` ).join(''); + const nameBlock = label + ? `
+ ${label} + ${iface.name} +
` + : `${iface.name}`; + card.innerHTML = `
- ${iface.name} + ${nameBlock} ${hasPending ? 'несохранено' : ''}
-
- ${modeLabel(iface.mode)} -
+ ${modeLabel(iface.mode)}
-
- Статус - ${iface.state || 'unknown'} -
IPv4 ${iface.ipv4 @@ -136,14 +158,13 @@ function buildCard(iface, vlans) { Шлюз ${iface.gateway || ''}
-
- RX + ↓ RX ${fmtBytes(iface.rx_bytes)}
- TX + ↑ TX ${fmtBytes(iface.tx_bytes)}
@@ -154,10 +175,15 @@ function buildCard(iface, vlans) {
- - - - + ${!isLo ? ` + + + ` : ''} +
${!isLo ? buildVLANSection(iface.name, vlans) : ''} @@ -170,6 +196,8 @@ function buildVLANSection(parentName, vlans) { const rows = vlans.map(v => { const sc = stateClass(v.state); const hasPending = state.pending.includes(v.name); + const isUp = v.state === 'up'; + const label = v.label || ''; const ip = v.ipv4 ? v.ipv4 + (v.ipv4_mask ? ' / ' + v.ipv4_mask : '') : ''; @@ -177,17 +205,21 @@ function buildVLANSection(parentName, vlans) {
- ${v.name} + ${label + ? `
${label}${v.name}
` + : `${v.name}`} VLAN ${vlanId(v.name)} ${modeLabel(v.mode)} ${hasPending ? 'несохранено' : ''}
${ip}
- - - - + + +
`; }).join(''); @@ -199,8 +231,8 @@ function buildVLANSection(parentName, vlans) { return `
- Теговые VLAN - + VLAN +
${rows} @@ -251,17 +283,13 @@ async function doAction(name, action) { return; } - const btn = document.querySelector(`[data-action="${action}"][data-iface="${name}"]`); - if (btn) { btn.disabled = true; btn.textContent = '...'; } - try { await post(`/api/interfaces/${name}/${action}`); showToast(`${name}: ${action} выполнено`, 'success'); await loadAll(); } catch (e) { showToast(`${name} ${action}: ${e.message}`, 'error'); - } finally { - if (btn) btn.disabled = false; + await loadAll(); // refresh to restore correct toggle state } } @@ -285,12 +313,12 @@ async function openConfig(name) { } try { - const [{ config, pending }, natData] = await Promise.all([ + const [configData, natData] = await Promise.all([ get(`/api/config/${name}`), get('/api/nat').catch(() => null), ]); if (natData) state.nat = natData; - fillForm(config, pending, name); + fillForm(configData.config, configData.pending, name, configData.label || ''); document.getElementById('modal').classList.remove('hidden'); } catch (e) { showToast('Ошибка загрузки конфига: ' + e.message, 'error'); @@ -313,11 +341,12 @@ async function openNewVLAN(parentName) { if (natData) state.nat = natData; } catch (_) {} - fillForm({ auto: true, mode: 'static' }, false, ''); + fillForm({ auto: true, mode: 'static' }, false, '', ''); document.getElementById('modal').classList.remove('hidden'); } -function fillForm(cfg, pending, name) { +function fillForm(cfg, pending, name, label = '') { + document.getElementById('cfgLabel').value = label; document.getElementById('cfgAuto').checked = !!cfg.auto; document.getElementById('cfgAddress').value = cfg.address || ''; document.getElementById('cfgNetmask').value = cfg.netmask || ''; @@ -327,7 +356,6 @@ function fillForm(cfg, pending, name) { const mode = cfg.mode === 'static' ? 'static' : 'dhcp'; setMode(mode); - // Mark pending visually if (pending && name) { document.getElementById('modalTitle').textContent = `Настройка: ${name} (несохранённые изменения)`; } @@ -390,6 +418,7 @@ async function saveConfig() { const mode = currentMode(); const cfg = { name, + label: document.getElementById('cfgLabel').value.trim(), auto: document.getElementById('cfgAuto').checked, mode, address: document.getElementById('cfgAddress').value.trim(), @@ -451,7 +480,6 @@ async function discardAll() { } state.pending = []; renderPendingBanner(); - renderAll(); showToast('Изменения отменены', 'info'); await loadAll(); } @@ -471,15 +499,17 @@ function showToast(msg, type = 'info') { // ── Event wiring ────────────────────────────────────────────────────────────── document.getElementById('refreshBtn').addEventListener('click', loadAll); - document.getElementById('applyBtn').addEventListener('click', applyAll); document.getElementById('discardAllBtn').addEventListener('click', discardAll); -// Card action buttons (delegated) +// Card button clicks (delegated) document.getElementById('ifaceGrid').addEventListener('click', e => { const btn = e.target.closest('[data-action]'); if (!btn) return; + // Don't handle toggle inputs here (handled by 'change' below) + if (btn.tagName === 'INPUT' && btn.type === 'checkbox') return; const { action, iface } = btn.dataset; + if (!action || !iface) return; if (action === 'config') { openConfig(iface); } else if (action === 'addvlan') { @@ -489,6 +519,13 @@ document.getElementById('ifaceGrid').addEventListener('click', e => { } }); +// Toggle switch (on/off) — delegated change event +document.getElementById('ifaceGrid').addEventListener('change', e => { + const input = e.target.closest('input[data-action="toggle"]'); + if (!input) return; + doAction(input.dataset.iface, input.checked ? 'up' : 'down'); +}); + // Modal close document.getElementById('closeModal').addEventListener('click', closeModal); document.getElementById('cancelConfigBtn').addEventListener('click', closeModal); diff --git a/public/index.html b/public/index.html index b43983f..f70a894 100644 --- a/public/index.html +++ b/public/index.html @@ -106,6 +106,11 @@
+
+ + +
+
- - -
- + + +
+ +
+
+ Mihomo не запущен — дашборд недоступен. Запустите ядро для просмотра статуса прокси в реальном времени. +
+ +
+ -
+