Initial commit: LAN Manager with Go backend and Vue frontend
This commit is contained in:
67
server/config/config.go
Normal file
67
server/config/config.go
Normal 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
107
server/db/db.go
Normal 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
55
server/handlers/auth.go
Normal 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
127
server/handlers/export.go
Normal 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
35
server/handlers/health.go
Normal 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
51
server/handlers/logs.go
Normal 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
335
server/handlers/machines.go
Normal 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"})
|
||||
}
|
||||
94
server/handlers/relationships.go
Normal file
94
server/handlers/relationships.go
Normal 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
139
server/handlers/services.go
Normal 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
156
server/main.go
Normal 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
53
server/middleware/auth.go
Normal 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
19
server/middleware/log.go
Normal 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
114
server/models/models.go
Normal 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
182
server/services/ping.go
Normal 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
113
server/services/ssh.go
Normal 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
78
server/utils/crypto.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user