Files
DragonCoreSSH-NewWEB/main.go
2026-05-27 15:09:42 -03:00

2901 lines
84 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package main
import (
"bufio"
"bytes"
"context"
"crypto/hmac"
"crypto/sha1"
"crypto/tls"
"database/sql"
"encoding/base32"
"encoding/binary"
"encoding/json"
"flag"
"fmt"
"io"
"log"
"net"
"net/http"
"net/url"
"os"
"sort"
"strconv"
"strings"
"sync"
"time"
_ "github.com/lib/pq"
"golang.org/x/crypto/ssh"
"golang.org/x/time/rate"
"runtime/debug"
)
const (
// Hard timeouts to ensure half-open connections don't leak goroutines.
sshHandshakeTimeout = 15 * time.Second
tlsHandshakeTimeout = 15 * time.Second
// Dial timeout for direct-tcpip backend connections.
directTCPIPDialTimeout = 10 * time.Second
// Default post-auth SSH inactivity timeout. This is based on real bytes
// moving in either direction, so live upload/download tunnels are not closed.
defaultSSHIdleTimeout = 5 * time.Minute
)
// ---------- Config types ----------
// TLSForwarderConfig specifies a TLS listener that accepts encrypted
// connections and passes the decrypted stream directly into the SSH
// handler. The listener binds to the given address (which should be
// specified in bracket form for IPv6, e.g. "[2001:db8::1]:443") and
// terminates TLS using the provided certificate and key files. Each
// connection is then handled by the same handleConn logic as the
// plain TCP listeners. If no TLS forwarders are configured, no
// TLS listeners are started.
type TLSForwarderConfig struct {
// Listen address for the TLSwrapped SSH service. For IPv6, use
// bracket form, e.g. "[2001:db8::10]:443" or "[::]:443".
Listen string `json:"listen"`
// CertFile is the path to the TLS certificate in PEM format.
CertFile string `json:"cert_file"`
// KeyFile is the path to the corresponding private key in PEM format.
KeyFile string `json:"key_file"`
}
type Config struct {
Listen string `json:"listen"`
// Optional extra public listen addresses (multiport). These
// addresses use the same HTTPcleanup and SSH handler as the
// primary Listen address. For IPv6, use bracket form, e.g.
// "[::]:80", "[2001:db8::20]:8080". Empty slice means no
// additional listeners.
ExtraListen []string `json:"extra_listen"`
// Legacy compatibility only. DragonCore no longer starts a local raw SSH listener.
LocalSSHListen string `json:"local_ssh_listen,omitempty"`
HostKeyFile string `json:"host_key_file"`
Quiet bool `json:"quiet"`
Banner string `json:"banner"`
BannerFile string `json:"banner_file"`
UserCount bool `json:"user_count"`
// SSHIdleTimeout controls how long an authenticated SSH connection may
// remain with no bytes moving in either direction before it is closed and
// released from the active user count. Empty = default 5m. Use "0s" to disable.
SSHIdleTimeout string `json:"ssh_idle_timeout,omitempty"`
// NEW: Directory to serve the admin panel from
AdminDir string `json:"admin_dir"`
// Default per-connection bandwidth limits (if user-specific = 0)
DefaultLimitMbpsUp int `json:"default_limit_mbps_up"`
DefaultLimitMbpsDown int `json:"default_limit_mbps_down"`
Users []UserConfig `json:"users"`
// DNSTT holds configuration for the DNS tunnel server. If nil, the DNS
// tunnel server is disabled. Domain is the DNS zone used for tunnelled
// queries (e.g. "t.example.com"). UDPListen specifies the UDP address
// to listen on (IPv6 syntax like "[::]:5300"). PrivKeyFile points to
// the private key used for the Noise handshake between client and server.
DNSTT *DNSTTConfig `json:"dnstt"`
// UDPGW holds configuration for the integrated UDP gateway. If nil, the
// gateway is disabled. The gateway implements the BadVPN udpgw protocol
// (connID + X + IPv4 + port framing) and forwards UDP datagrams between a
// TCP client and arbitrary destinations. See udpgwstandalone.go for
// implementation details.
UDPGW *UDPGWConfig `json:"udpgw"`
// Optional TLS listeners (stunnellike): accept TLS and handle SSH on
// the same process (no TCP backend). Each forwarder binds to the
// given address and terminates TLS using the specified certificate
// and key. If no TLS forwarders are defined, the server does not
// listen for TLS connections. See serveTLSSSH for implementation.
TLSForwarders []TLSForwarderConfig `json:"tls_forwarders"`
// Xray holds configuration for the integrated Xray-core process. If nil
// the Xray subprocess is not started. See xray_integration.go.
Xray *XrayConfig `json:"xray"`
}
// DNSTTConfig defines the settings for the integrated dnstt server. See
// https://www.bamsoftware.com/software/dnstt/ for background on dnstt. The
// server listens on the UDP address in UDPListen, uses Domain as the root of
// the tunnelled zone, and loads its private key from PrivKeyFile. The
// corresponding public key must be distributed to clients.
type DNSTTConfig struct {
// Domain is the root of the DNS zone reserved for the tunnel (e.g. "t.example.com").
Domain string `json:"domain"`
// UDPListen is the UDP address to listen on for incoming DNS queries.
// The address should be IPv6formatted (e.g. "[::]:5300") and reachable by
// recursive resolvers. Note: port 53 may require root privileges; binding
// to an unprivileged port and using iptables to redirect port 53 is
// recommended【561853413345496†L97-L109】.
UDPListen string `json:"udp_listen"`
// PrivKeyFile is the path to the Noise server private key. Generate a
// keypair with the dnstt tool (use -gen-key) and copy the resulting
// private key here; the matching public key must be distributed to
// clients【38494750697905†L172-L209】.
PrivKeyFile string `json:"privkey_file"`
// DisableStatsLog disables printing of periodic DNSTT statistics to the
// process stderr. When true, the server will still collect tunnel
// counters and expose them via the admin API, but the log lines like
// "dnstt: stats 5s: dns_rx=..." will not appear on the CLI. The default
// is false, meaning stats are printed. This flag is useful when running
// the server in quiet environments where repeated log output should be
// avoided, but visibility into tunnel health is still desired via the
// web panel.
DisableStatsLog bool `json:"disable_stats_log"`
// DisableConsoleLog disables printing of all DNSTT log lines to stderr. When
// true, messages such as new KCP sessions, smux streams, and SSH stream
// begin/end will not be written to the console. Instead, they are
// captured in an in-memory buffer and surfaced via the admin API for
// display in the web panel. The default is false, meaning logs are
// printed to the console. Set this to true in combination with
// disable_stats_log if you want a fully quiet DNSTT server.
DisableConsoleLog bool `json:"disable_console_log"`
}
// UDPGWConfig defines the settings for the integrated UDP gateway. The
// gateway accepts TCP connections speaking the BadVPN udpgw protocol and
// forwards framed UDP datagrams to arbitrary IPv4 destinations. All
// fields are optional; if a field is zero or empty, a sensible default
// matching the standalone udpgw implementation is used.
type UDPGWConfig struct {
// Listen is the TCP address to bind for incoming udpgw clients. Use
// IPv6 syntax in brackets when necessary (e.g. "[::]:7400"). If
// empty, the default "0.0.0.0:7400" is used.
Listen string `json:"listen"`
// MaxFrame limits the maximum payload length (in bytes) of a frame sent
// by the client. The default is 64*1024 (64 KiB). Frames larger
// than this are rejected.
MaxFrame int `json:"max_frame"`
// Debug enables verbose logging of connections, frames and
// mappings.
Debug bool `json:"debug"`
// HexdumpN controls how many bytes of each payload are hexdumped in
// debug logs. A value of zero suppresses hex dumps. Default is 64.
HexdumpN int `json:"hexdump"`
// WriteChan sets the size of the buffered channel used for sending
// reply frames back to the client. Larger values allow more queued
// replies before blocking. The default is 4096.
WriteChan int `json:"write_chan"`
// UDPBindIP, if nonempty, causes each perclient UDP socket to bind to
// the specified local IP address. The port is chosen automatically.
UDPBindIP string `json:"udp_bind"`
// UDPRBuf sets the size of the UDP socket read buffer in bytes. The
// default is 8 MiB. Setting this to zero uses the default.
UDPRBuf int `json:"udp_rbuf"`
// UDPWBuf sets the size of the UDP socket write buffer in bytes. The
// default is 8 MiB. Setting this to zero uses the default.
UDPWBuf int `json:"udp_wbuf"`
// MapTTL controls how long a destination->connID mapping remains
// valid after the last packet from that destination. Expressed as a
// duration string. Default is "90s".
MapTTL string `json:"map_ttl"`
// ReapEvery controls how often expired mappings are purged. Expressed
// as a duration string. Default is "10s".
ReapEvery string `json:"reap_every"`
// IdleTimeout controls how long a TCP client connection may remain
// idle (no frames received) before being closed. Expressed as a duration
// string. Default is "2m".
IdleTimeout string `json:"idle_timeout"`
// MaxClientConns limits how many distinct udpgw connIDs a single TCP
// client connection may keep active at once. This is the primary guard
// against a single client creating effectively infinite logical UDP
// sessions and growing RAM usage over time. Default is 10.
MaxClientConns int `json:"max_client_conns"`
// MaxMapEntries limits the maximum number of destination->connID
// mappings kept per client. This protects the server from unbounded
// growth if a client sprays packets to many unique destinations.
// Default is 32768.
MaxMapEntries int `json:"max_map_entries"`
}
type UserConfig struct {
Username string `json:"username"`
Password string `json:"password"`
PublicKeyFile string `json:"public_key_file"` // optional .pub path
// Optional rotating password mode for shared/public accounts. When
// totp_secret is set, the server accepts a TOTP code as the SSH
// password. This is useful when you want to make copied credentials
// expire quickly without relying on a separate API.
// Secret must be Base32 (with or without padding). Example generated
// secret: JBSWY3DPEHPK3PXP
TOTPSecret string `json:"totp_secret"`
// Period in seconds for each password step. Default: 60.
TOTPPeriod int `json:"totp_period"`
// Number of adjacent time windows accepted for clock drift.
// Default: 1 (previous/current/next).
TOTPWindow int `json:"totp_window"`
// Number of digits in the generated TOTP. Default: 6.
TOTPDigits int `json:"totp_digits"`
// When true, both the static Password and the TOTP code are accepted.
// When false and totp_secret is set, only the TOTP code is accepted.
AllowStaticPassword bool `json:"allow_static_password"`
MaxConnections int `json:"max_connections"`
ExpiresAt string `json:"expires_at"` // RFC3339 or empty
// Per-connection limits (one limiter per SSH connection)
LimitMbpsUp int `json:"limit_mbps_up"` // Mbps upstream
LimitMbpsDown int `json:"limit_mbps_down"` // Mbps downstream
// OwnerUsername is the reseller who created this SSH user. Empty = superadmin-owned.
OwnerUsername string `json:"owner_username,omitempty"`
}
type UserState struct {
Cfg UserConfig
ExpiresAt *time.Time
PubKey ssh.PublicKey // may be nil
mu sync.Mutex
ActiveConns int
conns map[*ssh.ServerConn]struct{} // active SSH connections for this user
}
type UserManager struct {
mu sync.RWMutex
users map[string]*UserState
}
func (m *UserManager) Get(username string) (*UserState, bool) {
m.mu.RLock()
defer m.mu.RUnlock()
u, ok := m.users[username]
return u, ok
}
func (m *UserManager) List() []*UserState {
m.mu.RLock()
defer m.mu.RUnlock()
out := make([]*UserState, 0, len(m.users))
for _, u := range m.users {
out = append(out, u)
}
return out
}
func (m *UserManager) ReplaceAll(newUsers map[string]*UserState) {
m.mu.Lock()
defer m.mu.Unlock()
m.users = newUsers
}
// ReplaceAllPreserveRuntime replaces the user map while keeping the same
// runtime UserState object for users that already exist. This is important:
// active handleConn goroutines hold a pointer to the old UserState and run the
// decrement in a defer. If we copy ActiveConns into a new UserState during a DB
// reload, that later decrement happens on the old object and the visible count
// in the new map stays stuck.
func (m *UserManager) ReplaceAllPreserveRuntime(newUsers map[string]*UserState) {
m.mu.Lock()
old := m.users
for username, nu := range newUsers {
if ou, ok := old[username]; ok && ou != nil && nu != nil {
ou.mu.Lock()
ou.Cfg = nu.Cfg
ou.ExpiresAt = nu.ExpiresAt
ou.PubKey = nu.PubKey
if ou.conns == nil {
ou.conns = make(map[*ssh.ServerConn]struct{})
}
// Self-heal any previously stale counter by trusting the live connection map.
ou.ActiveConns = len(ou.conns)
ou.mu.Unlock()
newUsers[username] = ou
}
}
m.users = newUsers
m.mu.Unlock()
}
// DisconnectUser closes all active SSH connections for the given user.
// Safe if the user does not exist or has no connections.
func (m *UserManager) DisconnectUser(username string) {
m.mu.RLock()
u, ok := m.users[username]
m.mu.RUnlock()
if !ok {
return
}
u.mu.Lock()
conns := make([]*ssh.ServerConn, 0, len(u.conns))
for c := range u.conns {
conns = append(conns, c)
}
u.mu.Unlock()
for _, c := range conns {
_ = c.Close()
}
}
// Global state
var (
userMgr = &UserManager{users: make(map[string]*UserState)}
userCountEnabled bool
sshIdleTimeoutMu sync.RWMutex
currentSSHIdleTimeout = defaultSSHIdleTimeout
displayMu sync.Mutex
lastDisplayLen int
// Server stats (CPU + network interfaces)
statsMu sync.RWMutex
currentStats StatsDTO
// Optional: persist interface byte counters across process restarts / reboots (requires PG_DSN).
ifaceTotalsMgr *IfaceTotalsManager
statsStore *Store
)
// ---------- Helpers ----------
func publicKeysEqual(a, b ssh.PublicKey) bool {
if a == nil || b == nil {
return a == b
}
return a.Type() == b.Type() && bytes.Equal(a.Marshal(), b.Marshal())
}
func mbpsToBytesPerSec(mbps int) int64 {
if mbps <= 0 {
return 0
}
return int64(mbps) * 1024 * 1024 / 8
}
var copyBufPool = sync.Pool{
New: func() interface{} { b := make([]byte, 32*1024); return &b },
}
func copyWithRateLimit(dst io.Writer, src io.Reader, lim *rate.Limiter) (written int64, err error) {
if lim == nil {
return io.Copy(dst, src)
}
bufp := copyBufPool.Get().(*[]byte)
buf := *bufp
defer copyBufPool.Put(bufp)
ctx := context.Background()
for {
nr, er := src.Read(buf)
if nr > 0 {
if err := lim.WaitN(ctx, nr); err != nil {
return written, err
}
nw, ew := dst.Write(buf[:nr])
if nw > 0 {
written += int64(nw)
}
if ew != nil {
err = ew
break
}
if nr != nw {
err = io.ErrShortWrite
break
}
}
if er != nil {
if er != io.EOF {
err = er
}
break
}
}
return written, err
}
func parseSSHIdleTimeout(raw string) time.Duration {
raw = strings.TrimSpace(raw)
if raw == "" {
return defaultSSHIdleTimeout
}
d, err := time.ParseDuration(raw)
if err != nil {
log.Printf("invalid ssh_idle_timeout %q: %v; using default %s", raw, err, defaultSSHIdleTimeout)
return defaultSSHIdleTimeout
}
if d < 0 {
log.Printf("invalid negative ssh_idle_timeout %q; using default %s", raw, defaultSSHIdleTimeout)
return defaultSSHIdleTimeout
}
return d
}
func setSSHIdleTimeoutFromConfig(raw string) {
d := parseSSHIdleTimeout(raw)
sshIdleTimeoutMu.Lock()
currentSSHIdleTimeout = d
sshIdleTimeoutMu.Unlock()
}
func getSSHIdleTimeout() time.Duration {
sshIdleTimeoutMu.RLock()
d := currentSSHIdleTimeout
sshIdleTimeoutMu.RUnlock()
return d
}
// activityConn tracks real SSH transport activity in both directions. The idle
// monitor uses this instead of a read deadline so download-only or upload-only
// tunnels are considered live and are not disconnected.
type activityConn struct {
net.Conn
mu sync.Mutex
last time.Time
}
func newActivityConn(c net.Conn) *activityConn {
return &activityConn{Conn: c, last: time.Now()}
}
func (c *activityConn) touch() {
c.mu.Lock()
c.last = time.Now()
c.mu.Unlock()
}
func (c *activityConn) LastActivity() time.Time {
c.mu.Lock()
last := c.last
c.mu.Unlock()
return last
}
func (c *activityConn) Read(p []byte) (int, error) {
n, err := c.Conn.Read(p)
if n > 0 {
c.touch()
}
return n, err
}
func (c *activityConn) Write(p []byte) (int, error) {
n, err := c.Conn.Write(p)
if n > 0 {
c.touch()
}
return n, err
}
func monitorSSHIdle(c *activityConn, sshConn *ssh.ServerConn, username string, idleTimeout time.Duration, done <-chan struct{}) {
if idleTimeout <= 0 {
return
}
checkEvery := idleTimeout / 4
if checkEvery < 5*time.Second {
checkEvery = 5 * time.Second
}
if checkEvery > 30*time.Second {
checkEvery = 30 * time.Second
}
ticker := time.NewTicker(checkEvery)
defer ticker.Stop()
for {
select {
case <-done:
return
case <-ticker.C:
idleFor := time.Since(c.LastActivity())
if idleFor >= idleTimeout {
log.Printf("ssh idle timeout: user=%s remote=%s idle=%s limit=%s; closing stale connection",
username, sshConn.RemoteAddr(), idleFor.Round(time.Second), idleTimeout)
_ = sshConn.Close()
_ = c.Close()
return
}
}
}
}
// ---------- Server stats (CPU + network interfaces) ----------
// per-interface stats returned by /api/stats
type InterfaceStats struct {
Name string `json:"name"`
RxBytes uint64 `json:"rx_bytes"`
TxBytes uint64 `json:"tx_bytes"`
RxMbps float64 `json:"rx_mbps"`
TxMbps float64 `json:"tx_mbps"`
}
type StatsDTO struct {
CPUPercent float64 `json:"cpu_percent"`
MemTotal uint64 `json:"mem_total_bytes"`
MemUsed uint64 `json:"mem_used_bytes"`
MemAvail uint64 `json:"mem_avail_bytes"`
MemPercent float64 `json:"mem_percent"`
Interfaces []InterfaceStats `json:"interfaces"`
}
type ifaceCounters struct {
RxBytes uint64
TxBytes uint64
}
func isIgnoredInterface(iface string) bool {
return iface == "" || iface == "lo"
}
func getCurrentStats() StatsDTO {
statsMu.RLock()
defer statsMu.RUnlock()
return currentStats
}
func setCurrentStats(s StatsDTO) {
statsMu.Lock()
currentStats = s
statsMu.Unlock()
}
// primeCurrentStats fills RAM and interface totals immediately at startup so
// the dashboard does not show placeholder values while waiting for the first
// polling interval. CPU still becomes accurate after the second /proc/stat
// sample, but it is rendered as 0.0% instead of --.
func primeCurrentStats() {
netMap, _ := readNetDev()
interfaces := make([]InterfaceStats, 0, len(netMap))
for name, ctrs := range netMap {
if isIgnoredInterface(name) {
continue
}
st := InterfaceStats{Name: name}
if ifaceTotalsMgr != nil {
rxTotal, txTotal := ifaceTotalsMgr.ApplyKernel(name, ctrs.RxBytes, ctrs.TxBytes)
st.RxBytes = rxTotal
st.TxBytes = txTotal
} else {
st.RxBytes = ctrs.RxBytes
st.TxBytes = ctrs.TxBytes
}
interfaces = append(interfaces, st)
}
sort.Slice(interfaces, func(i, j int) bool { return interfaces[i].Name < interfaces[j].Name })
memTotal, memAvail, _ := readMemInfo()
var memUsed uint64
var memPercent float64
if memTotal > 0 {
if memAvail <= memTotal {
memUsed = memTotal - memAvail
memPercent = 100.0 * float64(memUsed) / float64(memTotal)
}
}
setCurrentStats(StatsDTO{
CPUPercent: 0,
MemTotal: memTotal,
MemUsed: memUsed,
MemAvail: memAvail,
MemPercent: memPercent,
Interfaces: interfaces,
})
}
type IfaceTotals struct {
Iface string
TotalRxBytes uint64
TotalTxBytes uint64
LastKernelRxBytes uint64
LastKernelTxBytes uint64
UpdatedAt time.Time
ResetAt time.Time
}
type IfaceTotalsManager struct {
mu sync.Mutex
m map[string]*IfaceTotals
}
func NewIfaceTotalsManager() *IfaceTotalsManager {
return &IfaceTotalsManager{m: make(map[string]*IfaceTotals)}
}
// ApplyKernel updates cumulative totals using kernel counters (from /proc/net/dev).
// It is resilient to kernel counter resets (e.g. host reboot): if the kernel counter
// goes backwards, it treats the new value as "delta since reset".
func (tm *IfaceTotalsManager) ApplyKernel(iface string, kRx, kTx uint64) (totalRx, totalTx uint64) {
if isIgnoredInterface(iface) {
return 0, 0
}
tm.mu.Lock()
defer tm.mu.Unlock()
now := time.Now()
st, ok := tm.m[iface]
if !ok {
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 {
st.TotalRxBytes = kRx
} else if kRx >= st.LastKernelRxBytes {
st.TotalRxBytes += kRx - st.LastKernelRxBytes
} else {
// kernel reset or wrap
st.TotalRxBytes += kRx
}
st.LastKernelRxBytes = kRx
// TX
if st.LastKernelTxBytes == 0 && st.TotalTxBytes == 0 {
st.TotalTxBytes = kTx
} else if kTx >= st.LastKernelTxBytes {
st.TotalTxBytes += kTx - st.LastKernelTxBytes
} else {
st.TotalTxBytes += kTx
}
st.LastKernelTxBytes = kTx
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 {
if isIgnoredInterface(iface) {
continue
}
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()
for _, r := range rows {
if isIgnoredInterface(r.Iface) {
continue
}
cp := r // copy
tm.m[r.Iface] = &cp
}
}
func (tm *IfaceTotalsManager) Snapshot() []IfaceTotals {
tm.mu.Lock()
defer tm.mu.Unlock()
out := make([]IfaceTotals, 0, len(tm.m))
for _, v := range tm.m {
if v == nil || isIgnoredInterface(v.Iface) {
continue
}
out = append(out, *v)
}
return out
}
// startStatsCollector periodically reads /proc/stat and /proc/net/dev
// to compute CPU usage and per-interface traffic in Mbps.
func startStatsCollector() {
go func() {
// Recover from any panic so the goroutine doesn't silently die and
// leave stats frozen. Log the panic and restart after a short delay.
defer func() {
if r := recover(); r != nil {
log.Printf("startStatsCollector: panic recovered: %v; restarting in 5s", r)
time.Sleep(5 * time.Second)
go startStatsCollector()
}
}()
var (
prevIdle, prevTotal uint64
prevNet map[string]ifaceCounters
prevTime time.Time
)
// Use a ticker for the poll interval so the goroutine can be
// cleanly stopped in tests and so the flush ticker is always
// paired with a matching Stop().
pollTicker := time.NewTicker(2 * time.Second)
defer pollTicker.Stop()
var flushTicker *time.Ticker
if statsStore != nil && ifaceTotalsMgr != nil {
flushTicker = time.NewTicker(30 * time.Second)
defer flushTicker.Stop()
}
for range pollTicker.C {
now := time.Now()
idle, total, err := readCPUStat()
if err != nil {
// keep previous CPU if error
}
netMap, err := readNetDev()
if err != nil {
// keep previous net if error
}
// CPU usage
var cpuPercent float64
if prevTotal != 0 && total > prevTotal {
idleDelta := float64(idle - prevIdle)
totalDelta := float64(total - prevTotal)
if totalDelta > 0 {
cpuPercent = 100.0 * (1.0 - idleDelta/totalDelta)
}
}
// Per-interface Mbps (Rx/Tx)
var interfaces []InterfaceStats
dt := now.Sub(prevTime).Seconds()
if netMap != nil {
for name, ctrs := range netMap {
if isIgnoredInterface(name) {
continue
}
st := InterfaceStats{
Name: name,
}
// Bytes: if persistence enabled, show cumulative totals across restarts; else show kernel counters.
if ifaceTotalsMgr != nil {
rxTotal, txTotal := ifaceTotalsMgr.ApplyKernel(name, ctrs.RxBytes, ctrs.TxBytes)
st.RxBytes = rxTotal
st.TxBytes = txTotal
} else {
st.RxBytes = ctrs.RxBytes
st.TxBytes = ctrs.TxBytes
}
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
} else {
// kernel counter reset or wrap
rxDelta = ctrs.RxBytes
}
if 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)
}
}
sort.Slice(interfaces, func(i, j int) bool {
return interfaces[i].Name < interfaces[j].Name
})
// RAM usage (/proc/meminfo)
memTotal, memAvail, _ := readMemInfo()
var memUsed uint64
var memPercent float64
if memTotal > 0 {
if memAvail <= memTotal {
memUsed = memTotal - memAvail
memPercent = 100.0 * float64(memUsed) / float64(memTotal)
} else {
memAvail = memTotal
}
}
setCurrentStats(StatsDTO{
CPUPercent: cpuPercent,
MemTotal: memTotal,
MemUsed: memUsed,
MemAvail: memAvail,
MemPercent: memPercent,
Interfaces: interfaces,
})
// 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:
}
}
prevIdle, prevTotal = idle, total
prevNet = netMap
prevTime = now
}
}()
}
func readMemInfo() (totalBytes, availBytes uint64, err error) {
f, err := os.Open("/proc/meminfo")
if err != nil {
return 0, 0, err
}
defer f.Close()
var (
memTotalKB uint64
memAvailKB uint64
memFreeKB uint64
buffersKB uint64
cachedKB uint64
)
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := scanner.Text()
fields := strings.Fields(line)
if len(fields) < 2 {
continue
}
key := strings.TrimSuffix(fields[0], ":")
val, perr := strconv.ParseUint(fields[1], 10, 64)
if perr != nil {
continue
}
switch key {
case "MemTotal":
memTotalKB = val
case "MemAvailable":
memAvailKB = val
case "MemFree":
memFreeKB = val
case "Buffers":
buffersKB = val
case "Cached":
cachedKB = val
}
}
if err := scanner.Err(); err != nil {
return 0, 0, err
}
if memTotalKB == 0 {
return 0, 0, nil
}
if memAvailKB == 0 {
memAvailKB = memFreeKB + buffersKB + cachedKB
if memAvailKB > memTotalKB {
memAvailKB = memTotalKB
}
}
return memTotalKB * 1024, memAvailKB * 1024, nil
}
func readCPUStat() (idle, total uint64, err error) {
f, err := os.Open("/proc/stat")
if err != nil {
return 0, 0, err
}
defer f.Close()
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := scanner.Text()
if strings.HasPrefix(line, "cpu ") {
fields := strings.Fields(line)
if len(fields) < 5 {
break
}
for i := 1; i < len(fields); i++ {
v, err2 := strconv.ParseUint(fields[i], 10, 64)
if err2 != nil {
continue
}
total += v
// idle + iowait
if i == 4 || i == 5 {
idle += v
}
}
break
}
}
if err := scanner.Err(); err != nil {
return 0, 0, err
}
return idle, total, nil
}
func readNetDev() (map[string]ifaceCounters, error) {
f, err := os.Open("/proc/net/dev")
if err != nil {
return nil, err
}
defer f.Close()
stats := make(map[string]ifaceCounters)
scanner := bufio.NewScanner(f)
lineNum := 0
for scanner.Scan() {
lineNum++
if lineNum <= 2 {
// headers
continue
}
line := strings.TrimSpace(scanner.Text())
if line == "" {
continue
}
parts := strings.SplitN(line, ":", 2)
if len(parts) != 2 {
continue
}
iface := strings.TrimSpace(parts[0])
if isIgnoredInterface(iface) {
continue
}
fields := strings.Fields(parts[1])
if len(fields) < 9 {
continue
}
rx, err1 := strconv.ParseUint(fields[0], 10, 64)
tx, err2 := strconv.ParseUint(fields[8], 10, 64)
if err1 != nil || err2 != nil {
continue
}
stats[iface] = ifaceCounters{
RxBytes: rx,
TxBytes: tx,
}
}
if err := scanner.Err(); err != nil {
return nil, err
}
return stats, nil
}
// ---------- Config loading ----------
func loadConfig(path string) (*Config, map[string]*UserState, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, nil, fmt.Errorf("read config: %w", err)
}
var cfg Config
if err := json.Unmarshal(data, &cfg); err != nil {
return nil, nil, fmt.Errorf("parse config: %w", err)
}
if cfg.Listen == "" {
cfg.Listen = ":2222"
}
if cfg.HostKeyFile == "" {
cfg.HostKeyFile = "ssh_host_rsa_key"
}
userMap := make(map[string]*UserState)
for _, u := range cfg.Users {
if u.Username == "" {
return nil, nil, fmt.Errorf("config: user with empty username")
}
if _, exists := userMap[u.Username]; exists {
return nil, nil, fmt.Errorf("config: duplicate user %q", u.Username)
}
// Apply default bandwidth limits if not set per-user.
if u.LimitMbpsUp == 0 {
u.LimitMbpsUp = cfg.DefaultLimitMbpsUp
}
if u.LimitMbpsDown == 0 {
u.LimitMbpsDown = cfg.DefaultLimitMbpsDown
}
st := &UserState{Cfg: u}
if u.ExpiresAt != "" {
t, err := time.Parse(time.RFC3339, u.ExpiresAt)
if err != nil {
return nil, nil, fmt.Errorf("config: invalid expires_at for user %q: %w", u.Username, err)
}
st.ExpiresAt = &t
}
// Load per-user public key if configured
if u.PublicKeyFile != "" {
pkBytes, err := os.ReadFile(u.PublicKeyFile)
if err != nil {
return nil, nil, fmt.Errorf("config: could not read public_key_file for user %q: %w", u.Username, err)
}
pubKey, _, _, _, err := ssh.ParseAuthorizedKey(pkBytes)
if err != nil {
return nil, nil, fmt.Errorf("config: invalid public key in %s for user %q: %w",
u.PublicKeyFile, u.Username, err)
}
st.PubKey = pubKey
}
userMap[u.Username] = st
}
return &cfg, userMap, nil
}
// ---------- Database store & admin API ----------
type Store struct {
db *sql.DB
}
func NewStore(dsn string) (*Store, error) {
db, err := sql.Open("postgres", dsn)
if err != nil {
return nil, err
}
db.SetMaxOpenConns(5)
db.SetMaxIdleConns(5)
db.SetConnMaxLifetime(time.Hour)
if err := db.Ping(); err != nil {
return nil, err
}
store := &Store{db: db}
ctx := context.Background()
if err := store.EnsureUsersSchema(ctx); err != nil {
return nil, err
}
if err := store.EnsureAdminUsersSchema(ctx); err != nil {
return nil, err
}
if err := store.EnsureManagedServersSchema(ctx); err != nil {
return nil, err
}
return store, nil
}
func (s *Store) EnsureUsersSchema(ctx context.Context) error {
stmts := []string{
`CREATE TABLE IF NOT EXISTS ssh_users (
username TEXT PRIMARY KEY,
password TEXT NOT NULL DEFAULT '',
max_connections INT NOT NULL DEFAULT 0,
expires_at TEXT,
limit_mbps_up INT NOT NULL DEFAULT 0,
limit_mbps_down INT NOT NULL DEFAULT 0,
totp_secret TEXT NOT NULL DEFAULT '',
totp_period INT NOT NULL DEFAULT 60,
totp_window INT NOT NULL DEFAULT 1,
totp_digits INT NOT NULL DEFAULT 6,
allow_static_password BOOLEAN NOT NULL DEFAULT FALSE
)`,
`ALTER TABLE ssh_users ADD COLUMN IF NOT EXISTS totp_secret TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE ssh_users ADD COLUMN IF NOT EXISTS totp_period INT NOT NULL DEFAULT 60`,
`ALTER TABLE ssh_users ADD COLUMN IF NOT EXISTS totp_window INT NOT NULL DEFAULT 1`,
`ALTER TABLE ssh_users ADD COLUMN IF NOT EXISTS totp_digits INT NOT NULL DEFAULT 6`,
`ALTER TABLE ssh_users ADD COLUMN IF NOT EXISTS allow_static_password BOOLEAN NOT NULL DEFAULT FALSE`,
`ALTER TABLE ssh_users ALTER COLUMN password SET DEFAULT ''`,
}
for _, stmt := range stmts {
if _, err := s.db.ExecContext(ctx, stmt); err != nil {
return err
}
}
return nil
}
// LoadUsers loads all users from the ssh_users table into in-memory UserState map.
func (s *Store) LoadUsers(ctx context.Context) (map[string]*UserState, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT username, password, max_connections, expires_at, limit_mbps_up, limit_mbps_down,
COALESCE(totp_secret, ''), COALESCE(totp_period, 60), COALESCE(totp_window, 1),
COALESCE(totp_digits, 6), COALESCE(allow_static_password, FALSE),
COALESCE(owner_username, '')
FROM ssh_users`)
if err != nil {
return nil, err
}
defer rows.Close()
users := make(map[string]*UserState)
for rows.Next() {
var (
username string
password string
maxConnections int
expiresAt sql.NullString
limitUp int
limitDown int
totpSecret string
totpPeriod int
totpWindow int
totpDigits int
allowStaticPassword bool
ownerUsername string
)
if err := rows.Scan(&username, &password, &maxConnections, &expiresAt, &limitUp, &limitDown,
&totpSecret, &totpPeriod, &totpWindow, &totpDigits, &allowStaticPassword, &ownerUsername); err != nil {
return nil, err
}
cfg := UserConfig{
Username: username,
Password: password,
MaxConnections: maxConnections,
LimitMbpsUp: limitUp,
LimitMbpsDown: limitDown,
TOTPSecret: totpSecret,
TOTPPeriod: totpPeriod,
TOTPWindow: totpWindow,
TOTPDigits: totpDigits,
AllowStaticPassword: allowStaticPassword,
OwnerUsername: ownerUsername,
}
st := &UserState{Cfg: cfg}
if expiresAt.Valid && expiresAt.String != "" {
t, err := time.Parse(time.RFC3339, expiresAt.String)
if err != nil {
log.Printf("invalid expires_at for user %s in db: %v", username, err)
} else {
st.ExpiresAt = &t
st.Cfg.ExpiresAt = expiresAt.String
}
}
users[username] = st
}
if err := rows.Err(); err != nil {
return nil, err
}
return users, nil
}
// UpsertUser creates or updates a row in ssh_users.
func (s *Store) UpsertUser(ctx context.Context, u UserConfig) error {
_, err := s.db.ExecContext(ctx, `
INSERT INTO ssh_users (
username, password, max_connections, expires_at, limit_mbps_up, limit_mbps_down,
totp_secret, totp_period, totp_window, totp_digits, allow_static_password, owner_username
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
ON CONFLICT (username) DO UPDATE
SET password = EXCLUDED.password,
max_connections = EXCLUDED.max_connections,
expires_at = EXCLUDED.expires_at,
limit_mbps_up = EXCLUDED.limit_mbps_up,
limit_mbps_down = EXCLUDED.limit_mbps_down,
totp_secret = EXCLUDED.totp_secret,
totp_period = EXCLUDED.totp_period,
totp_window = EXCLUDED.totp_window,
totp_digits = EXCLUDED.totp_digits,
allow_static_password = EXCLUDED.allow_static_password`,
// owner_username is intentionally excluded from UPDATE — ownership is set at creation only.
u.Username, u.Password, u.MaxConnections, u.ExpiresAt, u.LimitMbpsUp, u.LimitMbpsDown,
u.TOTPSecret, u.TOTPPeriod, u.TOTPWindow, u.TOTPDigits, u.AllowStaticPassword, u.OwnerUsername)
return err
}
func (s *Store) DeleteUser(ctx context.Context, username string) error {
_, err := s.db.ExecContext(ctx, `DELETE FROM ssh_users WHERE username = $1`, username)
return err
}
// ---------- Optional persistence for interface totals ----------
func (s *Store) EnsureIfaceTotalsTable(ctx context.Context) error {
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(),
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, reset_at
FROM ssh_iface_totals
WHERE iface <> 'lo'`)
if err != nil {
return nil, err
}
defer rows.Close()
out := []IfaceTotals{}
for rows.Next() {
var r IfaceTotals
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 {
return nil, err
}
return out, nil
}
func (s *Store) UpsertIfaceTotals(ctx context.Context, rows []IfaceTotals) error {
if len(rows) == 0 {
return nil
}
// Simple loop (small N: number of interfaces). Keeps CPU/DB overhead minimal.
for _, r := range rows {
if isIgnoredInterface(r.Iface) {
continue
}
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, 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(),
reset_at = EXCLUDED.reset_at`,
r.Iface, r.TotalRxBytes, r.TotalTxBytes, r.LastKernelRxBytes, r.LastKernelTxBytes, resetAt)
if err != nil {
return err
}
}
return nil
}
func reloadUsersFromDB(ctx context.Context, store *Store) {
if store == nil {
return
}
users, err := store.LoadUsers(ctx)
if err != nil {
log.Printf("failed to reload users from db: %v", err)
return
}
userMgr.ReplaceAllPreserveRuntime(users)
updateUserDisplay()
}
func startAdminAPI(store *Store, addr string, adminDir string) {
mux := http.NewServeMux()
// Auth (no session required)
mux.Handle("/api/auth/login", http.HandlerFunc(handleLogin(store)))
// Auth (session required)
mux.Handle("/api/auth/logout", sessionMiddleware(http.HandlerFunc(handleLogout)))
mux.Handle("/api/auth/me", sessionMiddleware(http.HandlerFunc(handleMe)))
// SSH user management (session required; role-filtered inside handlers)
mux.Handle("/api/users", sessionMiddleware(http.HandlerFunc(handleListUsers)))
mux.Handle("/api/users/create", sessionMiddleware(http.HandlerFunc(handleCreateUser(store))))
mux.Handle("/api/users/delete", sessionMiddleware(http.HandlerFunc(handleDeleteUser(store))))
// Server stats: visible to authenticated sessions; reset remains superadmin-only.
mux.Handle("/api/stats", sessionMiddleware(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)))
// Superadmin-only: reseller management
mux.Handle("/api/resellers", saSession(http.HandlerFunc(handleListResellers(store))))
mux.Handle("/api/resellers/create", saSession(http.HandlerFunc(handleCreateReseller(store))))
mux.Handle("/api/resellers/delete", saSession(http.HandlerFunc(handleDeleteReseller(store))))
// Master/slave server management. Superadmins can add slave nodes; all authenticated
// users can read the enabled server list to pick where accounts are created.
mux.Handle("/api/servers", sessionMiddleware(http.HandlerFunc(handleServers(store))))
mux.Handle("/api/servers/test", saSession(http.HandlerFunc(handleServerTest(store))))
mux.Handle("/api/servers/config", saSession(http.HandlerFunc(handleManagedServerConfig(store))))
// Xray-core management. Service/config/log actions are superadmin-only;
// authenticated resellers may list inbounds and manage their own Xray clients.
mux.Handle("/api/xray/status", sessionMiddleware(http.HandlerFunc(handleXrayStatus)))
mux.Handle("/api/xray/start", saSession(http.HandlerFunc(handleXrayStart)))
mux.Handle("/api/xray/stop", saSession(http.HandlerFunc(handleXrayStop)))
mux.Handle("/api/xray/restart", saSession(http.HandlerFunc(handleXrayRestart)))
mux.Handle("/api/xray/stats/repair", saSession(http.HandlerFunc(handleXrayRepairStats)))
mux.Handle("/api/xray/config", saSession(http.HandlerFunc(handleXrayConfig)))
mux.Handle("/api/xray/logs", saSession(http.HandlerFunc(handleXrayLogs)))
mux.Handle("/api/xray/inbounds", sessionMiddleware(http.HandlerFunc(handleXrayInbounds)))
mux.Handle("/api/xray/clients/add", sessionMiddleware(http.HandlerFunc(handleXrayClientAdd)))
mux.Handle("/api/xray/clients/update", sessionMiddleware(http.HandlerFunc(handleXrayClientUpdate)))
mux.Handle("/api/xray/clients/remove", sessionMiddleware(http.HandlerFunc(handleXrayClientRemove)))
// Superadmin-only: TLS certificate generation
mux.Handle("/api/tls/generate-selfsigned", saSession(handleManagedProxyOrLocal(store, handleTLSGenerateSelfSigned)))
mux.Handle("/api/tls/letsencrypt", saSession(handleManagedProxyOrLocal(store, handleTLSLetsEncrypt)))
mux.Handle("/api/tls/upload-pem", saSession(handleManagedProxyOrLocal(store, handleTLSUploadPEM)))
// Superadmin-only: DNSTT key management
mux.Handle("/api/dnstt/genkey", saSession(handleManagedProxyOrLocal(store, handleDnsttGenKey)))
mux.Handle("/api/dnstt/pubkey", saSession(handleManagedProxyOrLocal(store, handleDnsttGetPubKey)))
// Superadmin-only: server config (read/write config.json + live banner apply)
mux.Handle("/api/server/config", saSession(http.HandlerFunc(handleServerConfig)))
// Public: user/UUID check — no auth, CORS *.
mux.Handle("/check", http.HandlerFunc(handleCheck))
// Static panel — delegates to a global handler so admin_dir can be hot-swapped.
setAdminHandler(http.FileServer(http.Dir(adminDir)))
mux.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
getAdminHandler().ServeHTTP(w, r)
}))
go func() {
log.Printf("Admin HTTP (panel + API) listening on %s", addr)
if err := http.ListenAndServe(addr, mux); err != nil {
log.Printf("admin http error: %v", err)
}
}()
}
// UserDTO is returned by the admin API for listing.
type UserDTO struct {
Username string `json:"username"`
ActiveConns int `json:"active_conns"`
MaxConnections int `json:"max_connections"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
LimitUpMbps int `json:"limit_mbps_up"`
LimitDownMbps int `json:"limit_mbps_down"`
TOTPSecret string `json:"totp_secret,omitempty"`
TOTPPeriod int `json:"totp_period"`
TOTPWindow int `json:"totp_window"`
TOTPDigits int `json:"totp_digits"`
AllowStaticPassword bool `json:"allow_static_password"`
TOTPEnabled bool `json:"totp_enabled"`
OwnerUsername string `json:"owner_username,omitempty"`
ServerID string `json:"server_id,omitempty"`
}
func handleListUsers(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
sess := sessionFromCtx(r.Context())
filterOwner := ""
if sess != nil && sess.Role == RoleReseller {
filterOwner = sess.Username
}
if proxyManagedServerFromRequest(w, r, statsStore, "/api/users", nil, filterOwner) {
return
}
states := userMgr.List()
out := make([]UserDTO, 0, len(states))
for _, u := range states {
u.mu.Lock()
c := len(u.conns)
u.ActiveConns = c
cfg := u.Cfg
expires := u.ExpiresAt
u.mu.Unlock()
// Resellers only see their own users
if sess != nil && sess.Role == RoleReseller && cfg.OwnerUsername != sess.Username {
continue
}
out = append(out, UserDTO{
Username: cfg.Username,
ActiveConns: c,
MaxConnections: cfg.MaxConnections,
ExpiresAt: expires,
LimitUpMbps: cfg.LimitMbpsUp,
LimitDownMbps: cfg.LimitMbpsDown,
TOTPSecret: cfg.TOTPSecret,
TOTPPeriod: cfg.TOTPPeriod,
TOTPWindow: cfg.TOTPWindow,
TOTPDigits: cfg.TOTPDigits,
AllowStaticPassword: cfg.AllowStaticPassword,
TOTPEnabled: strings.TrimSpace(cfg.TOTPSecret) != "",
OwnerUsername: cfg.OwnerUsername,
})
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(out)
}
// NOTE: Password is pointer to distinguish "field missing" vs "field present".
type UserPayload struct {
Username string `json:"username"`
Password *string `json:"password,omitempty"` // nil or empty = keep existing if user already exists
MaxConnections int `json:"max_connections"`
ExpiresAt string `json:"expires_at"`
LimitUpMbps int `json:"limit_mbps_up"`
LimitDownMbps int `json:"limit_mbps_down"`
TOTPSecret string `json:"totp_secret"`
TOTPPeriod int `json:"totp_period"`
TOTPWindow int `json:"totp_window"`
TOTPDigits int `json:"totp_digits"`
AllowStaticPassword bool `json:"allow_static_password"`
OwnerUsername string `json:"owner_username,omitempty"`
ServerID string `json:"server_id,omitempty"`
}
func handleCreateUser(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
}
var p UserPayload
if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
http.Error(w, "invalid json", http.StatusBadRequest)
return
}
if p.Username == "" {
http.Error(w, "username required", http.StatusBadRequest)
return
}
ctx := r.Context()
if ms, remote, err := managedServerFromID(ctx, store, p.ServerID); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
} else if remote {
if !ms.EnableSSH {
http.Error(w, "SSH creation is disabled for this server", http.StatusForbidden)
return
}
if sess := sessionFromCtx(ctx); sess != nil && sess.Role == RoleReseller {
p.OwnerUsername = sess.Username
}
p.ServerID = ""
body, _ := json.Marshal(p)
status, data, ct, err := proxyManagedServer(ctx, ms, http.MethodPost, "/api/users/create", body, "application/json")
if err != nil {
http.Error(w, "remote server error: "+err.Error(), http.StatusBadGateway)
return
}
writeProxyResponse(w, status, data, ct)
return
}
// Decide what password to use:
// - if payload has non-empty password -> use it
// - else try to read existing password from DB
// - if user exists -> keep existing password
// - if not exists and TOTP is enabled -> allow blank password
// - otherwise reject (password required for new user)
var password string
if p.Password != nil && *p.Password != "" {
password = *p.Password
} else {
var existing string
err := store.db.QueryRowContext(ctx,
`SELECT password FROM ssh_users WHERE username = $1`,
p.Username,
).Scan(&existing)
if err == sql.ErrNoRows {
if strings.TrimSpace(p.TOTPSecret) == "" {
http.Error(w, "password or totp_secret required for new user", http.StatusBadRequest)
return
}
password = ""
} else if err != nil {
log.Printf("failed to load existing password for %s: %v", p.Username, err)
http.Error(w, "db error", http.StatusInternalServerError)
return
} else {
password = existing
}
}
// Determine owner and enforce reseller quota
sess := sessionFromCtx(ctx)
ownerUsername := ""
if sess != nil && sess.Role == RoleReseller {
ownerUsername = sess.Username
// Enforce user limit — only count on new user creation
var existsInDB bool
_ = store.db.QueryRowContext(ctx,
`SELECT TRUE FROM ssh_users WHERE username=$1`, p.Username,
).Scan(&existsInDB)
if !existsInDB {
owner, ok := adminUsers.get(sess.Username)
if ok && owner.MaxUsers > 0 && countOwnedQuota(ctx, store, sess.Username) >= owner.MaxUsers {
http.Error(w, fmt.Sprintf("user limit reached (%d)", owner.MaxUsers), http.StatusForbidden)
return
}
}
} else if sess != nil && sess.Role == RoleSuperAdmin && strings.TrimSpace(p.OwnerUsername) != "" {
ownerUsername = strings.TrimSpace(p.OwnerUsername)
}
cfg := UserConfig{
Username: p.Username,
Password: password,
MaxConnections: p.MaxConnections,
ExpiresAt: p.ExpiresAt,
LimitMbpsUp: p.LimitUpMbps,
LimitMbpsDown: p.LimitDownMbps,
TOTPSecret: strings.TrimSpace(p.TOTPSecret),
TOTPPeriod: p.TOTPPeriod,
TOTPWindow: p.TOTPWindow,
TOTPDigits: p.TOTPDigits,
AllowStaticPassword: p.AllowStaticPassword,
OwnerUsername: ownerUsername,
}
if err := store.UpsertUser(ctx, cfg); err != nil {
log.Printf("failed to upsert user: %v", err)
http.Error(w, "db error", http.StatusInternalServerError)
return
}
// Force-disconnect all active sessions for this user so new config applies.
userMgr.DisconnectUser(p.Username)
reloadUsersFromDB(ctx, store)
w.WriteHeader(http.StatusCreated)
}
}
func handleDeleteUser(store *Store) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
if store == nil {
http.Error(w, "database not configured", http.StatusServiceUnavailable)
return
}
username := r.URL.Query().Get("username")
if username == "" {
http.Error(w, "username required", http.StatusBadRequest)
return
}
ctx := r.Context()
if ms, remote, err := managedServerFromID(ctx, store, requestedServerID(r)); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
} else if remote {
if sess := sessionFromCtx(ctx); sess != nil && sess.Role == RoleReseller && !remoteSSHUserOwned(ctx, ms, username, sess.Username) {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
remotePath := "/api/users/delete?username=" + url.QueryEscape(username)
status, data, ct, err := proxyManagedServer(ctx, ms, http.MethodDelete, remotePath, nil, "application/json")
if err != nil {
http.Error(w, "remote server error: "+err.Error(), http.StatusBadGateway)
return
}
writeProxyResponse(w, status, data, ct)
return
}
// Resellers may only delete their own users
sess := sessionFromCtx(ctx)
if sess != nil && sess.Role == RoleReseller {
u, ok := userMgr.Get(username)
if !ok || u.Cfg.OwnerUsername != sess.Username {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
}
if err := store.DeleteUser(ctx, username); err != nil {
log.Printf("failed to delete user: %v", err)
http.Error(w, "db error", http.StatusInternalServerError)
return
}
// Kick any live sessions for that user
userMgr.DisconnectUser(username)
reloadUsersFromDB(ctx, store)
w.WriteHeader(http.StatusNoContent)
}
}
// ---------- Stats API handler ----------
func handleStats(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
if proxyManagedServerFromRequest(w, r, statsStore, "/api/stats", nil, "") {
return
}
stats := getCurrentStats()
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(stats)
}
// handleDnsttStats returns the most recent DNSTT statistics snapshot. These
// counters represent activity in the last 5second window. The handler
// requires a GET request and is protected by the same bearer token as
// other admin API endpoints.
func handleDnsttStats(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
// Obtain a copy of the current snapshot.
stats := GetDNSTTStatsSnapshot()
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(stats)
}
// handleDnsttLogs returns the recent DNSTT log lines as a JSON array of strings.
// If DNSTT logging has not been initialised, it returns an empty array. The
// handler requires a GET request and uses the same bearer token as other
// admin endpoints.
func handleDnsttLogs(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
lines := getDNSTTLogLines()
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(lines)
}
// ---------- Auth helpers ----------
func normalizeBase32Secret(secret string) string {
secret = strings.ToUpper(strings.TrimSpace(secret))
secret = strings.ReplaceAll(secret, " ", "")
return secret
}
func totpDigitsOrDefault(n int) int {
if n < 6 || n > 8 {
return 6
}
return n
}
func totpPeriodOrDefault(n int) int64 {
if n <= 0 {
return 60
}
return int64(n)
}
func totpWindowOrDefault(n int) int {
if n < 0 {
return 0
}
if n == 0 {
return 1
}
return n
}
func pow10(n int) uint32 {
v := uint32(1)
for i := 0; i < n; i++ {
v *= 10
}
return v
}
func generateTOTPCode(secret string, ts time.Time, period int64, digits int) (string, error) {
secret = normalizeBase32Secret(secret)
key, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(secret)
if err != nil {
return "", fmt.Errorf("decode TOTP secret: %w", err)
}
counter := uint64(ts.Unix() / period)
var buf [8]byte
binary.BigEndian.PutUint64(buf[:], counter)
h := hmac.New(sha1.New, key)
_, _ = h.Write(buf[:])
sum := h.Sum(nil)
offset := sum[len(sum)-1] & 0x0f
code := (uint32(sum[offset])&0x7f)<<24 |
uint32(sum[offset+1])<<16 |
uint32(sum[offset+2])<<8 |
uint32(sum[offset+3])
mod := pow10(digits)
return fmt.Sprintf("%0*d", digits, code%mod), nil
}
func matchTOTPPassword(u *UserState, supplied string, now time.Time) bool {
secret := strings.TrimSpace(u.Cfg.TOTPSecret)
if secret == "" {
return false
}
period := totpPeriodOrDefault(u.Cfg.TOTPPeriod)
digits := totpDigitsOrDefault(u.Cfg.TOTPDigits)
window := totpWindowOrDefault(u.Cfg.TOTPWindow)
for i := -window; i <= window; i++ {
stepTime := now.Add(time.Duration(i) * time.Duration(period) * time.Second)
code, err := generateTOTPCode(secret, stepTime, period, digits)
if err != nil {
log.Printf("user %s has invalid TOTP configuration: %v", u.Cfg.Username, err)
return false
}
if supplied == code {
return true
}
}
return false
}
// ---------- Auth callbacks ----------
func passwordCallback(meta ssh.ConnMetadata, pass []byte) (*ssh.Permissions, error) {
u, ok := userMgr.Get(meta.User())
if !ok {
return nil, fmt.Errorf("authentication failed")
}
now := time.Now()
if u.ExpiresAt != nil && now.After(*u.ExpiresAt) {
log.Printf("user %s tried to connect but account is expired", meta.User())
return nil, fmt.Errorf("account expired")
}
if err := ownerIsActive(u.Cfg.OwnerUsername); err != nil {
return nil, fmt.Errorf("authentication failed: %w", err)
}
supplied := string(pass)
if strings.TrimSpace(u.Cfg.TOTPSecret) != "" {
if matchTOTPPassword(u, supplied, now) {
return nil, nil
}
if u.Cfg.AllowStaticPassword && u.Cfg.Password == supplied {
return nil, nil
}
return nil, fmt.Errorf("authentication failed")
}
if u.Cfg.Password != supplied {
return nil, fmt.Errorf("authentication failed")
}
return nil, nil
}
func publicKeyCallback(meta ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) {
u, ok := userMgr.Get(meta.User())
if !ok {
return nil, fmt.Errorf("authentication failed")
}
if u.ExpiresAt != nil && time.Now().After(*u.ExpiresAt) {
log.Printf("user %s tried to connect but account is expired", meta.User())
return nil, fmt.Errorf("account expired")
}
if err := ownerIsActive(u.Cfg.OwnerUsername); err != nil {
return nil, fmt.Errorf("authentication failed: %w", err)
}
if u.PubKey == nil {
return nil, fmt.Errorf("no public key configured")
}
if !publicKeysEqual(u.PubKey, key) {
return nil, fmt.Errorf("authentication failed")
}
return nil, nil
}
// ---------- Connection handling ----------
func handleConn(tcpConn net.Conn, config *ssh.ServerConfig) {
trackedConn := newActivityConn(tcpConn)
defer trackedConn.Close()
// Prevent goroutine leaks from clients that connect but never complete the SSH handshake.
_ = trackedConn.SetReadDeadline(time.Now().Add(sshHandshakeTimeout))
sshConn, chans, reqs, err := ssh.NewServerConn(trackedConn, config)
if err != nil {
log.Println("ssh handshake failed:", err)
return
}
// Clear deadlines after a successful handshake. Runtime cleanup is handled
// by monitorSSHIdle, which checks traffic in both directions.
_ = trackedConn.SetDeadline(time.Time{})
username := sshConn.User()
log.Printf("new SSH connection from %s as %s", sshConn.RemoteAddr(), username)
u, ok := userMgr.Get(username)
if !ok {
log.Printf("user %s not found in config (should not happen if auth is correct)", username)
sshConn.Close()
return
}
// Track active connection and enforce max_connections. The connection map is
// treated as the source of truth so stale counters can self-heal.
u.mu.Lock()
if u.conns == nil {
u.conns = make(map[*ssh.ServerConn]struct{})
}
activeConns := len(u.conns)
u.ActiveConns = activeConns
if u.Cfg.MaxConnections > 0 && activeConns >= u.Cfg.MaxConnections {
u.mu.Unlock()
log.Printf("user %s exceeded max_connections (%d)", username, u.Cfg.MaxConnections)
sshConn.Close()
return
}
u.conns[sshConn] = struct{}{}
u.ActiveConns = len(u.conns)
u.mu.Unlock()
// Use per-user limit if set; otherwise fall back to the global config default.
limitUp, limitDown := u.Cfg.LimitMbpsUp, u.Cfg.LimitMbpsDown
if limitUp == 0 || limitDown == 0 {
defUp, defDown := getDefaultLimits()
if limitUp == 0 {
limitUp = defUp
}
if limitDown == 0 {
limitDown = defDown
}
}
var upLimiter, downLimiter *rate.Limiter
if limitUp > 0 {
bps := mbpsToBytesPerSec(limitUp)
upLimiter = rate.NewLimiter(rate.Limit(bps), int(bps))
}
if limitDown > 0 {
bps := mbpsToBytesPerSec(limitDown)
downLimiter = rate.NewLimiter(rate.Limit(bps), int(bps))
}
updateUserDisplay()
idleDone := make(chan struct{})
if idleTimeout := getSSHIdleTimeout(); idleTimeout > 0 {
go monitorSSHIdle(trackedConn, sshConn, username, idleTimeout, idleDone)
}
defer func() {
close(idleDone)
u.mu.Lock()
delete(u.conns, sshConn)
u.ActiveConns = len(u.conns)
u.mu.Unlock()
updateUserDisplay()
sshConn.Close()
}()
go ssh.DiscardRequests(reqs)
for newChan := range chans {
switch newChan.ChannelType() {
case "direct-tcpip":
go handleDirectTCPIP(newChan, u, upLimiter, downLimiter)
case "session":
go handleDummySession(newChan)
default:
newChan.Reject(ssh.UnknownChannelType, "unsupported channel type")
}
}
}
type directTCPIPReq struct {
Host string
Port uint32
OriginAddr string
OriginPort uint32
}
func handleDirectTCPIP(newChan ssh.NewChannel, u *UserState, upLimiter, downLimiter *rate.Limiter) {
var req directTCPIPReq
if err := ssh.Unmarshal(newChan.ExtraData(), &req); err != nil {
newChan.Reject(ssh.Prohibited, "bad direct-tcpip request")
return
}
target := fmt.Sprintf("%s:%d", req.Host, req.Port)
log.Printf("direct-tcpip: user=%s connecting to %s from %s:%d",
u.Cfg.Username, target, req.OriginAddr, req.OriginPort)
dialer := &net.Dialer{Timeout: directTCPIPDialTimeout, KeepAlive: 30 * time.Second}
backend, err := dialer.Dial("tcp", target)
if err != nil {
log.Printf("failed to connect to %s: %v", target, err)
newChan.Reject(ssh.ConnectionFailed, err.Error())
return
}
ch, reqs, err := newChan.Accept()
if err != nil {
_ = backend.Close()
return
}
go func() {
defer ch.Close()
for req := range reqs {
if req.WantReply {
req.Reply(false, nil)
}
}
}()
// Use a WaitGroup + shared closer so that when either copy direction
// finishes (including a half-close that never completes), both sides
// are force-closed and the other goroutine is unblocked. Without
// this, a backend that issues CloseWrite but never closes the read
// side would leave the downstream goroutine blocked indefinitely,
// leaking a goroutine, a rate-limiter, and the SSH channel.
var wg sync.WaitGroup
closeAll := func() {
_ = backend.Close()
_ = ch.Close()
}
// upstream: SSH channel -> backend
wg.Add(1)
go func() {
defer wg.Done()
_, _ = copyWithRateLimit(backend, ch, upLimiter)
// Signal to the backend that we are done writing.
if cw, ok := backend.(interface{ CloseWrite() error }); ok {
_ = cw.CloseWrite()
}
closeAll()
}()
// downstream: backend -> SSH channel
wg.Add(1)
go func() {
defer wg.Done()
_, _ = copyWithRateLimit(ch, backend, downLimiter)
closeAll()
}()
// Wait for both goroutines to finish, then ensure everything is closed.
go func() {
wg.Wait()
closeAll()
}()
}
func handleDummySession(newChan ssh.NewChannel) {
ch, reqs, err := newChan.Accept()
if err != nil {
return
}
go func() {
defer ch.Close()
for req := range reqs {
if req.WantReply {
req.Reply(false, nil)
}
}
}()
}
// ---------- UI / display ----------
func updateUserDisplay() {
if !userCountEnabled {
return
}
userStates := userMgr.List()
parts := make([]string, 0, len(userStates))
for _, u := range userStates {
u.mu.Lock()
c := len(u.conns)
u.ActiveConns = c
name := u.Cfg.Username
u.mu.Unlock()
parts = append(parts, fmt.Sprintf("%s: %d", name, c))
}
sort.Strings(parts)
line := strings.Join(parts, " ")
if line == "" {
line = "no users"
}
displayMu.Lock()
defer displayMu.Unlock()
if len(line) < lastDisplayLen {
line = line + strings.Repeat(" ", lastDisplayLen-len(line))
}
lastDisplayLen = len(line)
fmt.Printf("\r%s", line)
}
// ---------- Main ----------
// ========================
// Port-80 HTTP-clean + SSH
// ========================
// bufferedConn is a net.Conn wrapper whose Read() is served by a bufio.Reader.
// This preserves any bytes already buffered/peeked during HTTP cleanup.
type bufferedConn struct {
net.Conn
r *bufio.Reader
}
func (c *bufferedConn) Read(p []byte) (int, error) { return c.r.Read(p) }
// handleHTTP80Conn applies the same "HTTP injection cleanup" that proxy.go did,
// then hands the cleaned connection directly to the SSH server handler, avoiding
// the extra io.Copy hop to a separate backend SSH process.
func handleHTTP80Conn(raw net.Conn, sshConfig *ssh.ServerConfig) {
// Best-effort TCP tuning (no hard dependency on TCPConn)
if tc, ok := raw.(*net.TCPConn); ok {
_ = tc.SetKeepAlive(true)
_ = tc.SetKeepAlivePeriod(30 * time.Second)
_ = tc.SetNoDelay(true)
}
// 101 response up-front (keeps behavior identical to the old proxy)
status := "Switching Protocols"
_, _ = raw.Write([]byte(fmt.Sprintf("HTTP/1.1 101 %s\r\n\r\n", status)))
skip200 := false
br := bufio.NewReaderSize(raw, 4<<10)
// Drain chained HTTP header blocks with a short rolling deadline so Peek/ReadBytes never stalls.
cleanWindow := 30 * time.Second
_ = raw.SetReadDeadline(time.Now().Add(cleanWindow))
for {
// swallow stray CR/LF between chained blocks
if skipped, _ := eatLeadingEOL(br); skipped > 0 {
log.Printf("[CLEAN] skipped %d EOL bytes between blocks", skipped)
}
if lit, _ := eatLiteralBackslashCRLF(br); lit > 0 {
log.Printf("[CLEAN] skipped %d bytes of literal \"\\r\\n\"", lit)
// keep rolling the deadline since we consumed data
_ = raw.SetReadDeadline(time.Now().Add(cleanWindow))
// continue to try cleaning more
continue
}
if litN, _ := eatLiteralBackslashLF(br); litN > 0 {
log.Printf("[CLEAN] skipped %d bytes of literal \"\\n\"", litN)
_ = raw.SetReadDeadline(time.Now().Add(cleanWindow))
continue
}
// peek a bit more so we can classify reliably
size := 8
if b := br.Buffered(); b > 0 && b < size {
size = b
}
if size < 1 {
size = 1
}
peek, err := br.Peek(size)
if err != nil {
if ne, ok := err.(net.Error); ok && ne.Timeout() {
log.Printf("[CLEAN] timeout; pass-through next=%s", peekPreview(br, 64))
break
}
if err == io.EOF {
// Normal when payload stops. Proceed.
log.Printf("[CLEAN] EOF; nothing left")
break
}
log.Println("[CLEAN] peek error:", err)
break
}
// Fast path: HTTP-ish at offset 0 (request-line OR status-line)
if maybeHTTPStartPrefix(peek) {
firstLine, headers, err := discardAndCaptureHTTP(br)
if err != nil {
log.Println("[CLEAN] discard error:", err)
break
}
if headerHasValue(headers, "npv-x", "ok") {
cleanWindow = 2 * time.Second
_ = raw.SetReadDeadline(time.Now().Add(cleanWindow))
skip200 = true
status = "OK"
_, err = raw.Write([]byte(fmt.Sprintf("HTTP/1.1 200 %s\r\n\r\n", status)))
if err != nil {
log.Println("Failed to write 200 OK:", err)
return
}
log.Printf("[CLEAN] NPV-X: OK; promote clean window to %s", cleanWindow)
}
if bytes.Contains(bytes.ToLower(headers), []byte("x-skip: 200")) {
skip200 = true
log.Printf("[CLEAN] detected and stripped X-Skip: 200 header")
}
log.Printf("[CLEAN] discarded HTTP block: %q", firstLine)
_ = raw.SetReadDeadline(time.Now().Add(cleanWindow))
continue
}
// Slow path: scan the already-buffered bytes for a later HTTP token
if bufN := br.Buffered(); bufN > 0 {
pbuf, _ := br.Peek(bufN)
// If we see SSH banner soon, stop cleaning now
if iSSH := bytes.Index(pbuf, []byte("SSH-")); iSSH >= 0 && iSSH < 64 {
log.Printf("[CLEAN] SSH banner detected at +%d; stop cleaning", iSSH)
log.Printf("[CLEAN] pass-through begins; next=%s", peekPreview(br, 64))
break
}
if pos := findHTTPStartIndex(pbuf); pos > 0 {
// 1) discard junk prefix before the HTTP token
noise := make([]byte, pos)
if _, err := io.ReadFull(br, noise); err != nil {
log.Printf("[CLEAN] error discarding noise prefix: %v", err)
break
}
log.Printf("[CLEAN] discarded NOISE prefix (%d bytes): %q (hex:%s)",
pos, asciiPreview(noise, 64), hexPreview(noise, 64))
// 2) discard the HTTP block starting at current position
firstLine, err := discardOneHTTP(br)
if err != nil {
log.Printf("[CLEAN] discard error after noise: %v", err)
break
}
log.Printf("[CLEAN] discarded HTTP block after noise: %q", firstLine)
_ = raw.SetReadDeadline(time.Now().Add(cleanWindow))
continue
}
// No HTTP tokens; pass-through whatever remains
log.Printf("[CLEAN] pass-through begins; next=%s", peekPreview(br, 64))
} else {
log.Printf("[CLEAN] pass-through (no buffered data)")
}
break
}
if skip200 {
log.Printf("[SKIP] X-Skip: 200 detected earlier; not sending 200 OK")
} else {
status = "OK"
_, err := raw.Write([]byte(fmt.Sprintf("HTTP/1.1 200 %s\r\n\r\n", status)))
if err != nil {
log.Println("Failed to write 200 OK:", err)
return
}
}
// Clear deadline before SSH handshake begins.
_ = raw.SetReadDeadline(time.Time{})
// Hand off to SSH server, ensuring any buffered bytes are preserved.
handleConn(&bufferedConn{Conn: raw, r: br}, sshConfig)
}
// serveHTTP80 accepts connections on ln and dispatches each to
// handleHTTP80Conn. It logs accept errors but otherwise runs
// indefinitely. This helper is used to support multiple listen
// addresses without duplicating accept loop code.
func serveHTTP80(ln net.Listener) {
for {
tcpConn, err := ln.Accept()
if err != nil {
if isListenerClosed(err) {
return
}
log.Printf("accept error on %s: %v", ln.Addr().String(), err)
continue
}
go handleHTTP80Conn(tcpConn, getSSHConfig())
}
}
// serveRawSSH accepts connections on ln and passes each directly into
// handleConn. Unlike serveHTTP80 it does not perform HTTP cleanup
// and starts the SSH handshake immediately. This is used for
// local-only listeners that other daemons (e.g. DNSTT) dial into.
func serveRawSSH(ln net.Listener) {
for {
c, err := ln.Accept()
if err != nil {
if isListenerClosed(err) {
return
}
log.Printf("local accept error on %s: %v", ln.Addr().String(), err)
continue
}
go handleConn(c, getSSHConfig())
}
}
// serveTLSSSH accepts TLS-wrapped connections on ln. It forces a
// TLS handshake on each accepted connection so handshake failures are
// surfaced early, then passes the decrypted stream to handleConn.
// There is no backend dial or data copying; the SSH handler runs
// directly on the TLS stream.
func serveTLSSSH(ln net.Listener) {
for {
c, err := ln.Accept()
if err != nil {
if isListenerClosed(err) {
return
}
log.Printf("tls accept error on %s: %v", ln.Addr().String(), err)
continue
}
go func(client net.Conn) {
_ = client.SetDeadline(time.Now().Add(tlsHandshakeTimeout))
if tc, ok := client.(*tls.Conn); ok {
if err := tc.Handshake(); err != nil {
log.Printf("tls handshake error from %s: %v", client.RemoteAddr().String(), err)
_ = client.Close()
return
}
}
_ = client.SetDeadline(time.Time{})
handleConn(client, getSSHConfig())
}(c)
}
}
var httpMethods = [][]byte{
// HTTP/1.1 + PATCH
[]byte("GET "), []byte("HEAD "), []byte("POST "), []byte("PUT "),
[]byte("DELETE "), []byte("CONNECT "), []byte("OPTIONS "), []byte("TRACE "),
[]byte("PATCH "),
// WebDAV (RFC 4918)
[]byte("PROPFIND "), []byte("PROPPATCH "), []byte("MKCOL "),
[]byte("COPY "), []byte("MOVE "), []byte("LOCK "), []byte("UNLOCK "),
// WebDAV SEARCH (RFC 5323)
[]byte("SEARCH "),
// WebDAV ACL (RFC 3744)
[]byte("ACL "),
// WebDAV Versioning/DeltaV (RFC 3253)
[]byte("VERSION-CONTROL "), []byte("REPORT "), []byte("CHECKOUT "),
[]byte("CHECKIN "), []byte("UNCHECKOUT "), []byte("MKWORKSPACE "),
[]byte("UPDATE "), []byte("LABEL "), []byte("MERGE "),
[]byte("BASELINE-CONTROL "), []byte("MKACTIVITY "),
// WebDAV Ordering (RFC 3648)
[]byte("ORDERPATCH "),
// WebDAV Bindings (RFC 5842)
[]byte("BIND "), []byte("UNBIND "), []byte("REBIND "),
// CalDAV (RFC 4791)
[]byte("MKCALENDAR "),
[]byte("x-skip: "),
[]byte("npv-x: "),
}
func eatLeadingEOL(br *bufio.Reader) (int, error) {
n := 0
for {
b, err := br.Peek(1)
if err != nil {
return n, err
}
if b[0] != '\r' && b[0] != '\n' {
return n, nil
}
if _, err := br.ReadByte(); err != nil {
return n, err
}
n++
}
}
func asciiPreview(b []byte, max int) string {
if len(b) > max {
b = b[:max]
}
out := make([]byte, len(b))
for i, c := range b {
if c < 32 || c == 127 {
out[i] = '.'
} else {
out[i] = c
}
}
return string(out)
}
func peekPreview(br *bufio.Reader, max int) string {
n := br.Buffered()
if n < 1 {
n = max
}
if n > max {
n = max
}
p, _ := br.Peek(n)
return fmt.Sprintf("%q (hex:% X)", asciiPreview(p, n), p)
}
func httpStartTokens() [][]byte {
toks := [][]byte{
[]byte("HTTP/"), []byte("HTTP/1."), []byte("HTTP/2"), []byte("HTTP/3"),
[]byte("PRI "), []byte("ICY "),
}
toks = append(toks, httpMethods...)
toks = append(toks, []byte("CONNECT")) // bare
return toks
}
func findHTTPStartIndex(b []byte) int {
if len(b) == 0 {
return -1
}
upper := bytes.ToUpper(b)
min := -1
for _, t := range httpStartTokens() {
tu := bytes.ToUpper(t)
if idx := bytes.Index(upper, tu); idx >= 0 {
if min == -1 || idx < min {
min = idx
}
}
}
return min
}
func hexPreview(b []byte, max int) string {
if len(b) > max {
b = b[:max]
}
return fmt.Sprintf("% X", b)
}
func eatLiteralBackslashCRLF(br *bufio.Reader) (int, error) {
count := 0
for {
p, err := br.Peek(4)
if err != nil {
// If fewer than 4 bytes buffered, nothing more to eat.
return count, nil
}
if len(p) >= 4 && p[0] == '\\' && p[1] == 'r' && p[2] == '\\' && p[3] == 'n' {
if _, err := br.Discard(4); err != nil {
return count, err
}
count += 4
continue
}
return count, nil
}
}
func eatLiteralBackslashLF(br *bufio.Reader) (int, error) {
p, err := br.Peek(2)
if err != nil || len(p) < 2 {
return 0, nil
}
if p[0] == '\\' && p[1] == 'n' {
if _, err := br.Discard(2); err != nil {
return 0, err
}
return 2, nil
}
return 0, nil
}
func headerHasValue(headers []byte, key, want string) bool {
lh := bytes.ToLower(headers)
keyb := []byte(strings.ToLower(key))
wantb := []byte(strings.ToLower(want))
// find "<key>:" (allow spaces before colon too)
i := bytes.Index(lh, keyb)
for i >= 0 {
rest := lh[i+len(keyb):]
rest = bytes.TrimLeft(rest, " \t")
if len(rest) > 0 && rest[0] == ':' {
// slice to end-of-line
line := rest[1:]
if j := bytes.IndexByte(line, '\n'); j >= 0 {
line = line[:j]
}
line = bytes.TrimSpace(bytes.TrimSuffix(line, []byte{'\r'}))
return bytes.Equal(line, wantb)
}
// keep searching (avoid false positives on substrings)
next := lh[i+len(keyb):]
j := bytes.Index(next, keyb)
if j < 0 {
return false
}
i += len(keyb) + j
}
return false
}
func discardAndCaptureHTTP(br *bufio.Reader) (string, []byte, error) {
const hardCap = 64 << 10 // 64 KiB
var buf bytes.Buffer
total := 0
first, err := br.ReadSlice('\n')
if err != nil {
return "", nil, err
}
buf.Write(first)
total += len(first)
if total > hardCap {
return "", nil, fmt.Errorf("http header too large")
}
for {
line, err := br.ReadSlice('\n')
if err != nil {
return "", nil, err
}
total += len(line)
if total > hardCap {
return "", nil, fmt.Errorf("http header too large")
}
buf.Write(line)
if (len(line) == 1 && line[0] == '\n') ||
(len(line) == 2 && line[0] == '\r' && line[1] == '\n') {
break
}
}
fl := strings.TrimRight(string(first), "\r\n")
return fl, buf.Bytes(), nil
}
func discardOneHTTP(br *bufio.Reader) (string, error) {
const hardCap = 64 << 10 // 64 KiB
total := 0
// capture first line for printing
first, err := br.ReadSlice('\n')
if err != nil {
return "", err
}
total += len(first)
if total > hardCap {
return "", fmt.Errorf("http header too large")
}
for {
line, err := br.ReadSlice('\n')
if err != nil {
return "", err
}
total += len(line)
if total > hardCap {
return "", fmt.Errorf("http header too large")
}
// End of headers: "\n" or "\r\n"
if (len(line) == 1 && line[0] == '\n') ||
(len(line) == 2 && line[0] == '\r' && line[1] == '\n') {
break
}
}
// Trim trailing CR/LF for nicer logs
fl := strings.TrimRight(string(first), "\r\n")
return fl, nil
}
func maybeHTTPStartPrefix(b []byte) bool {
u := bytes.ToUpper(b)
// Helper: partial prefix match
startsWithPartial := func(s string) bool {
su := []byte(s)
if len(u) <= len(su) {
return bytes.Equal(u, su[:len(u)])
}
return bytes.HasPrefix(u, su)
}
// Status-lines / prefaces (allow partial)
if startsWithPartial("HTTP/") || startsWithPartial("HTTP/1.") ||
startsWithPartial("HTTP/2") || startsWithPartial("HTTP/3") ||
startsWithPartial("PRI ") || startsWithPartial("ICY ") {
return true
}
// Request-lines (methods) with partial match
for _, m := range httpMethods {
um := bytes.ToUpper(m)
if len(u) <= len(um) && bytes.Equal(u, um[:len(u)]) { // partial ok
return true
}
if len(u) >= len(um) && bytes.HasPrefix(u, um) {
return true
}
}
return false
}
func main() {
if memTotal, _, err := readMemInfo(); err == nil && memTotal > 0 {
limit := int64(memTotal) * 80 / 100
debug.SetMemoryLimit(limit)
log.Printf("GOMEMLIMIT auto-set to 80%% of RAM: %d MB", limit/1024/1024)
}
configPath := flag.String("config", "config.json", "path to JSON config file")
quietFlag := flag.Bool("quiet", false, "override config and disable logs")
userCountFlag := flag.Bool("usercount", false, "show per-user connection counters (single line)")
flag.Parse()
cfg, userMap, err := loadConfig(*configPath)
if err != nil {
log.Fatalf("failed to load config: %v", err)
}
userMgr.ReplaceAll(userMap)
// Store config path and live config for hot-reload via admin API.
globalCfgPath = *configPath
setGlobalCfg(cfg)
userCountEnabled = cfg.UserCount || *userCountFlag
var store *Store
pgDSN := os.Getenv("PG_DSN")
if pgDSN != "" {
store, err = NewStore(pgDSN)
if err != nil {
log.Fatalf("failed to connect to postgres: %v", err)
}
ctx := context.Background()
dbUsers, err := store.LoadUsers(ctx)
if err != nil {
log.Fatalf("failed to load users from database: %v", err)
}
if len(dbUsers) > 0 {
userMgr.ReplaceAll(dbUsers)
}
}
// Bootstrap admin users and reseller expiry checker.
if store != nil {
ctx := context.Background()
if pw, err := store.BootstrapSuperAdmin(ctx); err != nil {
log.Printf("failed to bootstrap superadmin: %v", err)
} else if pw != "" {
log.Printf("=== FIRST RUN: superadmin created — username: admin password: %s ===", pw)
}
if err := loadAdminUsersIntoCache(ctx, store); err != nil {
log.Printf("failed to load admin users: %v", err)
}
startResellerExpiryChecker(store)
}
// Optional: initialize interface totals persistence (best-effort).
if store != nil {
statsStore = store
ctx := context.Background()
if err := store.EnsureXrayClientsSchema(ctx); err != nil {
log.Printf("xray clients table: %v", err)
} 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 {
ifaceTotalsMgr = NewIfaceTotalsManager()
ifaceTotalsMgr.Load(rows)
// Bootstrap with current kernel counters once at startup
if netMap, err3 := readNetDev(); err3 == nil {
for name, ctrs := range netMap {
ifaceTotalsMgr.ApplyKernel(name, ctrs.RxBytes, ctrs.TxBytes)
}
}
} else {
log.Printf("iface totals persistence disabled: %v", err2)
}
} else {
log.Printf("iface totals persistence disabled: %v", err)
}
}
// start background collector for CPU + interface stats
primeCurrentStats()
startStatsCollector()
adminAddr := os.Getenv("ADMIN_HTTP_ADDR")
if adminAddr == "" {
adminAddr = "0.0.0.0:9090"
}
// Determine admin directory: from config or default ./admin
adminDir := cfg.AdminDir
if adminDir == "" {
adminDir = "./admin"
}
startAdminAPI(store, adminAddr, adminDir)
// Start the integrated Xray-core subprocess if configured.
initXrayManager(cfg.Xray)
// Global banner text (from config or file) — stored in a global so the
// admin API can update it on the fly without a restart.
{
bt := cfg.Banner
if bt == "" && cfg.BannerFile != "" {
if data, err := os.ReadFile(cfg.BannerFile); err == nil {
bt = string(data)
} else {
log.Printf("failed to read banner file %s: %v", cfg.BannerFile, err)
}
}
setBannerText(bt)
}
sshConfig := &ssh.ServerConfig{
PasswordCallback: passwordCallback,
PublicKeyCallback: publicKeyCallback,
NoClientAuth: false,
}
// Optional compatibility mode for legacy SSH clients (weak algorithms).
// Enable only if you must support older embedded/mobile SSH stacks.
// Usage: SSH_LEGACY=1 ./server
if os.Getenv("SSH_LEGACY") == "1" {
sshConfig.Config = ssh.Config{
Ciphers: []string{
// Modern first
"chacha20-poly1305@openssh.com",
"aes128-ctr", "aes192-ctr", "aes256-ctr",
// Legacy (weak)
"aes128-cbc", "aes192-cbc", "aes256-cbc",
"3des-cbc",
},
KeyExchanges: []string{
// Modern first
"curve25519-sha256", "curve25519-sha256@libssh.org",
"diffie-hellman-group-exchange-sha256",
// Legacy (weak)
"diffie-hellman-group14-sha1",
"diffie-hellman-group1-sha1",
},
MACs: []string{
// Modern first
"hmac-sha2-256", "hmac-sha2-512",
// Legacy (weak)
"hmac-sha1",
},
}
log.Println("[SSH] legacy crypto enabled (weak algorithms allowed)")
}
// Fancy per-user banner:
// - Optional global banner first
// - Then block:
// Username: user
// -----------------
// Expiration: 10/12/2025
// -----------------
// Max Upload: 10 Mbps
// -----------------
// Max Download: 10 Mbps
sshConfig.BannerCallback = func(meta ssh.ConnMetadata) string {
var sb strings.Builder
// Global / custom banner (reads live value so admin-panel edits apply immediately)
if bt := getBannerText(); bt != "" {
sb.WriteString(bt)
if !strings.HasSuffix(bt, "\n") {
sb.WriteString("\n")
}
sb.WriteString("\n") // extra blank line before user block
}
u, ok := userMgr.Get(meta.User())
if !ok {
// If user not found (should be rare), just return global banner
return sb.String()
}
u.mu.Lock()
exp := u.ExpiresAt
up := u.Cfg.LimitMbpsUp
down := u.Cfg.LimitMbpsDown
name := u.Cfg.Username
u.mu.Unlock()
// BR-style date: dd/MM/yyyy
expStr := "Sem limite"
if exp != nil {
expStr = exp.Local().Format("02/01/2006")
}
sb.WriteString("\n<br>-----------------<br>\n")
sb.WriteString("Account Information")
sb.WriteString("\n<br>-----------------<br>\n")
sb.WriteString("Username: ")
sb.WriteString(name)
sb.WriteString("\n<br>-----------------<br>\n")
sb.WriteString("Expiration: ")
sb.WriteString(expStr)
sb.WriteString("\n<br>-----------------<br>\n")
sb.WriteString("Max Upload: ")
sb.WriteString(strconv.Itoa(up))
sb.WriteString(" Mbps")
sb.WriteString("\n<br>-----------------<br>\n")
sb.WriteString("Max Download: ")
sb.WriteString(strconv.Itoa(down))
sb.WriteString(" Mbps")
sb.WriteString("\n<br>-----------------<br>\n")
return sb.String()
}
hostKeyBytes, err := os.ReadFile(cfg.HostKeyFile)
if err != nil {
log.Fatalf("failed to load host key %s: %v", cfg.HostKeyFile, err)
}
hostKey, err := ssh.ParsePrivateKey(hostKeyBytes)
if err != nil {
log.Fatalf("failed to parse host key: %v", err)
}
// Enforce RSA host key (ssh-rsa) for maximum compatibility with legacy SSH clients.
// If you need ed25519 or other key types, remove this check.
if hostKey.PublicKey().Type() != ssh.KeyAlgoRSA {
log.Fatalf("host key %s is %s; this build requires RSA (ssh-rsa). Generate an RSA key with: ssh-keygen -t rsa -b 2048 -f %s -N \"\"", cfg.HostKeyFile, hostKey.PublicKey().Type(), cfg.HostKeyFile)
}
sshConfig.AddHostKey(hostKey)
// Store SSH config globally so hot-reloaded serve loops always use the latest.
setSSHConfig(sshConfig)
// Logging setup.
quietLogs := cfg.Quiet || *quietFlag || userCountEnabled
if quietLogs {
log.SetOutput(io.Discard)
}
startPanelLogLimiter()
// Initialise default per-connection bandwidth limits and SSH inactivity cleanup.
setDefaultLimits(cfg.DefaultLimitMbpsUp, cfg.DefaultLimitMbpsDown)
setSSHIdleTimeoutFromConfig(cfg.SSHIdleTimeout)
// Initialise listener pools (used for initial startup and hot-reload alike).
publicPool = newListenerPool(serveHTTP80)
tlsPool = newTLSListenerPool()
for _, msg := range normalizeRuntimePorts(cfg) {
log.Printf("startup config fallback: %s", msg)
}
// Start the integrated DNSTT and UDPGW if configured. Startup errors are logged
// but do not crash the panel; the admin UI exposes the logs and service status.
if err := startDNSTT(cfg.DNSTT, sshConfig); err != nil {
log.Printf("dnstt auto-start failed: %v", err)
}
if err := startUDPGW(cfg.UDPGW); err != nil {
log.Printf("udpgw auto-start failed: %v", err)
}
// Start public SSH listeners (listen + extra_listen).
publicAddrs := append([]string{cfg.Listen}, cfg.ExtraListen...)
for _, e := range publicPool.Sync(publicAddrs) {
log.Printf("failed to start listener: %v", e)
}
// Start TLS forwarder listeners if configured.
for _, e := range tlsPool.Sync(cfg.TLSForwarders) {
log.Printf("failed to start TLS listener: %v", e)
}
// Print user counts once at startup.
updateUserDisplay()
// Block forever. Individual accept loops run in goroutines.
select {}
}