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 Mbps30-day rolling
+
+
| Interface | Rx Mbps | Tx Mbps | Rx Total | Tx Total |
-
+
Totals can be cleaned here and auto-clean every 30 days. VnStat history is separate.
+
+
+
+
+
VnStat Usage daily / monthly
+
+
+
+
+
+
+
VnStat history does not auto-clean. Use the button when you want to reset it.
+
+
+
+
+
+
+
+ | Day | Interface | Rx | Tx | Total |
+
+
+
+
+
+
Monthly usage last 12 months
+
+
+ | Month | Interface | Rx | Tx | Total |
+
+
+
+
+
+
+
@@ -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
+}