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
No tunnels configured yet. Create one from the CLI menu (sudo backpack).
@@ -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
${ICON.logs} Logs
${ICON.info} Details
+ ${ICON.restart}Restart
`;
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))