diff --git a/docs/web-panel.md b/docs/web-panel.md index bdc0b5a..b258878 100644 --- a/docs/web-panel.md +++ b/docs/web-panel.md @@ -1,11 +1,12 @@ # Web panel -A **monitoring-only** dashboard on **port 7777**, matching the CLI's look. It +A monitoring-focused dashboard on **port 7777**, matching the CLI's look. It shows live CPU / RAM / disk / traffic, each tunnel's state, real ping, and logs. +A logged-in operator can also restart an individual tunnel from its card. Backup, Telegram setup and the panel password live in **Settings**. Run it on the **Iran** server, where you watch things from. It does not create -or change tunnels — that is the CLI's job. +or reconfigure tunnels — those remain the CLI's job. ## Getting in diff --git a/internal/webui/assets/dashboard.html b/internal/webui/assets/dashboard.html index 810520f..ab4686b 100644 --- a/internal/webui/assets/dashboard.html +++ b/internal/webui/assets/dashboard.html @@ -503,6 +503,7 @@ border-radius:12px;font-size:13px;font-weight:600;cursor:pointer; background:rgba(255,255,255,.05);border:1px solid var(--stroke);color:var(--txt);transition:.22s} .logbtn:hover{background:rgba(var(--accent-rgb),.15);border-color:rgba(var(--accent-rgb),.35);color:var(--red-soft)} + .logbtn:disabled{opacity:.55;cursor:wait;pointer-events:none} /* Details modal: the numbers worth a look but not worth card space — the cards stay cut back to what you actually read. */ @@ -930,7 +931,7 @@

Tunnels

0 - monitoring only + monitoring + restart
@@ -1370,6 +1371,9 @@

Logs

"Language":"زبان","Bot language":"زبان ربات","Theme":"پوسته","English":"English","Persian":"فارسی", // tunnels "State":"وضعیت","Transport":"ترنسپورت","Preset":"پروفایل", + "Restart":"راه‌اندازی مجدد","Restart tunnel":"راه‌اندازی مجدد تونل", + "Restarting…":"در حال راه‌اندازی مجدد…","Restarted":"راه‌اندازی شد","Restart failed":"راه‌اندازی ناموفق بود", + "Could not restart tunnel:":"راه‌اندازی مجدد تونل ناموفق بود:", "Traffic In":"ترافیک ورودی","Traffic Out":"ترافیک خروجی","Tunnel Uptime":"مدت کارکرد تونل", "Bot Relay":"رله ربات","Local port":"پورت محلی","Certificate":"گواهی","Failover":"جایگزینی", "Limits & forwarding":"محدودیت‌ها و فوروارد","Overview":"نمای کلی", @@ -1377,7 +1381,7 @@

Logs

"loading…":"در حال بارگذاری…", "Loading…":"در حال بارگذاری…", "Update now":"همین حالا به‌روزرسانی کن", - "monitoring only":"فقط پایش", + "monitoring + restart":"پایش و راه‌اندازی مجدد", "No tunnels configured yet. Create one from the CLI menu (sudo backpack).":"هنوز تونلی ساخته نشده. از منوی خط فرمان بساز (sudo backpack).", "Check GitHub for the latest version.":"آخرین نسخه را از گیت‌هاب بررسی کن.", "Stable — finished releases only (recommended)":"پایدار — فقط نسخه‌های نهایی (پیشنهادی)", @@ -1611,6 +1615,7 @@

Logs

logs:'', bot:'', info:'', + restart:'', }; const COUNTRIES=[ ['','—'],['DE','Germany'],['NL','Netherlands'],['FR','France'],['GB','United Kingdom'], @@ -1867,6 +1872,7 @@

Logs

+
`; return el; } @@ -1975,6 +1981,29 @@

Logs

}catch(e){} } +async function restartTunnel(name,btn){ + if(!confirm(T('Restart tunnel')+' "'+name+'"?')) return; + const label=btn.querySelector('span'); + btn.disabled=true; + label.textContent=T('Restarting…'); + try{ + const body=new URLSearchParams({name}); + const r=await fetch('/api/tunnels/restart',{ + method:'POST', + headers:{'Content-Type':'application/x-www-form-urlencoded'}, + body + }); + if(!r.ok) throw new Error((await r.text()).trim()||r.statusText); + label.textContent=T('Restarted'); + setTimeout(loadTunnels,500); + }catch(e){ + label.textContent=T('Restart failed'); + alert(T('Could not restart tunnel:')+' '+e.message); + }finally{ + setTimeout(()=>{ btn.disabled=false; label.textContent=T('Restart'); },1500); + } +} + let logTimer=null, logName=null, logRaw=''; async function fetchLog(){ if(!logName)return; diff --git a/internal/webui/assets_test.go b/internal/webui/assets_test.go index 77dd1dd..5f803a4 100644 --- a/internal/webui/assets_test.go +++ b/internal/webui/assets_test.go @@ -518,6 +518,32 @@ func TestDynamicMarkupIsTranslatedToo(t *testing.T) { } } +// Restart is the one tunnel-level action available from the monitoring panel. +// It must be deliberate, explain what it is doing while the request runs, and +// use the authenticated mutation endpoint rather than the read-only API. +func TestTunnelCardsOfferConfirmedRestart(t *testing.T) { + body := string(dashboardHTML) + + if !strings.Contains(body, `onclick="restartTunnel('${n}',this)"`) { + t.Fatal("tunnel cards do not offer a restart action") + } + fn := between(body, "async function restartTunnel(name,btn){", "\n}") + if fn == "" { + t.Fatal("restartTunnel function not found") + } + for _, want := range []string{ + "confirm(", + "btn.disabled=true", + "fetch('/api/tunnels/restart'", + "method:'POST'", + "if(!r.ok)", + } { + if !strings.Contains(fn, want) { + t.Errorf("restart action is missing %q", want) + } + } +} + // The login page has no settings of its own, so it follows the choice already // stored. An English door on a Persian panel is the first thing anybody sees. func TestLoginPageFollowsTheStoredLanguage(t *testing.T) { diff --git a/internal/webui/handlers_tunnels.go b/internal/webui/handlers_tunnels.go new file mode 100644 index 0000000..8041c8f --- /dev/null +++ b/internal/webui/handlers_tunnels.go @@ -0,0 +1,94 @@ +package webui + +import ( + "errors" + "fmt" + "net/http" + "strings" + "time" + + "github.com/backpack/backpack/internal/manage" +) + +var ( + errTunnelNameRequired = errors.New("tunnel name is required") + errTunnelNotFound = errors.New("tunnel not found") +) + +// handleTunnelRestart restarts one configured tunnel. It is registered behind +// requireAuth rather than requireReadAuth: the remote monitoring token may +// inspect a server, but it must never be able to change one. +func (s *server) handleTunnelRestart(w http.ResponseWriter, r *http.Request) { + handleTunnelRestartWith(w, r, manage.List, func(service string) error { + return restartAndWait(service, manage.RestartService, manage.WaitServiceActive) + }) +} + +// restartAndWait does not report success merely because systemd accepted the +// restart job. A service whose process immediately fails must be shown as a +// failed restart in the panel, not as a brief success followed by a red card. +func restartAndWait( + service string, + restart func(string) error, + waitActive func(string, time.Duration) bool, +) error { + if err := restart(service); err != nil { + return err + } + if !waitActive(service, 10*time.Second) { + return errors.New("service did not become active") + } + return nil +} + +// handleTunnelRestartWith keeps the systemd operation injectable for tests. +// The service name comes from manage.List, not from request input, so an +// operator can only restart a tunnel Backpack actually manages. +func handleTunnelRestartWith( + w http.ResponseWriter, + r *http.Request, + list func() []manage.Tunnel, + restart func(string) error, +) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + if err := r.ParseForm(); err != nil { + http.Error(w, "could not read request", http.StatusBadRequest) + return + } + + name := strings.TrimSpace(r.FormValue("name")) + service, err := restartServiceFor(name, list()) + if err != nil { + switch { + case errors.Is(err, errTunnelNameRequired): + http.Error(w, err.Error(), http.StatusBadRequest) + case errors.Is(err, errTunnelNotFound): + http.Error(w, err.Error(), http.StatusNotFound) + } + return + } + + if err := restart(service); err != nil { + http.Error(w, "could not restart tunnel", http.StatusInternalServerError) + return + } + writeJSON(w, map[string]string{"status": "restarted", "name": name}) +} + +func restartServiceFor(name string, tunnels []manage.Tunnel) (string, error) { + if name == "" { + return "", errTunnelNameRequired + } + for _, tunnel := range tunnels { + if tunnel.Name == name { + if tunnel.Service == "" { + return "", fmt.Errorf("%w: %s has no service", errTunnelNotFound, name) + } + return tunnel.Service, nil + } + } + return "", fmt.Errorf("%w: %s", errTunnelNotFound, name) +} diff --git a/internal/webui/handlers_tunnels_test.go b/internal/webui/handlers_tunnels_test.go new file mode 100644 index 0000000..8a8cecb --- /dev/null +++ b/internal/webui/handlers_tunnels_test.go @@ -0,0 +1,134 @@ +package webui + +import ( + "errors" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + "time" + + "github.com/backpack/backpack/internal/manage" +) + +func TestRestartWaitsForServiceToBecomeActive(t *testing.T) { + var restarted, waited string + err := restartAndWait("backpack-client.service", + func(service string) error { restarted = service; return nil }, + func(service string, timeout time.Duration) bool { + waited = service + if timeout != 10*time.Second { + t.Errorf("timeout = %s, want 10s", timeout) + } + return true + }, + ) + if err != nil { + t.Fatal(err) + } + if restarted != "backpack-client.service" || waited != restarted { + t.Fatalf("restarted %q and waited for %q", restarted, waited) + } +} + +func TestRestartFailsWhenServiceDoesNotBecomeActive(t *testing.T) { + err := restartAndWait("backpack-client.service", + func(string) error { return nil }, + func(string, time.Duration) bool { return false }, + ) + if err == nil { + t.Fatal("an inactive service was reported as restarted") + } +} + +func TestTunnelRestartRouteRequiresPanelSession(t *testing.T) { + src, err := os.ReadFile("server.go") + if err != nil { + t.Fatal(err) + } + registration := `mux.HandleFunc("/api/tunnels/restart", srv.requireAuth(srv.handleTunnelRestart))` + if !strings.Contains(string(src), registration) { + t.Fatal("restart endpoint is not registered behind full panel authentication") + } +} + +func TestTunnelRestartUsesConfiguredServiceName(t *testing.T) { + tunnels := []manage.Tunnel{{Name: "iran-main", Service: "backpack-iran-main.service"}} + var restarted string + + r := httptest.NewRequest(http.MethodPost, "/api/tunnels/restart", strings.NewReader("name=iran-main")) + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + handleTunnelRestartWith(w, r, func() []manage.Tunnel { return tunnels }, func(service string) error { + restarted = service + return nil + }) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String()) + } + if restarted != "backpack-iran-main.service" { + t.Fatalf("restarted %q, want the configured tunnel service", restarted) + } + if !strings.Contains(w.Body.String(), `"status":"restarted"`) { + t.Fatalf("response does not report success: %s", w.Body.String()) + } +} + +func TestTunnelRestartRejectsUnknownName(t *testing.T) { + called := false + r := httptest.NewRequest(http.MethodPost, "/api/tunnels/restart", strings.NewReader("name=../../ssh")) + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + handleTunnelRestartWith(w, r, + func() []manage.Tunnel { return []manage.Tunnel{{Name: "known", Service: "backpack-known.service"}} }, + func(string) error { called = true; return nil }, + ) + + if w.Code != http.StatusNotFound { + t.Fatalf("status = %d, want 404; body: %s", w.Code, w.Body.String()) + } + if called { + t.Fatal("restart was called for an unknown tunnel name") + } +} + +func TestTunnelRestartRequiresPostAndName(t *testing.T) { + list := func() []manage.Tunnel { return nil } + restart := func(string) error { t.Fatal("restart should not be called"); return nil } + + for _, tc := range []struct { + method string + body string + want int + }{ + {http.MethodGet, "", http.StatusMethodNotAllowed}, + {http.MethodPost, "", http.StatusBadRequest}, + } { + w := httptest.NewRecorder() + r := httptest.NewRequest(tc.method, "/api/tunnels/restart", strings.NewReader(tc.body)) + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") + handleTunnelRestartWith(w, r, list, restart) + if w.Code != tc.want { + t.Errorf("%s status = %d, want %d", tc.method, w.Code, tc.want) + } + } +} + +func TestTunnelRestartReportsSystemdFailure(t *testing.T) { + r := httptest.NewRequest(http.MethodPost, "/api/tunnels/restart", strings.NewReader("name=client")) + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + handleTunnelRestartWith(w, r, + func() []manage.Tunnel { return []manage.Tunnel{{Name: "client", Service: "backpack-client.service"}} }, + func(string) error { return errors.New("systemd unavailable") }, + ) + + if w.Code != http.StatusInternalServerError { + t.Fatalf("status = %d, want 500; body: %s", w.Code, w.Body.String()) + } + if strings.Contains(w.Body.String(), "systemd unavailable") { + t.Fatalf("internal systemd detail leaked to the browser: %s", w.Body.String()) + } +} diff --git a/internal/webui/server.go b/internal/webui/server.go index 72d52f3..f78f210 100644 --- a/internal/webui/server.go +++ b/internal/webui/server.go @@ -178,9 +178,9 @@ func Serve() error { // deliberately run elsewhere — in the backpack-monitor service. See // internal/monitor for why. - // The panel is a monitoring dashboard: live stats, tunnel state and logs. - // Tunnels are created and managed from the CLI; the only mutating actions - // here are panel-scoped (password, port, self-update). + // The panel is primarily a monitoring dashboard: live stats, tunnel state + // and logs. Configuration stays in the CLI, but a logged-in operator can + // restart a tunnel without opening a separate SSH session. mux := http.NewServeMux() mux.HandleFunc("/login", srv.handleLogin) mux.HandleFunc("/login2fa", srv.handleLogin2FA) @@ -190,6 +190,7 @@ func Serve() error { // or a Prometheus scraper can watch without holding a browser session. mux.HandleFunc("/api/stats", srv.requireReadAuth(srv.handleStats)) mux.HandleFunc("/api/tunnels", srv.requireReadAuth(srv.handleTunnels)) + mux.HandleFunc("/api/tunnels/restart", srv.requireAuth(srv.handleTunnelRestart)) mux.HandleFunc("/metrics", srv.requireReadAuth(srv.handlePrometheus)) mux.HandleFunc("/api/logs", srv.requireAuth(srv.handleLogs)) mux.HandleFunc("/api/password", srv.requireAuth(srv.handlePassword))