New Features and safe log

This commit is contained in:
2026-05-03 11:05:13 -03:00
parent 43482c88fa
commit c74f6e2282
8 changed files with 823 additions and 26 deletions

View File

@@ -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

View File

@@ -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;}
}
</style>
</head>
<body>
@@ -131,6 +169,7 @@ pre.log-box{background:rgba(15,23,42,.9);color:#9ca3af;border:1px solid rgba(55,
<button class="tab-btn superadmin-only hidden" data-tab="xray">Xray</button>
<button class="tab-btn superadmin-only hidden" data-tab="resellers">Resellers</button>
<button class="tab-btn superadmin-only hidden" data-tab="stats">Stats</button>
<button class="tab-btn superadmin-only hidden" data-tab="vnstat">VnStat</button>
<button class="tab-btn superadmin-only hidden" data-tab="logs">Logs</button>
<button class="tab-btn superadmin-only hidden" data-tab="server">Server Config</button>
</nav>
@@ -522,18 +561,61 @@ pre.log-box{background:rgba(15,23,42,.9);color:#9ca3af;border:1px solid rgba(55,
</div>
</div>
<div class="card">
<div class="card-hdr"><div class="card-title">Interfaces <span class="chip">rx/tx Mbps</span></div></div>
<div class="card-hdr">
<div class="card-title">Interfaces <span class="chip">rx/tx Mbps</span><span class="chip warn">30-day rolling</span></div>
<button class="btn btn-danger btn-sm" id="resetIfaceStatsBtn" type="button">Clean usage</button>
</div>
<div class="tbl-wrap">
<table>
<thead><tr><th>Interface</th><th>Rx Mbps</th><th>Tx Mbps</th><th>Rx Total</th><th>Tx Total</th></tr></thead>
<tbody id="ifaceBody"></tbody>
</table>
</div>
<div class="statusbar"><span id="ifaceSummary"></span></div>
<div class="statusbar"><span id="ifaceSummary"></span><span class="hint">Totals can be cleaned here and auto-clean every 30 days. VnStat history is separate.</span></div>
</div>
</div>
</div><!-- /tab-stats -->
<!-- ═══════════ VnStat Tab (superadmin only) ═══════════ -->
<div class="tab-pane" id="tab-vnstat">
<div class="card">
<div class="card-hdr">
<div class="card-title">VnStat Usage <span class="chip">daily / monthly</span></div>
<div class="form-actions" style="margin-top:0">
<button class="btn btn-ghost btn-sm" id="reloadVnstatBtn" type="button">Refresh</button>
<button class="btn btn-danger btn-sm" id="resetVnstatBtn" type="button">Clean VnStat history</button>
</div>
</div>
<div class="metrics">
<div class="metric"><div class="m-label">Today total</div><div class="m-val" id="vnTodayTotal">--</div></div>
<div class="metric"><div class="m-label">This month total</div><div class="m-val" id="vnMonthTotal">--</div></div>
<div class="metric"><div class="m-label">Interfaces tracked</div><div class="m-val" id="vnIfaceCount">--</div></div>
</div>
<div class="statusbar"><span id="vnstatStatus">VnStat history does not auto-clean. Use the button when you want to reset it.</span></div>
</div>
<div class="grid2" style="margin-top:12px;">
<div class="card">
<div class="card-hdr"><div class="card-title">Daily usage <span class="chip">last 31 days</span></div></div>
<div class="tbl-wrap">
<table>
<thead><tr><th>Day</th><th>Interface</th><th>Rx</th><th>Tx</th><th>Total</th></tr></thead>
<tbody id="vnstatDailyBody"></tbody>
</table>
</div>
</div>
<div class="card">
<div class="card-hdr"><div class="card-title">Monthly usage <span class="chip">last 12 months</span></div></div>
<div class="tbl-wrap">
<table>
<thead><tr><th>Month</th><th>Interface</th><th>Rx</th><th>Tx</th><th>Total</th></tr></thead>
<tbody id="vnstatMonthlyBody"></tbody>
</table>
</div>
</div>
</div>
</div><!-- /tab-vnstat -->
<!-- ═══════════ Logs Tab (superadmin only) ═══════════ -->
<div class="tab-pane" id="tab-logs">
<div class="card">
@@ -546,6 +628,7 @@ pre.log-box{background:rgba(15,23,42,.9);color:#9ca3af;border:1px solid rgba(55,
<option value="xray">Xray</option>
</select>
<button class="btn btn-sm" type="button" onclick="loadSystemLogs()">Refresh</button>
<button class="btn btn-danger btn-sm" type="button" id="clearPanelLogBtn">Clean panel log</button>
</div>
</div>
<pre class="log-box" id="systemLogBox" style="max-height:430px;min-height:260px;">Select a log source and click Refresh.</pre>
@@ -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 = `<td colspan="5" class="hint">${emptyLabel}</td>`;
body.appendChild(tr);
return;
}
rows.forEach(r => {
const tr = document.createElement("tr");
tr.innerHTML = `<td>${r.period || "--"}</td><td>${r.iface || "--"}</td><td>${fmtBytes(r.rx_bytes||0)}</td><td>${fmtBytes(r.tx_bytes||0)}</td><td>${fmtBytes(r.total_bytes||((r.rx_bytes||0)+(r.tx_bytes||0)))}</td>`;
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);

View File

@@ -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" <<EOF
[Unit]
Description=SSH Panel + Xray-core Server
After=network.target postgresql.service sshpanel-dnstt-redirect.service
After=local-fs.target network.target postgresql.service sshpanel-dnstt-redirect.service
Wants=postgresql.service sshpanel-dnstt-redirect.service
[Service]
@@ -422,6 +456,10 @@ Type=simple
WorkingDirectory=${INSTALL_DIR}
EnvironmentFile=${INSTALL_DIR}/.env
Environment=PANEL_LOG_FILE=${INSTALL_DIR}/logs/panel.log
Environment=PANEL_LOG_MAX_BYTES=${PANEL_LOG_MAX_BYTES}
ExecStartPre=/usr/bin/mkdir -p ${INSTALL_DIR}/logs
ExecStartPre=/bin/sh -c '/usr/bin/mountpoint -q ${INSTALL_DIR}/logs || /usr/bin/mount -t tmpfs -o size=${LOG_TMPFS_SIZE},mode=0755 tmpfs ${INSTALL_DIR}/logs || true'
ExecStartPre=/bin/sh -c '/usr/bin/touch ${INSTALL_DIR}/logs/panel.log && /usr/bin/chmod 0644 ${INSTALL_DIR}/logs/panel.log || true'
ExecStart=${INSTALL_DIR}/sshpanel -config ${INSTALL_DIR}/config.json
Restart=always
RestartSec=5

117
main.go
View File

@@ -564,6 +564,7 @@ type IfaceTotals struct {
LastKernelRxBytes uint64
LastKernelTxBytes uint64
UpdatedAt time.Time
ResetAt time.Time
}
type IfaceTotalsManager struct {
@@ -582,11 +583,27 @@ func (tm *IfaceTotalsManager) ApplyKernel(iface string, kRx, kTx uint64) (totalR
tm.mu.Lock()
defer tm.mu.Unlock()
now := time.Now()
st, ok := tm.m[iface]
if !ok {
st = &IfaceTotals{Iface: iface}
st = &IfaceTotals{Iface: iface, ResetAt: now}
tm.m[iface] = st
}
if st.ResetAt.IsZero() {
st.ResetAt = now
}
// The live interface counters in the Stats tab are a rolling 30-day total.
// This reset does not touch the vnstat-style daily/monthly history tables.
if now.Sub(st.ResetAt) >= 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)

105
panel_log_limiter.go Normal file
View File

@@ -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})
}

View File

@@ -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 {

View File

@@ -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 <<EOF2
[Unit]
Wants=sshpanel-dnstt-redirect.service
After=sshpanel-dnstt-redirect.service
After=local-fs.target sshpanel-dnstt-redirect.service
[Service]
Environment=PANEL_LOG_FILE=${INSTALL_DIR}/logs/panel.log
Environment=PANEL_LOG_MAX_BYTES=${PANEL_LOG_MAX_BYTES}
ExecStartPre=
ExecStartPre=/usr/bin/mkdir -p ${INSTALL_DIR}/logs
ExecStartPre=/bin/sh -c '/usr/bin/mountpoint -q ${INSTALL_DIR}/logs || /usr/bin/mount -t tmpfs -o size=${LOG_TMPFS_SIZE},mode=0755 tmpfs ${INSTALL_DIR}/logs || true'
ExecStartPre=/bin/sh -c '/usr/bin/touch ${INSTALL_DIR}/logs/panel.log && /usr/bin/chmod 0644 ${INSTALL_DIR}/logs/panel.log || true'
StandardOutput=append:${INSTALL_DIR}/logs/panel.log
StandardError=append:${INSTALL_DIR}/logs/panel.log
EOF2
systemctl daemon-reload

336
vnstat_api.go Normal file
View File

@@ -0,0 +1,336 @@
package main
import (
"context"
"encoding/json"
"log"
"net/http"
"strconv"
"sync"
"time"
)
type IfaceUsageDelta struct {
Iface string
RxBytes uint64
TxBytes uint64
At time.Time
}
var ifaceUsagePending = struct {
mu sync.Mutex
m map[string]ifaceCounters
}{m: make(map[string]ifaceCounters)}
func addPendingIfaceUsage(iface string, rxBytes, txBytes uint64) {
if iface == "" || (rxBytes == 0 && txBytes == 0) {
return
}
ifaceUsagePending.mu.Lock()
defer ifaceUsagePending.mu.Unlock()
p := ifaceUsagePending.m[iface]
p.RxBytes += rxBytes
p.TxBytes += txBytes
ifaceUsagePending.m[iface] = p
}
func flushPendingIfaceUsage(at time.Time) []IfaceUsageDelta {
ifaceUsagePending.mu.Lock()
defer ifaceUsagePending.mu.Unlock()
if len(ifaceUsagePending.m) == 0 {
return nil
}
deltas := make([]IfaceUsageDelta, 0, len(ifaceUsagePending.m))
for iface, ctrs := range ifaceUsagePending.m {
deltas = append(deltas, IfaceUsageDelta{Iface: iface, RxBytes: ctrs.RxBytes, TxBytes: ctrs.TxBytes, At: at})
}
ifaceUsagePending.m = make(map[string]ifaceCounters)
return deltas
}
func restorePendingIfaceUsage(deltas []IfaceUsageDelta) {
ifaceUsagePending.mu.Lock()
defer ifaceUsagePending.mu.Unlock()
for _, d := range deltas {
p := ifaceUsagePending.m[d.Iface]
p.RxBytes += d.RxBytes
p.TxBytes += d.TxBytes
ifaceUsagePending.m[d.Iface] = p
}
}
func clearPendingIfaceUsage() {
ifaceUsagePending.mu.Lock()
ifaceUsagePending.m = make(map[string]ifaceCounters)
ifaceUsagePending.mu.Unlock()
}
type VnstatUsageRow struct {
Iface string `json:"iface"`
Period string `json:"period"`
RxBytes uint64 `json:"rx_bytes"`
TxBytes uint64 `json:"tx_bytes"`
TotalBytes uint64 `json:"total_bytes"`
}
type VnstatDTO struct {
Daily []VnstatUsageRow `json:"daily"`
Monthly []VnstatUsageRow `json:"monthly"`
UpdatedAt time.Time `json:"updated_at"`
}
func (s *Store) EnsureIfaceUsageTables(ctx context.Context) error {
stmts := []string{
`CREATE TABLE IF NOT EXISTS ssh_iface_daily_usage (
usage_date DATE NOT NULL,
iface TEXT NOT NULL,
rx_bytes BIGINT NOT NULL DEFAULT 0,
tx_bytes BIGINT NOT NULL DEFAULT 0,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (usage_date, iface)
)`,
`CREATE TABLE IF NOT EXISTS ssh_iface_monthly_usage (
month_start DATE NOT NULL,
iface TEXT NOT NULL,
rx_bytes BIGINT NOT NULL DEFAULT 0,
tx_bytes BIGINT NOT NULL DEFAULT 0,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (month_start, iface)
)`,
}
for _, stmt := range stmts {
if _, err := s.db.ExecContext(ctx, stmt); err != nil {
return err
}
}
return nil
}
func (s *Store) UpsertIfaceUsageDeltas(ctx context.Context, deltas []IfaceUsageDelta) error {
if len(deltas) == 0 {
return nil
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
for _, d := range deltas {
if d.Iface == "" || (d.RxBytes == 0 && d.TxBytes == 0) {
continue
}
at := d.At
if at.IsZero() {
at = time.Now()
}
day := at.Format("2006-01-02")
month := time.Date(at.Year(), at.Month(), 1, 0, 0, 0, 0, at.Location()).Format("2006-01-02")
if _, err := tx.ExecContext(ctx, `
INSERT INTO ssh_iface_daily_usage (usage_date, iface, rx_bytes, tx_bytes, updated_at)
VALUES ($1, $2, $3, $4, NOW())
ON CONFLICT (usage_date, iface) DO UPDATE
SET rx_bytes = ssh_iface_daily_usage.rx_bytes + EXCLUDED.rx_bytes,
tx_bytes = ssh_iface_daily_usage.tx_bytes + EXCLUDED.tx_bytes,
updated_at = NOW()`,
day, d.Iface, d.RxBytes, d.TxBytes); err != nil {
return err
}
if _, err := tx.ExecContext(ctx, `
INSERT INTO ssh_iface_monthly_usage (month_start, iface, rx_bytes, tx_bytes, updated_at)
VALUES ($1, $2, $3, $4, NOW())
ON CONFLICT (month_start, iface) DO UPDATE
SET rx_bytes = ssh_iface_monthly_usage.rx_bytes + EXCLUDED.rx_bytes,
tx_bytes = ssh_iface_monthly_usage.tx_bytes + EXCLUDED.tx_bytes,
updated_at = NOW()`,
month, d.Iface, d.RxBytes, d.TxBytes); err != nil {
return err
}
}
return tx.Commit()
}
func (s *Store) LoadIfaceUsage(ctx context.Context, days, months int) (VnstatDTO, error) {
if days <= 0 || days > 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
}