Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions docs/web-panel.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down
33 changes: 31 additions & 2 deletions internal/webui/assets/dashboard.html
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -930,7 +931,7 @@
</div>

<div class="sec"><h2 data-i18n>Tunnels</h2><span class="count" id="tsum">0</span>
<span class="count" style="margin-left:auto" title="Tunnels are created and managed from the CLI" data-i18n>monitoring only</span>
<span class="count" style="margin-left:auto" title="Tunnel settings are managed from the CLI" data-i18n>monitoring + restart</span>
</div>
<div class="grid-tun" id="tunnels"></div>
<div class="empty" id="empty" style="display:none" data-i18n>No tunnels configured yet. Create one from the CLI menu (sudo backpack).</div>
Expand Down Expand Up @@ -1370,14 +1371,17 @@ <h3 id="logtitle" data-i18n>Logs</h3>
"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":"نمای کلی",
// completed dictionary
"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)":"پایدار — فقط نسخه‌های نهایی (پیشنهادی)",
Expand Down Expand Up @@ -1611,6 +1615,7 @@ <h3 id="logtitle" data-i18n>Logs</h3>
logs:'<svg class="ic" viewBox="0 0 24 24"><path d="M8 3H6a2 2 0 00-2 2v14a2 2 0 002 2h9a2 2 0 002-2v-2"/><path d="M16 3h4v4"/><path d="M9 8h5M9 12h6M9 16h4"/></svg>',
bot:'<svg class="ic" viewBox="0 0 24 24" style="width:12px;height:12px"><rect x="4" y="8" width="16" height="11" rx="2"/><path d="M12 8V5M9 3h6M9 13h.01M15 13h.01M8 19v2M16 19v2"/></svg>',
info:'<svg class="ic" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4M12 8h.01"/></svg>',
restart:'<svg class="ic" viewBox="0 0 24 24"><path d="M20 11a8 8 0 10-2.3 5.7"/><path d="M20 4v7h-7"/></svg>',
};
const COUNTRIES=[
['','—'],['DE','Germany'],['NL','Netherlands'],['FR','France'],['GB','United Kingdom'],
Expand Down Expand Up @@ -1867,6 +1872,7 @@ <h3 id="logtitle" data-i18n>Logs</h3>
<div class="cardbtns">
<button class="logbtn" onclick="openLogs('${n}')" aria-label="View logs for ${n}">${ICON.logs} Logs</button>
<button class="logbtn" onclick="openDetails('${n}')" aria-label="View details for ${n}">${ICON.info} Details</button>
<button class="logbtn" onclick="restartTunnel('${n}',this)" aria-label="Restart tunnel ${n}">${ICON.restart}<span data-i18n>Restart</span></button>
</div>`;
return el;
}
Expand Down Expand Up @@ -1975,6 +1981,29 @@ <h3 id="logtitle" data-i18n>Logs</h3>
}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;
Expand Down
26 changes: 26 additions & 0 deletions internal/webui/assets_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
94 changes: 94 additions & 0 deletions internal/webui/handlers_tunnels.go
Original file line number Diff line number Diff line change
@@ -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)
}
134 changes: 134 additions & 0 deletions internal/webui/handlers_tunnels_test.go
Original file line number Diff line number Diff line change
@@ -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())
}
}
7 changes: 4 additions & 3 deletions internal/webui/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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))
Expand Down