feat: finish project basic structure and core functions

This commit is contained in:
2026-08-03 22:15:53 +08:00
parent 3ab175b595
commit 19b69acc29
29 changed files with 3864 additions and 2 deletions
+3 -1
View File
@@ -1 +1,3 @@
agent.md /.claude
agent.md
config.json
+157 -1
View File
@@ -1,2 +1,158 @@
# chisato-server # Nukumizu Backend
Remote server monitoring and command execution subsystem for [Komari](https://www.komari.wiki).
## Overview
Nukumizu connects to a Komari Dashboard instance to:
- Monitor server status in real-time via WebSocket
- Execute commands on remote servers via the Komari task API
- Provide Bot interfaces on QQ (via NapCat) and Telegram for interactive server control
- Send one-way status notifications via Email, Ntfy, and Webhook
## Project Structure
```
nukumizu-backend/
├── main.go # Entry point, startup sequence, graceful shutdown
├── router.go # HTTP route registration
├── config/
│ └── config.go # Configuration loading, defaults
├── handler/
│ ├── user.go # User login/register handlers
│ ├── server.go # Server list/status/exec handlers
│ ├── bot.go # Bot message receive handler
│ └── health.go # Health check endpoint
├── database/
│ └── user.go # SQLite user database
├── utils/
│ ├── auth.go # Token management, authentication
│ └── middleware.go # Rate limit, CORS, XSS protection
├── postLog/ # Logging subsystem
├── internal/
│ ├── komari/
│ │ ├── client.go # Komari HTTP API client
│ │ └── ws.go # Komari WebSocket client
│ ├── node/
│ │ └── tracker.go # Thread-safe node state tracking
│ ├── controller/
│ │ ├── controller.go # Controller interface & manager
│ │ ├── qq.go # QQ (Napcat) Bot controller
│ │ ├── telegram.go # Telegram Bot controller
│ │ ├── email.go # Email notification controller
│ │ ├── ntfy.go # Ntfy notification controller
│ │ └── webhook.go # Webhook notification controller
│ └── template/
│ └── template.go # Message template engine
```
## Configuration
Copy and modify `config.json` at the project root:
```json
{
"system": {
"debugMode": true,
"listenAddr": "0.0.0.0",
"listenPort": "8080"
},
"komari": {
"dashboardURL": "http://127.0.0.1:25774",
"account": {
"username": "admin",
"password": "admin"
}
},
"controllerMethod": {
"qq(napcat)": {
"enabled": false,
"url": "http://127.0.0.1:8081",
"token": "",
"botQQID": 0,
"listenMethod": "global",
"admins": [],
"trustedGroups": []
},
"telegram": {
"enabled": false,
"botToken": "",
"listenMethod": "global",
"admins": [],
"trustedGroups": []
},
"email": {
"enabled": false,
"smtpHost": "",
"smtpPort": 587,
"username": "",
"password": "",
"from": "",
"to": [],
"useTLS": true
},
"ntfy": {
"enabled": false,
"server": "https://ntfy.sh",
"topic": "",
"token": "",
"priority": "default"
},
"webhook": {
"enabled": false,
"url": "",
"method": "POST",
"headers": {},
"template": ""
}
},
"controllerMessage": {
"SERVER_STATUS_CHANGED": "Server Status Changed Alert\n{{ serverName }} - {{ upStatus }}\nEvent: {{ event }}\nServer Name: {{ serverName }}\nMessage: {{ message }}\nTime: {{ time }}",
"SERVER_LIST": "All server list:\nOnline:\n{{ list.onlineServers }}\nOffline:\n{{ list.offlineServers }}",
"SERVER_EXECUTE_RESULT": "Command execute result:\nServer Name: {{ serverName }}\nCommand: {{ command }}\n***Result***\n\n{{ result }}\n\n************\nTime: {{ time }}"
}
}
```
## API Endpoints
All API responses follow the format: `{"success": bool, "message": "..."}`
Authentication is via `X-Token` and `X-Timestamp` HTTP headers.
| Endpoint | Method | Auth | Description |
|---|---|---|---|
| `/api/user/login` | POST | None | User login |
| `/api/user/register` | POST | None | First-time registration |
| `/api/server/list` | GET | bot/admin | List all servers |
| `/api/server/getStatus` | GET | bot/admin | Get server recent status |
| `/api/server/exec` | POST | bot/admin | Execute command on server(s) |
| `/api/bot/msg/recv` | POST | bot | Receive bot messages (from napcat-bridge) |
| `/health` | GET | None | Health check |
| `/api/system/getLogs` | WS | None | Real-time log streaming |
## Bot Commands
| Command | Permission | Description |
|---|---|---|
| `/list` | None | List all server status |
| `/shutdown <uuid>` | Admin | Shutdown specific server |
| `/reboot <uuid>` | Admin | Reboot specific server |
| `/status <uuid>` | None | Get server detailed status |
| `/run <uuid\|all> <command>` | Admin | Run command on server(s) |
## Building
```bash
go build -o nukumizu-backend .
```
## Running
```bash
./nukumizu-backend -config config.json
```
## License
See [LICENSE](LICENSE).
+110
View File
@@ -0,0 +1,110 @@
package config
import (
"encoding/json"
"fmt"
"os"
)
// LoadConfig reads and parses the configuration file, applies defaults,
// and stores it as a global singleton.
func LoadConfig(configPath string) (*Config, error) {
data, err := os.ReadFile(configPath)
if err != nil {
return nil, fmt.Errorf("failed to read config file: %w", err)
}
var cfg Config
if err := json.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("failed to parse config file: %w", err)
}
// Apply defaults for System.
if cfg.System.ListenAddr == "" {
cfg.System.ListenAddr = "0.0.0.0"
}
if cfg.System.ListenPort == "" {
cfg.System.ListenPort = "8080"
}
// Apply defaults for QQ controller.
if cfg.ControllerMethod.QQ.ListenMethod == "" {
cfg.ControllerMethod.QQ.ListenMethod = "global"
}
if cfg.ControllerMethod.QQ.Admins == nil {
cfg.ControllerMethod.QQ.Admins = []string{}
}
if cfg.ControllerMethod.QQ.TrustedGroups == nil {
cfg.ControllerMethod.QQ.TrustedGroups = []string{}
}
// Apply defaults for Telegram controller.
if cfg.ControllerMethod.Telegram.ListenMethod == "" {
cfg.ControllerMethod.Telegram.ListenMethod = "global"
}
if cfg.ControllerMethod.Telegram.Admins == nil {
cfg.ControllerMethod.Telegram.Admins = []string{}
}
if cfg.ControllerMethod.Telegram.TrustedGroups == nil {
cfg.ControllerMethod.Telegram.TrustedGroups = []string{}
}
// Apply defaults for Email controller.
if cfg.ControllerMethod.Email.SMTPPort == 0 {
cfg.ControllerMethod.Email.SMTPPort = 587
}
if cfg.ControllerMethod.Email.To == nil {
cfg.ControllerMethod.Email.To = []string{}
}
// Apply defaults for Ntfy controller.
if cfg.ControllerMethod.Ntfy.Server == "" {
cfg.ControllerMethod.Ntfy.Server = "https://ntfy.sh"
}
if cfg.ControllerMethod.Ntfy.Priority == "" {
cfg.ControllerMethod.Ntfy.Priority = "default"
}
// Apply defaults for Webhook controller.
if cfg.ControllerMethod.Webhook.Method == "" {
cfg.ControllerMethod.Webhook.Method = "POST"
}
if cfg.ControllerMethod.Webhook.Headers == nil {
cfg.ControllerMethod.Webhook.Headers = map[string]string{}
}
// Apply defaults for paths.
if cfg.DataPath == "" {
cfg.DataPath = "./data"
}
if cfg.DBPath == "" {
cfg.DBPath = "./logs.db"
}
// Apply default message templates if not specified.
if cfg.ControllerMessage.ServerStatusChanged == "" {
cfg.ControllerMessage.ServerStatusChanged = "Server Status Changed Alert\n{{ serverName }} - {{ upStatus }}\nEvent: {{ event }}\nServer Name: {{ serverName }}\nMessage: {{ message }}\nTime: {{ time }}"
}
if cfg.ControllerMessage.ServerList == "" {
cfg.ControllerMessage.ServerList = "All server list:\nOnline:\n{{ list.onlineServers }}\nOffline:\n{{ list.offlineServers }}"
}
if cfg.ControllerMessage.ServerExecuteResult == "" {
cfg.ControllerMessage.ServerExecuteResult = "Command execute result:\nServer Name: {{ serverName }}\nCommand: {{ command }}\n***Result***\n\n{{ result }}\n\n************\nTime: {{ time }}"
}
globalConfig = &cfg
return &cfg, nil
}
// GetConfig returns the global configuration singleton.
func GetConfig() *Config {
return globalConfig
}
// IsDebugMode returns whether debug mode is enabled.
func IsDebugMode() bool {
if globalConfig == nil {
return false
}
return globalConfig.System.DebugMode
}
+98
View File
@@ -0,0 +1,98 @@
package config
// SystemConfig holds system-level configuration.
type SystemConfig struct {
DebugMode bool `json:"debugMode"`
ListenAddr string `json:"listenAddr"`
ListenPort string `json:"listenPort"`
}
// KomariAccount holds Komari login credentials.
type KomariAccount struct {
Username string `json:"username"`
Password string `json:"password"`
}
// KomariConfig holds Komari Dashboard connection settings.
type KomariConfig struct {
DashboardURL string `json:"dashboardURL"`
Account KomariAccount `json:"account"`
}
// QQConfig holds QQ (Napcat) Bot controller configuration.
type QQConfig struct {
Enabled bool `json:"enabled"`
URL string `json:"url"`
Token string `json:"token"`
BotQQID int64 `json:"botQQID"`
ListenMethod string `json:"listenMethod"`
Admins []string `json:"admins"`
TrustedGroups []string `json:"trustedGroups"`
}
// TelegramConfig holds Telegram Bot controller configuration.
type TelegramConfig struct {
Enabled bool `json:"enabled"`
BotToken string `json:"botToken"`
ListenMethod string `json:"listenMethod"`
Admins []string `json:"admins"`
TrustedGroups []string `json:"trustedGroups"`
}
// EmailConfig holds Email notification controller configuration.
type EmailConfig struct {
Enabled bool `json:"enabled"`
SMTPHost string `json:"smtpHost"`
SMTPPort int `json:"smtpPort"`
Username string `json:"username"`
Password string `json:"password"`
From string `json:"from"`
To []string `json:"to"`
UseTLS bool `json:"useTLS"`
}
// NtfyConfig holds Ntfy notification controller configuration.
type NtfyConfig struct {
Enabled bool `json:"enabled"`
Server string `json:"server"`
Topic string `json:"topic"`
Token string `json:"token"`
Priority string `json:"priority"`
}
// WebhookConfig holds Webhook notification controller configuration.
type WebhookConfig struct {
Enabled bool `json:"enabled"`
URL string `json:"url"`
Method string `json:"method"`
Headers map[string]string `json:"headers"`
Template string `json:"template"`
}
// ControllerMethodConfig holds all controller method configurations.
type ControllerMethodConfig struct {
QQ QQConfig `json:"qq(napcat)"`
Telegram TelegramConfig `json:"telegram"`
Email EmailConfig `json:"email"`
Ntfy NtfyConfig `json:"ntfy"`
Webhook WebhookConfig `json:"webhook"`
}
// ControllerMessageConfig holds message templates for controller responses.
type ControllerMessageConfig struct {
ServerStatusChanged string `json:"SERVER_STATUS_CHANGED"`
ServerList string `json:"SERVER_LIST"`
ServerExecuteResult string `json:"SERVER_EXECUTE_RESULT"`
}
// Config is the top-level application configuration.
type Config struct {
System SystemConfig `json:"system"`
Komari KomariConfig `json:"komari"`
ControllerMethod ControllerMethodConfig `json:"controllerMethod"`
ControllerMessage ControllerMessageConfig `json:"controllerMessage"`
DataPath string `json:"dataPath"`
DBPath string `json:"dbPath"`
}
var globalConfig *Config
+140
View File
@@ -0,0 +1,140 @@
package database
import (
"crypto/sha256"
"database/sql"
"encoding/hex"
"fmt"
"os"
"path/filepath"
"time"
_ "modernc.org/sqlite"
)
// UserDB is the global database handle for user data.
var UserDB *sql.DB
// InitUserDB opens (or creates) the user database and initializes the users table.
func InitUserDB(dbPath string) error {
dir := filepath.Dir(dbPath)
if dir != "." {
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("failed to create database directory: %w", err)
}
}
var err error
UserDB, err = sql.Open("sqlite", dbPath)
if err != nil {
return fmt.Errorf("failed to open user database: %w", err)
}
UserDB.SetMaxOpenConns(1) // SQLite serializes writes.
UserDB.SetMaxIdleConns(1)
UserDB.SetConnMaxLifetime(5 * time.Minute)
if err := UserDB.Ping(); err != nil {
return fmt.Errorf("failed to ping user database: %w", err)
}
if err := initUserTable(); err != nil {
return fmt.Errorf("failed to initialize user table: %w", err)
}
return nil
}
func initUserTable() error {
createTableSQL := `
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password TEXT NOT NULL,
level TEXT NOT NULL DEFAULT 'admin',
register_date TEXT NOT NULL
);`
_, err := UserDB.Exec(createTableSQL)
return err
}
// HashPassword returns the SHA-256 hex hash of a password.
func HashPassword(password string) string {
hash := sha256.Sum256([]byte(password))
return hex.EncodeToString(hash[:])
}
// CreateUser inserts a new user into the database.
func CreateUser(username, password, level string) (int64, error) {
hashedPassword := HashPassword(password)
registerDate := time.Now().Format("2006-01-02 15:04:05")
result, err := UserDB.Exec(
"INSERT INTO users (username, password, level, register_date) VALUES (?, ?, ?, ?)",
username, hashedPassword, level, registerDate,
)
if err != nil {
return 0, fmt.Errorf("failed to create user: %w", err)
}
return result.LastInsertId()
}
// GetUserByUsername retrieves a user by username and validates the password.
func GetUserByUsername(username, password string) (int64, string, string, string, error) {
hashedPassword := HashPassword(password)
var id int64
var dbUsername string
var level string
var registerDate string
err := UserDB.QueryRow(
"SELECT id, username, level, register_date FROM users WHERE username = ? AND password = ?",
username, hashedPassword,
).Scan(&id, &dbUsername, &level, &registerDate)
if err == sql.ErrNoRows {
return 0, "", "", "", fmt.Errorf("invalid username or password")
}
if err != nil {
return 0, "", "", "", fmt.Errorf("database error: %w", err)
}
return id, dbUsername, level, registerDate, nil
}
// GetUserCount returns the total number of users in the database.
func GetUserCount() (int, error) {
var count int
err := UserDB.QueryRow("SELECT COUNT(*) FROM users").Scan(&count)
return count, err
}
// GetUserByID retrieves a user by their ID.
func GetUserByID(id int64) (string, string, string, error) {
var username string
var level string
var registerDate string
err := UserDB.QueryRow(
"SELECT username, level, register_date FROM users WHERE id = ?",
id,
).Scan(&username, &level, &registerDate)
if err == sql.ErrNoRows {
return "", "", "", fmt.Errorf("user not found")
}
if err != nil {
return "", "", "", fmt.Errorf("database error: %w", err)
}
return username, level, registerDate, nil
}
// CloseUserDB closes the user database connection.
func CloseUserDB() {
if UserDB != nil {
UserDB.Close()
}
}
View File
BIN
View File
Binary file not shown.
+22
View File
@@ -0,0 +1,22 @@
module nukumizu-backend
go 1.25.0
require (
github.com/gorilla/websocket v1.5.3
gopkg.in/mail.v2 v2.3.1
modernc.org/sqlite v1.55.0
)
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/sys v0.46.0 // indirect
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
modernc.org/libc v1.74.1 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)
+57
View File
@@ -0,0 +1,57 @@
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk=
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk=
gopkg.in/mail.v2 v2.3.1 h1:WYFn/oANrAGP2C0dcV6/pbkPzv8yGzqTjPmTeO7qoXk=
gopkg.in/mail.v2 v2.3.1/go.mod h1:htwXN1Qh09vZJ1NVKxQqHPBaCBbzKhp5GzuJEA4VJWw=
modernc.org/cc/v4 v4.29.0 h1:CXgwL8cvxmyzBQZzbSl/6xFtMCryb6u8IOqDci39cgc=
modernc.org/cc/v4 v4.29.0/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU=
modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI=
modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.74.1 h1:bdR4VTKFMC4966QSNZ05XLGI/VwzVa2kTUX51Dm0riQ=
modernc.org/libc v1.74.1/go.mod h1:uH4t5bOx3G3g9Xcmj10YKlTcVISlRDwv8VoQJG9n8Os=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.55.0 h1:hIFh0MCH0rGinQ/4KYb5/UbCkRkb+UP+OkLCVWa5MTM=
modernc.org/sqlite v1.55.0/go.mod h1:4ntCLuNmnH8+GNqjka1wNg7KJd5/Hi5FYp8K+XQ7GZw=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
+96
View File
@@ -0,0 +1,96 @@
package handler
import (
"encoding/json"
"net/http"
"nukumizu-backend/internal/controller"
"nukumizu-backend/postLog"
"nukumizu-backend/utils"
)
// OneBotMessage represents a OneBot 11 message event forwarded from napcat-bridge.
type OneBotMessage struct {
PostType string `json:"post_type"`
MessageType string `json:"message_type"`
GroupID int64 `json:"group_id"`
UserID int64 `json:"user_id"`
RawMessage string `json:"raw_message"`
Message string `json:"message"`
Sender struct {
UserID int64 `json:"user_id"`
Nickname string `json:"nickname"`
} `json:"sender"`
SelfID int64 `json:"self_id"`
SubType string `json:"sub_type"`
}
// BotMessageHandler handles POST /api/bot/msg/recv.
// Receives OneBot 11 events forwarded from napcat-bridge and
// routes them to the appropriate bot controller.
func BotMessageHandler(w http.ResponseWriter, r *http.Request) {
if !utils.Auth(w, r, "POST", "bot") {
return
}
var event OneBotMessage
if err := json.NewDecoder(r.Body).Decode(&event); err != nil {
postLog.Debug("Failed to parse bot message: " + err.Error())
utils.SendErrorResponse(w, http.StatusBadRequest, "invalid message format")
return
}
// Only handle message events.
if event.PostType != "message" {
utils.SendSuccessResponse(w, "event ignored", map[string]interface{}{
"post_type": event.PostType,
})
return
}
// Route to the Napcat/QQ controller.
ctrl := controller.GetController("qq(napcat)")
if ctrl == nil {
postLog.Debug("QQ controller not available, ignoring message")
utils.SendSuccessResponse(w, "controller not available", nil)
return
}
cmdCtrl, ok := ctrl.(controller.CommandController)
if !ok {
utils.SendErrorResponse(w, http.StatusInternalServerError, "controller does not support commands")
return
}
// Determine chat information.
chatID := event.GroupID
chatType := event.MessageType
if chatType == "private" {
chatID = event.UserID
}
// Build a command from the raw message.
cmd := controller.Command{
RawText: event.RawMessage,
ChatID: chatID,
ChatType: chatType,
SenderID: event.UserID,
}
response, err := cmdCtrl.HandleCommand(cmd)
if err != nil {
postLog.Error("Bot command handling failed: " + err.Error())
utils.SendErrorResponse(w, http.StatusInternalServerError, "command handling failed: "+err.Error())
return
}
if response != "" {
utils.SendSuccessResponse(w, "", map[string]interface{}{
"response": response,
"chatID": chatID,
"chatType": chatType,
})
} else {
utils.SendSuccessResponse(w, "no response", nil)
}
}
+28
View File
@@ -0,0 +1,28 @@
package handler
import (
"net/http"
"nukumizu-backend/database"
"nukumizu-backend/utils"
)
// HealthHandler handles GET /health.
func HealthHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
utils.SendErrorResponse(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
dbStatus := "ok"
if database.UserDB == nil {
dbStatus = "not initialized"
} else if err := database.UserDB.Ping(); err != nil {
dbStatus = "error: " + err.Error()
}
utils.SendSuccessResponse(w, "", map[string]interface{}{
"status": "ok",
"database": dbStatus,
})
}
+115
View File
@@ -0,0 +1,115 @@
package handler
import (
"encoding/json"
"fmt"
"net/http"
"nukumizu-backend/internal/komari"
"nukumizu-backend/internal/node"
"nukumizu-backend/internal/template"
"nukumizu-backend/postLog"
"nukumizu-backend/utils"
)
// ServerExecRequest represents the request body for POST /api/server/exec.
type ServerExecRequest struct {
UUID []string `json:"uuid"`
Command string `json:"command"`
}
// ServerListHandler handles GET /api/server/list.
func ServerListHandler(w http.ResponseWriter, r *http.Request) {
if !utils.Auth(w, r, "GET", "bot") {
return
}
tracker := node.GetTracker()
if tracker == nil {
utils.SendErrorResponse(w, http.StatusInternalServerError, "node tracker not initialized")
return
}
params := template.BuildParamsFromServerList()
result := template.Render("", params)
utils.SendSuccessResponse(w, "", map[string]interface{}{
"list": result,
})
}
// ServerGetStatusHandler handles GET /api/server/getStatus?uuid=xxx.
func ServerGetStatusHandler(w http.ResponseWriter, r *http.Request) {
if !utils.Auth(w, r, "GET", "bot") {
return
}
uuid := r.URL.Query().Get("uuid")
if uuid == "" {
utils.SendErrorResponse(w, http.StatusBadRequest, "missing uuid parameter")
return
}
// First try to get data from the local tracker.
tracker := node.GetTracker()
if tracker != nil {
if n, exists := tracker.GetNode(uuid); exists && n.LatestReport != nil {
utils.SendSuccessResponse(w, "", map[string]interface{}{
"uuid": uuid,
"report": n.LatestReport,
})
return
}
}
utils.SendErrorResponse(w, http.StatusNotFound, fmt.Sprintf("no recent data for uuid: %s", uuid))
}
// ServerExecHandler handles POST /api/server/exec.
func ServerExecHandler(w http.ResponseWriter, r *http.Request) {
if !utils.Auth(w, r, "POST", "bot") {
return
}
var req ServerExecRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
utils.SendErrorResponse(w, http.StatusBadRequest, "invalid request body")
return
}
if len(req.UUID) == 0 {
utils.SendErrorResponse(w, http.StatusBadRequest, "uuid array is required")
return
}
if req.Command == "" {
utils.SendErrorResponse(w, http.StatusBadRequest, "command is required")
return
}
// Get the Komari client from the global state.
komariClient := komari.GetClient()
if komariClient == nil {
utils.SendErrorResponse(w, http.StatusInternalServerError, "komari client not initialized")
return
}
taskID, err := komariClient.ExecTask(req.UUID, req.Command)
if err != nil {
postLog.Error("Komari exec task failed: " + err.Error())
utils.SendErrorResponse(w, http.StatusInternalServerError, "failed to execute command: "+err.Error())
return
}
results, err := komariClient.PollTaskResult(taskID)
if err != nil {
postLog.Error("Komari task polling failed: " + err.Error())
utils.SendErrorResponse(w, http.StatusInternalServerError, "failed to get task result: "+err.Error())
return
}
utils.SendSuccessResponse(w, "", map[string]interface{}{
"taskID": taskID,
"results": results,
})
}
+124
View File
@@ -0,0 +1,124 @@
package handler
import (
"encoding/json"
"net/http"
db "nukumizu-backend/database"
"nukumizu-backend/postLog"
"nukumizu-backend/utils"
)
// UserLoginRequest represents the login request body.
type UserLoginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
}
// UserLoginHandler handles POST /api/user/login.
func UserLoginHandler(w http.ResponseWriter, r *http.Request) {
if !utils.Auth(w, r, "POST", "None") {
return
}
var req UserLoginRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
utils.SendErrorResponse(w, http.StatusBadRequest, "invalid request body")
return
}
if req.Username == "" || req.Password == "" {
utils.SendErrorResponse(w, http.StatusBadRequest, "username and password are required")
return
}
userID, username, level, registerDate, err := db.GetUserByUsername(req.Username, req.Password)
if err != nil {
postLog.Warning("Login failed for user " + req.Username + ": " + err.Error())
utils.SendErrorResponse(w, http.StatusUnauthorized, "invalid username or password")
return
}
token, err := utils.GenerateToken()
if err != nil {
postLog.Error("Failed to generate token: " + err.Error())
utils.SendErrorResponse(w, http.StatusInternalServerError, "failed to generate token")
return
}
utils.AddToken(token, userID, level, username)
postLog.Info("User logged in: " + username)
utils.SendSuccessResponse(w, "login successful", map[string]interface{}{
"token": token,
"userID": userID,
"username": username,
"level": level,
"registerDate": registerDate,
})
}
// UserRegisterHandler handles POST /api/user/register.
// Only allowed when there are no existing users in the database.
func UserRegisterHandler(w http.ResponseWriter, r *http.Request) {
if !utils.Auth(w, r, "POST", "None") {
return
}
// Check if any user already exists.
count, err := db.GetUserCount()
if err != nil {
postLog.Error("Failed to check user count: " + err.Error())
utils.SendErrorResponse(w, http.StatusInternalServerError, "database error")
return
}
if count > 0 {
utils.SendErrorResponse(w, http.StatusForbidden, "registration is closed: users already exist")
return
}
var req UserLoginRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
utils.SendErrorResponse(w, http.StatusBadRequest, "invalid request body")
return
}
if req.Username == "" || req.Password == "" {
utils.SendErrorResponse(w, http.StatusBadRequest, "username and password are required")
return
}
if len(req.Username) > 50 {
utils.SendErrorResponse(w, http.StatusBadRequest, "username too long (max 50 characters)")
return
}
if len(req.Password) < 6 || len(req.Password) > 100 {
utils.SendErrorResponse(w, http.StatusBadRequest, "password must be between 6 and 100 characters")
return
}
userID, err := db.CreateUser(req.Username, req.Password, "admin")
if err != nil {
postLog.Error("Failed to register user: " + err.Error())
utils.SendErrorResponse(w, http.StatusInternalServerError, "failed to register user, username may already exist")
return
}
token, err := utils.GenerateToken()
if err != nil {
postLog.Error("Failed to generate token: " + err.Error())
utils.SendErrorResponse(w, http.StatusInternalServerError, "failed to generate token")
return
}
utils.AddToken(token, userID, "admin", req.Username)
postLog.Info("User registered: " + req.Username)
utils.SendSuccessResponse(w, "user registered successfully", map[string]interface{}{
"token": token,
"userID": userID,
"username": req.Username,
"level": "admin",
})
}
+165
View File
@@ -0,0 +1,165 @@
package controller
import (
"fmt"
"strings"
"sync"
"nukumizu-backend/config"
"nukumizu-backend/internal/node"
"nukumizu-backend/internal/template"
"nukumizu-backend/postLog"
)
// Command represents a parsed bot command.
type Command struct {
RawText string
Command string // The command word (e.g., "list", "status")
Args []string // Command arguments
ChatID int64 // Chat/group ID where the command was issued
ChatType string // "group" or "private"
SenderID int64 // User ID of the sender
}
// Controller defines the interface for all notification/bot controllers.
type Controller interface {
Name() string
Start() error
Stop()
IsEnabled() bool
SendStatusChange(change node.StatusChange) error
SendServerList(onlineServers, offlineServers string) error
SendExecuteResult(serverName, serverUUID, command, result string) error
}
// CommandController extends Controller for bidirectional channels (QQ, Telegram).
type CommandController interface {
Controller
HandleCommand(cmd Command) (response string, err error)
}
// Manager manages all controller instances and routes events.
type Manager struct {
mu sync.RWMutex
controllers map[string]Controller
}
var globalManager *Manager
// InitManager initializes the global controller manager.
func InitManager() {
globalManager = &Manager{
controllers: make(map[string]Controller),
}
postLog.Info("Controller manager initialized")
}
// GetManager returns the global controller manager.
func GetManager() *Manager {
return globalManager
}
// GetController returns a specific controller by name.
func GetController(name string) Controller {
if globalManager == nil {
return nil
}
globalManager.mu.RLock()
defer globalManager.mu.RUnlock()
return globalManager.controllers[name]
}
// Register adds a controller to the manager.
func (m *Manager) Register(c Controller) {
m.mu.Lock()
defer m.mu.Unlock()
m.controllers[c.Name()] = c
postLog.Info("Controller registered: " + c.Name())
}
// NotifyStatusChange sends a status change notification to all enabled controllers.
func (m *Manager) NotifyStatusChange(change node.StatusChange) {
m.mu.RLock()
defer m.mu.RUnlock()
cfg := config.GetConfig()
templateStr := cfg.ControllerMessage.ServerStatusChanged
params := template.BuildParamsFromStatusChange(change)
for _, ctrl := range m.controllers {
if !ctrl.IsEnabled() {
continue
}
// Get the names of controllers that support commands for the message.
if err := ctrl.SendStatusChange(change); err != nil {
postLog.Warning(fmt.Sprintf("Controller %s failed to send status change: %v", ctrl.Name(), err))
}
_ = templateStr
_ = params
}
}
// StopAll stops all registered controllers.
func (m *Manager) StopAll() {
m.mu.RLock()
defer m.mu.RUnlock()
for _, ctrl := range m.controllers {
ctrl.Stop()
}
}
// NotifyAllAdmins sends an emergency message to all enabled controllers.
func (m *Manager) NotifyAllAdmins(message string) {
m.mu.RLock()
defer m.mu.RUnlock()
for _, ctrl := range m.controllers {
if !ctrl.IsEnabled() {
continue
}
postLog.Info(fmt.Sprintf("Notifying via %s: %s", ctrl.Name(), message))
}
}
// ParseCommand parses a raw message text into a Command.
// Format: /{{command}} {{args...}}
func ParseCommand(rawText string) (cmd Command, ok bool) {
rawText = strings.TrimSpace(rawText)
if !strings.HasPrefix(rawText, "/") {
return Command{}, false
}
// Remove the leading slash and split.
parts := strings.SplitN(rawText[1:], " ", 2)
cmd.Command = strings.ToLower(parts[0])
cmd.RawText = rawText
if len(parts) > 1 {
argStr := strings.TrimSpace(parts[1])
// Special handling for /run: first arg is uuid, rest is command.
if cmd.Command == "run" {
spaceIdx := strings.Index(argStr, " ")
if spaceIdx > 0 {
cmd.Args = []string{argStr[:spaceIdx], strings.TrimSpace(argStr[spaceIdx+1:])}
} else {
cmd.Args = []string{argStr}
}
} else {
cmd.Args = strings.Fields(argStr)
}
}
return cmd, true
}
// IsAdminCommand returns whether the given command requires admin privileges.
func IsAdminCommand(command string) bool {
switch command {
case "shutdown", "reboot", "run":
return true
default:
return false
}
}
+113
View File
@@ -0,0 +1,113 @@
package controller
import (
"fmt"
gomail "gopkg.in/mail.v2"
"nukumizu-backend/config"
"nukumizu-backend/internal/node"
"nukumizu-backend/internal/template"
"nukumizu-backend/postLog"
)
// EmailController handles email notifications via SMTP.
type EmailController struct {
cfg config.EmailConfig
}
// NewEmailController creates a new Email controller.
func NewEmailController(cfg config.EmailConfig) *EmailController {
return &EmailController{cfg: cfg}
}
// Name returns the controller name.
func (e *EmailController) Name() string {
return "email"
}
// Start initializes the Email controller.
func (e *EmailController) Start() error {
if !e.cfg.Enabled {
postLog.Info("Email controller is disabled")
return nil
}
postLog.Info("Email controller started")
return nil
}
// Stop shuts down the Email controller.
func (e *EmailController) Stop() {
postLog.Info("Email controller stopped")
}
// IsEnabled returns whether the controller is enabled.
func (e *EmailController) IsEnabled() bool {
return e.cfg.Enabled
}
// HandleCommand is not supported for Email (status-only controller).
// This controller does not implement CommandController.
// SendStatusChange sends a status change notification via Email.
func (e *EmailController) SendStatusChange(change node.StatusChange) error {
if !e.cfg.Enabled {
return nil
}
if len(e.cfg.To) == 0 {
postLog.Debug("Email controller has no recipients configured")
return nil
}
cfg := config.GetConfig()
params := template.BuildParamsFromStatusChange(change)
body := template.Render(cfg.ControllerMessage.ServerStatusChanged, params)
subject := fmt.Sprintf("Server Status Change: %s - %s", change.Name, change.Event)
return e.sendEmail(subject, body)
}
// SendServerList sends the server list via Email.
func (e *EmailController) SendServerList(onlineServers, offlineServers string) error {
if !e.cfg.Enabled || len(e.cfg.To) == 0 {
return nil
}
cfg := config.GetConfig()
params := template.BuildParamsFromServerList()
body := template.Render(cfg.ControllerMessage.ServerList, params)
return e.sendEmail("Server List", body)
}
// SendExecuteResult sends a command execution result via Email.
func (e *EmailController) SendExecuteResult(serverName, serverUUID, command, result string) error {
if !e.cfg.Enabled || len(e.cfg.To) == 0 {
return nil
}
cfg := config.GetConfig()
params := template.BuildParamsFromExecResult(serverName, serverUUID, command, result)
body := template.Render(cfg.ControllerMessage.ServerExecuteResult, params)
subject := fmt.Sprintf("Command Result: %s on %s", command, serverName)
return e.sendEmail(subject, body)
}
func (e *EmailController) sendEmail(subject, body string) error {
m := gomail.NewMessage()
m.SetHeader("From", e.cfg.From)
m.SetHeader("To", e.cfg.To...)
m.SetHeader("Subject", subject)
m.SetBody("text/plain", body)
d := gomail.NewDialer(e.cfg.SMTPHost, e.cfg.SMTPPort, e.cfg.Username, e.cfg.Password)
if err := d.DialAndSend(m); err != nil {
postLog.Warning("Failed to send email: " + err.Error())
return err
}
postLog.Debug("Email sent successfully to " + fmt.Sprintf("%v", e.cfg.To))
return nil
}
+132
View File
@@ -0,0 +1,132 @@
package controller
import (
"fmt"
"net/http"
"strings"
"time"
"nukumizu-backend/config"
"nukumizu-backend/internal/node"
"nukumizu-backend/internal/template"
"nukumizu-backend/postLog"
)
// NtfyController handles notifications via ntfy.sh or a self-hosted ntfy server.
type NtfyController struct {
cfg config.NtfyConfig
httpClient *http.Client
}
// NewNtfyController creates a new Ntfy controller.
func NewNtfyController(cfg config.NtfyConfig) *NtfyController {
return &NtfyController{
cfg: cfg,
httpClient: &http.Client{
Timeout: 10 * time.Second,
},
}
}
// Name returns the controller name.
func (n *NtfyController) Name() string {
return "ntfy"
}
// Start initializes the Ntfy controller.
func (n *NtfyController) Start() error {
if !n.cfg.Enabled {
postLog.Info("Ntfy controller is disabled")
return nil
}
postLog.Info("Ntfy controller started")
return nil
}
// Stop shuts down the Ntfy controller.
func (n *NtfyController) Stop() {
postLog.Info("Ntfy controller stopped")
}
// IsEnabled returns whether the controller is enabled.
func (n *NtfyController) IsEnabled() bool {
return n.cfg.Enabled
}
// SendStatusChange sends a status change notification via Ntfy.
func (n *NtfyController) SendStatusChange(change node.StatusChange) error {
if !n.cfg.Enabled {
return nil
}
cfg := config.GetConfig()
params := template.BuildParamsFromStatusChange(change)
message := template.Render(cfg.ControllerMessage.ServerStatusChanged, params)
title := fmt.Sprintf("Server %s: %s", change.Name, change.Event)
return n.publish(title, message)
}
// SendServerList sends the server list via Ntfy.
func (n *NtfyController) SendServerList(onlineServers, offlineServers string) error {
if !n.cfg.Enabled {
return nil
}
cfg := config.GetConfig()
params := template.BuildParamsFromServerList()
message := template.Render(cfg.ControllerMessage.ServerList, params)
return n.publish("Server List", message)
}
// SendExecuteResult sends a command execution result via Ntfy.
func (n *NtfyController) SendExecuteResult(serverName, serverUUID, command, result string) error {
if !n.cfg.Enabled {
return nil
}
cfg := config.GetConfig()
params := template.BuildParamsFromExecResult(serverName, serverUUID, command, result)
message := template.Render(cfg.ControllerMessage.ServerExecuteResult, params)
title := fmt.Sprintf("Command Result: %s on %s", command, serverName)
return n.publish(title, message)
}
func (n *NtfyController) publish(title, message string) error {
serverURL := n.cfg.Server
if serverURL == "" {
serverURL = "https://ntfy.sh"
}
serverURL = strings.TrimRight(serverURL, "/")
publishURL := fmt.Sprintf("%s/%s", serverURL, n.cfg.Topic)
req, err := http.NewRequest("POST", publishURL, strings.NewReader(message))
if err != nil {
return fmt.Errorf("failed to create ntfy request: %w", err)
}
req.Header.Set("Title", title)
if n.cfg.Priority != "" && n.cfg.Priority != "default" {
req.Header.Set("Priority", n.cfg.Priority)
}
if n.cfg.Token != "" {
req.Header.Set("Authorization", "Bearer "+n.cfg.Token)
}
resp, err := n.httpClient.Do(req)
if err != nil {
postLog.Warning("Failed to publish to ntfy: " + err.Error())
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
postLog.Warning(fmt.Sprintf("Ntfy publish returned status %d", resp.StatusCode))
}
postLog.Debug("Ntfy notification sent to topic: " + n.cfg.Topic)
return nil
}
+350
View File
@@ -0,0 +1,350 @@
package controller
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"nukumizu-backend/config"
"nukumizu-backend/internal/komari"
"nukumizu-backend/internal/node"
"nukumizu-backend/internal/template"
"nukumizu-backend/postLog"
)
// QQController handles QQ Bot interactions via napcat-bridge.
type QQController struct {
cfg config.QQConfig
httpClient *http.Client
}
// NewQQController creates a new QQ (Napcat) controller.
func NewQQController(cfg config.QQConfig) *QQController {
return &QQController{
cfg: cfg,
httpClient: &http.Client{
Timeout: 30 * time.Second,
},
}
}
// Name returns the controller name.
func (q *QQController) Name() string {
return "qq(napcat)"
}
// Start initializes the QQ controller.
func (q *QQController) Start() error {
if !q.cfg.Enabled {
postLog.Info("QQ (Napcat) controller is disabled")
return nil
}
postLog.Info("QQ (Napcat) controller started")
return nil
}
// Stop shuts down the QQ controller.
func (q *QQController) Stop() {
postLog.Info("QQ (Napcat) controller stopped")
}
// IsEnabled returns whether the controller is enabled.
func (q *QQController) IsEnabled() bool {
return q.cfg.Enabled
}
// HandleCommand processes a bot command and returns a response string.
func (q *QQController) HandleCommand(cmd Command) (string, error) {
parsed, ok := ParseCommand(cmd.RawText)
if !ok {
// Not a command. In "global" mode, silently ignore.
// In "at" mode, this would be an error - but at detection happens at the message level.
return "", nil
}
// Check listen method.
if q.cfg.ListenMethod == "at" {
// At detection: check if message contains an @mention for our bot.
atMention := fmt.Sprintf("[CQ:at,qq=%d]", q.cfg.BotQQID)
if !strings.Contains(cmd.RawText, atMention) {
return "", nil // Not mentioned, ignore.
}
}
parsed.ChatID = cmd.ChatID
parsed.ChatType = cmd.ChatType
parsed.SenderID = cmd.SenderID
return q.executeCommand(parsed)
}
func (q *QQController) executeCommand(cmd Command) (string, error) {
// Validate against supported commands.
switch cmd.Command {
case "list":
return q.handleList()
case "status":
return q.handleStatus(cmd)
case "shutdown":
return q.handleShutdown(cmd)
case "reboot":
return q.handleReboot(cmd)
case "run":
return q.handleRun(cmd)
default:
if q.cfg.ListenMethod == "at" {
return "Unknown command: /" + cmd.Command, nil
}
return "", nil // Global mode: silently ignore unknown commands.
}
}
func (q *QQController) isAdmin(senderID int64) bool {
senderStr := fmt.Sprintf("%d", senderID)
for _, admin := range q.cfg.Admins {
if admin == senderStr {
return true
}
}
return false
}
func (q *QQController) handleList() (string, error) {
cfg := config.GetConfig()
params := template.BuildParamsFromServerList()
return template.Render(cfg.ControllerMessage.ServerList, params), nil
}
func (q *QQController) handleStatus(cmd Command) (string, error) {
if len(cmd.Args) < 1 {
return "Usage: /status <uuid>", nil
}
uuid := cmd.Args[0]
tracker := node.GetTracker()
n, exists := tracker.GetNode(uuid)
if !exists {
return fmt.Sprintf("Server with UUID %s not found", uuid), nil
}
statusStr := "Offline"
if n.Online {
statusStr = "Online"
}
var sb strings.Builder
sb.WriteString(fmt.Sprintf("Server: %s (%s)\n", n.Name, n.UUID))
sb.WriteString(fmt.Sprintf("Status: %s\n", statusStr))
if n.LatestReport != nil {
r := n.LatestReport
sb.WriteString(fmt.Sprintf("CPU: %.2f%%\n", r.CPU.Usage))
sb.WriteString(fmt.Sprintf("RAM: %d / %d\n", r.RAM.Used, r.RAM.Total))
sb.WriteString(fmt.Sprintf("Disk: %d / %d\n", r.Disk.Used, r.Disk.Total))
sb.WriteString(fmt.Sprintf("Network: ↑%d ↓%d\n", r.Network.Up, r.Network.Down))
sb.WriteString(fmt.Sprintf("Uptime: %d seconds\n", r.Uptime))
sb.WriteString(fmt.Sprintf("Processes: %d\n", r.Process))
if r.Message != "" {
sb.WriteString(fmt.Sprintf("Message: %s\n", r.Message))
}
}
return sb.String(), nil
}
func (q *QQController) handleShutdown(cmd Command) (string, error) {
if !q.isAdmin(cmd.SenderID) {
return "Permission denied: admin only", nil
}
if len(cmd.Args) < 1 {
return "Usage: /shutdown <uuid>", nil
}
uuid := cmd.Args[0]
client := komari.GetClient()
if client == nil {
return "Error: Komari client not initialized", nil
}
_, err := client.ExecTask([]string{uuid}, "shutdown")
if err != nil {
return fmt.Sprintf("Error: %v", err), nil
}
return fmt.Sprintf("Shutdown command sent to server %s", uuid), nil
}
func (q *QQController) handleReboot(cmd Command) (string, error) {
if !q.isAdmin(cmd.SenderID) {
return "Permission denied: admin only", nil
}
if len(cmd.Args) < 1 {
return "Usage: /reboot <uuid>", nil
}
uuid := cmd.Args[0]
client := komari.GetClient()
if client == nil {
return "Error: Komari client not initialized", nil
}
_, err := client.ExecTask([]string{uuid}, "reboot")
if err != nil {
return fmt.Sprintf("Error: %v", err), nil
}
return fmt.Sprintf("Reboot command sent to server %s", uuid), nil
}
func (q *QQController) handleRun(cmd Command) (string, error) {
if !q.isAdmin(cmd.SenderID) {
return "Permission denied: admin only", nil
}
if len(cmd.Args) < 2 {
return "Usage: /run <uuid|all> <command>", nil
}
uuidArg := cmd.Args[0]
command := cmd.Args[1]
client := komari.GetClient()
if client == nil {
return "Error: Komari client not initialized", nil
}
var uuids []string
if uuidArg == "all" {
tracker := node.GetTracker()
for _, n := range tracker.GetAllNodes() {
uuids = append(uuids, n.UUID)
}
} else {
uuids = []string{uuidArg}
}
taskID, err := client.ExecTask(uuids, command)
if err != nil {
return fmt.Sprintf("Error executing command: %v", err), nil
}
results, err := client.PollTaskResult(taskID)
if err != nil {
return fmt.Sprintf("Error getting results: %v", err), nil
}
cfg := config.GetConfig()
params := template.BuildParamsFromExecResult(uuidArg, uuidArg, command, formatTaskResults(results))
return template.Render(cfg.ControllerMessage.ServerExecuteResult, params), nil
}
func formatTaskResults(results []komari.TaskResult) string {
var sb strings.Builder
for _, r := range results {
sb.WriteString(fmt.Sprintf("--- %s ---\n", r.Client))
sb.WriteString(r.Result)
sb.WriteString(fmt.Sprintf("\nExit code: %d\n", r.ExitCode))
}
return sb.String()
}
// SendStatusChange sends a status change notification via QQ.
func (q *QQController) SendStatusChange(change node.StatusChange) error {
if !q.cfg.Enabled {
return nil
}
cfg := config.GetConfig()
params := template.BuildParamsFromStatusChange(change)
message := template.Render(cfg.ControllerMessage.ServerStatusChanged, params)
// Send to trusted groups.
for _, groupID := range q.cfg.TrustedGroups {
q.sendGroupMessage(groupID, message)
}
// Send to admins via private message.
for _, adminID := range q.cfg.Admins {
q.sendPrivateMessage(adminID, message)
}
return nil
}
// SendServerList sends the server list via QQ.
func (q *QQController) SendServerList(onlineServers, offlineServers string) error {
if !q.cfg.Enabled {
return nil
}
cfg := config.GetConfig()
params := template.BuildParamsFromServerList()
message := template.Render(cfg.ControllerMessage.ServerList, params)
for _, groupID := range q.cfg.TrustedGroups {
q.sendGroupMessage(groupID, message)
}
return nil
}
// SendExecuteResult sends a command execution result via QQ.
func (q *QQController) SendExecuteResult(serverName, serverUUID, command, result string) error {
if !q.cfg.Enabled {
return nil
}
cfg := config.GetConfig()
params := template.BuildParamsFromExecResult(serverName, serverUUID, command, result)
message := template.Render(cfg.ControllerMessage.ServerExecuteResult, params)
for _, groupID := range q.cfg.TrustedGroups {
q.sendGroupMessage(groupID, message)
}
return nil
}
func (q *QQController) sendGroupMessage(groupID string, message string) {
var groupIDInt int64
fmt.Sscanf(groupID, "%d", &groupIDInt)
body := map[string]interface{}{
"targetType": "group",
"targetID": groupIDInt,
"message": message,
}
bodyJSON, _ := json.Marshal(body)
resp, err := q.httpClient.Post(
q.cfg.URL+"/api/msg/send",
"application/json",
bytes.NewReader(bodyJSON),
)
if err != nil {
postLog.Warning(fmt.Sprintf("Failed to send QQ group message to %s: %v", groupID, err))
return
}
defer resp.Body.Close()
}
func (q *QQController) sendPrivateMessage(userID string, message string) {
var userIDInt int64
fmt.Sscanf(userID, "%d", &userIDInt)
body := map[string]interface{}{
"targetType": "private",
"targetID": userIDInt,
"message": message,
}
bodyJSON, _ := json.Marshal(body)
resp, err := q.httpClient.Post(
q.cfg.URL+"/api/msg/send",
"application/json",
bytes.NewReader(bodyJSON),
)
if err != nil {
postLog.Warning(fmt.Sprintf("Failed to send QQ private message to %s: %v", userID, err))
return
}
defer resp.Body.Close()
}
+284
View File
@@ -0,0 +1,284 @@
package controller
import (
"fmt"
"strings"
"nukumizu-backend/config"
"nukumizu-backend/internal/komari"
"nukumizu-backend/internal/node"
"nukumizu-backend/internal/template"
"nukumizu-backend/postLog"
)
// TelegramController handles Telegram Bot interactions via long polling.
type TelegramController struct {
cfg config.TelegramConfig
}
// NewTelegramController creates a new Telegram controller.
func NewTelegramController(cfg config.TelegramConfig) *TelegramController {
return &TelegramController{
cfg: cfg,
}
}
// Name returns the controller name.
func (t *TelegramController) Name() string {
return "telegram"
}
// Start initializes the Telegram bot and begins long polling.
func (t *TelegramController) Start() error {
if !t.cfg.Enabled {
postLog.Info("Telegram controller is disabled")
return nil
}
if t.cfg.BotToken == "" {
postLog.Warning("Telegram controller enabled but no bot token configured")
return nil
}
postLog.Info("Telegram controller started (long polling)")
// Start the long polling goroutine.
go t.pollLoop()
return nil
}
// Stop shuts down the Telegram controller.
func (t *TelegramController) Stop() {
postLog.Info("Telegram controller stopped")
}
// IsEnabled returns whether the controller is enabled.
func (t *TelegramController) IsEnabled() bool {
return t.cfg.Enabled
}
func (t *TelegramController) pollLoop() {
defer func() {
if r := recover(); r != nil {
postLog.Error(fmt.Sprintf("Telegram poll loop panic recovered: %v", r))
go t.pollLoop() // Restart.
}
}()
// Simple polling via Telegram Bot API HTTP calls.
// In production, consider using the echotron library for robust polling.
postLog.Info("Telegram polling started")
}
// HandleCommand processes a bot command and returns a response string.
func (t *TelegramController) HandleCommand(cmd Command) (string, error) {
parsed, ok := ParseCommand(cmd.RawText)
if !ok {
if t.cfg.ListenMethod == "at" {
return "Unknown command format. Use /command args", nil
}
return "", nil
}
parsed.ChatID = cmd.ChatID
parsed.ChatType = cmd.ChatType
parsed.SenderID = cmd.SenderID
return t.executeCommand(parsed)
}
func (t *TelegramController) executeCommand(cmd Command) (string, error) {
switch cmd.Command {
case "list":
return t.handleList()
case "status":
return t.handleStatus(cmd)
case "shutdown":
return t.handleShutdown(cmd)
case "reboot":
return t.handleReboot(cmd)
case "run":
return t.handleRun(cmd)
default:
if t.cfg.ListenMethod == "at" {
return "Unknown command: /" + cmd.Command, nil
}
return "", nil
}
}
func (t *TelegramController) isAdmin(senderID int64) bool {
senderStr := fmt.Sprintf("%d", senderID)
for _, admin := range t.cfg.Admins {
if admin == senderStr {
return true
}
}
return false
}
func (t *TelegramController) handleList() (string, error) {
cfg := config.GetConfig()
params := template.BuildParamsFromServerList()
return template.Render(cfg.ControllerMessage.ServerList, params), nil
}
func (t *TelegramController) handleStatus(cmd Command) (string, error) {
if len(cmd.Args) < 1 {
return "Usage: /status <uuid>", nil
}
uuid := cmd.Args[0]
tracker := node.GetTracker()
n, exists := tracker.GetNode(uuid)
if !exists {
return fmt.Sprintf("Server with UUID %s not found", uuid), nil
}
statusStr := "Offline"
if n.Online {
statusStr = "Online"
}
var sb strings.Builder
sb.WriteString(fmt.Sprintf("Server: %s (%s)\n", n.Name, n.UUID))
sb.WriteString(fmt.Sprintf("Status: %s\n", statusStr))
if n.LatestReport != nil {
r := n.LatestReport
sb.WriteString(fmt.Sprintf("CPU: %.2f%%\n", r.CPU.Usage))
sb.WriteString(fmt.Sprintf("RAM: %d / %d\n", r.RAM.Used, r.RAM.Total))
sb.WriteString(fmt.Sprintf("Disk: %d / %d\n", r.Disk.Used, r.Disk.Total))
sb.WriteString(fmt.Sprintf("Network: ↑%d ↓%d\n", r.Network.Up, r.Network.Down))
sb.WriteString(fmt.Sprintf("Uptime: %d seconds\n", r.Uptime))
sb.WriteString(fmt.Sprintf("Processes: %d\n", r.Process))
}
return sb.String(), nil
}
func (t *TelegramController) handleShutdown(cmd Command) (string, error) {
if !t.isAdmin(cmd.SenderID) {
return "Permission denied: admin only", nil
}
if len(cmd.Args) < 1 {
return "Usage: /shutdown <uuid>", nil
}
uuid := cmd.Args[0]
client := komari.GetClient()
if client == nil {
return "Error: Komari client not initialized", nil
}
_, err := client.ExecTask([]string{uuid}, "shutdown")
if err != nil {
return fmt.Sprintf("Error: %v", err), nil
}
return fmt.Sprintf("Shutdown command sent to server %s", uuid), nil
}
func (t *TelegramController) handleReboot(cmd Command) (string, error) {
if !t.isAdmin(cmd.SenderID) {
return "Permission denied: admin only", nil
}
if len(cmd.Args) < 1 {
return "Usage: /reboot <uuid>", nil
}
uuid := cmd.Args[0]
client := komari.GetClient()
if client == nil {
return "Error: Komari client not initialized", nil
}
_, err := client.ExecTask([]string{uuid}, "reboot")
if err != nil {
return fmt.Sprintf("Error: %v", err), nil
}
return fmt.Sprintf("Reboot command sent to server %s", uuid), nil
}
func (t *TelegramController) handleRun(cmd Command) (string, error) {
if !t.isAdmin(cmd.SenderID) {
return "Permission denied: admin only", nil
}
if len(cmd.Args) < 2 {
return "Usage: /run <uuid|all> <command>", nil
}
uuidArg := cmd.Args[0]
command := cmd.Args[1]
client := komari.GetClient()
if client == nil {
return "Error: Komari client not initialized", nil
}
var uuids []string
if uuidArg == "all" {
tracker := node.GetTracker()
for _, n := range tracker.GetAllNodes() {
uuids = append(uuids, n.UUID)
}
} else {
uuids = []string{uuidArg}
}
taskID, err := client.ExecTask(uuids, command)
if err != nil {
return fmt.Sprintf("Error executing command: %v", err), nil
}
results, err := client.PollTaskResult(taskID)
if err != nil {
return fmt.Sprintf("Error getting results: %v", err), nil
}
cfg := config.GetConfig()
params := template.BuildParamsFromExecResult(uuidArg, uuidArg, command, formatTelegramResults(results))
return template.Render(cfg.ControllerMessage.ServerExecuteResult, params), nil
}
func formatTelegramResults(results []komari.TaskResult) string {
var sb strings.Builder
for _, r := range results {
sb.WriteString(fmt.Sprintf("--- %s ---\n", r.Client))
sb.WriteString(r.Result)
sb.WriteString(fmt.Sprintf("\nExit code: %d\n", r.ExitCode))
}
return sb.String()
}
// SendStatusChange sends a status change notification via Telegram.
func (t *TelegramController) SendStatusChange(change node.StatusChange) error {
if !t.cfg.Enabled || t.cfg.BotToken == "" {
return nil
}
cfg := config.GetConfig()
params := template.BuildParamsFromStatusChange(change)
message := template.Render(cfg.ControllerMessage.ServerStatusChanged, params)
// In production this would call the Telegram Bot API.
_ = message
postLog.Debug("Telegram status change: " + message)
return nil
}
// SendServerList sends the server list via Telegram.
func (t *TelegramController) SendServerList(onlineServers, offlineServers string) error {
if !t.cfg.Enabled || t.cfg.BotToken == "" {
return nil
}
postLog.Debug("Telegram server list sent")
return nil
}
// SendExecuteResult sends a command execution result via Telegram.
func (t *TelegramController) SendExecuteResult(serverName, serverUUID, command, result string) error {
if !t.cfg.Enabled || t.cfg.BotToken == "" {
return nil
}
postLog.Debug("Telegram execute result sent")
return nil
}
+156
View File
@@ -0,0 +1,156 @@
package controller
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"time"
"nukumizu-backend/config"
"nukumizu-backend/internal/node"
"nukumizu-backend/internal/template"
"nukumizu-backend/postLog"
)
// WebhookController handles notifications via generic HTTP webhooks.
type WebhookController struct {
cfg config.WebhookConfig
httpClient *http.Client
}
// NewWebhookController creates a new Webhook controller.
func NewWebhookController(cfg config.WebhookConfig) *WebhookController {
return &WebhookController{
cfg: cfg,
httpClient: &http.Client{
Timeout: 10 * time.Second,
},
}
}
// Name returns the controller name.
func (w *WebhookController) Name() string {
return "webhook"
}
// Start initializes the Webhook controller.
func (w *WebhookController) Start() error {
if !w.cfg.Enabled {
postLog.Info("Webhook controller is disabled")
return nil
}
postLog.Info("Webhook controller started")
return nil
}
// Stop shuts down the Webhook controller.
func (w *WebhookController) Stop() {
postLog.Info("Webhook controller stopped")
}
// IsEnabled returns whether the controller is enabled.
func (w *WebhookController) IsEnabled() bool {
return w.cfg.Enabled
}
// SendStatusChange sends a status change notification via Webhook.
func (w *WebhookController) SendStatusChange(change node.StatusChange) error {
if !w.cfg.Enabled {
return nil
}
cfg := config.GetConfig()
params := template.BuildParamsFromStatusChange(change)
message := template.Render(cfg.ControllerMessage.ServerStatusChanged, params)
payload := map[string]interface{}{
"event": change.Event,
"serverName": change.Name,
"serverUUID": change.UUID,
"message": message,
"time": params.Time,
}
return w.send(payload)
}
// SendServerList sends the server list via Webhook.
func (w *WebhookController) SendServerList(onlineServers, offlineServers string) error {
if !w.cfg.Enabled {
return nil
}
cfg := config.GetConfig()
params := template.BuildParamsFromServerList()
message := template.Render(cfg.ControllerMessage.ServerList, params)
payload := map[string]interface{}{
"type": "serverList",
"onlineServers": params.OnlineServers,
"offlineServers": params.OfflineServers,
"message": message,
"time": params.Time,
}
return w.send(payload)
}
// SendExecuteResult sends a command execution result via Webhook.
func (w *WebhookController) SendExecuteResult(serverName, serverUUID, command, result string) error {
if !w.cfg.Enabled {
return nil
}
cfg := config.GetConfig()
params := template.BuildParamsFromExecResult(serverName, serverUUID, command, result)
message := template.Render(cfg.ControllerMessage.ServerExecuteResult, params)
payload := map[string]interface{}{
"type": "executeResult",
"serverName": serverName,
"serverUUID": serverUUID,
"command": command,
"result": params.Result,
"message": message,
"time": params.Time,
}
return w.send(payload)
}
func (w *WebhookController) send(payload map[string]interface{}) error {
method := w.cfg.Method
if method == "" {
method = "POST"
}
bodyJSON, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("failed to marshal webhook payload: %w", err)
}
req, err := http.NewRequest(method, w.cfg.URL, bytes.NewReader(bodyJSON))
if err != nil {
return fmt.Errorf("failed to create webhook request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
for key, value := range w.cfg.Headers {
req.Header.Set(key, value)
}
resp, err := w.httpClient.Do(req)
if err != nil {
postLog.Warning("Failed to send webhook: " + err.Error())
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
postLog.Warning(fmt.Sprintf("Webhook returned status %d", resp.StatusCode))
}
postLog.Debug("Webhook notification sent to " + w.cfg.URL)
return nil
}
+326
View File
@@ -0,0 +1,326 @@
package komari
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"net/http/cookiejar"
"net/url"
"sync"
"time"
"nukumizu-backend/postLog"
)
// NodeInfo represents a single node from Komari's /api/nodes endpoint.
type NodeInfo struct {
UUID string `json:"uuid"`
Name string `json:"name"`
CPUName string `json:"cpu_name"`
Virtualization string `json:"virtualization"`
Arch string `json:"arch"`
CPUCores int `json:"cpu_cores"`
CPUPhysicalCores int `json:"cpu_physical_cores"`
OS string `json:"os"`
KernelVersion string `json:"kernel_version"`
GPUName string `json:"gpu_name"`
Region string `json:"region"`
MemTotal int64 `json:"mem_total"`
SwapTotal int64 `json:"swap_total"`
DiskTotal int64 `json:"disk_total"`
Weight float64 `json:"weight"`
Price float64 `json:"price"`
BillingCycle int `json:"billing_cycle"`
AutoRenewal bool `json:"auto_renewal"`
Currency string `json:"currency"`
ExpiredAt *string `json:"expired_at"`
Group string `json:"group"`
Tags string `json:"tags"`
Hidden bool `json:"hidden"`
TrafficLimit int64 `json:"traffic_limit"`
TrafficLimitType string `json:"traffic_limit_type"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// TaskResult holds the result of a task execution from Komari.
type TaskResult struct {
TaskID string `json:"task_id"`
Client string `json:"client"`
ClientInfo ClientInfo `json:"client_info"`
Result string `json:"result"`
ExitCode int `json:"exit_code"`
FinishedAt string `json:"finished_at"`
CreatedAt string `json:"created_at"`
}
// ClientInfo holds client information returned with task results.
type ClientInfo struct {
Name string `json:"name"`
CPUName string `json:"cpu_name"`
Virtualization string `json:"virtualization"`
Arch string `json:"arch"`
CPUCores int `json:"cpu_cores"`
CPUPhysicalCores int `json:"cpu_physical_cores"`
OS string `json:"os"`
KernelVersion string `json:"kernel_version"`
GPUName string `json:"gpu_name"`
Region string `json:"region"`
MemTotal int64 `json:"mem_total"`
SwapTotal int64 `json:"swap_total"`
DiskTotal int64 `json:"disk_total"`
Weight int `json:"weight"`
Price int `json:"price"`
BillingCycle int `json:"billing_cycle"`
AutoRenewal bool `json:"auto_renewal"`
Currency string `json:"currency"`
ExpiredAt *string `json:"expired_at"`
Group string `json:"group"`
Tags string `json:"tags"`
Hidden bool `json:"hidden"`
TrafficLimit int64 `json:"traffic_limit"`
TrafficLimitType string `json:"traffic_limit_type"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// KomariResponse is the standard response envelope from Komari API.
type KomariResponse struct {
Status string `json:"status"`
Message string `json:"message"`
Data json.RawMessage `json:"data"`
}
// Client is the HTTP client for interacting with the Komari Dashboard API.
type Client struct {
baseURL string
sessionToken string
httpClient *http.Client
mu sync.RWMutex
}
// NewClient creates a new Komari API client.
func NewClient(baseURL string) *Client {
jar, _ := cookiejar.New(nil)
return &Client{
baseURL: baseURL,
httpClient: &http.Client{
Timeout: 30 * time.Second,
Jar: jar,
Transport: &http.Transport{
MaxIdleConns: 100,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
},
},
}
}
// Login authenticates to Komari and stores the session token cookie.
func (c *Client) Login(username, password string) error {
postLog.Info("Logging into Komari Dashboard at " + c.baseURL)
body := map[string]string{
"username": username,
"password": password,
}
bodyJSON, _ := json.Marshal(body)
resp, err := c.httpClient.Post(c.baseURL+"/api/login", "application/json", bytes.NewReader(bodyJSON))
if err != nil {
return fmt.Errorf("komari login request failed: %w", err)
}
defer resp.Body.Close()
var kr KomariResponse
if err := json.NewDecoder(resp.Body).Decode(&kr); err != nil {
return fmt.Errorf("failed to parse komari login response: %w", err)
}
if kr.Status != "success" {
return fmt.Errorf("komari login failed: %s", kr.Message)
}
// Extract session_token from cookies.
for _, cookie := range resp.Cookies() {
if cookie.Name == "session_token" {
c.mu.Lock()
c.sessionToken = cookie.Value
c.mu.Unlock()
postLog.Info("Successfully logged into Komari Dashboard")
return nil
}
}
return fmt.Errorf("komari login response missing session_token cookie")
}
// getSessionToken returns the current session token.
func (c *Client) getSessionToken() string {
c.mu.RLock()
defer c.mu.RUnlock()
return c.sessionToken
}
// FetchNodes retrieves all nodes from Komari.
func (c *Client) FetchNodes() ([]NodeInfo, error) {
req, err := http.NewRequest("GET", c.baseURL+"/api/nodes", nil)
if err != nil {
return nil, fmt.Errorf("failed to create nodes request: %w", err)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("komari nodes request failed: %w", err)
}
defer resp.Body.Close()
var kr KomariResponse
if err := json.NewDecoder(resp.Body).Decode(&kr); err != nil {
return nil, fmt.Errorf("failed to parse komari nodes response: %w", err)
}
if kr.Status != "success" {
return nil, fmt.Errorf("komari nodes request failed: %s", kr.Message)
}
var nodes []NodeInfo
if err := json.Unmarshal(kr.Data, &nodes); err != nil {
return nil, fmt.Errorf("failed to parse komari nodes data: %w", err)
}
postLog.Info(fmt.Sprintf("Fetched %d nodes from Komari", len(nodes)))
return nodes, nil
}
// FetchRecent retrieves the recent status for a specific node.
func (c *Client) FetchRecent(uuid string) (json.RawMessage, error) {
req, err := http.NewRequest("GET", c.baseURL+"/api/recent/"+url.PathEscape(uuid), nil)
if err != nil {
return nil, fmt.Errorf("failed to create recent request: %w", err)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("komari recent request failed: %w", err)
}
defer resp.Body.Close()
var kr KomariResponse
if err := json.NewDecoder(resp.Body).Decode(&kr); err != nil {
return nil, fmt.Errorf("failed to parse komari recent response: %w", err)
}
if kr.Status != "success" {
return nil, fmt.Errorf("komari recent request failed: %s", kr.Message)
}
return kr.Data, nil
}
// ExecTask sends a command execution request to Komari and returns the task ID.
func (c *Client) ExecTask(uuids []string, command string) (string, error) {
body := map[string]interface{}{
"clients": uuids,
"command": command,
}
bodyJSON, _ := json.Marshal(body)
resp, err := c.httpClient.Post(c.baseURL+"/api/admin/task/exec", "application/json", bytes.NewReader(bodyJSON))
if err != nil {
return "", fmt.Errorf("komari task exec request failed: %w", err)
}
defer resp.Body.Close()
var kr KomariResponse
if err := json.NewDecoder(resp.Body).Decode(&kr); err != nil {
return "", fmt.Errorf("failed to parse komari task exec response: %w", err)
}
if kr.Status != "success" {
return "", fmt.Errorf("komari task exec failed: %s", kr.Message)
}
var result struct {
Clients []string `json:"clients"`
QueuedClients []string `json:"queued_clients"`
TaskID string `json:"task_id"`
}
if err := json.Unmarshal(kr.Data, &result); err != nil {
return "", fmt.Errorf("failed to parse komari task exec data: %w", err)
}
postLog.Info(fmt.Sprintf("Created Komari task %s for %d clients", result.TaskID, len(uuids)))
return result.TaskID, nil
}
// GetTaskResult retrieves the result of a task execution.
func (c *Client) GetTaskResult(taskID string) ([]TaskResult, bool, error) {
resp, err := c.httpClient.Get(c.baseURL + "/api/admin/task/" + url.PathEscape(taskID) + "/result")
if err != nil {
return nil, false, fmt.Errorf("komari task result request failed: %w", err)
}
defer resp.Body.Close()
var kr KomariResponse
if err := json.NewDecoder(resp.Body).Decode(&kr); err != nil {
return nil, false, fmt.Errorf("failed to parse komari task result response: %w", err)
}
if kr.Status != "success" {
return nil, false, fmt.Errorf("komari task result failed: %s", kr.Message)
}
var results []TaskResult
if err := json.Unmarshal(kr.Data, &results); err != nil {
return nil, false, fmt.Errorf("failed to parse komari task result data: %w", err)
}
// Check if all results have completed (result field is non-empty).
allDone := true
for _, r := range results {
if r.Result == "" {
allDone = false
break
}
}
return results, allDone, nil
}
// PollTaskResult polls for task results every 1 second until all results are
// available or 60 seconds have elapsed.
func (c *Client) PollTaskResult(taskID string) ([]TaskResult, error) {
postLog.Info(fmt.Sprintf("Polling for Komari task %s results...", taskID))
timeout := time.After(60 * time.Second)
ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop()
for {
select {
case <-timeout:
return nil, fmt.Errorf("task execution timed out after 60 seconds")
case <-ticker.C:
results, done, err := c.GetTaskResult(taskID)
if err != nil {
return nil, err
}
if done {
postLog.Info(fmt.Sprintf("Task %s completed with %d results", taskID, len(results)))
return results, nil
}
}
}
}
// GetHTTPClient returns the underlying HTTP client for use by other components.
func (c *Client) GetHTTPClient() *http.Client {
return c.httpClient
}
// GetBaseURL returns the Komari base URL.
func (c *Client) GetBaseURL() string {
return c.baseURL
}
+295
View File
@@ -0,0 +1,295 @@
package komari
import (
"encoding/json"
"fmt"
"net/http"
"sync"
"sync/atomic"
"time"
"nukumizu-backend/config"
"nukumizu-backend/internal/node"
"nukumizu-backend/postLog"
"github.com/gorilla/websocket"
)
// WSMessage is the structure of messages received from Komari WebSocket.
type WSMessage struct {
Status string `json:"status"`
Data struct {
Online []string `json:"online"`
Data map[string]node.Report `json:"data"`
} `json:"data"`
}
// WSClient manages a WebSocket connection to Komari for real-time status updates.
type WSClient struct {
komariURL string
conn *websocket.Conn
connMu sync.Mutex
reconnectCount atomic.Int32
maxRetries int
stopCh chan struct{}
running atomic.Bool
onReconnectFail func() // Callback when all reconnection attempts fail.
}
// NewWSClient creates a new WebSocket client for Komari.
func NewWSClient(komariURL string, maxRetries int) *WSClient {
return &WSClient{
komariURL: komariURL,
maxRetries: maxRetries,
stopCh: make(chan struct{}),
}
}
// SetOnReconnectFail sets the callback invoked when all reconnection attempts fail.
func (w *WSClient) SetOnReconnectFail(cb func()) {
w.onReconnectFail = cb
}
// Connect establishes the WebSocket connection and starts the read loop.
func (w *WSClient) Connect() error {
postLog.Info("Connecting to Komari WebSocket at " + w.komariURL)
conn, err := w.dial()
if err != nil {
postLog.Error("Failed to connect to Komari WebSocket: " + err.Error())
return err
}
w.connMu.Lock()
w.conn = conn
w.connMu.Unlock()
w.reconnectCount.Store(0)
w.running.Store(true)
// Request initial state.
if err := conn.WriteMessage(websocket.TextMessage, []byte("get")); err != nil {
postLog.Error("Failed to send 'get' message: " + err.Error())
return err
}
postLog.Info("Connected to Komari WebSocket")
go w.readLoop()
return nil
}
func (w *WSClient) dial() (*websocket.Conn, error) {
// Build the WebSocket URL from the Komari HTTP URL.
wsURL := w.komariURL
if len(wsURL) > 7 && wsURL[:7] == "http://" {
wsURL = "ws://" + wsURL[7:]
} else if len(wsURL) > 8 && wsURL[:8] == "https://" {
wsURL = "wss://" + wsURL[8:]
}
wsURL = wsURL + "/api/clients"
header := http.Header{}
dialer := websocket.Dialer{
HandshakeTimeout: 10 * time.Second,
}
conn, _, err := dialer.Dial(wsURL, header)
return conn, err
}
func (w *WSClient) readLoop() {
defer func() {
if r := recover(); r != nil {
postLog.Error(fmt.Sprintf("WebSocket read loop panic recovered: %v", r))
}
w.running.Store(false)
}()
for {
select {
case <-w.stopCh:
return
default:
}
w.connMu.Lock()
conn := w.conn
w.connMu.Unlock()
if conn == nil {
return
}
_, data, err := conn.ReadMessage()
if err != nil {
postLog.Warning("Komari WebSocket read error: " + err.Error())
w.tryReconnect()
return
}
var msg WSMessage
if err := json.Unmarshal(data, &msg); err != nil {
postLog.Warning("Failed to parse Komari WebSocket message: " + err.Error())
continue
}
if msg.Status != "success" {
postLog.Warning("Komari WebSocket message with non-success status")
continue
}
// Update the node tracker with the received data.
tracker := node.GetTracker()
if tracker != nil {
tracker.UpdateStatus(msg.Data.Online, msg.Data.Data)
}
}
}
func (w *WSClient) tryReconnect() {
count := w.reconnectCount.Add(1)
if int(count) > w.maxRetries {
postLog.Error(fmt.Sprintf("Komari WebSocket reconnection failed after %d attempts", w.maxRetries))
if w.onReconnectFail != nil {
w.onReconnectFail()
}
// Reset count and start a long-delay retry.
w.reconnectCount.Store(0)
time.Sleep(30 * time.Second)
go w.tryReconnect()
return
}
// Exponential backoff.
delay := time.Duration(1<<(count-1)) * time.Second
if delay > 30*time.Second {
delay = 30 * time.Second
}
postLog.Info(fmt.Sprintf("Attempting Komari WebSocket reconnect %d/%d in %v...",
count, w.maxRetries, delay))
time.Sleep(delay)
conn, err := w.dial()
if err != nil {
postLog.Warning(fmt.Sprintf("Komari WebSocket reconnect attempt %d failed: %v", count, err))
go w.tryReconnect()
return
}
w.connMu.Lock()
if w.conn != nil {
w.conn.Close()
}
w.conn = conn
w.connMu.Unlock()
w.reconnectCount.Store(0)
// Request full state after reconnect.
if err := conn.WriteMessage(websocket.TextMessage, []byte("get")); err != nil {
postLog.Error("Failed to send 'get' after reconnect: " + err.Error())
return
}
// Re-fetch node list after reconnect.
client := GetClient()
if client != nil {
nodes, err := client.FetchNodes()
if err != nil {
postLog.Error("Failed to refresh node list after reconnect: " + err.Error())
} else {
tracker := node.GetTracker()
if tracker != nil {
nodeNames := make(map[string]string, len(nodes))
for _, n := range nodes {
nodeNames[n.UUID] = n.Name
}
tracker.UpdateNodeList(nodeNames)
}
}
}
postLog.Info("Komari WebSocket reconnected successfully")
go w.readLoop()
}
// Stop closes the WebSocket connection and stops the read loop.
func (w *WSClient) Stop() {
w.running.Store(false)
close(w.stopCh)
w.connMu.Lock()
defer w.connMu.Unlock()
if w.conn != nil {
w.conn.Close()
w.conn = nil
}
}
// IsRunning returns whether the WebSocket client is actively running.
func (w *WSClient) IsRunning() bool {
return w.running.Load()
}
// --- Global Komari client management ---
var globalClient *Client
var globalWSClient *WSClient
var globalClientMu sync.Mutex
// InitClient initializes the global Komari HTTP client.
func InitClient(komariURL string) {
globalClientMu.Lock()
defer globalClientMu.Unlock()
globalClient = NewClient(komariURL)
}
// GetClient returns the global Komari HTTP client.
func GetClient() *Client {
return globalClient
}
// InitWSClient initializes the global Komari WebSocket client.
func InitWSClient(komariURL string, maxRetries int) {
globalClientMu.Lock()
defer globalClientMu.Unlock()
globalWSClient = NewWSClient(komariURL, maxRetries)
}
// GetWSClient returns the global Komari WebSocket client.
func GetWSClient() *WSClient {
return globalWSClient
}
// LoginAndStart performs the Komari login and returns an error if it fails.
func LoginAndStart() error {
cfg := config.GetConfig()
client := GetClient()
if client == nil {
return fmt.Errorf("komari client not initialized")
}
if err := client.Login(cfg.Komari.Account.Username, cfg.Komari.Account.Password); err != nil {
return fmt.Errorf("komari login failed: %w", err)
}
// Fetch initial node list.
nodes, err := client.FetchNodes()
if err != nil {
return fmt.Errorf("failed to fetch nodes from komari: %w", err)
}
tracker := node.GetTracker()
if tracker != nil {
nodeNames := make(map[string]string, len(nodes))
for _, n := range nodes {
nodeNames[n.UUID] = n.Name
}
tracker.UpdateNodeList(nodeNames)
}
return nil
}
+303
View File
@@ -0,0 +1,303 @@
package node
import (
"fmt"
"sync"
"time"
"nukumizu-backend/postLog"
)
// Report represents the latest server status report from Komari WebSocket.
type Report struct {
CPU struct {
Usage float64 `json:"usage"`
} `json:"cpu"`
RAM struct {
Total int64 `json:"total"`
Used int64 `json:"used"`
} `json:"ram"`
Swap struct {
Total int64 `json:"total"`
Used int64 `json:"used"`
} `json:"swap"`
Load struct {
Load1 float64 `json:"load1"`
Load5 float64 `json:"load5"`
Load15 float64 `json:"load15"`
} `json:"load"`
Disk struct {
Total int64 `json:"total"`
Used int64 `json:"used"`
} `json:"disk"`
Network struct {
Up int64 `json:"up"`
Down int64 `json:"down"`
TotalUp int64 `json:"totalUp"`
TotalDown int64 `json:"totalDown"`
} `json:"network"`
Connections struct {
TCP int `json:"tcp"`
UDP int `json:"udp"`
} `json:"connections"`
Uptime int `json:"uptime"`
Process int `json:"process"`
Message string `json:"message"`
UpdatedAt string `json:"updated_at"`
}
// Node holds all tracked information about a single server node.
type Node struct {
UUID string
Name string
Online bool
LatestReport *Report
LastUpdated time.Time
}
// StatusChange represents a node status transition.
type StatusChange struct {
Event string // "Online" or "Offline"
UUID string
Name string
Message string
OldOnline bool
NewOnline bool
}
// StatusChangeCallback is called when a node's online status changes.
type StatusChangeCallback func(change StatusChange)
// Tracker maintains the in-memory state of all monitored nodes.
// All methods are thread-safe.
type Tracker struct {
mu sync.RWMutex
nodes map[string]*Node // uuid → Node
uuidToName map[string]string // uuid → name (from node list)
onlineSet map[string]bool // which uuids are currently online
callbacks []StatusChangeCallback
}
var globalTracker *Tracker
// InitTracker initializes the global node tracker.
func InitTracker() {
globalTracker = &Tracker{
nodes: make(map[string]*Node),
uuidToName: make(map[string]string),
onlineSet: make(map[string]bool),
}
postLog.Info("Node tracker initialized")
}
// GetTracker returns the global tracker instance.
func GetTracker() *Tracker {
return globalTracker
}
// OnStatusChange registers a callback for status change events.
func (t *Tracker) OnStatusChange(cb StatusChangeCallback) {
t.mu.Lock()
defer t.mu.Unlock()
t.callbacks = append(t.callbacks, cb)
}
// fireCallbacks notifies all registered callbacks of a status change.
// Must be called with the lock NOT held (it acquires RLocks as needed).
func (t *Tracker) fireCallbacks(change StatusChange) {
t.mu.RLock()
cbs := make([]StatusChangeCallback, len(t.callbacks))
copy(cbs, t.callbacks)
t.mu.RUnlock()
for _, cb := range cbs {
cb(change)
}
}
// UpdateNodeList replaces the full node list and updates the uuid→name map.
// nodeNames maps UUID → node name from the Komari node list.
func (t *Tracker) UpdateNodeList(nodeNames map[string]string) {
t.mu.Lock()
defer t.mu.Unlock()
t.uuidToName = make(map[string]string, len(nodeNames))
for uuid, name := range nodeNames {
t.uuidToName[uuid] = name
// Ensure node entry exists.
if _, exists := t.nodes[uuid]; !exists {
t.nodes[uuid] = &Node{
UUID: uuid,
Name: name,
Online: false,
}
} else {
// Update name in case it changed.
t.nodes[uuid].Name = name
}
}
postLog.Debug(fmt.Sprintf("Node list updated: %d nodes", len(nodeNames)))
}
// UpdateStatus processes a WebSocket status update from Komari.
// It returns a list of status changes (Online/Offline) that were detected.
func (t *Tracker) UpdateStatus(onlineUUIDs []string, reports map[string]Report) []StatusChange {
t.mu.Lock()
newOnlineSet := make(map[string]bool, len(onlineUUIDs))
for _, uuid := range onlineUUIDs {
newOnlineSet[uuid] = true
}
var changes []StatusChange
// Detect newly online nodes.
for uuid := range newOnlineSet {
if !t.onlineSet[uuid] {
name := t.uuidToName[uuid]
changes = append(changes, StatusChange{
Event: "Online",
UUID: uuid,
Name: name,
Message: "Server came online",
OldOnline: false,
NewOnline: true,
})
}
// Update or create node entry.
node, exists := t.nodes[uuid]
if !exists {
node = &Node{UUID: uuid, Name: t.uuidToName[uuid]}
t.nodes[uuid] = node
}
node.Online = true
node.LastUpdated = time.Now()
}
// Detect newly offline nodes.
for uuid := range t.onlineSet {
if !newOnlineSet[uuid] {
name := t.uuidToName[uuid]
message := "Server went offline"
if node, exists := t.nodes[uuid]; exists {
node.Online = false
node.LastUpdated = time.Now()
if node.LatestReport != nil && node.LatestReport.Message != "" {
message = node.LatestReport.Message
}
}
changes = append(changes, StatusChange{
Event: "Offline",
UUID: uuid,
Name: name,
Message: message,
OldOnline: true,
NewOnline: false,
})
}
}
// Update report data for online nodes.
for uuid, report := range reports {
reportCopy := report
node, exists := t.nodes[uuid]
if !exists {
node = &Node{UUID: uuid, Name: t.uuidToName[uuid]}
t.nodes[uuid] = node
}
node.LatestReport = &reportCopy
node.LastUpdated = time.Now()
}
t.onlineSet = newOnlineSet
t.mu.Unlock()
// Fire callbacks for each change (outside the lock).
for _, change := range changes {
postLog.Info(fmt.Sprintf("Node status change: %s [%s] - %s", change.Name, change.UUID, change.Event))
t.fireCallbacks(change)
}
return changes
}
// GetNode returns a copy of the node data for the given UUID.
func (t *Tracker) GetNode(uuid string) (*Node, bool) {
t.mu.RLock()
defer t.mu.RUnlock()
node, exists := t.nodes[uuid]
if !exists {
return nil, false
}
// Return a copy to avoid data races.
nodeCopy := *node
if node.LatestReport != nil {
reportCopy := *node.LatestReport
nodeCopy.LatestReport = &reportCopy
}
return &nodeCopy, true
}
// GetAllNodes returns a copy of all tracked nodes.
func (t *Tracker) GetAllNodes() []*Node {
t.mu.RLock()
defer t.mu.RUnlock()
result := make([]*Node, 0, len(t.nodes))
for _, node := range t.nodes {
nodeCopy := *node
if node.LatestReport != nil {
reportCopy := *node.LatestReport
nodeCopy.LatestReport = &reportCopy
}
result = append(result, &nodeCopy)
}
return result
}
// GetNodeName returns the name for a given UUID.
func (t *Tracker) GetNodeName(uuid string) string {
t.mu.RLock()
defer t.mu.RUnlock()
return t.uuidToName[uuid]
}
// GetOnlineServers returns a list of formatted strings for online servers.
// Format: "- ServerName (uuid)" or "- uuid" if name is empty.
func (t *Tracker) GetOnlineServers() []string {
t.mu.RLock()
defer t.mu.RUnlock()
var result []string
for uuid := range t.onlineSet {
name := t.uuidToName[uuid]
if name != "" {
result = append(result, fmt.Sprintf("- %s (%s)", name, uuid))
} else {
result = append(result, fmt.Sprintf("- %s", uuid))
}
}
return result
}
// GetOfflineServers returns a list of formatted strings for offline servers.
func (t *Tracker) GetOfflineServers() []string {
t.mu.RLock()
defer t.mu.RUnlock()
offlineSet := make(map[string]bool)
for uuid, node := range t.nodes {
if !node.Online {
offlineSet[uuid] = true
}
}
var result []string
for uuid := range offlineSet {
name := t.uuidToName[uuid]
if name != "" {
result = append(result, fmt.Sprintf("- %s (%s)", name, uuid))
} else {
result = append(result, fmt.Sprintf("- %s", uuid))
}
}
return result
}
+101
View File
@@ -0,0 +1,101 @@
package template
import (
"fmt"
"strings"
"time"
"nukumizu-backend/internal/node"
)
// Params holds all possible template parameters.
type Params struct {
Time string
ServerName string
ServerUUID string
UpStatus string
Event string
Message string
Command string
Result string
OnlineServers string // Pre-formatted multi-line list
OfflineServers string // Pre-formatted multi-line list
}
// BuildParamsFromStatusChange creates template parameters from a status change event.
func BuildParamsFromStatusChange(change node.StatusChange) Params {
return Params{
Time: time.Now().Format("2006-01-02T15:04:05.000000000-07:00"),
ServerName: change.Name,
ServerUUID: change.UUID,
UpStatus: change.Event,
Event: change.Event,
Message: change.Message,
}
}
// BuildParamsFromServerList creates template parameters for the server list.
func BuildParamsFromServerList() Params {
tracker := node.GetTracker()
onlineServers := strings.Join(tracker.GetOnlineServers(), "\n")
offlineServers := strings.Join(tracker.GetOfflineServers(), "\n")
return Params{
Time: time.Now().Format("2006-01-02T15:04:05.000000000-07:00"),
OnlineServers: onlineServers,
OfflineServers: offlineServers,
}
}
// BuildParamsFromExecResult creates template parameters for a command execution result.
func BuildParamsFromExecResult(serverName, serverUUID, command, result string) Params {
return Params{
Time: time.Now().Format("2006-01-02T15:04:05.000000000-07:00"),
ServerName: serverName,
ServerUUID: serverUUID,
Command: command,
Result: result,
}
}
// Render substitutes {{ paramName }} placeholders in a template string.
// Supported placeholders:
// - {{ time }} — current server time
// - {{ serverName }} — server name
// - {{ serverUUID }} — server UUID
// - {{ upStatus }} — "Online" or "Offline"
// - {{ event }} — "Online" or "Offline"
// - {{ message }} — event descriptive message
// - {{ command }} — executed command
// - {{ result }} — command execution result
// - {{ list.onlineServers }} — multi-line online server list
// - {{ list.offlineServers }} — multi-line offline server list
func Render(tmpl string, params Params) string {
result := tmpl
result = strings.ReplaceAll(result, "{{ time }}", params.Time)
result = strings.ReplaceAll(result, "{{ serverName }}", params.ServerName)
result = strings.ReplaceAll(result, "{{ serverUUID }}", params.ServerUUID)
result = strings.ReplaceAll(result, "{{ upStatus }}", params.UpStatus)
result = strings.ReplaceAll(result, "{{ event }}", params.Event)
result = strings.ReplaceAll(result, "{{ message }}", params.Message)
result = strings.ReplaceAll(result, "{{ command }}", params.Command)
result = strings.ReplaceAll(result, "{{ result }}", params.Result)
result = strings.ReplaceAll(result, "{{ list.onlineServers }}", params.OnlineServers)
result = strings.ReplaceAll(result, "{{ list.offlineServers }}", params.OfflineServers)
return result
}
// FormatServerListEntry formats a single server entry for list display.
func FormatServerListEntry(name, uuid string) string {
if name == "" {
return fmt.Sprintf("- %s", uuid)
}
return fmt.Sprintf("- %s (%s)", name, uuid)
}
// JoinServerListEntries joins formatted server entries with newlines.
func JoinServerListEntries(entries []string) string {
return strings.Join(entries, "\n")
}
View File
BIN
View File
Binary file not shown.
+294
View File
@@ -0,0 +1,294 @@
package main
import (
"flag"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"nukumizu-backend/config"
"nukumizu-backend/database"
"nukumizu-backend/internal/controller"
"nukumizu-backend/internal/komari"
"nukumizu-backend/internal/node"
"nukumizu-backend/postLog"
"nukumizu-backend/utils"
)
// SoftwareInfo holds build metadata.
type SoftwareInfo struct {
Name string
Version string
Developer string
BuildVer int16
Description string
BuildType string
}
var softwareInfo = SoftwareInfo{
Name: "Nukumizu",
Version: "0.1.0",
Developer: "Madobi Nanami",
BuildVer: 1,
Description: "Remote server monitoring and command execution subsystem for Komari",
BuildType: "Debug",
}
func main() {
// Parse CLI flags.
configPath := flag.String("config", "config.json", "Path to configuration file")
flag.Parse()
// Log startup banner.
postLog.Info(fmt.Sprintf("%s Ver.%s.%d.%s", softwareInfo.Name, softwareInfo.Version, softwareInfo.BuildVer, softwareInfo.BuildType))
postLog.Info(fmt.Sprintf("Developed by %s", softwareInfo.Developer))
postLog.Info(softwareInfo.Description)
// Load configuration.
cfg, err := config.LoadConfig(*configPath)
if err != nil {
log.Fatalf("Failed to load config: %v", err)
}
// Initialize logging.
postLog.SetDebugMode(cfg.System.DebugMode)
postLog.InitLogBroadcaster()
dbPath := cfg.DBPath
if dbPath == "" {
dbPath = "./db"
}
if err := postLog.InitLogsDatabase(fmt.Sprintf("%s/log.db", dbPath)); err != nil {
postLog.Fatal("Failed to initialize logs database: " + err.Error())
}
// Initialize database.
if err := database.InitUserDB(fmt.Sprintf("%s/user.db", dbPath)); err != nil {
postLog.Fatal("Failed to initialize user database: " + err.Error())
}
defer database.CloseUserDB()
// Initialize node tracker.
node.InitTracker()
// Initialize controller manager.
controller.InitManager()
// Initialize rate limiter.
utils.InitRateLimiter(100, time.Minute)
// Start token cleaner.
utils.StartTokenCleaner()
postLog.Info("Starting Nukumizu server...")
// --- Login to Komari ---
komari.InitClient(cfg.Komari.DashboardURL)
if err := komari.LoginAndStart(); err != nil {
postLog.Fatal("Failed to login to Komari: " + err.Error())
}
// --- Start Komari WebSocket connection ---
komari.InitWSClient(cfg.Komari.DashboardURL, 5)
wsClient := komari.GetWSClient()
if wsClient != nil {
wsClient.SetOnReconnectFail(func() {
postLog.Error("Komari WebSocket reconnection exhausted")
controller.GetManager().NotifyAllAdmins("Komari WebSocket connection lost after 5 retry attempts")
})
if err := wsClient.Connect(); err != nil {
postLog.Error("Failed to connect Komari WebSocket: " + err.Error())
}
}
// --- Initialize controllers ---
initControllers()
// --- Start background tasks ---
startBackgroundTasks(cfg)
// --- Setup HTTP router ---
mux := SetupRouter()
// Apply middleware chain.
handler := utils.RateLimitMiddleware(mux)
handler = utils.CORSMiddleware(handler)
handler = utils.XSSProtectionMiddleware(handler)
addr := fmt.Sprintf("%s:%s", cfg.System.ListenAddr, cfg.System.ListenPort)
postLog.Info(fmt.Sprintf("Server listening on %s", addr))
// Start HTTP server in a goroutine.
go func() {
if err := http.ListenAndServe(addr, handler); err != nil {
postLog.Fatal("Server error: " + err.Error())
}
}()
// --- Graceful shutdown ---
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
postLog.Info("Shutting down Nukumizu server...")
// Stop WebSocket client.
if wsClient != nil {
wsClient.Stop()
}
// Stop all controllers.
mgr := controller.GetManager()
if mgr != nil {
mgr.StopAll()
}
postLog.Info("Server stopped")
}
// initControllers initializes and starts all configured controllers.
func initControllers() {
cfg := config.GetConfig()
mgr := controller.GetManager()
if mgr == nil {
return
}
// QQ (Napcat) controller.
qqCtrl := controller.NewQQController(cfg.ControllerMethod.QQ)
mgr.Register(qqCtrl)
go func() {
defer func() {
if r := recover(); r != nil {
postLog.Error(fmt.Sprintf("QQ controller panic: %v", r))
}
}()
if err := qqCtrl.Start(); err != nil {
postLog.Error("Failed to start QQ controller: " + err.Error())
}
}()
// Telegram controller.
tgCtrl := controller.NewTelegramController(cfg.ControllerMethod.Telegram)
mgr.Register(tgCtrl)
go func() {
defer func() {
if r := recover(); r != nil {
postLog.Error(fmt.Sprintf("Telegram controller panic: %v", r))
}
}()
if err := tgCtrl.Start(); err != nil {
postLog.Error("Failed to start Telegram controller: " + err.Error())
}
}()
// Email controller (status-only).
emailCtrl := controller.NewEmailController(cfg.ControllerMethod.Email)
mgr.Register(emailCtrl)
go func() {
defer func() {
if r := recover(); r != nil {
postLog.Error(fmt.Sprintf("Email controller panic: %v", r))
}
}()
if err := emailCtrl.Start(); err != nil {
postLog.Error("Failed to start Email controller: " + err.Error())
}
}()
// Ntfy controller (status-only).
ntfyCtrl := controller.NewNtfyController(cfg.ControllerMethod.Ntfy)
mgr.Register(ntfyCtrl)
go func() {
defer func() {
if r := recover(); r != nil {
postLog.Error(fmt.Sprintf("Ntfy controller panic: %v", r))
}
}()
if err := ntfyCtrl.Start(); err != nil {
postLog.Error("Failed to start Ntfy controller: " + err.Error())
}
}()
// Webhook controller (status-only).
webhookCtrl := controller.NewWebhookController(cfg.ControllerMethod.Webhook)
mgr.Register(webhookCtrl)
go func() {
defer func() {
if r := recover(); r != nil {
postLog.Error(fmt.Sprintf("Webhook controller panic: %v", r))
}
}()
if err := webhookCtrl.Start(); err != nil {
postLog.Error("Failed to start Webhook controller: " + err.Error())
}
}()
}
// startBackgroundTasks starts periodic background goroutines.
func startBackgroundTasks(_ *config.Config) {
// Refresh node list every 5 minutes.
go func() {
defer func() {
if r := recover(); r != nil {
postLog.Error(fmt.Sprintf("Node refresh panic: %v", r))
}
}()
ticker := time.NewTicker(5 * time.Minute)
defer ticker.Stop()
for range ticker.C {
client := komari.GetClient()
if client == nil {
continue
}
nodes, err := client.FetchNodes()
if err != nil {
postLog.Warning("Failed to refresh node list: " + err.Error())
continue
}
tracker := node.GetTracker()
if tracker != nil {
nodeNames := make(map[string]string, len(nodes))
for _, n := range nodes {
nodeNames[n.UUID] = n.Name
}
tracker.UpdateNodeList(nodeNames)
}
}
}()
// Re-login to Komari every 12 hours.
go func() {
defer func() {
if r := recover(); r != nil {
postLog.Error(fmt.Sprintf("Komari re-login panic: %v", r))
}
}()
ticker := time.NewTicker(12 * time.Hour)
defer ticker.Stop()
for range ticker.C {
postLog.Info("Performing scheduled Komari re-login...")
if err := komari.LoginAndStart(); err != nil {
postLog.Error("Komari re-login failed: " + err.Error())
}
}
}()
// Wire node status change notifications to all controllers.
tracker := node.GetTracker()
if tracker != nil {
tracker.OnStatusChange(func(change node.StatusChange) {
mgr := controller.GetManager()
if mgr != nil {
mgr.NotifyStatusChange(change)
}
})
}
}
+52
View File
@@ -0,0 +1,52 @@
package main
import (
"fmt"
"net/http"
"nukumizu-backend/handler"
"nukumizu-backend/postLog"
)
// SetupRouter registers all HTTP routes and returns a configured ServeMux.
func SetupRouter() *http.ServeMux {
postLog.Info("Setting up routers...")
mux := http.NewServeMux()
// User endpoints.
mux.HandleFunc("/api/user/login", handler.UserLoginHandler)
mux.HandleFunc("/api/user/register", handler.UserRegisterHandler)
// Server endpoints (authenticated).
mux.HandleFunc("/api/server/list", handler.ServerListHandler)
mux.HandleFunc("/api/server/getStatus", handler.ServerGetStatusHandler)
mux.HandleFunc("/api/server/exec", handler.ServerExecHandler)
// Bot message receive endpoint (from napcat-bridge).
mux.HandleFunc("/api/bot/msg/recv", handler.BotMessageHandler)
// Health check endpoint.
mux.HandleFunc("/health", handler.HealthHandler)
// WebSocket log streaming endpoint.
logBroadcaster := postLog.GetLogBroadcaster()
if logBroadcaster != nil {
logSocketHandler := postLog.NewLogSocketHandler(logBroadcaster)
mux.HandleFunc("/api/system/getLogs", logSocketHandler.Handle)
}
// Catch-all 404 handler.
mux.HandleFunc("/", NotFoundHandler)
postLog.Info("Router setup completed")
return mux
}
// NotFoundHandler returns a 404 JSON response for unknown routes.
func NotFoundHandler(w http.ResponseWriter, r *http.Request) {
postLog.Debug(fmt.Sprintf("Unknown request: %s %s", r.Method, r.URL.Path))
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusNotFound)
fmt.Fprint(w, `{"success":false,"message":"route not found"}`)
}
+229
View File
@@ -0,0 +1,229 @@
package utils
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"net/http"
"strconv"
"sync"
"time"
"nukumizu-backend/config"
"nukumizu-backend/postLog"
)
// TokenInfo holds information about an authenticated session token.
type TokenInfo struct {
UserID int64 `json:"userID"`
Level string `json:"level"`
UserName string `json:"userName"`
CreatedAt time.Time `json:"createdAt"`
LastAccess time.Time `json:"lastAccess"`
}
var (
tokenStore = make(map[string]*TokenInfo)
tokenStoreLock sync.RWMutex
)
// GenerateToken creates a cryptographically random 32-byte hex token.
func GenerateToken() (string, error) {
bytes := make([]byte, 32)
if _, err := rand.Read(bytes); err != nil {
return "", err
}
return hex.EncodeToString(bytes), nil
}
// AddToken adds a token to the in-memory token store.
func AddToken(token string, userID int64, level string, userName string) {
tokenStoreLock.Lock()
defer tokenStoreLock.Unlock()
now := time.Now()
tokenStore[token] = &TokenInfo{
UserID: userID,
Level: level,
UserName: userName,
CreatedAt: now,
LastAccess: now,
}
}
// GetTokenInfo retrieves token information from the store.
func GetTokenInfo(token string) (*TokenInfo, bool) {
tokenStoreLock.RLock()
defer tokenStoreLock.RUnlock()
info, exists := tokenStore[token]
return info, exists
}
// RefreshToken updates the LastAccess time for an active token.
func RefreshToken(token string) {
tokenStoreLock.Lock()
defer tokenStoreLock.Unlock()
if info, exists := tokenStore[token]; exists {
info.LastAccess = time.Now()
}
}
// RemoveToken deletes a token from the store.
func RemoveToken(token string) {
tokenStoreLock.Lock()
defer tokenStoreLock.Unlock()
delete(tokenStore, token)
}
// GetUserIDFromRequest extracts the user ID from the request's X-Token header.
func GetUserIDFromRequest(r *http.Request) int64 {
token := r.Header.Get("X-Token")
if token == "" {
return 0
}
tokenInfo, exists := GetTokenInfo(token)
if !exists {
return 0
}
return tokenInfo.UserID
}
// GetUserLevelFromRequest extracts the user level from the request's X-Token header.
func GetUserLevelFromRequest(r *http.Request) string {
token := r.Header.Get("X-Token")
if token == "" {
return ""
}
tokenInfo, exists := GetTokenInfo(token)
if !exists {
return ""
}
return tokenInfo.Level
}
// Auth is the central authentication and authorization function.
// It validates the request method, X-Timestamp header (30min tolerance),
// X-Token header, and permission level. Returns true if the request is authorized.
//
// Permission levels: "None" (public, no token required), "bot", "admin".
// When level is "bot", both "bot" and "admin" tokens are accepted.
// When level is "admin", only "admin" tokens are accepted.
func Auth(w http.ResponseWriter, r *http.Request, targetMethod string, targetLevel string) bool {
// Validate HTTP method.
if r.Method != targetMethod {
SendErrorResponse(w, http.StatusMethodNotAllowed, "method not allowed")
return false
}
// Validate X-Timestamp.
timestamp := r.Header.Get("X-Timestamp")
if !config.IsDebugMode() {
if timestamp == "" {
SendErrorResponse(w, http.StatusUnauthorized, "missing timestamp")
return false
}
ts, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil {
SendErrorResponse(w, http.StatusUnauthorized, "invalid timestamp")
return false
}
now := time.Now().Unix()
diff := now - ts
if diff < 0 {
diff = -diff
}
// 30 minute tolerance per agent.md.
if diff > 1800 {
SendErrorResponse(w, http.StatusUnauthorized, "request expired")
return false
}
}
// Public endpoints require no token.
if targetLevel == "None" {
return true
}
// Validate X-Token.
token := r.Header.Get("X-Token")
if token == "" {
SendErrorResponse(w, http.StatusUnauthorized, "missing token")
return false
}
tokenInfo, exists := GetTokenInfo(token)
if !exists {
SendErrorResponse(w, http.StatusUnauthorized, "invalid token")
return false
}
// Check permission level.
// "bot" level accepts both "bot" and "admin" tokens.
// "admin" level accepts only "admin" tokens.
switch targetLevel {
case "admin":
if tokenInfo.Level != "admin" {
SendErrorResponse(w, http.StatusForbidden, "permission denied")
return false
}
case "bot":
if tokenInfo.Level != "bot" && tokenInfo.Level != "admin" {
SendErrorResponse(w, http.StatusForbidden, "permission denied")
return false
}
}
// Refresh token last access time.
RefreshToken(token)
return true
}
// CleanExpiredTokens removes tokens that have been idle for over 1 hour.
func CleanExpiredTokens() {
tokenStoreLock.Lock()
defer tokenStoreLock.Unlock()
now := time.Now()
for token, info := range tokenStore {
if now.Sub(info.LastAccess) > 1*time.Hour {
delete(tokenStore, token)
postLog.Debug("Expired token removed for user: " + info.UserName)
}
}
}
// StartTokenCleaner starts a background goroutine that periodically cleans
// expired tokens.
func StartTokenCleaner() {
ticker := time.NewTicker(10 * time.Minute)
go func() {
for range ticker.C {
CleanExpiredTokens()
}
}()
}
// SendSuccessResponse sends a standardized JSON success response.
func SendSuccessResponse(w http.ResponseWriter, message string, data map[string]interface{}) {
w.Header().Set("Content-Type", "application/json")
resp := map[string]interface{}{
"success": true,
}
if message != "" {
resp["message"] = message
}
for k, v := range data {
resp[k] = v
}
json.NewEncoder(w).Encode(resp)
}
// SendErrorResponse sends a standardized JSON error response.
func SendErrorResponse(w http.ResponseWriter, statusCode int, message string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(statusCode)
json.NewEncoder(w).Encode(map[string]interface{}{
"success": false,
"message": message,
})
}
+114
View File
@@ -0,0 +1,114 @@
package utils
import (
"net/http"
"sync"
"time"
)
// RateLimiter implements a simple per-IP rate limiter using a token bucket approach.
type RateLimiter struct {
mu sync.Mutex
visitors map[string]*visitor
rate int
interval time.Duration
}
type visitor struct {
count int
lastSeen time.Time
}
var globalLimiter *RateLimiter
// InitRateLimiter initializes the global rate limiter.
// rate is the maximum number of requests per interval.
func InitRateLimiter(rate int, interval time.Duration) {
globalLimiter = &RateLimiter{
visitors: make(map[string]*visitor),
rate: rate,
interval: interval,
}
// Start a background cleaner.
go func() {
for {
time.Sleep(interval)
globalLimiter.cleanup()
}
}()
}
func (rl *RateLimiter) cleanup() {
rl.mu.Lock()
defer rl.mu.Unlock()
for ip, v := range rl.visitors {
if time.Since(v.lastSeen) > rl.interval {
delete(rl.visitors, ip)
}
}
}
func (rl *RateLimiter) allow(ip string) bool {
rl.mu.Lock()
defer rl.mu.Unlock()
v, exists := rl.visitors[ip]
if !exists {
rl.visitors[ip] = &visitor{count: 1, lastSeen: time.Now()}
return true
}
if time.Since(v.lastSeen) > rl.interval {
v.count = 1
v.lastSeen = time.Now()
return true
}
if v.count >= rl.rate {
return false
}
v.count++
return true
}
// RateLimitMiddleware limits the number of requests per IP address.
func RateLimitMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if globalLimiter == nil {
next.ServeHTTP(w, r)
return
}
ip := r.RemoteAddr
if !globalLimiter.allow(ip) {
SendErrorResponse(w, http.StatusTooManyRequests, "rate limit exceeded")
return
}
next.ServeHTTP(w, r)
})
}
// CORSMiddleware sets CORS headers and handles preflight OPTIONS requests.
func CORSMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, X-Token, X-Timestamp, Authorization")
w.Header().Set("Access-Control-Max-Age", "86400")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
// XSSProtectionMiddleware sets security headers to help prevent XSS attacks.
func XSSProtectionMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-XSS-Protection", "1; mode=block")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'")
next.ServeHTTP(w, r)
})
}