diff --git a/README.md b/README.md index d4c80af..b6ab63b 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,8 @@ Admin token /etc/systemd/system/sshpanel.service ``` +O instalador monta `/opt/sshpanel/logs` como tmpfs de 15 MiB quando possível, para reduzir gravações no SD card. O `panel.log` é limpo automaticamente quando passa de 1 MiB, e também pode ser limpo manualmente pela aba Logs do painel. + ### Portas padrão ```text @@ -489,6 +491,8 @@ Follow panel log file: tail -f /opt/sshpanel/logs/panel.log ``` +When possible, `/opt/sshpanel/logs` is mounted as a 15 MiB tmpfs RAM disk by the service. `panel.log` is automatically cleaned after it exceeds 1 MiB, and the Logs tab also has a manual clean button. + Restart service: ```bash diff --git a/admin/index.html b/admin/index.html index 804445d..1d2ee88 100644 --- a/admin/index.html +++ b/admin/index.html @@ -102,6 +102,44 @@ tbody tr:hover{background:rgba(15,23,42,.85);} /* ── Xray log / config editor ── */ .code-area{width:100%;background:rgba(15,23,42,.9);color:#e5e7eb;border:1px solid rgba(55,65,81,.9);border-radius:8px;padding:10px;font-family:monospace;font-size:.7rem;resize:vertical;outline:none;} pre.log-box{background:rgba(15,23,42,.9);color:#9ca3af;border:1px solid rgba(55,65,81,.9);border-radius:8px;padding:10px;font-size:.68rem;overflow-y:auto;max-height:180px;white-space:pre-wrap;word-break:break-all;} + +/* ── Mobile hardening ── + Keep the shell/cards inside the phone viewport and let wide tables scroll + inside their own card instead of pushing the whole panel sideways. */ +html,body{width:100%;max-width:100%;overflow-x:hidden;} +.app,.shell,.tab-pane,.grid2,.grid2>*,.card,.tbl-wrap,.form-grid,.field,.metric{min-width:0;max-width:100%;} +.card-title{flex-wrap:wrap;min-width:0;} +.card-hdr>*{min-width:0;} +.field input,.field select,.field textarea,.ov-field,.code-area{max-width:100%;min-width:0;} + +@media(max-width:640px){ + .app{padding:8px;} + .shell{width:100%;padding:12px;border-radius:16px;overflow:hidden;} + header{align-items:flex-start;} + header nav{order:2;width:100%;flex-wrap:nowrap;overflow-x:auto;overflow-y:hidden;padding-bottom:4px;-webkit-overflow-scrolling:touch;scrollbar-width:thin;} + .tab-btn{flex:0 0 auto;padding:5px 10px;} + .hright{order:3;width:100%;flex-wrap:wrap;} + .grid2{display:block;} + .grid2>.card+.card,.grid2>div+.card,.grid2>.card+div,.grid2>div+div{margin-top:12px;} + .card{width:100%;padding:12px;overflow:hidden;} + .card-hdr{align-items:flex-start;} + .card-hdr>div[style*="display:flex"],.card-hdr .form-actions{flex-wrap:wrap;} + .metrics{display:grid;grid-template-columns:1fr;gap:8px;} + .metric{width:100%;} + .tbl-wrap{width:100%;overflow-x:auto;-webkit-overflow-scrolling:touch;padding-bottom:4px;} + .tbl-wrap table{width:max-content;min-width:100%;} + table{font-size:.7rem;} + th,td{padding:6px 8px;} + .statusbar{display:block;} + .statusbar>span{display:block;margin-top:3px;} + .field-row{flex-wrap:wrap;} + .field-row input{flex:1 1 160px;min-width:0;} + .field-row .btn{flex:0 0 auto;} + /* Override inline 2-column grids in Xray/TLS forms on phones. */ + .form-grid[style*="grid-template-columns"], + #wzVlessFields,#wzSSFields,#wzCertSrcFile,#wzCertSrcPaste>div, + #tlsLEFields,#tlsCustomFields,#tlsPasteFields .form-grid{grid-template-columns:1fr!important;} +} @@ -131,6 +169,7 @@ pre.log-box{background:rgba(15,23,42,.9);color:#9ca3af;border:1px solid rgba(55, + @@ -522,18 +561,61 @@ pre.log-box{background:rgba(15,23,42,.9);color:#9ca3af;border:1px solid rgba(55,
-
Interfaces rx/tx Mbps
+
+
Interfaces rx/tx Mbps30-day rolling
+ +
InterfaceRx MbpsTx MbpsRx TotalTx Total
-
+
Totals can be cleaned here and auto-clean every 30 days. VnStat history is separate.
+ +
+
+
+
VnStat Usage daily / monthly
+
+ + +
+
+
+
Today total
--
+
This month total
--
+
Interfaces tracked
--
+
+
VnStat history does not auto-clean. Use the button when you want to reset it.
+
+ +
+
+
Daily usage last 31 days
+
+ + + +
DayInterfaceRxTxTotal
+
+
+
+
Monthly usage last 12 months
+
+ + + +
MonthInterfaceRxTxTotal
+
+
+
+
+
@@ -546,6 +628,7 @@ pre.log-box{background:rgba(15,23,42,.9);color:#9ca3af;border:1px solid rgba(55, +
Select a log source and click Refresh.
@@ -858,6 +941,17 @@ const memDetail = document.getElementById("memDetail"); const ifaceBody = document.getElementById("ifaceBody"); const ifaceSummary = document.getElementById("ifaceSummary"); const statsUpdated = document.getElementById("statsUpdated"); +const resetIfaceStatsBtn = document.getElementById("resetIfaceStatsBtn"); + +// VnStat +const vnstatDailyBody = document.getElementById("vnstatDailyBody"); +const vnstatMonthlyBody = document.getElementById("vnstatMonthlyBody"); +const vnstatStatus = document.getElementById("vnstatStatus"); +const vnTodayTotal = document.getElementById("vnTodayTotal"); +const vnMonthTotal = document.getElementById("vnMonthTotal"); +const vnIfaceCount = document.getElementById("vnIfaceCount"); +const reloadVnstatBtn = document.getElementById("reloadVnstatBtn"); +const resetVnstatBtn = document.getElementById("resetVnstatBtn"); // ─── API helper ─────────────────────────────────────────────────────────────── async function api(path, opts = {}) { @@ -1551,14 +1645,99 @@ async function loadStats() { } catch (e) { if (e.message==="auth") doAuthError(); } } +resetIfaceStatsBtn?.addEventListener("click", resetInterfaceStats); + +async function resetInterfaceStats() { + if (!confirm("Clean the live Interface totals now? This does not delete VnStat daily/monthly history.")) return; + resetIfaceStatsBtn.disabled = true; + ifaceSummary.textContent = "Cleaning interface totals…"; + try { + const res = await api("/api/stats/interfaces/reset", { method:"POST" }); + if (!res.ok) throw new Error(await res.text()); + ifaceSummary.textContent = "Interface totals cleaned. Auto-clean remains every 30 days."; + loadStats(); + } catch (e) { + if (e.message === "auth") doAuthError(); + else ifaceSummary.textContent = "Error cleaning totals: " + e.message; + } finally { + resetIfaceStatsBtn.disabled = false; + } +} + +// ─── VnStat ─────────────────────────────────────────────────────────────────── +document.querySelector("[data-tab='vnstat']")?.addEventListener("click", loadVnstat); +reloadVnstatBtn?.addEventListener("click", loadVnstat); +resetVnstatBtn?.addEventListener("click", resetVnstatHistory); + +function renderVnstatRows(body, rows, emptyLabel) { + body.innerHTML = ""; + if (!rows.length) { + const tr = document.createElement("tr"); + tr.innerHTML = `${emptyLabel}`; + body.appendChild(tr); + return; + } + rows.forEach(r => { + const tr = document.createElement("tr"); + tr.innerHTML = `${r.period || "--"}${r.iface || "--"}${fmtBytes(r.rx_bytes||0)}${fmtBytes(r.tx_bytes||0)}${fmtBytes(r.total_bytes||((r.rx_bytes||0)+(r.tx_bytes||0)))}`; + body.appendChild(tr); + }); +} + +async function loadVnstat() { + vnstatStatus.textContent = "Loading VnStat usage…"; + try { + const res = await api("/api/vnstat?days=31&months=12"); + if (!res.ok) throw new Error(await res.text()); + const data = await res.json(); + const daily = Array.isArray(data.daily) ? data.daily : []; + const monthly = Array.isArray(data.monthly) ? data.monthly : []; + renderVnstatRows(vnstatDailyBody, daily, "No daily usage recorded yet."); + renderVnstatRows(vnstatMonthlyBody, monthly, "No monthly usage recorded yet."); + + const today = new Date().toISOString().slice(0,10); + const month = today.slice(0,7); + const todayTotal = daily.filter(r => r.period === today).reduce((sum, r) => sum + (r.total_bytes||0), 0); + const monthTotal = monthly.filter(r => r.period === month).reduce((sum, r) => sum + (r.total_bytes||0), 0); + const ifaces = new Set([...daily, ...monthly].map(r => r.iface).filter(Boolean)); + vnTodayTotal.textContent = fmtBytes(todayTotal); + vnMonthTotal.textContent = fmtBytes(monthTotal); + vnIfaceCount.textContent = String(ifaces.size || 0); + vnstatStatus.textContent = "Updated: " + new Date().toLocaleTimeString() + " · history is kept until manually cleaned."; + } catch (e) { + if (e.message === "auth") doAuthError(); + else vnstatStatus.textContent = "Error loading VnStat usage: " + e.message; + } +} + +async function resetVnstatHistory() { + if (!confirm("Clean all VnStat daily/monthly usage history? This does not reset the live Interface totals.")) return; + resetVnstatBtn.disabled = true; + vnstatStatus.textContent = "Cleaning VnStat history…"; + try { + const res = await api("/api/vnstat/reset", { method:"POST" }); + if (!res.ok) throw new Error(await res.text()); + vnstatStatus.textContent = "VnStat history cleaned."; + loadVnstat(); + } catch (e) { + if (e.message === "auth") doAuthError(); + else vnstatStatus.textContent = "Error cleaning VnStat history: " + e.message; + } finally { + resetVnstatBtn.disabled = false; + } +} + // ─── Logs ───────────────────────────────────────────────────────────────────── document.querySelector("[data-tab='logs']")?.addEventListener("click", loadSystemLogs); document.getElementById("logSource")?.addEventListener("change", loadSystemLogs); +document.getElementById("clearPanelLogBtn")?.addEventListener("click", clearPanelLog); async function loadSystemLogs() { const box = document.getElementById("systemLogBox"); const st = document.getElementById("systemLogStatus"); const source = document.getElementById("logSource")?.value || "panel"; + const clearBtn = document.getElementById("clearPanelLogBtn"); + if (clearBtn) clearBtn.disabled = source !== "panel"; st.textContent = "Loading…"; try { const res = await api(`/api/system/logs?source=${encodeURIComponent(source)}&lines=500`); @@ -1574,6 +1753,22 @@ async function loadSystemLogs() { } } +async function clearPanelLog() { + const st = document.getElementById("systemLogStatus"); + if (!confirm("Clean the panel log now? Logs are already auto-cleaned after 1 MiB.")) return; + st.textContent = "Cleaning panel log…"; + try { + const res = await api("/api/system/logs/reset", { method:"POST" }); + if (!res.ok) throw new Error(await res.text()); + const data = await res.json(); + st.textContent = `Panel log cleaned · ${data.path || "panel.log"} · max ${fmtBytes(data.max_bytes || 1048576)}`; + await loadSystemLogs(); + } catch (e) { + if (e.message === "auth") doAuthError(); + else st.textContent = "Error cleaning panel log: " + e.message; + } +} + // ─── Server Config ──────────────────────────────────────────────────────────── document.querySelector("[data-tab='server']")?.addEventListener("click", loadServerConfig); diff --git a/install.sh b/install.sh index 06e370b..d2f50c5 100644 --- a/install.sh +++ b/install.sh @@ -11,12 +11,45 @@ error() { echo -e "${RED}[x]${NC} $*"; exit 1; } # ── config ────────────────────────────────────────────────────────────────── INSTALL_DIR="/opt/sshpanel" SERVICE_NAME="sshpanel" +LOG_TMPFS_SIZE="${LOG_TMPFS_SIZE:-15m}" +PANEL_LOG_MAX_BYTES="${PANEL_LOG_MAX_BYTES:-1048576}" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" GO_VERSION="${GO_VERSION:-$(awk '$1 == "go" {print $2; exit}' "$SCRIPT_DIR/go.mod" 2>/dev/null || echo "1.22.5")}" # ──────────────────────────────────────────────────────────────────────────── [[ $EUID -ne 0 ]] && error "Run as root: sudo bash $0" +ensure_log_tmpfs_mount() { + local log_dir="${INSTALL_DIR}/logs" + local opts="rw,nosuid,nodev,noexec,noatime,nofail,size=${LOG_TMPFS_SIZE},mode=0755" + local tmp_fstab + + mkdir -p "$log_dir" + + if [[ -f /etc/fstab ]]; then + cp /etc/fstab "/etc/fstab.sshpanel.bak.$(date +%s)" 2>/dev/null || true + tmp_fstab="$(mktemp)" + awk -v mp="$log_dir" '!(($1 == "tmpfs") && ($2 == mp) && ($3 == "tmpfs")) {print}' /etc/fstab > "$tmp_fstab" + printf 'tmpfs %s tmpfs %s 0 0\n' "$log_dir" "$opts" >> "$tmp_fstab" + cat "$tmp_fstab" > /etc/fstab + rm -f "$tmp_fstab" + info " Log RAM disk automount saved in /etc/fstab: $log_dir (${LOG_TMPFS_SIZE})" + else + warn " /etc/fstab not found; service startup fallback will mount $log_dir as tmpfs" + fi + + systemctl daemon-reload >/dev/null 2>&1 || true + if mountpoint -q "$log_dir"; then + mount -o "remount,size=${LOG_TMPFS_SIZE},mode=0755" "$log_dir" >/dev/null 2>&1 || true + else + mount "$log_dir" >/dev/null 2>&1 || mount -t tmpfs -o "size=${LOG_TMPFS_SIZE},mode=0755" tmpfs "$log_dir" >/dev/null 2>&1 || \ + warn " Could not mount $log_dir as tmpfs now; systemd service will try again on start" + fi + + touch "$log_dir/panel.log" >/dev/null 2>&1 || true + chmod 0644 "$log_dir/panel.log" >/dev/null 2>&1 || true +} + echo -e "\n${GREEN}══════════════════════════════════════════${NC}" echo -e "${GREEN} SSH Panel + Xray-core · Installer ${NC}" echo -e "${GREEN}══════════════════════════════════════════${NC}\n" @@ -96,6 +129,7 @@ go version # ── 4. Directory layout ────────────────────────────────────────────────────── info "[4/9] Setting up ${INSTALL_DIR}…" mkdir -p "$INSTALL_DIR/admin" "$INSTALL_DIR/keys" "$INSTALL_DIR/logs" +ensure_log_tmpfs_mount # ── 5. Build SSH panel binary ──────────────────────────────────────────────── info "[5/9] Building SSH Panel binary…" @@ -414,7 +448,7 @@ info "[10/10] Creating systemd service '${SERVICE_NAME}'…" cat > "/etc/systemd/system/${SERVICE_NAME}.service" <= 30*24*time.Hour { + st.TotalRxBytes = 0 + st.TotalTxBytes = 0 + st.LastKernelRxBytes = kRx + st.LastKernelTxBytes = kTx + st.ResetAt = now + st.UpdatedAt = now + return 0, 0 + } // RX if st.LastKernelRxBytes == 0 && st.TotalRxBytes == 0 { @@ -609,10 +626,33 @@ func (tm *IfaceTotalsManager) ApplyKernel(iface string, kRx, kTx uint64) (totalR } st.LastKernelTxBytes = kTx - st.UpdatedAt = time.Now() + st.UpdatedAt = now return st.TotalRxBytes, st.TotalTxBytes } +func (tm *IfaceTotalsManager) ResetAllToKernel(netMap map[string]ifaceCounters) []IfaceTotals { + tm.mu.Lock() + defer tm.mu.Unlock() + + now := time.Now() + tm.m = make(map[string]*IfaceTotals, len(netMap)) + out := make([]IfaceTotals, 0, len(netMap)) + for iface, ctrs := range netMap { + st := &IfaceTotals{ + Iface: iface, + TotalRxBytes: 0, + TotalTxBytes: 0, + LastKernelRxBytes: ctrs.RxBytes, + LastKernelTxBytes: ctrs.TxBytes, + UpdatedAt: now, + ResetAt: now, + } + tm.m[iface] = st + out = append(out, *st) + } + return out +} + func (tm *IfaceTotalsManager) Load(rows []IfaceTotals) { tm.mu.Lock() defer tm.mu.Unlock() @@ -706,14 +746,27 @@ func startStatsCollector() { } if prevNet != nil && dt > 0 { if prev, ok := prevNet[name]; ok { + var rxDelta, txDelta uint64 if ctrs.RxBytes >= prev.RxBytes { - rxDelta := ctrs.RxBytes - prev.RxBytes - st.RxMbps = float64(rxDelta*8) / dt / 1_000_000 + rxDelta = ctrs.RxBytes - prev.RxBytes + } else { + // kernel counter reset or wrap + rxDelta = ctrs.RxBytes } if ctrs.TxBytes >= prev.TxBytes { - txDelta := ctrs.TxBytes - prev.TxBytes + txDelta = ctrs.TxBytes - prev.TxBytes + } else { + txDelta = ctrs.TxBytes + } + if rxDelta > 0 { + st.RxMbps = float64(rxDelta*8) / dt / 1_000_000 + } + if txDelta > 0 { st.TxMbps = float64(txDelta*8) / dt / 1_000_000 } + if statsStore != nil && (rxDelta > 0 || txDelta > 0) { + addPendingIfaceUsage(name, rxDelta, txDelta) + } } } interfaces = append(interfaces, st) @@ -746,12 +799,18 @@ func startStatsCollector() { Interfaces: interfaces, }) - // Persist interface totals periodically (optional). + // Persist interface totals and vnstat-style usage periodically (optional). if flushTicker != nil && statsStore != nil && ifaceTotalsMgr != nil { select { case <-flushTicker.C: ctx := context.Background() _ = statsStore.UpsertIfaceTotals(ctx, ifaceTotalsMgr.Snapshot()) + if deltas := flushPendingIfaceUsage(now); len(deltas) > 0 { + if err := statsStore.UpsertIfaceUsageDeltas(ctx, deltas); err != nil { + log.Printf("vnstat usage flush failed: %v", err) + restorePendingIfaceUsage(deltas) + } + } default: } } @@ -1123,21 +1182,29 @@ func (s *Store) DeleteUser(ctx context.Context, username string) error { // ---------- Optional persistence for interface totals ---------- func (s *Store) EnsureIfaceTotalsTable(ctx context.Context) error { - _, err := s.db.ExecContext(ctx, ` - CREATE TABLE IF NOT EXISTS ssh_iface_totals ( + stmts := []string{ + `CREATE TABLE IF NOT EXISTS ssh_iface_totals ( iface TEXT PRIMARY KEY, total_rx_bytes BIGINT NOT NULL DEFAULT 0, total_tx_bytes BIGINT NOT NULL DEFAULT 0, last_kernel_rx_bytes BIGINT NOT NULL DEFAULT 0, last_kernel_tx_bytes BIGINT NOT NULL DEFAULT 0, - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() - )`) - return err + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + reset_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + )`, + `ALTER TABLE ssh_iface_totals ADD COLUMN IF NOT EXISTS reset_at TIMESTAMPTZ NOT NULL DEFAULT NOW()`, + } + for _, stmt := range stmts { + if _, err := s.db.ExecContext(ctx, stmt); err != nil { + return err + } + } + return nil } func (s *Store) LoadIfaceTotals(ctx context.Context) ([]IfaceTotals, error) { rows, err := s.db.QueryContext(ctx, ` - SELECT iface, total_rx_bytes, total_tx_bytes, last_kernel_rx_bytes, last_kernel_tx_bytes, updated_at + SELECT iface, total_rx_bytes, total_tx_bytes, last_kernel_rx_bytes, last_kernel_tx_bytes, updated_at, reset_at FROM ssh_iface_totals`) if err != nil { return nil, err @@ -1147,11 +1214,12 @@ func (s *Store) LoadIfaceTotals(ctx context.Context) ([]IfaceTotals, error) { out := []IfaceTotals{} for rows.Next() { var r IfaceTotals - var updated time.Time - if err := rows.Scan(&r.Iface, &r.TotalRxBytes, &r.TotalTxBytes, &r.LastKernelRxBytes, &r.LastKernelTxBytes, &updated); err != nil { + var updated, resetAt time.Time + if err := rows.Scan(&r.Iface, &r.TotalRxBytes, &r.TotalTxBytes, &r.LastKernelRxBytes, &r.LastKernelTxBytes, &updated, &resetAt); err != nil { return nil, err } r.UpdatedAt = updated + r.ResetAt = resetAt out = append(out, r) } if err := rows.Err(); err != nil { @@ -1166,16 +1234,21 @@ func (s *Store) UpsertIfaceTotals(ctx context.Context, rows []IfaceTotals) error } // Simple loop (small N: number of interfaces). Keeps CPU/DB overhead minimal. for _, r := range rows { + resetAt := r.ResetAt + if resetAt.IsZero() { + resetAt = time.Now() + } _, err := s.db.ExecContext(ctx, ` - INSERT INTO ssh_iface_totals (iface, total_rx_bytes, total_tx_bytes, last_kernel_rx_bytes, last_kernel_tx_bytes, updated_at) - VALUES ($1, $2, $3, $4, $5, NOW()) + INSERT INTO ssh_iface_totals (iface, total_rx_bytes, total_tx_bytes, last_kernel_rx_bytes, last_kernel_tx_bytes, updated_at, reset_at) + VALUES ($1, $2, $3, $4, $5, NOW(), $6) ON CONFLICT (iface) DO UPDATE SET total_rx_bytes = EXCLUDED.total_rx_bytes, total_tx_bytes = EXCLUDED.total_tx_bytes, last_kernel_rx_bytes = EXCLUDED.last_kernel_rx_bytes, last_kernel_tx_bytes = EXCLUDED.last_kernel_tx_bytes, - updated_at = NOW()`, - r.Iface, r.TotalRxBytes, r.TotalTxBytes, r.LastKernelRxBytes, r.LastKernelTxBytes) + updated_at = NOW(), + reset_at = EXCLUDED.reset_at`, + r.Iface, r.TotalRxBytes, r.TotalTxBytes, r.LastKernelRxBytes, r.LastKernelTxBytes, resetAt) if err != nil { return err } @@ -1213,7 +1286,11 @@ func startAdminAPI(store *Store, addr string, adminDir string) { // Superadmin-only: server stats + DNSTT mux.Handle("/api/stats", saSession(http.HandlerFunc(handleStats))) + mux.Handle("/api/stats/interfaces/reset", saSession(http.HandlerFunc(handleResetInterfaceStats(store)))) + mux.Handle("/api/vnstat", saSession(http.HandlerFunc(handleVnstat(store)))) + mux.Handle("/api/vnstat/reset", saSession(http.HandlerFunc(handleVnstatReset(store)))) mux.Handle("/api/system/logs", saSession(http.HandlerFunc(handleSystemLogs))) + mux.Handle("/api/system/logs/reset", saSession(http.HandlerFunc(handleSystemLogsReset))) mux.Handle("/api/dnstt", saSession(http.HandlerFunc(handleDnsttStats))) mux.Handle("/api/dnstt/logs", saSession(http.HandlerFunc(handleDnsttLogs))) @@ -2457,6 +2534,9 @@ func main() { } else { startXrayClientExpiryChecker(store) } + if err := store.EnsureIfaceUsageTables(ctx); err != nil { + log.Printf("vnstat usage tables disabled: %v", err) + } if err := store.EnsureIfaceTotalsTable(ctx); err == nil { rows, err2 := store.LoadIfaceTotals(ctx) if err2 == nil { @@ -2632,6 +2712,7 @@ func main() { if quietLogs { log.SetOutput(io.Discard) } + startPanelLogLimiter() // Initialise default per-connection bandwidth limits and SSH inactivity cleanup. setDefaultLimits(cfg.DefaultLimitMbpsUp, cfg.DefaultLimitMbpsDown) diff --git a/panel_log_limiter.go b/panel_log_limiter.go new file mode 100644 index 0000000..b52ef7d --- /dev/null +++ b/panel_log_limiter.go @@ -0,0 +1,105 @@ +package main + +import ( + "encoding/json" + "fmt" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + "time" +) + +const ( + defaultPanelLogMaxBytes int64 = 1 * 1024 * 1024 + defaultPanelLogCheckEvery = 10 * time.Second +) + +type panelLogResetResponse struct { + OK bool `json:"ok"` + Path string `json:"path"` + MaxBytes int64 `json:"max_bytes"` +} + +func panelLogFilePath() string { + path := strings.TrimSpace(os.Getenv("PANEL_LOG_FILE")) + if path == "" { + path = defaultPanelLogFile + } + return path +} + +func panelLogMaxBytes() int64 { + raw := strings.TrimSpace(os.Getenv("PANEL_LOG_MAX_BYTES")) + if raw == "" { + return defaultPanelLogMaxBytes + } + n, err := strconv.ParseInt(raw, 10, 64) + if err != nil || n <= 0 { + return defaultPanelLogMaxBytes + } + // Do not allow a tiny limit that would cause continuous truncation. + if n < 64*1024 { + return 64 * 1024 + } + return n +} + +func startPanelLogLimiter() { + path := panelLogFilePath() + maxBytes := panelLogMaxBytes() + if path == "" || maxBytes <= 0 { + return + } + go func() { + _ = enforcePanelLogLimit(path, maxBytes) + ticker := time.NewTicker(defaultPanelLogCheckEvery) + defer ticker.Stop() + for range ticker.C { + _ = enforcePanelLogLimit(path, maxBytes) + } + }() +} + +func enforcePanelLogLimit(path string, maxBytes int64) error { + st, err := os.Stat(path) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + if st.Size() <= maxBytes { + return nil + } + return truncatePanelLog(path, maxBytes, "automatic 1 MiB log limit") +} + +func truncatePanelLog(path string, maxBytes int64, reason string) error { + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return err + } + f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644) + if err != nil { + return err + } + defer f.Close() + _, err = fmt.Fprintf(f, "%s sshpanel: panel log cleaned (%s, max=%d bytes)\n", time.Now().Format(time.RFC3339), reason, maxBytes) + return err +} + +func handleSystemLogsReset(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + path := panelLogFilePath() + maxBytes := panelLogMaxBytes() + if err := truncatePanelLog(path, maxBytes, "manual clean from admin panel"); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(panelLogResetResponse{OK: true, Path: path, MaxBytes: maxBytes}) +} diff --git a/system_logs_api.go b/system_logs_api.go index c5df239..535e456 100644 --- a/system_logs_api.go +++ b/system_logs_api.go @@ -45,10 +45,7 @@ func handleSystemLogs(w http.ResponseWriter, r *http.Request) { resp.Lines = limitLines(xrayLogBuf.snapshot(), limit) default: resp.Source = "panel" - path := strings.TrimSpace(os.Getenv("PANEL_LOG_FILE")) - if path == "" { - path = defaultPanelLogFile - } + path := panelLogFilePath() resp.Path = path lines, err := tailTextFile(path, limit) if err != nil { diff --git a/update.sh b/update.sh index 29bd510..5e4eb74 100644 --- a/update.sh +++ b/update.sh @@ -26,6 +26,8 @@ error() { echo -e "${RED}[x]${NC} $*"; exit 1; } # Config INSTALL_DIR="${INSTALL_DIR:-/opt/sshpanel}" SERVICE_NAME="${SERVICE_NAME:-sshpanel}" +LOG_TMPFS_SIZE="${LOG_TMPFS_SIZE:-15m}" +PANEL_LOG_MAX_BYTES="${PANEL_LOG_MAX_BYTES:-1048576}" REPO_URL="${REPO_URL:-https://git.dr2.site/penguinehis/DragonCoreSSH-NewWEB.git}" UPDATE_REF="${UPDATE_REF:-}" SOURCE_CACHE_DIR="${SOURCE_CACHE_DIR:-${INSTALL_DIR}/source}" @@ -44,6 +46,37 @@ need_cmd() { command -v "$1" >/dev/null 2>&1 || error "Required command not found: $1" } +ensure_log_tmpfs_mount() { + local log_dir="${INSTALL_DIR}/logs" + local opts="rw,nosuid,nodev,noexec,noatime,nofail,size=${LOG_TMPFS_SIZE},mode=0755" + local tmp_fstab + + mkdir -p "$log_dir" + + if [[ -f /etc/fstab ]]; then + cp /etc/fstab "/etc/fstab.sshpanel.bak.$(date +%s)" 2>/dev/null || true + tmp_fstab="$(mktemp)" + awk -v mp="$log_dir" '!(($1 == "tmpfs") && ($2 == mp) && ($3 == "tmpfs")) {print}' /etc/fstab > "$tmp_fstab" + printf 'tmpfs %s tmpfs %s 0 0\n' "$log_dir" "$opts" >> "$tmp_fstab" + cat "$tmp_fstab" > /etc/fstab + rm -f "$tmp_fstab" + info " Log RAM disk automount saved in /etc/fstab: $log_dir (${LOG_TMPFS_SIZE})" + else + warn " /etc/fstab not found; service startup fallback will mount $log_dir as tmpfs" + fi + + systemctl daemon-reload >/dev/null 2>&1 || true + if mountpoint -q "$log_dir"; then + mount -o "remount,size=${LOG_TMPFS_SIZE},mode=0755" "$log_dir" >/dev/null 2>&1 || true + else + mount "$log_dir" >/dev/null 2>&1 || mount -t tmpfs -o "size=${LOG_TMPFS_SIZE},mode=0755" tmpfs "$log_dir" >/dev/null 2>&1 || \ + warn " Could not mount $log_dir as tmpfs now; systemd service will try again on start" + fi + + touch "$log_dir/panel.log" >/dev/null 2>&1 || true + chmod 0644 "$log_dir/panel.log" >/dev/null 2>&1 || true +} + install_git_if_missing() { if command -v git >/dev/null 2>&1; then return 0 @@ -196,6 +229,7 @@ apply_update() { info "[5/7] Applying update..." mkdir -p "$INSTALL_DIR/admin" "$INSTALL_DIR/logs" "$INSTALL_DIR/certs" + ensure_log_tmpfs_mount if [[ -f "$INSTALL_DIR/sshpanel" ]]; then cp "$INSTALL_DIR/sshpanel" "$INSTALL_DIR/sshpanel.bak" @@ -344,10 +378,17 @@ EOF2 cat > /etc/systemd/system/sshpanel.service.d/override.conf < 366 { + days = 31 + } + if months <= 0 || months > 60 { + months = 12 + } + + out := VnstatDTO{UpdatedAt: time.Now()} + + dailyRows, err := s.db.QueryContext(ctx, ` + SELECT iface, usage_date::text, rx_bytes, tx_bytes + FROM ssh_iface_daily_usage + WHERE usage_date >= CURRENT_DATE - $1::int + ORDER BY usage_date DESC, iface ASC`, days-1) + if err != nil { + return out, err + } + defer dailyRows.Close() + for dailyRows.Next() { + var r VnstatUsageRow + if err := dailyRows.Scan(&r.Iface, &r.Period, &r.RxBytes, &r.TxBytes); err != nil { + return out, err + } + r.TotalBytes = r.RxBytes + r.TxBytes + out.Daily = append(out.Daily, r) + } + if err := dailyRows.Err(); err != nil { + return out, err + } + + monthlyRows, err := s.db.QueryContext(ctx, ` + SELECT iface, to_char(month_start, 'YYYY-MM') AS period, rx_bytes, tx_bytes + FROM ssh_iface_monthly_usage + WHERE month_start >= (date_trunc('month', CURRENT_DATE)::date - ($1::int * INTERVAL '1 month')) + ORDER BY month_start DESC, iface ASC`, months-1) + if err != nil { + return out, err + } + defer monthlyRows.Close() + for monthlyRows.Next() { + var r VnstatUsageRow + if err := monthlyRows.Scan(&r.Iface, &r.Period, &r.RxBytes, &r.TxBytes); err != nil { + return out, err + } + r.TotalBytes = r.RxBytes + r.TxBytes + out.Monthly = append(out.Monthly, r) + } + if err := monthlyRows.Err(); err != nil { + return out, err + } + + return out, nil +} + +func (s *Store) ResetIfaceUsage(ctx context.Context) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + if _, err := tx.ExecContext(ctx, `TRUNCATE TABLE ssh_iface_daily_usage, ssh_iface_monthly_usage`); err != nil { + return err + } + return tx.Commit() +} + +func (s *Store) ReplaceIfaceTotals(ctx context.Context, rows []IfaceTotals) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + if _, err := tx.ExecContext(ctx, `DELETE FROM ssh_iface_totals`); err != nil { + return err + } + for _, r := range rows { + resetAt := r.ResetAt + if resetAt.IsZero() { + resetAt = time.Now() + } + if _, err := tx.ExecContext(ctx, ` + INSERT INTO ssh_iface_totals (iface, total_rx_bytes, total_tx_bytes, last_kernel_rx_bytes, last_kernel_tx_bytes, updated_at, reset_at) + VALUES ($1, $2, $3, $4, $5, NOW(), $6)`, + r.Iface, r.TotalRxBytes, r.TotalTxBytes, r.LastKernelRxBytes, r.LastKernelTxBytes, resetAt); err != nil { + return err + } + } + return tx.Commit() +} + +func handleVnstat(store *Store) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + if store == nil { + http.Error(w, "database not configured", http.StatusServiceUnavailable) + return + } + days := parsePositiveInt(r.URL.Query().Get("days"), 31) + months := parsePositiveInt(r.URL.Query().Get("months"), 12) + data, err := store.LoadIfaceUsage(r.Context(), days, months) + if err != nil { + log.Printf("failed to load vnstat usage: %v", err) + http.Error(w, "db error", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(data) + } +} + +func handleVnstatReset(store *Store) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + if store == nil { + http.Error(w, "database not configured", http.StatusServiceUnavailable) + return + } + if err := store.ResetIfaceUsage(r.Context()); err != nil { + log.Printf("failed to reset vnstat usage: %v", err) + http.Error(w, "db error", http.StatusInternalServerError) + return + } + clearPendingIfaceUsage() + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"ok": true}) + } +} + +func handleResetInterfaceStats(store *Store) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + if store == nil || ifaceTotalsMgr == nil { + http.Error(w, "interface totals persistence not configured", http.StatusServiceUnavailable) + return + } + + netMap, err := readNetDev() + if err != nil { + log.Printf("failed to read interfaces for reset: %v", err) + http.Error(w, "failed to read interfaces", http.StatusInternalServerError) + return + } + rows := ifaceTotalsMgr.ResetAllToKernel(netMap) + if err := store.ReplaceIfaceTotals(r.Context(), rows); err != nil { + log.Printf("failed to reset interface totals: %v", err) + http.Error(w, "db error", http.StatusInternalServerError) + return + } + + stats := getCurrentStats() + for i := range stats.Interfaces { + stats.Interfaces[i].RxBytes = 0 + stats.Interfaces[i].TxBytes = 0 + } + setCurrentStats(stats) + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"ok": true}) + } +} + +func parsePositiveInt(raw string, fallback int) int { + if raw == "" { + return fallback + } + n, err := strconv.Atoi(raw) + if err != nil || n <= 0 { + return fallback + } + return n +}