diff --git a/.gitignore b/.gitignore index e54c74e..e28f240 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,4 @@ go.work.sum # env file .env +agent.md \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..2b96ea1 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "markdown.validate.enabled": true +} \ No newline at end of file diff --git a/auth.go b/auth.go new file mode 100644 index 0000000..8b04fde --- /dev/null +++ b/auth.go @@ -0,0 +1,170 @@ +package main + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "errors" + "fmt" + "strings" + "sync" + "time" +) + +type TokenInfo struct { + Token string + CreatedAt time.Time + UserID int +} + +var ( + tokenMap = make(map[int]*TokenInfo) + tokenMux sync.RWMutex + tokenTTL = time.Hour +) + +func GenerateToken(userID int) (string, error) { + randomBytes := make([]byte, 32) + _, err := rand.Read(randomBytes) + if err != nil { + return "", fmt.Errorf("failed to generate random bytes: %w", err) + } + + hash := sha256.Sum256(append(randomBytes, []byte(fmt.Sprintf("%d", userID))...)) + token := base64.URLEncoding.EncodeToString(hash[:]) + + tokenMux.Lock() + defer tokenMux.Unlock() + + tokenMap[userID] = &TokenInfo{ + Token: token, + CreatedAt: time.Now(), + UserID: userID, + } + + return token, nil +} + +func ValidateToken(userID int, token string) error { + tokenMux.RLock() + defer tokenMux.RUnlock() + + tokenInfo, exists := tokenMap[userID] + if !exists { + return errors.New("token not found") + } + + if tokenInfo.Token != token { + return errors.New("invalid token") + } + + if time.Since(tokenInfo.CreatedAt) > tokenTTL { + return errors.New("token expired") + } + + return nil +} + +func RefreshToken(userID int) (string, error) { + tokenMux.Lock() + defer tokenMux.Unlock() + + randomBytes := make([]byte, 32) + _, err := rand.Read(randomBytes) + if err != nil { + return "", fmt.Errorf("failed to generate random bytes: %w", err) + } + + hash := sha256.Sum256(append(randomBytes, []byte(fmt.Sprintf("%d", userID))...)) + token := base64.URLEncoding.EncodeToString(hash[:]) + + tokenMap[userID] = &TokenInfo{ + Token: token, + CreatedAt: time.Now(), + UserID: userID, + } + + return token, nil +} + +func RemoveToken(userID int) { + tokenMux.Lock() + defer tokenMux.Unlock() + delete(tokenMap, userID) +} + +func GetTokenInfo(userID int) (*TokenInfo, error) { + tokenMux.RLock() + defer tokenMux.RUnlock() + + tokenInfo, exists := tokenMap[userID] + if !exists { + return nil, errors.New("token not found") + } + + return tokenInfo, nil +} + +func extractUserIDFromToken(token string) (int, error) { + tokenMux.RLock() + defer tokenMux.RUnlock() + for userID, tokenInfo := range tokenMap { + if tokenInfo.Token == token { + return userID, nil + } + } + return 0, errors.New("invalid token") +} + +func CleanupExpiredTokens() { + tokenMux.Lock() + defer tokenMux.Unlock() + + for userID, tokenInfo := range tokenMap { + if time.Since(tokenInfo.CreatedAt) > tokenTTL { + delete(tokenMap, userID) + } + } +} + +func hashPassword(password string) (string, error) { + hash := sha256.Sum256([]byte(password)) + return hex.EncodeToString(hash[:]), nil +} + +func verifyPassword(password, hashedPassword string) bool { + hash, err := hashPassword(password) + if err != nil { + return false + } + return hash == hashedPassword +} + +func isValidPassword(password string) bool { + if len(password) < 8 { + return false + } + + hasUpper := false + hasLower := false + hasDigit := false + hasSpecial := false + + specialChars := "!@#$%^&*()_+-=[]{}|;:,.<>?" + + for _, char := range password { + switch { + case char >= 'A' && char <= 'Z': + hasUpper = true + case char >= 'a' && char <= 'z': + hasLower = true + case char >= '0' && char <= '9': + hasDigit = true + case strings.ContainsRune(specialChars, char): + hasSpecial = true + } + } + + return hasUpper && hasLower && hasDigit && hasSpecial +} diff --git a/config.go b/config.go new file mode 100644 index 0000000..d83af94 --- /dev/null +++ b/config.go @@ -0,0 +1,74 @@ +package main + +import ( + "encoding/json" + "errors" + "fmt" + "os" +) + +type Config struct { + ListenAddr string `json:"listenAddr"` + ListenPort string `json:"listenPort"` + FrpcPath string `json:"frpcPath"` + InstancePath string `json:"instancePath"` + Debug bool `json:"debug"` +} + +var globalConfig *Config + +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 config Config + if err := json.Unmarshal(data, &config); err != nil { + return nil, fmt.Errorf("failed to parse config file: %w", err) + } + + if config.ListenAddr == "" { + config.ListenAddr = "0.0.0.0" + } + + if config.ListenPort == "" { + config.ListenPort = "8080" + } + + if config.FrpcPath == "" { + config.FrpcPath = "/usr/bin/frpc" + } + + if config.InstancePath == "" { + config.InstancePath = "./configs" + } + + if err := os.MkdirAll(config.InstancePath, 0755); err != nil { + return nil, fmt.Errorf("failed to create config directory: %w", err) + } + + globalConfig = &config + return &config, nil +} + +func GetConfig() (*Config, error) { + if globalConfig == nil { + return nil, errors.New("config not loaded") + } + return globalConfig, nil +} + +func SaveConfig(configPath string, config *Config) error { + data, err := json.MarshalIndent(config, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal config: %w", err) + } + + if err := os.WriteFile(configPath, data, 0644); err != nil { + return fmt.Errorf("failed to write config file: %w", err) + } + + globalConfig = config + return nil +} diff --git a/config.json b/config.json new file mode 100644 index 0000000..7ed520d --- /dev/null +++ b/config.json @@ -0,0 +1,7 @@ +{ + "listenAddr": "0.0.0.0", + "listenPort": "8080", + "frpcPath": "/usr/bin/frpc", + "instancePath": "./configs", + "debug": false +} diff --git a/database.db b/database.db new file mode 100644 index 0000000..1e1667d Binary files /dev/null and b/database.db differ diff --git a/database.go b/database.go new file mode 100644 index 0000000..87db93a --- /dev/null +++ b/database.go @@ -0,0 +1,246 @@ +package main + +import ( + "database/sql" + "errors" + "fmt" + "strings" + + _ "modernc.org/sqlite" +) + +var db *sql.DB + +type User struct { + UserID int + Username string + Passwd string + Type string +} + +func InitDatabase(dbPath string) error { + var err error + db, err = sql.Open("sqlite", dbPath) + if err != nil { + return fmt.Errorf("failed to open database: %w", err) + } + + if err = db.Ping(); err != nil { + return fmt.Errorf("failed to ping database: %w", err) + } + + createTableSQL := ` + CREATE TABLE IF NOT EXISTS userLogin ( + userID INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + passwd TEXT NOT NULL, + type TEXT NOT NULL DEFAULT 'visitor' + ); + ` + _, err = db.Exec(createTableSQL) + if err != nil { + return fmt.Errorf("failed to create table: %w", err) + } + + return nil +} + +func CloseDatabase() error { + if db != nil { + return db.Close() + } + return nil +} + +func isValidInput(input string) bool { + invalidChars := []string{"'", "\"", ";", "--", "/*", "*/", "xp_", "sp_", "EXEC", "EXECUTE", "DROP", "INSERT", "UPDATE", "DELETE", "SELECT"} + lowerInput := strings.ToLower(input) + for _, chars := range invalidChars { + if strings.Contains(lowerInput, chars) { + return false + } + } + return true +} + +func AddUser(username, passwd, userType string) (int, error) { + if !isValidInput(username) || !isValidInput(passwd) { + return 0, errors.New("invalid input: contains illegal characters") + } + + if !isValidPassword(passwd) { + return 0, errors.New("password does not meet complexity requirements") + } + + hashedPasswd, err := hashPassword(passwd) + if err != nil { + return 0, fmt.Errorf("failed to hash password: %w", err) + } + + result, err := db.Exec("INSERT INTO userLogin (username, passwd, type) VALUES (?, ?, ?)", + username, hashedPasswd, userType) + if err != nil { + if strings.Contains(err.Error(), "UNIQUE constraint failed") { + return 0, errors.New("username already exists") + } + return 0, fmt.Errorf("failed to insert user: %w", err) + } + + lastID, err := result.LastInsertId() + if err != nil { + return 0, fmt.Errorf("failed to get last insert id: %w", err) + } + + var count int + err = db.QueryRow("SELECT COUNT(*) FROM userLogin WHERE userID = ?", lastID).Scan(&count) + if err != nil { + return 0, fmt.Errorf("failed to verify user insertion: %w", err) + } + + if count == 0 { + return 0, errors.New("user insertion verification failed") + } + + return int(lastID), nil +} + +func GetUserByUsername(username string) (*User, error) { + if !isValidInput(username) { + return nil, errors.New("invalid input: contains illegal characters") + } + + var user User + err := db.QueryRow("SELECT userID, username, passwd, type FROM userLogin WHERE username = ?", username). + Scan(&user.UserID, &user.Username, &user.Passwd, &user.Type) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, errors.New("user not found") + } + return nil, fmt.Errorf("failed to query user: %w", err) + } + + return &user, nil +} + +func GetUserByID(userID int) (*User, error) { + var user User + err := db.QueryRow("SELECT userID, username, passwd, type FROM userLogin WHERE userID = ?", userID). + Scan(&user.UserID, &user.Username, &user.Passwd, &user.Type) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, errors.New("user not found") + } + return nil, fmt.Errorf("failed to query user: %w", err) + } + + return &user, nil +} + +func UpdateUserPassword(userID int, newPasswd string) error { + if !isValidInput(newPasswd) { + return errors.New("invalid input: contains illegal characters") + } + + if !isValidPassword(newPasswd) { + return errors.New("password does not meet complexity requirements") + } + + hashedPasswd, err := hashPassword(newPasswd) + if err != nil { + return fmt.Errorf("failed to hash password: %w", err) + } + + result, err := db.Exec("UPDATE userLogin SET passwd = ? WHERE userID = ?", hashedPasswd, userID) + if err != nil { + return fmt.Errorf("failed to update password: %w", err) + } + + rowsAffected, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("failed to get rows affected: %w", err) + } + + if rowsAffected == 0 { + return errors.New("user not found") + } + + return nil +} + +func UpdateUserType(userID int, newType string) error { + validTypes := map[string]bool{ + "superuser": true, + "admin": true, + "visitor": true, + } + + if !validTypes[newType] { + return errors.New("invalid user type") + } + + result, err := db.Exec("UPDATE userLogin SET type = ? WHERE userID = ?", newType, userID) + if err != nil { + return fmt.Errorf("failed to update user type: %w", err) + } + + rowsAffected, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("failed to get rows affected: %w", err) + } + + if rowsAffected == 0 { + return errors.New("user not found") + } + + return nil +} + +func DeleteUser(userID int) error { + result, err := db.Exec("DELETE FROM userLogin WHERE userID = ?", userID) + if err != nil { + return fmt.Errorf("failed to delete user: %w", err) + } + + rowsAffected, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("failed to get rows affected: %w", err) + } + + if rowsAffected == 0 { + return errors.New("user not found") + } + + return nil +} + +func GetNextAvailableUserID() (int, error) { + var maxID int + err := db.QueryRow("SELECT COALESCE(MAX(userID), 0) FROM userLogin").Scan(&maxID) + if err != nil { + return 0, fmt.Errorf("failed to get max userID: %w", err) + } + return maxID + 1, nil +} + +func GetAllUsers() ([]User, error) { + rows, err := db.Query("SELECT userID, username, type FROM userLogin") + if err != nil { + return nil, fmt.Errorf("failed to query users: %w", err) + } + defer rows.Close() + + var users []User + for rows.Next() { + var user User + if err := rows.Scan(&user.UserID, &user.Username, &user.Type); err != nil { + return nil, fmt.Errorf("failed to scan user: %w", err) + } + users = append(users, user) + } + + if err = rows.Err(); err != nil { + return nil, fmt.Errorf("rows error: %w", err) + } + + return users, nil +} diff --git a/frpc.go b/frpc.go new file mode 100644 index 0000000..008894b --- /dev/null +++ b/frpc.go @@ -0,0 +1,733 @@ +package main + +import ( + "database/sql" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "strings" + "time" +) + +type InstanceInfo struct { + Name string `json:"name"` + ServerAddr string `json:"serverAddr"` + ServerPort string `json:"serverPort"` + AuthMethod string `json:"auth_method"` + BootAtStart bool `json:"bootAtStart"` + RunUser string `json:"runUser"` + Additional map[string]interface{} `json:"additionalProperties"` +} + +type CreateInstanceRequest struct { + Token string `json:"token"` + TimeStamp int64 `json:"timeStamp"` + InstanceInfo InstanceInfo `json:"instanceInfo"` + BootAtStart bool `json:"bootAtStart"` + RunUser string `json:"runUser"` + Additional map[string]interface{} `json:"additionalProperties"` +} + +type FrpcInstance struct { + ID int + UserID int + Name string + ServerAddr string + ServerPort string + AuthMethod string + BootAtStart bool + RunUser string + ConfigPath string + CreatedAt time.Time +} + +var frpcDB *sql.DB + +func InitFrpcDatabase(dbPath string) error { + var err error + frpcDB, err = sql.Open("sqlite", dbPath) + if err != nil { + return fmt.Errorf("failed to open frpc database: %w", err) + } + + if err = frpcDB.Ping(); err != nil { + return fmt.Errorf("failed to ping frpc database: %w", err) + } + + createTableSQL := ` + CREATE TABLE IF NOT EXISTS frpcInstances ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + userID INTEGER NOT NULL, + name TEXT NOT NULL, + serverAddr TEXT NOT NULL, + serverPort TEXT NOT NULL, + auth_method TEXT NOT NULL, + bootAtStart INTEGER NOT NULL DEFAULT 0, + runUser TEXT NOT NULL DEFAULT 'root', + configPath TEXT NOT NULL, + createdAt TEXT NOT NULL, + UNIQUE(userID, name) + ); + ` + _, err = frpcDB.Exec(createTableSQL) + if err != nil { + return fmt.Errorf("failed to create frpcInstances table: %w", err) + } + + return nil +} + +func CloseFrpcDatabase() error { + if frpcDB != nil { + return frpcDB.Close() + } + return nil +} + +func CreateInstanceHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + SendErrorResponse(w, http.StatusMethodNotAllowed, "invalid request method") + return + } + + body, err := io.ReadAll(r.Body) + if err != nil { + SendErrorResponse(w, http.StatusBadRequest, "failed to read request body") + return + } + defer r.Body.Close() + + var req CreateInstanceRequest + if err := json.Unmarshal(body, &req); err != nil { + SendErrorResponse(w, http.StatusBadRequest, "invalid request format") + return + } + + userID, _, err := ValidateRequest(w, r) + if err != nil { + SendErrorResponse(w, http.StatusUnauthorized, err.Error()) + return + } + + if err := CheckPermission(userID, "superuser", "admin"); err != nil { + SendErrorResponse(w, http.StatusForbidden, err.Error()) + return + } + + if req.InstanceInfo.Name == "" || req.InstanceInfo.ServerAddr == "" || + req.InstanceInfo.ServerPort == "" || req.InstanceInfo.AuthMethod == "" { + SendErrorResponse(w, http.StatusBadRequest, "missing required fields in instanceInfo") + return + } + + runUser := req.RunUser + if runUser == "" { + runUser = "root" + } + + user, err := GetUserByID(userID) + if err != nil { + SendErrorResponse(w, http.StatusInternalServerError, "failed to get user info") + return + } + + configDir, err := GetConfigDir() + if err != nil { + SendErrorResponse(w, http.StatusInternalServerError, "failed to get config directory") + return + } + + configFileName := fmt.Sprintf("superfrpc_%s_%s.toml", user.Username, req.InstanceInfo.Name) + configPath := filepath.Join(configDir, configFileName) + + configContent := generateFrpcConfig(req.InstanceInfo) + if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil { + SendErrorResponse(w, http.StatusInternalServerError, "failed to create config file") + return + } + + _, err = frpcDB.Exec(` + INSERT INTO frpcInstances (userID, name, serverAddr, serverPort, auth_method, bootAtStart, runUser, configPath, createdAt) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `, userID, req.InstanceInfo.Name, req.InstanceInfo.ServerAddr, req.InstanceInfo.ServerPort, + req.InstanceInfo.AuthMethod, req.BootAtStart, runUser, configPath, time.Now().Format(time.RFC3339)) + + if err != nil { + os.Remove(configPath) + SendErrorResponse(w, http.StatusInternalServerError, "failed to save instance to database") + return + } + + if req.BootAtStart { + if err := createBootService(user.Username, req.InstanceInfo.Name, configPath, runUser); err != nil { + frpcDB.Exec("DELETE FROM frpcInstances WHERE userID = ? AND name = ?", userID, req.InstanceInfo.Name) + os.Remove(configPath) + SendErrorResponse(w, http.StatusInternalServerError, "failed to create boot service") + return + } + } + + SendSuccessResponse(w, "instance created successfully", map[string]interface{}{ + "name": req.InstanceInfo.Name, + "configPath": configPath, + "bootAtStart": req.BootAtStart, + }) +} + +func DeleteInstanceHandler(w http.ResponseWriter, r *http.Request, instanceName string) { + if r.Method != http.MethodPost { + SendErrorResponse(w, http.StatusMethodNotAllowed, "invalid request method") + return + } + + userID, _, err := ValidateRequest(w, r) + if err != nil { + SendErrorResponse(w, http.StatusUnauthorized, err.Error()) + return + } + + if err := CheckPermission(userID, "superuser", "admin"); err != nil { + SendErrorResponse(w, http.StatusForbidden, err.Error()) + return + } + + user, err := GetUserByID(userID) + if err != nil { + SendErrorResponse(w, http.StatusInternalServerError, "failed to get user info") + return + } + + var instance FrpcInstance + err = frpcDB.QueryRow(` + SELECT id, userID, name, serverAddr, serverPort, auth_method, bootAtStart, runUser, configPath, createdAt + FROM frpcInstances WHERE userID = ? AND name = ? + `, userID, instanceName).Scan( + &instance.ID, &instance.UserID, &instance.Name, &instance.ServerAddr, &instance.ServerPort, + &instance.AuthMethod, &instance.BootAtStart, &instance.RunUser, &instance.ConfigPath, &instance.CreatedAt) + + if err == sql.ErrNoRows { + SendErrorResponse(w, http.StatusNotFound, "instance not found") + return + } + if err != nil { + SendErrorResponse(w, http.StatusInternalServerError, "failed to query instance") + return + } + + if instance.BootAtStart { + if err := removeBootService(user.Username, instanceName); err != nil { + SendErrorResponse(w, http.StatusInternalServerError, "failed to remove boot service") + return + } + } + + if _, err := os.Stat(instance.ConfigPath); err == nil { + if err := os.Remove(instance.ConfigPath); err != nil { + SendErrorResponse(w, http.StatusInternalServerError, "failed to remove config file") + return + } + } + + _, err = frpcDB.Exec("DELETE FROM frpcInstances WHERE id = ?", instance.ID) + if err != nil { + SendErrorResponse(w, http.StatusInternalServerError, "failed to delete instance from database") + return + } + + SendSuccessResponse(w, "instance deleted successfully", map[string]interface{}{ + "name": instanceName, + }) +} + +func ModifyInstanceHandler(w http.ResponseWriter, r *http.Request, instanceName string) { + if r.Method != http.MethodPost { + SendErrorResponse(w, http.StatusMethodNotAllowed, "invalid request method") + return + } + + body, err := io.ReadAll(r.Body) + if err != nil { + SendErrorResponse(w, http.StatusBadRequest, "failed to read request body") + return + } + defer r.Body.Close() + + var reqMap map[string]interface{} + if err := json.Unmarshal(body, &reqMap); err != nil { + SendErrorResponse(w, http.StatusBadRequest, "invalid request format") + return + } + + userID, _, err := ValidateRequest(w, r) + if err != nil { + SendErrorResponse(w, http.StatusUnauthorized, err.Error()) + return + } + + if err := CheckPermission(userID, "superuser", "admin"); err != nil { + SendErrorResponse(w, http.StatusForbidden, err.Error()) + return + } + + user, err := GetUserByID(userID) + if err != nil { + SendErrorResponse(w, http.StatusInternalServerError, "failed to get user info") + return + } + + var instance FrpcInstance + err = frpcDB.QueryRow(` + SELECT id, userID, name, serverAddr, serverPort, auth_method, bootAtStart, runUser, configPath, createdAt + FROM frpcInstances WHERE userID = ? AND name = ? + `, userID, instanceName).Scan( + &instance.ID, &instance.UserID, &instance.Name, &instance.ServerAddr, &instance.ServerPort, + &instance.AuthMethod, &instance.BootAtStart, &instance.RunUser, &instance.ConfigPath, &instance.CreatedAt) + + if err == sql.ErrNoRows { + SendErrorResponse(w, http.StatusNotFound, "instance not found") + return + } + if err != nil { + SendErrorResponse(w, http.StatusInternalServerError, "failed to query instance") + return + } + + newName := instance.Name + newServerAddr := instance.ServerAddr + newServerPort := instance.ServerPort + newAuthMethod := instance.AuthMethod + newRunUser := instance.RunUser + newBootAtStart := instance.BootAtStart + + if v, ok := reqMap["name"].(string); ok && v != "" { + newName = v + } + if v, ok := reqMap["serverAddr"].(string); ok && v != "" { + newServerAddr = v + } + if v, ok := reqMap["serverPort"].(string); ok && v != "" { + newServerPort = v + } + if v, ok := reqMap["auth_method"].(string); ok && v != "" { + newAuthMethod = v + } + if v, ok := reqMap["runUser"].(string); ok && v != "" { + newRunUser = v + } + if v, ok := reqMap["bootAtStart"].(bool); ok { + newBootAtStart = v + } + + oldConfigPath := instance.ConfigPath + var newConfigPath string + if newName != instance.Name || newRunUser != instance.RunUser { + configDir, err := GetConfigDir() + if err != nil { + SendErrorResponse(w, http.StatusInternalServerError, "failed to get config directory") + return + } + + newConfigFileName := fmt.Sprintf("superfrpc_%s_%s.toml", user.Username, newName) + newConfigPath = filepath.Join(configDir, newConfigFileName) + + if oldConfigPath != newConfigPath { + if _, err := os.Stat(oldConfigPath); err == nil { + if err := os.Rename(oldConfigPath, newConfigPath); err != nil { + SendErrorResponse(w, http.StatusInternalServerError, "failed to rename config file") + return + } + } + } + } else { + newConfigPath = oldConfigPath + } + + info := InstanceInfo{ + Name: newName, + ServerAddr: newServerAddr, + ServerPort: newServerPort, + AuthMethod: newAuthMethod, + RunUser: newRunUser, + } + + configContent := generateFrpcConfig(info) + if err := os.WriteFile(newConfigPath, []byte(configContent), 0644); err != nil { + SendErrorResponse(w, http.StatusInternalServerError, "failed to update config file") + return + } + + _, err = frpcDB.Exec(` + UPDATE frpcInstances + SET name = ?, serverAddr = ?, serverPort = ?, auth_method = ?, bootAtStart = ?, runUser = ?, configPath = ? + WHERE id = ? + `, newName, newServerAddr, newServerPort, newAuthMethod, newBootAtStart, newRunUser, newConfigPath, instance.ID) + + if err != nil { + SendErrorResponse(w, http.StatusInternalServerError, "failed to update instance in database") + return + } + + if instance.BootAtStart && !newBootAtStart { + removeBootService(user.Username, instanceName) + } else if !instance.BootAtStart && newBootAtStart { + createBootService(user.Username, newName, newConfigPath, newRunUser) + } else if instance.BootAtStart && newBootAtStart && (instance.Name != newName || instance.RunUser != newRunUser) { + removeBootService(user.Username, instanceName) + createBootService(user.Username, newName, newConfigPath, newRunUser) + } + + SendSuccessResponse(w, "instance modified successfully", map[string]interface{}{ + "name": newName, + "configPath": newConfigPath, + }) +} + +func ListInstancesHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + SendErrorResponse(w, http.StatusMethodNotAllowed, "invalid request method") + return + } + + userID, _, err := ValidateRequest(w, r) + if err != nil { + SendErrorResponse(w, http.StatusUnauthorized, err.Error()) + return + } + + userType, err := GetUserType(userID) + if err != nil { + SendErrorResponse(w, http.StatusInternalServerError, "failed to get user type") + return + } + + rows, err := frpcDB.Query(` + SELECT id, userID, name, serverAddr, serverPort, auth_method, bootAtStart, runUser, configPath, createdAt + FROM frpcInstances WHERE userID = ? + `, userID) + if err != nil { + SendErrorResponse(w, http.StatusInternalServerError, "failed to query instances") + return + } + defer rows.Close() + + var instances []map[string]interface{} + for rows.Next() { + var instance FrpcInstance + var createdAtStr string + if err := rows.Scan( + &instance.ID, &instance.UserID, &instance.Name, &instance.ServerAddr, &instance.ServerPort, + &instance.AuthMethod, &instance.BootAtStart, &instance.RunUser, &instance.ConfigPath, &createdAtStr, + ); err != nil { + SendErrorResponse(w, http.StatusInternalServerError, "failed to scan instance") + return + } + + instanceData := map[string]interface{}{ + "name": instance.Name, + "serverAddr": instance.ServerAddr, + "serverPort": instance.ServerPort, + "auth_method": instance.AuthMethod, + "bootAtStart": instance.BootAtStart, + "runUser": instance.RunUser, + "configPath": instance.ConfigPath, + "createdAt": createdAtStr, + } + + if userType == "visitor" { + delete(instanceData, "serverAddr") + delete(instanceData, "serverPort") + delete(instanceData, "auth_method") + } + + instances = append(instances, instanceData) + } + + if instances == nil { + instances = []map[string]interface{}{} + } + + SendSuccessResponse(w, "instances retrieved successfully", instances) +} + +func generateFrpcConfig(info InstanceInfo) string { + var sb strings.Builder + sb.WriteString("[common]\n") + sb.WriteString(fmt.Sprintf("server_addr = %s\n", info.ServerAddr)) + sb.WriteString(fmt.Sprintf("server_port = %s\n", info.ServerPort)) + sb.WriteString(fmt.Sprintf("auth_method = %s\n", info.AuthMethod)) + + for key, value := range info.Additional { + sb.WriteString(fmt.Sprintf("%s = %v\n", key, value)) + } + + return sb.String() +} + +func GetConfigDir() (string, error) { + config, err := GetConfig() + if err != nil { + return "", err + } + return config.InstancePath, nil +} + +func GetFrpcPath() (string, error) { + config, err := GetConfig() + if err != nil { + return "", err + } + return config.FrpcPath, nil +} + +func detectInitSystem() string { + if _, err := os.Stat("/run/systemd/system"); err == nil { + return "systemd" + } + if _, err := os.Stat("/etc/init.d"); err == nil { + return "init.d" + } + return "unknown" +} + +func createBootService(username, instanceName, configPath, runUser string) error { + initSystem := detectInitSystem() + + switch initSystem { + case "systemd": + return createSystemdService(username, instanceName, configPath, runUser) + case "init.d": + return createInitDService(username, instanceName, configPath, runUser) + default: + return errors.New("unsupported init system") + } +} + +func createSystemdService(username, instanceName, configPath, runUser string) error { + frpcPath, err := GetFrpcPath() + if err != nil { + return err + } + + serviceName := fmt.Sprintf("superfrpc_%s_%s", username, instanceName) + serviceContent := fmt.Sprintf(`[Unit] +Description=superfrpc_%s_%s +After=network.target + +[Service] +Type=simple +ExecStart=%s -c %s +User=%s +Restart=on-failure +RestartSec=5 + +[Install] +WantedBy=multi-user.target +`, username, instanceName, frpcPath, configPath, runUser) + + servicePath := filepath.Join("/etc/systemd/system", serviceName+".service") + if err := os.WriteFile(servicePath, []byte(serviceContent), 0644); err != nil { + return fmt.Errorf("failed to create systemd service file: %w", err) + } + + cmd := exec.Command("systemctl", "enable", serviceName) + if output, err := cmd.CombinedOutput(); err != nil { + os.Remove(servicePath) + return fmt.Errorf("failed to enable service: %s, output: %s", err, output) + } + + cmd = exec.Command("systemctl", "daemon-reload") + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("failed to reload daemon: %s, output: %s", err, output) + } + + return nil +} + +func createInitDService(username, instanceName, configPath, runUser string) error { + frpcPath, err := GetFrpcPath() + if err != nil { + return err + } + + serviceName := fmt.Sprintf("superfrpc_%s_%s", username, instanceName) + + var serviceContent string + runUserArg := "" + if runUser != "" && runUser != "root" { + runUserArg = runUser + serviceContent = fmt.Sprintf(`#!/bin/sh + +### BEGIN INIT INFO +# Provides: %s +# Required-Start: $network $remote_fs $syslog +# Required-Stop: $network $remote_fs $syslog +# Default-Start: 2 3 4 5 +# Default-Stop: 0 1 6 +# Description: superfrpc %s %s +### END INIT INFO + +NAME="%s" +PATH=/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin +DAEMON=%s +DAEMON_ARGS="-c %s" +SCRIPTNAME=/etc/init.d/$NAME +USER=%s + +[ -x "$DAEMON" ] || exit 0 + +case "$1" in + start) + echo -n "Starting $NAME: " + start-stop-daemon -S -c $USER -b -m -p /var/run/$NAME.pid --start --exec $DAEMON -- $DAEMON_ARGS + echo "$NAME." + ;; + stop) + echo -n "Stopping $NAME: " + start-stop-daemon -K -p /var/run/$NAME.pid + rm -f /var/run/$NAME.pid + echo "$NAME." + ;; + restart) + $0 stop + $0 start + ;; + status) + if [ -f /var/run/$NAME.pid ]; then + echo "$NAME is running (pid $(cat /var/run/$NAME.pid))" + else + echo "$NAME is not running" + fi + ;; + *) + echo "Usage: $SCRIPTNAME {start|stop|restart|status}" + exit 1 + ;; +esac + +exit 0 +`, serviceName, username, instanceName, serviceName, frpcPath, configPath, runUserArg) + } else { + serviceContent = fmt.Sprintf(`#!/bin/sh + +### BEGIN INIT INFO +# Provides: %s +# Required-Start: $network $remote_fs $syslog +# Required-Stop: $network $remote_fs $syslog +# Default-Start: 2 3 4 5 +# Default-Stop: 0 1 6 +# Description: superfrpc %s %s +### END INIT INFO + +NAME="%s" +PATH=/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin +DAEMON=%s +DAEMON_ARGS="-c %s" +SCRIPTNAME=/etc/init.d/$NAME + +[ -x "$DAEMON" ] || exit 0 + +case "$1" in + start) + echo -n "Starting $NAME: " + start-stop-daemon -S -b -m -p /var/run/$NAME.pid --start --exec $DAEMON -- $DAEMON_ARGS + echo "$NAME." + ;; + stop) + echo -n "Stopping $NAME: " + start-stop-daemon -K -p /var/run/$NAME.pid + rm -f /var/run/$NAME.pid + echo "$NAME." + ;; + restart) + $0 stop + $0 start + ;; + status) + if [ -f /var/run/$NAME.pid ]; then + echo "$NAME is running (pid $(cat /var/run/$NAME.pid))" + else + echo "$NAME is not running" + fi + ;; + *) + echo "Usage: $SCRIPTNAME {start|stop|restart|status}" + exit 1 + ;; +esac + +exit 0 +`, serviceName, username, instanceName, serviceName, frpcPath, configPath) + } + + servicePath := filepath.Join("/etc/init.d", serviceName) + if err := os.WriteFile(servicePath, []byte(serviceContent), 0755); err != nil { + return fmt.Errorf("failed to create init.d service file: %w", err) + } + + cmd := exec.Command("/etc/init.d", serviceName, "enable") + if output, err := cmd.CombinedOutput(); err != nil { + os.Remove(servicePath) + return fmt.Errorf("failed to enable service: %s, output: %s", err, output) + } + + return nil +} + +func removeBootService(username, instanceName string) error { + initSystem := detectInitSystem() + + serviceName := fmt.Sprintf("superfrpc_%s_%s", username, instanceName) + + switch initSystem { + case "systemd": + cmd := exec.Command("systemctl", "disable", serviceName) + cmd.CombinedOutput() + + servicePath := filepath.Join("/etc/systemd/system", serviceName+".service") + os.Remove(servicePath) + + cmd = exec.Command("systemctl", "daemon-reload") + cmd.CombinedOutput() + + case "init.d": + cmd := exec.Command("/etc/init.d", serviceName, "disable") + cmd.CombinedOutput() + + servicePath := filepath.Join("/etc/init.d", serviceName) + os.Remove(servicePath) + } + + return nil +} + +func GetUserInstances(userID int) ([]FrpcInstance, error) { + rows, err := frpcDB.Query(` + SELECT id, userID, name, serverAddr, serverPort, auth_method, bootAtStart, runUser, configPath, createdAt + FROM frpcInstances WHERE userID = ? + `, userID) + if err != nil { + return nil, err + } + defer rows.Close() + + var instances []FrpcInstance + for rows.Next() { + var instance FrpcInstance + var createdAtStr string + if err := rows.Scan( + &instance.ID, &instance.UserID, &instance.Name, &instance.ServerAddr, &instance.ServerPort, + &instance.AuthMethod, &instance.BootAtStart, &instance.RunUser, &instance.ConfigPath, &createdAtStr, + ); err != nil { + return nil, err + } + instance.CreatedAt, _ = time.Parse(time.RFC3339, createdAtStr) + instances = append(instances, instance) + } + + return instances, nil +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..035103c --- /dev/null +++ b/go.mod @@ -0,0 +1,18 @@ +module super-frpc + +go 1.24.0 + +require modernc.org/sqlite v1.46.1 + +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/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect + golang.org/x/sys v0.37.0 // indirect + modernc.org/libc v1.67.6 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..28bd8d8 --- /dev/null +++ b/go.sum @@ -0,0 +1,25 @@ +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/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +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/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk= +github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +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/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY= +golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= +golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +modernc.org/libc v1.67.6 h1:eVOQvpModVLKOdT+LvBPjdQqfrZq+pC39BygcT+E7OI= +modernc.org/libc v1.67.6/go.mod h1:JAhxUVlolfYDErnwiqaLvUqc8nfb2r6S6slAgZOnaiE= +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/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU= +modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA= diff --git a/handlers.go b/handlers.go new file mode 100644 index 0000000..fd6322b --- /dev/null +++ b/handlers.go @@ -0,0 +1,286 @@ +package main + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "log" + "net/http" + "strconv" + "time" +) + +type RegisterRequest struct { + Username string `json:"username"` + Passwd string `json:"passwd"` + TimeStamp int64 `json:"timeStamp"` + Type string `json:"type"` +} + +type LoginRequest struct { + Username string `json:"username"` + Passwd string `json:"passwd"` + TimeStamp int64 `json:"timeStamp"` +} + +type Response struct { + Success bool `json:"success"` + Message string `json:"message,omitempty"` + Data interface{} `json:"data,omitempty"` +} + +func RegisterHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + SendErrorResponse(w, http.StatusMethodNotAllowed, "invalid request method") + return + } + + body, err := io.ReadAll(r.Body) + if err != nil { + SendErrorResponse(w, http.StatusBadRequest, "failed to read request body") + return + } + defer r.Body.Close() + + var req RegisterRequest + if err := json.Unmarshal(body, &req); err != nil { + SendErrorResponse(w, http.StatusBadRequest, "invalid request format") + return + } + + if req.Username == "" || req.Passwd == "" { + SendErrorResponse(w, http.StatusBadRequest, "username and password are required") + return + } + + currentTime := time.Now().UnixMilli() + if !globalConfig.Debug && (currentTime-req.TimeStamp > 3000 || req.TimeStamp-currentTime > 3000) { + SendErrorResponse(w, http.StatusBadRequest, "timestamp out of valid range") + return + } + + if !isValidInput(req.Username) || !isValidInput(req.Passwd) { + SendErrorResponse(w, http.StatusBadRequest, "invalid input: contains illegal characters") + return + } + + if !isValidPassword(req.Passwd) { + SendErrorResponse(w, http.StatusBadRequest, "password does not meet complexity requirements (must contain uppercase, lowercase, digit, and special character)") + return + } + + userType := req.Type + if userType == "" { + userType = "visitor" + } + + validTypes := map[string]bool{ + "superuser": true, + "admin": true, + "visitor": true, + } + if !validTypes[userType] { + SendErrorResponse(w, http.StatusBadRequest, "invalid user type") + return + } + + userID, err := AddUser(req.Username, req.Passwd, userType) + if err != nil { + SendErrorResponse(w, http.StatusInternalServerError, err.Error()) + return + } + + user, err := GetUserByID(userID) + if err != nil { + SendErrorResponse(w, http.StatusInternalServerError, "failed to retrieve user after registration") + return + } + + SendSuccessResponse(w, "user registered successfully", map[string]interface{}{ + "userID": user.UserID, + "username": user.Username, + "type": user.Type, + }) +} + +func LoginHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + SendErrorResponse(w, http.StatusMethodNotAllowed, "invalid request method") + return + } + + body, err := io.ReadAll(r.Body) + if err != nil { + SendErrorResponse(w, http.StatusBadRequest, "failed to read request body") + return + } + defer r.Body.Close() + + var req LoginRequest + if err := json.Unmarshal(body, &req); err != nil { + SendErrorResponse(w, http.StatusBadRequest, "invalid request format") + return + } + + if req.Username == "" || req.Passwd == "" { + SendErrorResponse(w, http.StatusBadRequest, "username and password are required") + return + } + + currentTime := time.Now().UnixMilli() + if !globalConfig.Debug && (currentTime-req.TimeStamp > 3000 || req.TimeStamp-currentTime > 3000) { + SendErrorResponse(w, http.StatusBadRequest, "timestamp out of valid range") + return + } + + if !isValidInput(req.Username) || !isValidInput(req.Passwd) { + SendErrorResponse(w, http.StatusBadRequest, "invalid input: contains illegal characters") + return + } + + user, err := GetUserByUsername(req.Username) + if err != nil { + SendErrorResponse(w, http.StatusUnauthorized, "invalid username or password") + return + } + + if !verifyPassword(req.Passwd, user.Passwd) { + SendErrorResponse(w, http.StatusUnauthorized, "invalid username or password") + return + } + + token, err := GenerateToken(user.UserID) + if err != nil { + SendErrorResponse(w, http.StatusInternalServerError, "failed to generate token") + return + } + + SendSuccessResponse(w, "login successful", map[string]interface{}{ + "token": token, + "userID": user.UserID, + "username": user.Username, + "type": user.Type, + }) +} + +func SendErrorResponse(w http.ResponseWriter, statusCode int, message string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(statusCode) + resp := Response{ + Success: false, + Message: message, + } + jsonResp, err := json.Marshal(resp) + if err != nil { + log.Printf("failed to marshal error response: %v", err) + return + } + w.Write(jsonResp) +} + +func SendSuccessResponse(w http.ResponseWriter, message string, data interface{}) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + resp := Response{ + Success: true, + Message: message, + Data: data, + } + jsonResp, err := json.Marshal(resp) + if err != nil { + log.Printf("failed to marshal success response: %v", err) + return + } + w.Write(jsonResp) +} + +func ValidateRequest(w http.ResponseWriter, r *http.Request, requiredFields ...string) (int, string, error) { + body, err := io.ReadAll(r.Body) + if err != nil { + return 0, "", errors.New("failed to read request body") + } + defer r.Body.Close() + + var reqMap map[string]interface{} + if err := json.Unmarshal(body, &reqMap); err != nil { + return 0, "", errors.New("invalid request format") + } + + token, ok := reqMap["token"].(string) + if !ok || token == "" { + return 0, "", errors.New("token is required") + } + + timeStamp, ok := reqMap["timeStamp"].(float64) + if !ok { + return 0, "", errors.New("timeStamp is required") + } + + currentTime := time.Now().UnixMilli() + if !globalConfig.Debug && (currentTime-int64(timeStamp) > 3000 || int64(timeStamp)-currentTime > 3000) { + return 0, "", errors.New("timestamp out of valid range") + } + + userID, err := extractUserIDFromToken(token) + if err != nil { + return 0, "", err + } + + if err := ValidateToken(userID, token); err != nil { + return 0, "", err + } + + for _, field := range requiredFields { + if _, ok := reqMap[field]; !ok { + return 0, "", fmt.Errorf("required field %s is missing", field) + } + } + + return userID, token, nil +} + +func GetUserType(userID int) (string, error) { + user, err := GetUserByID(userID) + if err != nil { + return "", err + } + return user.Type, nil +} + +func CheckPermission(userID int, requiredTypes ...string) error { + userType, err := GetUserType(userID) + if err != nil { + return err + } + + for _, t := range requiredTypes { + if userType == t { + return nil + } + } + + return errors.New("permission denied") +} + +func GetClientIP(r *http.Request) string { + forwarded := r.Header.Get("X-Forwarded-For") + if forwarded != "" { + return forwarded + } + return r.RemoteAddr +} + +func LogRequest(r *http.Request, userID int) { + log.Printf("[%s] %s %s - UserID: %d - IP: %s", + time.Now().Format("2006-01-02 15:04:05"), + r.Method, + r.URL.Path, + userID, + GetClientIP(r), + ) +} + +func IntToString(i int) string { + return strconv.Itoa(i) +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..25090b9 --- /dev/null +++ b/main.go @@ -0,0 +1,141 @@ +package main + +import ( + "flag" + "fmt" + "log" + "net/http" + "os" + "os/signal" + "strings" + "syscall" + "time" +) + +func main() { + configPath := flag.String("config", "./config.json", "path to config file") + dbPath := flag.String("db", "./database.db", "path to database file") + flag.Parse() + + if _, err := os.Stat(*configPath); os.IsNotExist(err) { + defaultConfig := `{ + listenAddr": " "0.0.0.0", + "listenPort": "8080", + "configDir": "./configs" +}` + if err := os.WriteFile(*configPath, []byte(defaultConfig), 0644); err != nil { + log.Fatalf("failed to create default config file: %v", err) + } + log.Printf("created default config file at %s", *configPath) + } + + _, err := LoadConfig(*configPath) + if err != nil { + log.Fatalf("failed to load config: %v", err) + } + + if err := InitDatabase(*dbPath); err != nil { + log.Fatalf("failed to initialize database: %v", err) + } + log.Println("database initialized successfully") + + if err := InitFrpcDatabase(*dbPath); err != nil { + log.Printf("warning: failed to initialize frpc database: %v", err) + } + + config, err := GetConfig() + if err != nil { + log.Fatalf("failed to get config: %v", err) + } + + setupRoutes() + + addr := fmt.Sprintf("%s:%s", config.ListenAddr, config.ListenPort) + server := &http.Server{ + Addr: addr, + ReadTimeout: 15 * time.Second, + WriteTimeout: 15 * time.Second, + IdleTimeout: 60 * time.Second, + } + + go func() { + log.Printf("server starting on %s", addr) + if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatalf("failed to start server: %v", err) + } + }() + + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + <-quit + + log.Println("shutting down server...") + + if err := server.Close(); err != nil { + log.Printf("server closed with error: %v", err) + } + + if err := CloseDatabase(); err != nil { + log.Printf("error closing database: %v", err) + } + + if err := CloseFrpcDatabase(); err != nil { + log.Printf("error closing frpc database: %v", err) + } + + log.Println("server stopped") +} + +func setupRoutes() { + http.HandleFunc("/register", RegisterHandler) + http.HandleFunc("/login", LoginHandler) + + http.HandleFunc("/frpcAct/instanceMgr/create", CreateInstanceHandler) + http.HandleFunc("/frpcAct/instanceMgr/list", ListInstancesHandler) + + http.HandleFunc("/frpcAct/instanceMgr/", func(w http.ResponseWriter, r *http.Request) { + path := r.URL.Path + if len(path) < len("/frpcAct/instanceMgr/") { + SendErrorResponse(w, http.StatusNotFound, "invalid path") + return + } + + remainingPath := path[len("/frpcAct/instanceMgr/"):] + + if r.Method == http.MethodPost { + if remainingPath == "create" { + CreateInstanceHandler(w, r) + return + } + + if remainingPath == "list" { + ListInstancesHandler(w, r) + return + } + + if strings.HasSuffix(remainingPath, "/delete") { + instanceName := strings.TrimSuffix(remainingPath, "/delete") + instanceName = strings.Trim(instanceName, "/") + DeleteInstanceHandler(w, r, instanceName) + return + } + + if strings.Contains(remainingPath, "/modify/") { + parts := strings.SplitN(remainingPath, "/modify/", 2) + if len(parts) == 2 { + instanceName := strings.Trim(parts[0], "/") + ModifyInstanceHandler(w, r, instanceName) + return + } + } + + instanceName := strings.Trim(remainingPath, "/") + if instanceName != "" { + ListInstancesHandler(w, r) + return + } + } + + SendErrorResponse(w, http.StatusNotFound, "endpoint not found") + }) +} diff --git a/super-frpc-linux b/super-frpc-linux new file mode 100644 index 0000000..198561a Binary files /dev/null and b/super-frpc-linux differ