Initial commit: LAN Manager with Go backend and Vue frontend

This commit is contained in:
shirainbown
2026-04-15 00:52:25 +08:00
commit d28aae585f
44 changed files with 7349 additions and 0 deletions

67
server/config/config.go Normal file
View File

@@ -0,0 +1,67 @@
package config
import (
"log"
"os"
"strconv"
)
type Config struct {
Host string
Port string
DBPath string
DataDir string
PingInterval int // seconds
SSHTimeout int // seconds
LogLevel string
WebStaticPath string
AdminUser string
AdminPass string
SessionSecret string
LogRetentionDays int // 0 = keep forever
UIRefreshInterval int // milliseconds, 0 = disable auto refresh
EncryptKey string // for AES password encryption
}
func Load() *Config {
cfg := &Config{
Host: getEnv("HOST", "0.0.0.0"),
Port: getEnv("PORT", "8080"),
DBPath: getEnv("DB_PATH", "./data/lan-manager.db"),
DataDir: getEnv("DATA_DIR", "./data"),
PingInterval: getEnvInt("PING_INTERVAL", 60),
SSHTimeout: getEnvInt("SSH_TIMEOUT", 10),
LogLevel: getEnv("LOG_LEVEL", "info"),
WebStaticPath: getEnv("WEB_STATIC_PATH", ""),
AdminUser: getEnv("ADMIN_USER", "admin"),
AdminPass: getEnv("ADMIN_PASS", "admin"),
SessionSecret: getEnv("SESSION_SECRET", "lan-manager-secret-change-in-production"),
LogRetentionDays: getEnvInt("LOG_RETENTION_DAYS", 0),
UIRefreshInterval: getEnvInt("UI_REFRESH_INTERVAL", 10000),
EncryptKey: getEnv("ENCRYPT_KEY", ""),
}
if cfg.AdminPass == "admin" {
log.Println("[WARN] Using default admin password. Please set ADMIN_PASS env variable in production.")
}
return cfg
}
func getEnv(key, defaultValue string) string {
if v := os.Getenv(key); v != "" {
return v
}
return defaultValue
}
func getEnvInt(key string, defaultValue int) int {
v := os.Getenv(key)
if v == "" {
return defaultValue
}
n, err := strconv.Atoi(v)
if err != nil {
return defaultValue
}
return n
}

107
server/db/db.go Normal file
View File

@@ -0,0 +1,107 @@
package db
import (
"database/sql"
"fmt"
"os"
"path/filepath"
"strings"
"lan-manager/server/config"
_ "modernc.org/sqlite"
)
var DB *sql.DB
func Init(cfg *config.Config) error {
if err := os.MkdirAll(filepath.Dir(cfg.DBPath), 0750); err != nil {
return err
}
var err error
DB, err = sql.Open("sqlite", cfg.DBPath+"?_pragma=foreign_keys(1)")
if err != nil {
return err
}
if err := DB.Ping(); err != nil {
return err
}
DB.SetMaxOpenConns(1)
return migrate()
}
func migrate() error {
stmts := []string{
`CREATE TABLE IF NOT EXISTS machines (
id INTEGER PRIMARY KEY AUTOINCREMENT,
hostname TEXT NOT NULL,
ip TEXT NOT NULL UNIQUE,
mac TEXT,
os_type TEXT NOT NULL,
os_version TEXT,
notes TEXT,
ssh_port INTEGER DEFAULT 22,
is_online INTEGER DEFAULT 0,
last_ping_at DATETIME,
cpu_info TEXT,
memory_info TEXT,
disk_info TEXT,
uptime TEXT,
listen_ports TEXT,
ssh_synced_at DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`,
`ALTER TABLE machines ADD COLUMN ssh_port INTEGER DEFAULT 22`,
`ALTER TABLE machines ADD COLUMN ssh_username TEXT`,
`ALTER TABLE machines ADD COLUMN ssh_password TEXT`,
`CREATE TABLE IF NOT EXISTS services (
id INTEGER PRIMARY KEY AUTOINCREMENT,
machine_id INTEGER NOT NULL REFERENCES machines(id) ON DELETE CASCADE,
name TEXT NOT NULL,
port INTEGER NOT NULL,
protocol TEXT DEFAULT 'TCP',
notes TEXT,
target_machine_id INTEGER REFERENCES machines(id) ON DELETE SET NULL,
target_notes TEXT
)`,
`ALTER TABLE services ADD COLUMN target_machine_id INTEGER REFERENCES machines(id) ON DELETE SET NULL`,
`ALTER TABLE services ADD COLUMN target_notes TEXT`,
`CREATE TABLE IF NOT EXISTS relationships (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source_machine_id INTEGER NOT NULL REFERENCES machines(id) ON DELETE CASCADE,
target_machine_id INTEGER NOT NULL REFERENCES machines(id) ON DELETE CASCADE,
relation_type TEXT NOT NULL,
source_port INTEGER,
target_port INTEGER,
notes TEXT
)`,
`CREATE TABLE IF NOT EXISTS operation_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
action TEXT NOT NULL,
entity_type TEXT NOT NULL,
entity_id INTEGER,
entity_name TEXT,
old_value TEXT,
new_value TEXT,
source_ip TEXT,
username TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`,
`CREATE INDEX IF NOT EXISTS idx_machines_ip ON machines(ip)`,
`CREATE INDEX IF NOT EXISTS idx_services_machine ON services(machine_id)`,
`CREATE INDEX IF NOT EXISTS idx_rel_src ON relationships(source_machine_id)`,
`CREATE INDEX IF NOT EXISTS idx_rel_tgt ON relationships(target_machine_id)`,
`CREATE INDEX IF NOT EXISTS idx_logs_created ON operation_logs(created_at)`,
}
for _, s := range stmts {
if _, err := DB.Exec(s); err != nil {
// ignore "duplicate column" errors for ALTER TABLE compatibility
if strings.Contains(err.Error(), "duplicate column name") {
continue
}
return fmt.Errorf("migration failed: %w", err)
}
}
return nil
}

55
server/handlers/auth.go Normal file
View File

@@ -0,0 +1,55 @@
package handlers
import (
"net/http"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
"lan-manager/server/config"
"lan-manager/server/middleware"
"lan-manager/server/models"
)
type AuthHandler struct {
Cfg *config.Config
}
func NewAuthHandler(cfg *config.Config) *AuthHandler {
return &AuthHandler{Cfg: cfg}
}
func (h *AuthHandler) Login(c *gin.Context) {
var req models.LoginRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if req.Username != h.Cfg.AdminUser || req.Password != h.Cfg.AdminPass {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
return
}
session := sessions.Default(c)
session.Set(middleware.AdminSessionKey, req.Username)
if err := session.Save(); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"username": req.Username, "is_admin": true})
}
func (h *AuthHandler) Logout(c *gin.Context) {
session := sessions.Default(c)
session.Delete(middleware.AdminSessionKey)
_ = session.Save()
c.JSON(http.StatusOK, gin.H{"message": "logged out"})
}
func (h *AuthHandler) Me(c *gin.Context) {
session := sessions.Default(c)
user := session.Get(middleware.AdminSessionKey)
if user == nil {
c.JSON(http.StatusOK, gin.H{"is_admin": false, "ui_refresh_interval": h.Cfg.UIRefreshInterval})
return
}
c.JSON(http.StatusOK, gin.H{"is_admin": true, "username": user.(string), "ui_refresh_interval": h.Cfg.UIRefreshInterval})
}

127
server/handlers/export.go Normal file
View File

@@ -0,0 +1,127 @@
package handlers
import (
"database/sql"
"encoding/json"
"net/http"
"time"
"github.com/gin-gonic/gin"
"lan-manager/server/db"
"lan-manager/server/middleware"
"lan-manager/server/models"
)
type ExportHandler struct{}
func NewExportHandler() *ExportHandler {
return &ExportHandler{}
}
func (h *ExportHandler) Export(c *gin.Context) {
data := map[string]interface{}{}
machines := []models.Machine{}
if rows, err := db.DB.Query(`SELECT id, hostname, ip, mac, os_type, os_version, notes, ssh_port, ssh_username, ssh_password, is_online, last_ping_at, cpu_info, memory_info, disk_info, uptime, listen_ports, ssh_synced_at, created_at, updated_at FROM machines`); err == nil {
defer rows.Close()
for rows.Next() {
var m models.Machine
var lp, ss *time.Time
if err := rows.Scan(&m.ID, &m.Hostname, &m.IP, &m.MAC, &m.OsType, &m.OsVersion, &m.Notes, &m.SSHPort, &m.SSHUsername, &m.SSHPassword, &m.IsOnline, &lp, &m.CPUInfo, &m.MemoryInfo, &m.DiskInfo, &m.Uptime, &m.ListenPorts, &ss, &m.CreatedAt, &m.UpdatedAt); err == nil {
m.LastPingAt = lp
m.SSHSyncedAt = ss
machines = append(machines, m)
}
}
}
data["machines"] = machines
services := []models.Service{}
if rows, err := db.DB.Query(`SELECT id, machine_id, name, port, protocol, notes, target_machine_id, target_notes FROM services`); err == nil {
defer rows.Close()
for rows.Next() {
var s models.Service
var tm sql.NullInt64
if err := rows.Scan(&s.ID, &s.MachineID, &s.Name, &s.Port, &s.Protocol, &s.Notes, &tm, &s.TargetNotes); err == nil {
if tm.Valid {
tmid := tm.Int64
s.TargetMachineID = &tmid
}
services = append(services, s)
}
}
}
data["services"] = services
relationships := []models.Relationship{}
if rows, err := db.DB.Query(`SELECT id, source_machine_id, target_machine_id, relation_type, source_port, target_port, notes FROM relationships`); err == nil {
defer rows.Close()
for rows.Next() {
var r models.Relationship
if err := rows.Scan(&r.ID, &r.SourceMachineID, &r.TargetMachineID, &r.RelationType, &r.SourcePort, &r.TargetPort, &r.Notes); err == nil {
relationships = append(relationships, r)
}
}
}
data["relationships"] = relationships
b, _ := json.MarshalIndent(data, "", " ")
filename := "lan-manager-backup-" + time.Now().Format("20060102") + ".json"
c.Header("Content-Disposition", "attachment; filename="+filename)
c.Data(http.StatusOK, "application/json", b)
}
func (h *ExportHandler) Import(c *gin.Context) {
file, _, err := c.Request.FormFile("file")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
defer file.Close()
var data map[string]json.RawMessage
if err := json.NewDecoder(file).Decode(&data); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
mode := c.DefaultPostForm("mode", "append")
if mode == "overwrite" {
_, _ = db.DB.Exec(`DELETE FROM relationships`)
_, _ = db.DB.Exec(`DELETE FROM services`)
_, _ = db.DB.Exec(`DELETE FROM machines`)
}
if raw, ok := data["machines"]; ok {
var list []models.Machine
if err := json.Unmarshal(raw, &list); err == nil {
for _, m := range list {
_, _ = db.DB.Exec(`INSERT OR IGNORE INTO machines (id, hostname, ip, mac, os_type, os_version, notes, ssh_port, ssh_username, ssh_password, is_online, cpu_info, memory_info, disk_info, uptime, listen_ports) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
m.ID, m.Hostname, m.IP, m.MAC, m.OsType, m.OsVersion, m.Notes, m.SSHPort, m.SSHUsername, m.SSHPassword, 0, m.CPUInfo, m.MemoryInfo, m.DiskInfo, m.Uptime, m.ListenPorts)
}
}
}
if raw, ok := data["services"]; ok {
var list []models.Service
if err := json.Unmarshal(raw, &list); err == nil {
for _, s := range list {
_, _ = db.DB.Exec(`INSERT OR IGNORE INTO services (id, machine_id, name, port, protocol, notes, target_machine_id, target_notes) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
s.ID, s.MachineID, s.Name, s.Port, s.Protocol, s.Notes, s.TargetMachineID, s.TargetNotes)
}
}
}
if raw, ok := data["relationships"]; ok {
var list []models.Relationship
if err := json.Unmarshal(raw, &list); err == nil {
for _, r := range list {
_, _ = db.DB.Exec(`INSERT OR IGNORE INTO relationships (id, source_machine_id, target_machine_id, relation_type, source_port, target_port, notes) VALUES (?, ?, ?, ?, ?, ?, ?)`,
r.ID, r.SourceMachineID, r.TargetMachineID, r.RelationType, r.SourcePort, r.TargetPort, r.Notes)
}
}
}
middleware.LogOperation("import", "system", nil, "json-import", "", "", c.ClientIP(), middleware.CurrentUser(c))
c.JSON(http.StatusOK, gin.H{"message": "imported"})
}

35
server/handlers/health.go Normal file
View File

@@ -0,0 +1,35 @@
package handlers
import (
"net/http"
"runtime"
"time"
"github.com/gin-gonic/gin"
"lan-manager/server/db"
)
var startTime = time.Now()
type HealthHandler struct{}
func NewHealthHandler() *HealthHandler {
return &HealthHandler{}
}
func (h *HealthHandler) Check(c *gin.Context) {
dbErr := db.DB.Ping()
status := "ok"
dbStatus := "ok"
if dbErr != nil {
status = "error"
dbStatus = "error"
}
c.JSON(http.StatusOK, gin.H{
"status": status,
"db": dbStatus,
"version": "1.0.0",
"uptime": time.Since(startTime).String(),
"go": runtime.Version(),
})
}

51
server/handlers/logs.go Normal file
View File

@@ -0,0 +1,51 @@
package handlers
import (
"database/sql"
"net/http"
"github.com/gin-gonic/gin"
"lan-manager/server/db"
"lan-manager/server/models"
)
type LogHandler struct{}
func NewLogHandler() *LogHandler {
return &LogHandler{}
}
func (h *LogHandler) List(c *gin.Context) {
action := c.Query("action")
date := c.Query("date")
query := `SELECT id, action, entity_type, entity_id, entity_name, old_value, new_value, source_ip, username, created_at FROM operation_logs WHERE 1=1`
args := []interface{}{}
if action != "" {
query += ` AND action = ?`
args = append(args, action)
}
if date != "" {
query += ` AND DATE(created_at) = ?`
args = append(args, date)
}
query += ` ORDER BY created_at DESC LIMIT 1000`
rows, err := db.DB.Query(query, args...)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
defer rows.Close()
logs := []models.OperationLog{}
for rows.Next() {
var l models.OperationLog
var eid sql.NullInt64
if err := rows.Scan(&l.ID, &l.Action, &l.EntityType, &eid, &l.EntityName, &l.OldValue, &l.NewValue, &l.SourceIP, &l.Username, &l.CreatedAt); err == nil {
if eid.Valid {
l.EntityID = &eid.Int64
}
logs = append(logs, l)
}
}
c.JSON(http.StatusOK, logs)
}

335
server/handlers/machines.go Normal file
View File

@@ -0,0 +1,335 @@
package handlers
import (
"database/sql"
"encoding/json"
"net/http"
"strconv"
"time"
"github.com/gin-gonic/gin"
"lan-manager/server/db"
"lan-manager/server/middleware"
"lan-manager/server/models"
"lan-manager/server/services"
"lan-manager/server/utils"
)
type MachineHandler struct{}
func NewMachineHandler() *MachineHandler {
return &MachineHandler{}
}
// sanitizeMachine removes sensitive fields for guest users
func sanitizeMachine(m *models.Machine) {
m.IP = ""
m.MAC = models.NullString{}
m.Notes = models.NullString{}
m.SSHUsername = models.NullString{}
m.SSHPassword = models.NullString{}
m.ListenPorts = models.NullString{}
m.CreatedAt = time.Time{}
m.UpdatedAt = time.Time{}
}
func (h *MachineHandler) List(c *gin.Context) {
isAdmin := middleware.IsAdmin(c)
osFilter := c.Query("os_type")
search := c.Query("search")
query := `SELECT id, hostname, ip, mac, os_type, os_version, notes, ssh_port, ssh_username, ssh_password, is_online, last_ping_at, cpu_info, memory_info, disk_info, uptime, listen_ports, ssh_synced_at, created_at, updated_at FROM machines WHERE 1=1`
args := []interface{}{}
if osFilter != "" {
query += ` AND os_type = ?`
args = append(args, osFilter)
}
if search != "" {
if isAdmin {
query += ` AND (hostname LIKE ? OR ip LIKE ?)`
args = append(args, "%"+search+"%", "%"+search+"%")
} else {
query += ` AND hostname LIKE ?`
args = append(args, "%"+search+"%")
}
}
query += ` ORDER BY created_at DESC`
rows, err := db.DB.Query(query, args...)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
defer rows.Close()
machines := []models.Machine{}
for rows.Next() {
var m models.Machine
var lp, ss *time.Time
err := rows.Scan(&m.ID, &m.Hostname, &m.IP, &m.MAC, &m.OsType, &m.OsVersion, &m.Notes, &m.SSHPort, &m.SSHUsername, &m.SSHPassword, &m.IsOnline, &lp, &m.CPUInfo, &m.MemoryInfo, &m.DiskInfo, &m.Uptime, &m.ListenPorts, &ss, &m.CreatedAt, &m.UpdatedAt)
if err != nil {
continue
}
m.LastPingAt = lp
m.SSHSyncedAt = ss
m.SSHPassword = models.NullString{} // never return password
if !isAdmin {
sanitizeMachine(&m)
}
machines = append(machines, m)
}
rows.Close()
// Fetch service counts
if len(machines) > 0 {
scRows, err := db.DB.Query(`SELECT machine_id, COUNT(*) FROM services GROUP BY machine_id`)
if err == nil {
defer scRows.Close()
for scRows.Next() {
var mid int64
var cnt int
if err := scRows.Scan(&mid, &cnt); err == nil {
for i := range machines {
if machines[i].ID == mid {
machines[i].ServiceCount = cnt
break
}
}
}
}
}
}
c.JSON(http.StatusOK, machines)
}
func (h *MachineHandler) Get(c *gin.Context) {
isAdmin := middleware.IsAdmin(c)
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
var m models.Machine
var lp, ss *time.Time
err = db.DB.QueryRow(`SELECT id, hostname, ip, mac, os_type, os_version, notes, ssh_port, ssh_username, ssh_password, is_online, last_ping_at, cpu_info, memory_info, disk_info, uptime, listen_ports, ssh_synced_at, created_at, updated_at FROM machines WHERE id = ?`, id).
Scan(&m.ID, &m.Hostname, &m.IP, &m.MAC, &m.OsType, &m.OsVersion, &m.Notes, &m.SSHPort, &m.SSHUsername, &m.SSHPassword, &m.IsOnline, &lp, &m.CPUInfo, &m.MemoryInfo, &m.DiskInfo, &m.Uptime, &m.ListenPorts, &ss, &m.CreatedAt, &m.UpdatedAt)
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
m.LastPingAt = lp
m.SSHSyncedAt = ss
services := []models.Service{}
if rows, err := db.DB.Query(`SELECT id, machine_id, name, port, protocol, notes FROM services WHERE machine_id = ?`, id); err == nil {
defer rows.Close()
for rows.Next() {
var s models.Service
if err := rows.Scan(&s.ID, &s.MachineID, &s.Name, &s.Port, &s.Protocol, &s.Notes); err == nil {
if !isAdmin {
s.Notes = ""
}
services = append(services, s)
}
}
}
relationships := []models.Relationship{}
if isAdmin {
if rows, err := db.DB.Query(`SELECT id, source_machine_id, target_machine_id, relation_type, source_port, target_port, notes FROM relationships WHERE source_machine_id = ? OR target_machine_id = ?`, id, id); err == nil {
defer rows.Close()
for rows.Next() {
var r models.Relationship
if err := rows.Scan(&r.ID, &r.SourceMachineID, &r.TargetMachineID, &r.RelationType, &r.SourcePort, &r.TargetPort, &r.Notes); err == nil {
relationships = append(relationships, r)
}
}
}
}
m.SSHPassword = models.NullString{} // never return password
if !isAdmin {
sanitizeMachine(&m)
}
c.JSON(http.StatusOK, gin.H{
"machine": m,
"services": services,
"relationships": relationships,
})
}
func (h *MachineHandler) Create(c *gin.Context) {
var m models.Machine
if err := c.ShouldBindJSON(&m); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
encPass, _ := utils.Encrypt(m.SSHPassword.String)
passField := models.NullString{}
passField.String = encPass
passField.Valid = encPass != ""
res, err := db.DB.Exec(`INSERT INTO machines (hostname, ip, mac, os_type, os_version, notes, ssh_port, ssh_username, ssh_password) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
m.Hostname, m.IP, m.MAC, m.OsType, m.OsVersion, m.Notes, m.SSHPort, m.SSHUsername, passField)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
id, _ := res.LastInsertId()
m.ID = id
m.CreatedAt = time.Now()
m.UpdatedAt = time.Now()
middleware.LogOperation("create", "machine", &m.ID, m.Hostname, "", middleware.ToJSON(m), c.ClientIP(), middleware.CurrentUser(c))
c.JSON(http.StatusCreated, m)
}
func (h *MachineHandler) Update(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
var m models.Machine
if err := c.ShouldBindJSON(&m); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
var old models.Machine
var oldPass string
var lp, ss *time.Time
_ = db.DB.QueryRow(`SELECT id, hostname, ip, mac, os_type, os_version, notes, ssh_port, ssh_username, ssh_password, is_online, last_ping_at, cpu_info, memory_info, disk_info, uptime, listen_ports, ssh_synced_at, created_at, updated_at FROM machines WHERE id = ?`, id).
Scan(&old.ID, &old.Hostname, &old.IP, &old.MAC, &old.OsType, &old.OsVersion, &old.Notes, &old.SSHPort, &old.SSHUsername, &oldPass, &old.IsOnline, &lp, &old.CPUInfo, &old.MemoryInfo, &old.DiskInfo, &old.Uptime, &old.ListenPorts, &ss, &old.CreatedAt, &old.UpdatedAt)
old.LastPingAt = lp
old.SSHSyncedAt = ss
// Keep old password if new one is empty
passToSave := oldPass
if m.SSHPassword.String != "" {
encPass, err := utils.Encrypt(m.SSHPassword.String)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "encrypt failed"})
return
}
passToSave = encPass
}
_, err = db.DB.Exec(`UPDATE machines SET hostname=?, ip=?, mac=?, os_type=?, os_version=?, notes=?, ssh_port=?, ssh_username=?, ssh_password=?, updated_at=CURRENT_TIMESTAMP WHERE id=?`,
m.Hostname, m.IP, m.MAC, m.OsType, m.OsVersion, m.Notes, m.SSHPort, m.SSHUsername, passToSave, id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
m.ID = id
old.SSHPassword = models.NullString{}
m.SSHPassword = models.NullString{}
middleware.LogOperation("update", "machine", &m.ID, m.Hostname, middleware.ToJSON(old), middleware.ToJSON(m), c.ClientIP(), middleware.CurrentUser(c))
c.JSON(http.StatusOK, m)
}
func (h *MachineHandler) Delete(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
var name string
_ = db.DB.QueryRow(`SELECT hostname FROM machines WHERE id = ?`, id).Scan(&name)
_, err = db.DB.Exec(`DELETE FROM machines WHERE id = ?`, id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
middleware.LogOperation("delete", "machine", &id, name, "", "", c.ClientIP(), middleware.CurrentUser(c))
c.JSON(http.StatusOK, gin.H{"message": "deleted"})
}
func (h *MachineHandler) SSHInfo(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
var req models.SSHInfoRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
var ip string
var sshPort int
var savedUser, savedPass string
if err := db.DB.QueryRow(`SELECT ip, ssh_port, ssh_username, ssh_password FROM machines WHERE id = ?`, id).Scan(&ip, &sshPort, &savedUser, &savedPass); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "machine not found"})
return
}
user := req.Username
pass := req.Password
// 如果前端没传密码,尝试用数据库保存的凭据
if pass == "" {
if savedUser == "" || savedPass == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "未保存 SSH 凭据,请手动输入"})
return
}
plainPass, decErr := utils.Decrypt(savedPass)
if decErr != nil || plainPass == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "未保存 SSH 凭据,请手动输入"})
return
}
user = savedUser
pass = plainPass
}
result, err := services.GetSSHInfo(ip, sshPort, user, pass)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Save credentials to machine record for auto-sync (only when user explicitly provided password)
if req.Password != "" {
encPass, _ := utils.Encrypt(req.Password)
_, _ = db.DB.Exec(`UPDATE machines SET ssh_username=?, ssh_password=? WHERE id=?`, req.Username, encPass, id)
}
c.JSON(http.StatusOK, result)
}
func (h *MachineHandler) SyncSSH(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
var req models.SSHInfoResult
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
portsStr := ""
if len(req.ListenPorts) > 0 {
b, _ := json.Marshal(req.ListenPorts)
portsStr = string(b)
// remove brackets
if len(portsStr) > 2 {
portsStr = portsStr[1 : len(portsStr)-1]
}
}
_, err = db.DB.Exec(`UPDATE machines SET cpu_info=?, memory_info=?, disk_info=?, uptime=?, listen_ports=?, ssh_synced_at=CURRENT_TIMESTAMP, updated_at=CURRENT_TIMESTAMP WHERE id=?`,
req.RawCPUInfo, req.RawMemoryInfo, req.RawDiskInfo, req.Uptime, portsStr, id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
middleware.LogOperation("update", "machine", &id, "sync-ssh", "", "", c.ClientIP(), middleware.CurrentUser(c))
c.JSON(http.StatusOK, gin.H{"message": "synced"})
}

View File

@@ -0,0 +1,94 @@
package handlers
import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"lan-manager/server/db"
"lan-manager/server/middleware"
"lan-manager/server/models"
)
type RelationshipHandler struct{}
func NewRelationshipHandler() *RelationshipHandler {
return &RelationshipHandler{}
}
func (h *RelationshipHandler) List(c *gin.Context) {
rows, err := db.DB.Query(`SELECT id, source_machine_id, target_machine_id, relation_type, source_port, target_port, notes FROM relationships`)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
defer rows.Close()
list := []models.Relationship{}
for rows.Next() {
var r models.Relationship
if err := rows.Scan(&r.ID, &r.SourceMachineID, &r.TargetMachineID, &r.RelationType, &r.SourcePort, &r.TargetPort, &r.Notes); err == nil {
list = append(list, r)
}
}
c.JSON(http.StatusOK, list)
}
func (h *RelationshipHandler) Create(c *gin.Context) {
var r models.Relationship
if err := c.ShouldBindJSON(&r); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
res, err := db.DB.Exec(`INSERT INTO relationships (source_machine_id, target_machine_id, relation_type, source_port, target_port, notes) VALUES (?, ?, ?, ?, ?, ?)`,
r.SourceMachineID, r.TargetMachineID, r.RelationType, r.SourcePort, r.TargetPort, r.Notes)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
id, _ := res.LastInsertId()
r.ID = id
middleware.LogOperation("create", "relationship", &r.ID, strconv.FormatInt(r.SourceMachineID, 10)+" -> "+strconv.FormatInt(r.TargetMachineID, 10), "", middleware.ToJSON(r), c.ClientIP(), middleware.CurrentUser(c))
c.JSON(http.StatusCreated, r)
}
func (h *RelationshipHandler) Update(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
var r models.Relationship
if err := c.ShouldBindJSON(&r); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
var old models.Relationship
_ = db.DB.QueryRow(`SELECT id, source_machine_id, target_machine_id, relation_type, source_port, target_port, notes FROM relationships WHERE id = ?`, id).
Scan(&old.ID, &old.SourceMachineID, &old.TargetMachineID, &old.RelationType, &old.SourcePort, &old.TargetPort, &old.Notes)
_, err = db.DB.Exec(`UPDATE relationships SET source_machine_id=?, target_machine_id=?, relation_type=?, source_port=?, target_port=?, notes=? WHERE id=?`,
r.SourceMachineID, r.TargetMachineID, r.RelationType, r.SourcePort, r.TargetPort, r.Notes, id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
r.ID = id
middleware.LogOperation("update", "relationship", &r.ID, strconv.FormatInt(r.SourceMachineID, 10)+" -> "+strconv.FormatInt(r.TargetMachineID, 10), middleware.ToJSON(old), middleware.ToJSON(r), c.ClientIP(), middleware.CurrentUser(c))
c.JSON(http.StatusOK, r)
}
func (h *RelationshipHandler) Delete(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
var src, tgt int64
_ = db.DB.QueryRow(`SELECT source_machine_id, target_machine_id FROM relationships WHERE id = ?`, id).Scan(&src, &tgt)
_, err = db.DB.Exec(`DELETE FROM relationships WHERE id = ?`, id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
middleware.LogOperation("delete", "relationship", &id, strconv.FormatInt(src, 10)+" -> "+strconv.FormatInt(tgt, 10), "", "", c.ClientIP(), middleware.CurrentUser(c))
c.JSON(http.StatusOK, gin.H{"message": "deleted"})
}

139
server/handlers/services.go Normal file
View File

@@ -0,0 +1,139 @@
package handlers
import (
"database/sql"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"lan-manager/server/db"
"lan-manager/server/middleware"
"lan-manager/server/models"
)
type ServiceHandler struct{}
func NewServiceHandler() *ServiceHandler {
return &ServiceHandler{}
}
func (h *ServiceHandler) Create(c *gin.Context) {
machineID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid machine_id"})
return
}
var s models.Service
if err := c.ShouldBindJSON(&s); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
s.MachineID = machineID
res, err := db.DB.Exec(`INSERT INTO services (machine_id, name, port, protocol, notes, target_machine_id, target_notes) VALUES (?, ?, ?, ?, ?, ?, ?)`,
s.MachineID, s.Name, s.Port, s.Protocol, s.Notes, s.TargetMachineID, s.TargetNotes)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
id, _ := res.LastInsertId()
s.ID = id
middleware.LogOperation("create", "service", &s.ID, s.Name+" on machine "+strconv.FormatInt(machineID, 10), "", middleware.ToJSON(s), c.ClientIP(), middleware.CurrentUser(c))
c.JSON(http.StatusCreated, s)
}
func (h *ServiceHandler) Update(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
var s models.Service
if err := c.ShouldBindJSON(&s); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
var old models.Service
_ = db.DB.QueryRow(`SELECT id, machine_id, name, port, protocol, notes, target_machine_id, target_notes FROM services WHERE id = ?`, id).
Scan(&old.ID, &old.MachineID, &old.Name, &old.Port, &old.Protocol, &old.Notes, &old.TargetMachineID, &old.TargetNotes)
_, err = db.DB.Exec(`UPDATE services SET name=?, port=?, protocol=?, notes=?, target_machine_id=?, target_notes=? WHERE id=?`,
s.Name, s.Port, s.Protocol, s.Notes, s.TargetMachineID, s.TargetNotes, id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
s.ID = id
middleware.LogOperation("update", "service", &s.ID, s.Name, middleware.ToJSON(old), middleware.ToJSON(s), c.ClientIP(), middleware.CurrentUser(c))
c.JSON(http.StatusOK, s)
}
func (h *ServiceHandler) Delete(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
var name string
var machineID int64
_ = db.DB.QueryRow(`SELECT name, machine_id FROM services WHERE id = ?`, id).Scan(&name, &machineID)
_, err = db.DB.Exec(`DELETE FROM services WHERE id = ?`, id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
middleware.LogOperation("delete", "service", &id, name+" on machine "+strconv.FormatInt(machineID, 10), "", "", c.ClientIP(), middleware.CurrentUser(c))
c.JSON(http.StatusOK, gin.H{"message": "deleted"})
}
func (h *ServiceHandler) ListByMachine(c *gin.Context) {
machineID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid machine_id"})
return
}
isAdmin := middleware.IsAdmin(c)
rows, err := db.DB.Query(`SELECT id, machine_id, name, port, protocol, notes, target_machine_id, target_notes FROM services WHERE machine_id = ?`, machineID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
defer rows.Close()
list := []models.Service{}
for rows.Next() {
var s models.Service
var tm sql.NullInt64
if err := rows.Scan(&s.ID, &s.MachineID, &s.Name, &s.Port, &s.Protocol, &s.Notes, &tm, &s.TargetNotes); err == nil {
if tm.Valid {
tmid := tm.Int64
s.TargetMachineID = &tmid
}
if !isAdmin {
s.Notes = ""
s.TargetNotes = ""
}
list = append(list, s)
}
}
c.JSON(http.StatusOK, list)
}
func (h *ServiceHandler) ListAll(c *gin.Context) {
rows, err := db.DB.Query(`SELECT id, machine_id, name, port, protocol, notes, target_machine_id, target_notes FROM services`)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
defer rows.Close()
list := []models.Service{}
for rows.Next() {
var s models.Service
var tm sql.NullInt64
if err := rows.Scan(&s.ID, &s.MachineID, &s.Name, &s.Port, &s.Protocol, &s.Notes, &tm, &s.TargetNotes); err == nil {
if tm.Valid {
tmid := tm.Int64
s.TargetMachineID = &tmid
}
list = append(list, s)
}
}
c.JSON(http.StatusOK, list)
}

156
server/main.go Normal file
View File

@@ -0,0 +1,156 @@
package main
import (
"context"
"embed"
"fmt"
"io/fs"
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"
"github.com/gin-contrib/sessions"
"github.com/gin-contrib/sessions/cookie"
"github.com/gin-gonic/gin"
"lan-manager/server/config"
"lan-manager/server/db"
"lan-manager/server/handlers"
"lan-manager/server/middleware"
"lan-manager/server/services"
)
//go:embed all:static
var webFS embed.FS
func main() {
cfg := config.Load()
if err := db.Init(cfg); err != nil {
fmt.Fprintf(os.Stderr, "db init failed: %v\n", err)
os.Exit(1)
}
gin.SetMode(gin.ReleaseMode)
r := gin.New()
r.RedirectTrailingSlash = false
r.RedirectFixedPath = false
r.Use(gin.Recovery())
store := cookie.NewStore([]byte(cfg.SessionSecret))
store.Options(sessions.Options{
Path: "/",
MaxAge: 86400 * 7,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
})
r.Use(sessions.Sessions("lanmgr_session", store))
// API routes
api := r.Group("/api")
{
authH := handlers.NewAuthHandler(cfg)
api.POST("/auth/login", authH.Login)
api.POST("/auth/logout", authH.Logout)
api.GET("/auth/me", authH.Me)
api.GET("/health", handlers.NewHealthHandler().Check)
// Guest + Admin read-only routes
guest := api.Group("/")
guest.Use(middleware.OptionalAuth())
{
mh := handlers.NewMachineHandler()
guest.GET("/machines", mh.List)
}
// Admin only routes
admin := api.Group("/")
admin.Use(middleware.AuthRequired())
{
mh := handlers.NewMachineHandler()
admin.GET("/machines/:id", mh.Get)
admin.POST("/machines", mh.Create)
admin.PUT("/machines/:id", mh.Update)
admin.DELETE("/machines/:id", mh.Delete)
admin.POST("/machines/:id/ssh-info", mh.SSHInfo)
admin.POST("/machines/:id/sync-ssh", mh.SyncSSH)
sh := handlers.NewServiceHandler()
admin.GET("/machines/:id/services", sh.ListByMachine)
admin.POST("/machines/:id/services", sh.Create)
admin.PUT("/services/:id", sh.Update)
admin.DELETE("/services/:id", sh.Delete)
admin.GET("/services", sh.ListAll)
rh := handlers.NewRelationshipHandler()
admin.GET("/relationships", rh.List)
admin.POST("/relationships", rh.Create)
admin.PUT("/relationships/:id", rh.Update)
admin.DELETE("/relationships/:id", rh.Delete)
admin.GET("/logs", handlers.NewLogHandler().List)
eh := handlers.NewExportHandler()
admin.GET("/export", eh.Export)
admin.POST("/import", eh.Import)
}
}
// Static files
if cfg.WebStaticPath != "" {
r.Static("/", cfg.WebStaticPath)
r.NoRoute(func(c *gin.Context) {
c.File(cfg.WebStaticPath + "/index.html")
})
} else {
staticFS, err := fs.Sub(webFS, "static")
if err != nil {
fmt.Fprintf(os.Stderr, "static embed failed: %v\n", err)
os.Exit(1)
}
fileServer := http.FileServer(http.FS(staticFS))
r.NoRoute(func(c *gin.Context) {
path := c.Request.URL.Path
if path != "/" && path != "" && !strings.HasPrefix(path, "/assets/") && path != "/favicon.ico" {
c.Request.URL.Path = "/"
}
fileServer.ServeHTTP(c.Writer, c.Request)
})
}
// Start ping service
services.StartPingService(cfg.PingInterval)
// Start log cleanup
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
services.CleanupLogs(ctx, cfg.LogRetentionDays)
srv := &http.Server{
Addr: cfg.Host + ":" + cfg.Port,
Handler: r,
}
go func() {
fmt.Printf("Server listening on %s:%s\n", cfg.Host, cfg.Port)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
fmt.Fprintf(os.Stderr, "server error: %v\n", err)
os.Exit(1)
}
}()
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
fmt.Println("Shutting down server...")
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer shutdownCancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
fmt.Fprintf(os.Stderr, "server shutdown error: %v\n", err)
}
fmt.Println("Server exited")
}

53
server/middleware/auth.go Normal file
View File

@@ -0,0 +1,53 @@
package middleware
import (
"net/http"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
)
const AdminSessionKey = "admin_user"
func AuthRequired() gin.HandlerFunc {
return func(c *gin.Context) {
session := sessions.Default(c)
user := session.Get(AdminSessionKey)
if user == nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
return
}
c.Set("admin_user", user.(string))
c.Set("is_admin", true)
c.Next()
}
}
func OptionalAuth() gin.HandlerFunc {
return func(c *gin.Context) {
session := sessions.Default(c)
if user := session.Get(AdminSessionKey); user != nil {
c.Set("admin_user", user.(string))
c.Set("is_admin", true)
} else {
c.Set("is_admin", false)
}
c.Next()
}
}
func IsAdmin(c *gin.Context) bool {
v, exists := c.Get("is_admin")
if !exists {
return false
}
return v.(bool)
}
func CurrentUser(c *gin.Context) string {
u, _ := c.Get("admin_user")
if u == nil {
return ""
}
return u.(string)
}

19
server/middleware/log.go Normal file
View File

@@ -0,0 +1,19 @@
package middleware
import (
"encoding/json"
"lan-manager/server/db"
)
func LogOperation(action, entityType string, entityID *int64, entityName, oldValue, newValue, sourceIP, username string) {
_, _ = db.DB.Exec(
`INSERT INTO operation_logs (action, entity_type, entity_id, entity_name, old_value, new_value, source_ip, username)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
action, entityType, entityID, entityName, oldValue, newValue, sourceIP, username,
)
}
func ToJSON(v interface{}) string {
b, _ := json.Marshal(v)
return string(b)
}

114
server/models/models.go Normal file
View File

@@ -0,0 +1,114 @@
package models
import (
"database/sql"
"encoding/json"
"time"
)
type NullString struct {
sql.NullString
}
func (ns NullString) MarshalJSON() ([]byte, error) {
if ns.Valid {
return json.Marshal(ns.String)
}
return json.Marshal("")
}
func (ns *NullString) UnmarshalJSON(data []byte) error {
if string(data) == "null" {
ns.Valid = false
ns.String = ""
return nil
}
var s string
if err := json.Unmarshal(data, &s); err != nil {
return err
}
ns.String = s
ns.Valid = true
return nil
}
type Machine struct {
ID int64 `json:"id"`
Hostname string `json:"hostname"`
IP string `json:"ip"`
MAC NullString `json:"mac"`
OsType string `json:"os_type"`
OsVersion NullString `json:"os_version"`
Notes NullString `json:"notes"`
SSHPort int `json:"ssh_port"`
SSHUsername NullString `json:"ssh_username"`
SSHPassword NullString `json:"ssh_password"`
IsOnline bool `json:"is_online"`
LastPingAt *time.Time `json:"last_ping_at"`
CPUInfo NullString `json:"cpu_info"`
MemoryInfo NullString `json:"memory_info"`
DiskInfo NullString `json:"disk_info"`
Uptime NullString `json:"uptime"`
ListenPorts NullString `json:"listen_ports"`
SSHSyncedAt *time.Time `json:"ssh_synced_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ServiceCount int `json:"service_count"`
}
type Service struct {
ID int64 `json:"id"`
MachineID int64 `json:"machine_id"`
Name string `json:"name"`
Port int `json:"port"`
Protocol string `json:"protocol"`
Notes string `json:"notes"`
TargetMachineID *int64 `json:"target_machine_id"`
TargetNotes string `json:"target_notes"`
}
type Relationship struct {
ID int64 `json:"id"`
SourceMachineID int64 `json:"source_machine_id"`
TargetMachineID int64 `json:"target_machine_id"`
RelationType string `json:"relation_type"`
SourcePort *int `json:"source_port"`
TargetPort *int `json:"target_port"`
Notes string `json:"notes"`
}
type OperationLog struct {
ID int64 `json:"id"`
Action string `json:"action"`
EntityType string `json:"entity_type"`
EntityID *int64 `json:"entity_id"`
EntityName string `json:"entity_name"`
OldValue string `json:"old_value"`
NewValue string `json:"new_value"`
SourceIP string `json:"source_ip"`
Username string `json:"username"`
CreatedAt time.Time `json:"created_at"`
}
type LoginRequest struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
}
type SSHInfoRequest struct {
Username string `json:"username" binding:"required"`
Password string `json:"password"`
}
type SSHInfoResult struct {
Hostname string `json:"hostname"`
OsVersion string `json:"os_version"`
CPU string `json:"cpu"`
Memory string `json:"memory"`
Disk string `json:"disk"`
Uptime string `json:"uptime"`
ListenPorts []int `json:"listen_ports"`
RawCPUInfo string `json:"raw_cpu_info"`
RawMemoryInfo string `json:"raw_memory_info"`
RawDiskInfo string `json:"raw_disk_info"`
}

182
server/services/ping.go Normal file
View File

@@ -0,0 +1,182 @@
package services
import (
"context"
"encoding/json"
"fmt"
"lan-manager/server/db"
"lan-manager/server/utils"
"os/exec"
"runtime"
"strings"
"time"
"net"
"golang.org/x/net/icmp"
"golang.org/x/net/ipv4"
)
func PingHost(ip string) bool {
// Try system ping first (no special privileges needed on Linux)
if ok := pingWithSystem(ip); ok {
return true
}
// Fallback to raw ICMP
return pingWithICMP(ip)
}
func pingWithSystem(ip string) bool {
var cmd *exec.Cmd
if runtime.GOOS == "windows" {
cmd = exec.Command("ping", "-n", "1", "-w", "3000", ip)
} else if runtime.GOOS == "darwin" {
// macOS ping -W is in milliseconds
cmd = exec.Command("ping", "-c", "1", "-W", "3000", ip)
} else {
// Linux ping -W is in seconds
cmd = exec.Command("ping", "-c", "1", "-W", "3", ip)
}
err := cmd.Run()
return err == nil
}
func pingWithICMP(ip string) bool {
c, err := icmp.ListenPacket("ip4:icmp", "0.0.0.0")
if err != nil {
return false
}
defer c.Close()
m := &icmp.Message{
Type: ipv4.ICMPTypeEcho,
Code: 0,
Body: &icmp.Echo{ID: 1, Seq: 1, Data: []byte("lan-manager")},
}
wb, err := m.Marshal(nil)
if err != nil {
return false
}
if _, err := c.WriteTo(wb, &net.IPAddr{IP: parseIP(ip)}); err != nil {
return false
}
rb := make([]byte, 1500)
_ = c.SetReadDeadline(time.Now().Add(3 * time.Second))
n, _, err := c.ReadFrom(rb)
if err != nil {
return false
}
rm, err := icmp.ParseMessage(ipv4.ICMPTypeEchoReply.Protocol(), rb[:n])
if err != nil {
return false
}
return rm.Type == ipv4.ICMPTypeEchoReply
}
func parseIP(ip string) []byte {
parts := strings.Split(ip, ".")
if len(parts) != 4 {
return nil
}
res := make([]byte, 4)
for i, p := range parts {
var v byte
fmt.Sscanf(p, "%d", &v)
res[i] = v
}
return res
}
func StartPingService(interval int) {
ticker := time.NewTicker(time.Duration(interval) * time.Second)
go func() {
for range ticker.C {
rows, err := db.DB.Query(`SELECT id, ip, ssh_port, ssh_username, ssh_password FROM machines`)
if err != nil {
fmt.Printf("[Ping] query error: %v\n", err)
continue
}
type machinePing struct {
id int64
ip string
sshPort int
sshUsername string
sshPassword string
}
list := []machinePing{}
for rows.Next() {
var m machinePing
if err := rows.Scan(&m.id, &m.ip, &m.sshPort, &m.sshUsername, &m.sshPassword); err == nil {
list = append(list, m)
} else {
fmt.Printf("[Ping] scan error: %v\n", err)
}
}
rows.Close()
fmt.Printf("[Ping] tick processed %d machines\n", len(list))
for _, m := range list {
online := PingHost(m.ip)
var onlineInt int
if online {
onlineInt = 1
}
_, dbErr := db.DB.Exec(`UPDATE machines SET is_online = ?, last_ping_at = CURRENT_TIMESTAMP WHERE id = ?`, onlineInt, m.id)
if dbErr != nil {
fmt.Printf("[Ping] update status error for %s: %v\n", m.ip, dbErr)
} else {
fmt.Printf("[Ping] %s -> online=%v\n", m.ip, online)
}
// Auto fetch SSH info if credentials are saved
if m.sshUsername != "" && m.sshPassword != "" {
go func(mid int64, mip string, mport int, muser, mpass string) {
plainPass, decryptErr := utils.Decrypt(mpass)
if decryptErr != nil {
fmt.Printf("[SSH] decrypt failed for %s:%d: %v\n", mip, mport, decryptErr)
return
}
result, err := GetSSHInfo(mip, mport, muser, plainPass)
if err != nil {
fmt.Printf("[SSH] auto-fetch failed for %s:%d: %v\n", mip, mport, err)
return
}
portsStr := ""
if len(result.ListenPorts) > 0 {
b, _ := json.Marshal(result.ListenPorts)
portsStr = string(b)
if len(portsStr) > 2 {
portsStr = portsStr[1 : len(portsStr)-1]
}
}
_, dbErr := db.DB.Exec(`UPDATE machines SET cpu_info=?, memory_info=?, disk_info=?, uptime=?, listen_ports=?, ssh_synced_at=CURRENT_TIMESTAMP, updated_at=CURRENT_TIMESTAMP WHERE id=?`,
result.RawCPUInfo, result.RawMemoryInfo, result.RawDiskInfo, result.Uptime, portsStr, mid)
if dbErr != nil {
fmt.Printf("[SSH] auto-fetch db error for %s:%d: %v\n", mip, mport, dbErr)
} else {
fmt.Printf("[SSH] auto-fetch success for %s:%d\n", mip, mport)
}
}(m.id, m.ip, m.sshPort, m.sshUsername, m.sshPassword)
}
}
}
}()
}
func CleanupLogs(ctx context.Context, retentionDays int) {
if retentionDays <= 0 {
return
}
ticker := time.NewTicker(24 * time.Hour)
go func() {
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
_, _ = db.DB.Exec(`DELETE FROM operation_logs WHERE created_at < datetime('now', '-`+fmt.Sprintf("%d", retentionDays)+` days')`)
}
}
}()
}

113
server/services/ssh.go Normal file
View File

@@ -0,0 +1,113 @@
package services
import (
"bufio"
"bytes"
"lan-manager/server/config"
"lan-manager/server/models"
"strconv"
"strings"
"time"
"golang.org/x/crypto/ssh"
)
func GetSSHInfo(ip string, port int, user, pass string) (*models.SSHInfoResult, error) {
if port <= 0 {
port = 22
}
cfg := &ssh.ClientConfig{
User: user,
Auth: []ssh.AuthMethod{
ssh.Password(pass),
},
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
Timeout: time.Duration(config.Load().SSHTimeout) * time.Second,
}
client, err := ssh.Dial("tcp", ip+":"+strconv.Itoa(port), cfg)
if err != nil {
return nil, err
}
defer client.Close()
result := &models.SSHInfoResult{}
// Hostname
if out, err := runSSHCommand(client, "hostname"); err == nil {
result.Hostname = strings.TrimSpace(out)
}
// OS Version
if out, err := runSSHCommand(client, "cat /etc/os-release | grep PRETTY_NAME | cut -d= -f2 | tr -d '\"'"); err == nil && out != "" {
result.OsVersion = strings.TrimSpace(out)
} else if out, err := runSSHCommand(client, "uname -sr"); err == nil {
result.OsVersion = strings.TrimSpace(out)
}
// CPU (two samples from /proc/stat for accurate real-time usage)
cpuScript := `c1=$(awk '/^cpu /{print $2+$4,$2+$4+$5}' /proc/stat) && sleep 1 && c2=$(awk '/^cpu /{print $2+$4,$2+$4+$5}' /proc/stat) && awk 'BEGIN{split("'"$c1"'", a, " "); split("'"$c2"'", b, " "); if(b[2]-a[2]>0) printf "%.1f", 100*(b[1]-a[1])/(b[2]-a[2]); else print "0.0"}'`
if out, err := runSSHCommand(client, cpuScript); err == nil && out != "" {
cpuUsage := strings.TrimSpace(out)
if cpuUsage == "" {
cpuUsage = "0.0"
}
cores := ""
if cout, err := runSSHCommand(client, "nproc"); err == nil {
cores = strings.TrimSpace(cout)
}
result.CPU = cpuUsage + "%"
if cores != "" {
result.CPU += " (" + cores + " cores)"
}
result.RawCPUInfo = result.CPU
}
// Memory
if out, err := runSSHCommand(client, "free -m | awk 'NR==2{printf \"%.1f/%.1f (%.0f%%)\", $3/1024,$2/1024,$3*100/$2 }'"); err == nil && out != "" {
result.Memory = strings.TrimSpace(out)
result.RawMemoryInfo = result.Memory
}
// Disk (exclude virtual filesystems, keep all real partitions)
// Use df -hP to prevent long filesystem names from wrapping to next line
if out, err := runSSHCommand(client, "df -hP | awk 'NR>1 && $1 !~ /^(tmpfs|devtmpfs|squashfs|overlay|efivarfs|tracefs|securityfs|pstore|bpf|configfs|fusectl|mqueue|hugetlbfs|rpc_pipefs|nfsd|binfmt_misc|autofs|devpts|proc|sysfs|cgroup|cgroup2|ramfs)$/ {printf \"%s %s/%s (%s) \", $6,$3,$2,$5}'"); err == nil && out != "" {
result.Disk = strings.TrimSpace(out)
result.RawDiskInfo = result.Disk
}
// Uptime
if out, err := runSSHCommand(client, "uptime -p 2>/dev/null || uptime | awk -F',' '{print $1}'"); err == nil {
result.Uptime = strings.TrimSpace(out)
}
// Listen ports
if out, err := runSSHCommand(client, "ss -tlnp | awk 'NR>1 {print $4}' | sed 's/.*://' | sort -n | uniq"); err == nil && out != "" {
ports := []int{}
scanner := bufio.NewScanner(strings.NewReader(out))
for scanner.Scan() {
p := strings.TrimSpace(scanner.Text())
if p == "" {
continue
}
if pi, err := strconv.Atoi(p); err == nil {
ports = append(ports, pi)
}
}
result.ListenPorts = ports
}
return result, nil
}
func runSSHCommand(client *ssh.Client, cmd string) (string, error) {
session, err := client.NewSession()
if err != nil {
return "", err
}
defer session.Close()
var b bytes.Buffer
session.Stdout = &b
err = session.Run(cmd)
return b.String(), err
}

78
server/utils/crypto.go Normal file
View File

@@ -0,0 +1,78 @@
package utils
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"io"
"log"
"os"
)
var encryptKey []byte
func init() {
key := os.Getenv("ENCRYPT_KEY")
if key == "" {
key = "lan-manager-default-key-change-in-production"
log.Println("[WARN] ENCRYPT_KEY not set, using default key. Please set ENCRYPT_KEY in production.")
}
h := sha256.Sum256([]byte(key))
encryptKey = h[:]
}
// Encrypt 使用 AES-GCM 加密明文,返回 base64 编码的密文
func Encrypt(plaintext string) (string, error) {
if plaintext == "" {
return "", nil
}
block, err := aes.NewCipher(encryptKey)
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
nonce := make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return "", err
}
ciphertext := gcm.Seal(nonce, nonce, []byte(plaintext), nil)
return base64.StdEncoding.EncodeToString(ciphertext), nil
}
// Decrypt 使用 AES-GCM 解密密文,输入为 base64 编码。
// 为了兼容升级前的明文密码,如果 base64 解码失败或解密失败,则直接返回原始字符串。
func Decrypt(ciphertext string) (string, error) {
if ciphertext == "" {
return "", nil
}
data, err := base64.StdEncoding.DecodeString(ciphertext)
if err != nil {
// 兼容旧明文密码
return ciphertext, nil
}
block, err := aes.NewCipher(encryptKey)
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
nonceSize := gcm.NonceSize()
if len(data) < nonceSize {
// 兼容旧明文密码
return ciphertext, nil
}
nonce, cipherData := data[:nonceSize], data[nonceSize:]
plain, err := gcm.Open(nil, nonce, cipherData, nil)
if err != nil {
// 兼容旧明文密码
return ciphertext, nil
}
return string(plain), nil
}