New Features and safe log
This commit is contained in:
@@ -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
|
||||
|
||||
199
admin/index.html
199
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;}
|
||||
}
|
||||
</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);
|
||||
|
||||
|
||||
40
install.sh
40
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" <<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
117
main.go
@@ -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
105
panel_log_limiter.go
Normal 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})
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
43
update.sh
43
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 <<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
336
vnstat_api.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user