From 5580db0cf99c223e341445f82c118d790313c326 Mon Sep 17 00:00:00 2001 From: crazydi4mond <255249920+crazydi4mond@users.noreply.github.com> Date: Fri, 13 Mar 2026 19:01:08 +0100 Subject: [PATCH 1/2] feat: add x-ui panel integration for server mode - Add xui package with API client, inbound management, and config manipulation - Support dual install modes: standalone and x-ui integration - Extend ServerConfig with x-ui tracking fields - Adapt configure, status, and uninstall handlers for both modes - Use random available ports instead of hardcoded defaults - Update TUI menu state and input collection for x-ui awareness --- .gitignore | 3 + internal/actions/server.go | 123 +++++++++++++--- internal/config/types.go | 12 +- internal/handlers/helpers.go | 21 +++ internal/handlers/server_configure.go | 124 +++++++++++++++- internal/handlers/server_install.go | 157 ++++++++++++++++++++- internal/handlers/server_status.go | 71 ++++++---- internal/handlers/server_uninstall.go | 101 +++++++++++++- internal/menu/adapter.go | 13 ++ internal/menu/main.go | 4 +- internal/menu/state.go | 30 ++-- internal/xui/client.go | 111 +++++++++++++++ internal/xui/config.go | 194 ++++++++++++++++++++++++++ internal/xui/detect.go | 90 ++++++++++++ internal/xui/inbound.go | 81 +++++++++++ 15 files changed, 1066 insertions(+), 69 deletions(-) create mode 100644 internal/xui/client.go create mode 100644 internal/xui/config.go create mode 100644 internal/xui/detect.go create mode 100644 internal/xui/inbound.go diff --git a/.gitignore b/.gitignore index d42eb6e..6437238 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,9 @@ /nhclient dist/ +# Configurations +*.json + # AI Agents CLAUDE.md claude.md \ No newline at end of file diff --git a/internal/actions/server.go b/internal/actions/server.go index ce738fd..2b57ec0 100644 --- a/internal/actions/server.go +++ b/internal/actions/server.go @@ -5,6 +5,14 @@ import ( "strconv" ) +var portValidator = func(value string) error { + n, err := strconv.Atoi(value) + if err != nil || n < 1 || n > 65535 { + return fmt.Errorf("invalid port number") + } + return nil +} + // RegisterServerActions registers all server-side actions. func RegisterServerActions() { Register(&Action{ @@ -12,6 +20,58 @@ func RegisterServerActions() { Use: "install", Short: "Install xray and configure server", RequiresRoot: true, + Inputs: []InputField{ + { + Name: "install-mode", + Label: "Installation mode", + Type: InputTypeSelect, + InteractiveOnly: true, + ShowIf: func(ctx *Context) bool { + return ctx.GetBool("xui-detected") + }, + Options: []SelectOption{ + {Label: "Configure via x-ui panel", Value: "xui"}, + {Label: "Install standalone", Value: "standalone"}, + }, + }, + { + Name: "standalone", + Label: "Install standalone (skip x-ui integration)", + Type: InputTypeBool, + }, + { + Name: "xui-user", + Label: "x-ui panel username", + Type: InputTypeText, + Required: true, + ShowIf: func(ctx *Context) bool { + return ctx.GetString("install-mode") == "xui" + }, + }, + { + Name: "xui-pass", + Label: "x-ui panel password", + Type: InputTypePassword, + Required: true, + ShowIf: func(ctx *Context) bool { + return ctx.GetString("install-mode") == "xui" + }, + }, + { + Name: "socks-port", + Label: "SOCKS5 port", + Description: "Port for SOCKS5 inbound", + Type: InputTypeNumber, + Validate: portValidator, + }, + { + Name: "tunnel-port", + Label: "Tunnel port", + Description: "Port for VLESS tunnel inbound", + Type: InputTypeNumber, + Validate: portValidator, + }, + }, }) Register(&Action{ @@ -20,33 +80,37 @@ func RegisterServerActions() { Short: "Configure server settings", RequiresRoot: true, Inputs: []InputField{ + { + Name: "xui-user", + Label: "x-ui panel username", + Type: InputTypeText, + Required: true, + ShowIf: func(ctx *Context) bool { + return ctx.GetBool("xui-mode") + }, + }, + { + Name: "xui-pass", + Label: "x-ui panel password", + Type: InputTypePassword, + Required: true, + ShowIf: func(ctx *Context) bool { + return ctx.GetBool("xui-mode") + }, + }, { Name: "socks-port", Label: "SOCKS5 port", Description: "Port for SOCKS5 inbound", Type: InputTypeNumber, - Default: "1080", - Validate: func(value string) error { - n, err := strconv.Atoi(value) - if err != nil || n < 1 || n > 65535 { - return fmt.Errorf("invalid port number") - } - return nil - }, + Validate: portValidator, }, { Name: "tunnel-port", Label: "Tunnel port", Description: "Port for VLESS tunnel inbound", Type: InputTypeNumber, - Default: "2083", - Validate: func(value string) error { - n, err := strconv.Atoi(value) - if err != nil || n < 1 || n > 65535 { - return fmt.Errorf("invalid port number") - } - return nil - }, + Validate: portValidator, }, }, }) @@ -62,8 +126,33 @@ func RegisterServerActions() { Use: "uninstall", Short: "Remove server installation", RequiresRoot: true, + Inputs: []InputField{ + { + Name: "xui-user", + Label: "x-ui panel username", + Type: InputTypeText, + Required: true, + ShowIf: func(ctx *Context) bool { + return ctx.GetBool("xui-mode") + }, + }, + { + Name: "xui-pass", + Label: "x-ui panel password", + Type: InputTypePassword, + Required: true, + ShowIf: func(ctx *Context) bool { + return ctx.GetBool("xui-mode") + }, + }, + { + Name: "keep-xui", + Label: "Keep x-ui configuration (only remove nethopper config)", + Type: InputTypeBool, + }, + }, Confirm: &ConfirmConfig{ - Message: "Remove xray, config, and systemd service?", + Message: "Remove nethopper server configuration?", DefaultNo: true, ForceFlag: "force", }, diff --git a/internal/config/types.go b/internal/config/types.go index 6d89533..c38a409 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -9,9 +9,15 @@ import ( // ServerConfig holds server-side configuration. type ServerConfig struct { - SocksPort int `json:"socks_port"` - TunnelPort int `json:"tunnel_port"` - UUID string `json:"uuid"` + SocksPort int `json:"socks_port"` + TunnelPort int `json:"tunnel_port"` + UUID string `json:"uuid"` + XUIMode bool `json:"xui_mode,omitempty"` + XUISocksInboundID int `json:"xui_socks_inbound_id,omitempty"` + XUITunnelInboundID int `json:"xui_tunnel_inbound_id,omitempty"` + XUISocksTag string `json:"xui_socks_tag,omitempty"` + XUITunnelTag string `json:"xui_tunnel_tag,omitempty"` + XUIPortalTag string `json:"xui_portal_tag,omitempty"` } // ClientConfig holds client-side configuration. diff --git a/internal/handlers/helpers.go b/internal/handlers/helpers.go index e2ff299..09c67e7 100644 --- a/internal/handlers/helpers.go +++ b/internal/handlers/helpers.go @@ -82,6 +82,27 @@ func copyFile(src, dst string) error { return out.Close() } +// randomAvailablePort finds a random available TCP port. +func randomAvailablePort() (int, error) { + l, err := net.Listen("tcp", ":0") + if err != nil { + return 0, err + } + port := l.Addr().(*net.TCPAddr).Port + l.Close() + return port, nil +} + +// isPortAvailable checks if a specific TCP port is available for binding. +func isPortAvailable(port int) bool { + l, err := net.Listen("tcp", fmt.Sprintf(":%d", port)) + if err != nil { + return false + } + l.Close() + return true +} + // writeFile writes data to a file, creating parent directories as needed. func writeFile(path string, data []byte) error { if err := os.MkdirAll(filepath.Dir(path), 0750); err != nil { diff --git a/internal/handlers/server_configure.go b/internal/handlers/server_configure.go index 01eea85..1fb733c 100644 --- a/internal/handlers/server_configure.go +++ b/internal/handlers/server_configure.go @@ -6,19 +6,131 @@ import ( "github.com/net2share/nethopper/internal/actions" "github.com/net2share/nethopper/internal/config" + "github.com/net2share/nethopper/internal/firewall" "github.com/net2share/nethopper/internal/service" "github.com/net2share/nethopper/internal/xray" + "github.com/net2share/nethopper/internal/xui" ) func HandleServerConfigure(ctx *actions.Context) error { - beginProgress(ctx, "Configuring Server") - - // Load existing config var serverCfg config.ServerConfig if err := config.LoadJSON(config.ServerConfigPath(), &serverCfg); err != nil { - return failProgress(ctx, fmt.Errorf("failed to load config (run install first): %w", err)) + return fmt.Errorf("failed to load config (run install first): %w", err) + } + + if serverCfg.XUIMode { + return handleXUIConfigure(ctx, &serverCfg) + } + return handleStandaloneConfigure(ctx, &serverCfg) +} + +func handleXUIConfigure(ctx *actions.Context, serverCfg *config.ServerConfig) error { + // Verify x-ui is running + if !xui.IsXUIRunning() { + return fmt.Errorf("x-ui is not running (required for x-ui mode configuration)") + } + + user := ctx.GetString("xui-user") + pass := ctx.GetString("xui-pass") + if !ctx.IsInteractive && (user == "" || pass == "") { + return fmt.Errorf("x-ui mode: --xui-user and --xui-pass are required") + } + + beginProgress(ctx, "Configuring via x-ui") + + // Step 1: Read panel info and authenticate + ctx.Output.Step(1, 5, "Authenticating with x-ui panel") + panelInfo, err := xui.ReadPanelInfo() + if err != nil { + return failProgress(ctx, fmt.Errorf("failed to read x-ui settings: %w", err)) + } + client := xui.NewClient(panelInfo) + if err := client.Login(user, pass); err != nil { + return failProgress(ctx, fmt.Errorf("authentication failed: %w", err)) + } + ctx.Output.Success("Authenticated") + + // Step 2: Get new port values + ctx.Output.Step(2, 5, "Updating configuration") + newSocksPort := ctx.GetInt("socks-port") + newTunnelPort := ctx.GetInt("tunnel-port") + if newSocksPort == 0 { + newSocksPort = serverCfg.SocksPort + } + if newTunnelPort == 0 { + newTunnelPort = serverCfg.TunnelPort + } + + portsChanged := newSocksPort != serverCfg.SocksPort || newTunnelPort != serverCfg.TunnelPort + + if !portsChanged { + ctx.Output.Info("No changes detected") + endProgress(ctx) + return nil + } + + // Step 3: Delete old inbounds and create new ones + ctx.Output.Step(3, 5, "Updating inbounds in x-ui") + if err := client.DeleteInbound(serverCfg.XUISocksInboundID); err != nil { + ctx.Output.Warning(fmt.Sprintf("Failed to delete old SOCKS5 inbound: %v", err)) + } + if err := client.DeleteInbound(serverCfg.XUITunnelInboundID); err != nil { + ctx.Output.Warning(fmt.Sprintf("Failed to delete old tunnel inbound: %v", err)) } + socksID, socksTag, err := client.AddSocksInbound(newSocksPort) + if err != nil { + return failProgress(ctx, err) + } + tunnelID, tunnelTag, err := client.AddVLESSInbound(newTunnelPort, serverCfg.UUID) + if err != nil { + return failProgress(ctx, err) + } + ctx.Output.Success(fmt.Sprintf("Inbounds updated (SOCKS5: #%d, Tunnel: #%d)", socksID, tunnelID)) + + // Step 4: Update routing rules + ctx.Output.Step(4, 5, "Updating routing rules") + xrayCfg, err := client.GetXrayConfig() + if err != nil { + return failProgress(ctx, err) + } + xui.UpdateRoutingTags(xrayCfg, serverCfg.XUISocksTag, serverCfg.XUITunnelTag, socksTag, tunnelTag) + if err := client.SaveXrayConfig(xrayCfg); err != nil { + return failProgress(ctx, err) + } + if err := client.RestartXray(); err != nil { + ctx.Output.Warning(fmt.Sprintf("Failed to restart xray: %v", err)) + } + ctx.Output.Success("Routing rules updated") + + // Step 5: Update firewall and save config + ctx.Output.Step(5, 5, "Saving configuration") + // Update firewall: remove old, add new + fm := firewall.DetectFirewall() + oldPorts := []uint16{uint16(serverCfg.SocksPort), uint16(serverCfg.TunnelPort)} + firewall.RemovePorts(fm, oldPorts, "tcp") + newPorts := []uint16{uint16(newSocksPort), uint16(newTunnelPort)} + firewall.AllowPorts(fm, newPorts, "tcp", "nethopper") + + serverCfg.SocksPort = newSocksPort + serverCfg.TunnelPort = newTunnelPort + serverCfg.XUISocksInboundID = socksID + serverCfg.XUITunnelInboundID = tunnelID + serverCfg.XUISocksTag = socksTag + serverCfg.XUITunnelTag = tunnelTag + + if err := config.SaveJSON(config.ServerConfigPath(), serverCfg); err != nil { + return failProgress(ctx, fmt.Errorf("failed to save config: %w", err)) + } + ctx.Output.Success("Configuration updated") + + endProgress(ctx) + return nil +} + +func handleStandaloneConfigure(ctx *actions.Context, serverCfg *config.ServerConfig) error { + beginProgress(ctx, "Configuring Server") + // Apply new values if port := ctx.GetInt("socks-port"); port > 0 { serverCfg.SocksPort = port @@ -28,12 +140,12 @@ func HandleServerConfigure(ctx *actions.Context) error { } // Save updated config - if err := config.SaveJSON(config.ServerConfigPath(), &serverCfg); err != nil { + if err := config.SaveJSON(config.ServerConfigPath(), serverCfg); err != nil { return failProgress(ctx, fmt.Errorf("failed to save config: %w", err)) } // Regenerate xray config - xrayCfg, err := xray.GenerateServerConfig(&serverCfg) + xrayCfg, err := xray.GenerateServerConfig(serverCfg) if err != nil { return failProgress(ctx, fmt.Errorf("failed to generate xray config: %w", err)) } diff --git a/internal/handlers/server_install.go b/internal/handlers/server_install.go index d5ce3f6..59042dd 100644 --- a/internal/handlers/server_install.go +++ b/internal/handlers/server_install.go @@ -13,9 +13,139 @@ import ( "github.com/net2share/nethopper/internal/firewall" "github.com/net2share/nethopper/internal/service" "github.com/net2share/nethopper/internal/xray" + "github.com/net2share/nethopper/internal/xui" ) func HandleServerInstall(ctx *actions.Context) error { + xuiMode := false + if ctx.IsInteractive { + xuiMode = ctx.GetString("install-mode") == "xui" + } else { + xuiMode = xui.IsXUIRunning() && !ctx.GetBool("standalone") + } + + if xuiMode { + return handleXUIInstall(ctx) + } + return handleStandaloneInstall(ctx) +} + +func handleXUIInstall(ctx *actions.Context) error { + // Validate credentials + user := ctx.GetString("xui-user") + pass := ctx.GetString("xui-pass") + if !ctx.IsInteractive && (user == "" || pass == "") { + return fmt.Errorf("x-ui detected: --xui-user and --xui-pass are required") + } + + beginProgress(ctx, "Installing via x-ui") + + // Step 1: Read panel info + ctx.Output.Step(1, 7, "Reading x-ui panel configuration") + panelInfo, err := xui.ReadPanelInfo() + if err != nil { + return failProgress(ctx, fmt.Errorf("failed to read x-ui settings: %w", err)) + } + ctx.Output.Success(fmt.Sprintf("Panel found at %s", panelInfo.BaseURL)) + + // Step 2: Authenticate + ctx.Output.Step(2, 7, "Authenticating with x-ui panel") + client := xui.NewClient(panelInfo) + if err := client.Login(user, pass); err != nil { + return failProgress(ctx, fmt.Errorf("authentication failed: %w", err)) + } + ctx.Output.Success("Authenticated") + + // Step 3: Select/validate ports + ctx.Output.Step(3, 7, "Selecting ports") + socksPort := ctx.GetInt("socks-port") + tunnelPort := ctx.GetInt("tunnel-port") + if socksPort == 0 { + if p, err := randomAvailablePort(); err == nil { + socksPort = p + } else { + return failProgress(ctx, fmt.Errorf("failed to find available port: %w", err)) + } + } else if !isPortAvailable(socksPort) { + return failProgress(ctx, fmt.Errorf("SOCKS5 port %d is not available", socksPort)) + } + if tunnelPort == 0 { + if p, err := randomAvailablePort(); err == nil { + tunnelPort = p + } else { + return failProgress(ctx, fmt.Errorf("failed to find available port: %w", err)) + } + } else if !isPortAvailable(tunnelPort) { + return failProgress(ctx, fmt.Errorf("tunnel port %d is not available", tunnelPort)) + } + ctx.Output.Success(fmt.Sprintf("SOCKS5: %d, Tunnel: %d", socksPort, tunnelPort)) + + // Step 4: Generate UUID + add inbounds + ctx.Output.Step(4, 7, "Creating inbounds in x-ui") + uuid, err := generateUUID() + if err != nil { + return failProgress(ctx, fmt.Errorf("failed to generate UUID: %w", err)) + } + socksID, socksTag, err := client.AddSocksInbound(socksPort) + if err != nil { + return failProgress(ctx, err) + } + tunnelID, tunnelTag, err := client.AddVLESSInbound(tunnelPort, uuid) + if err != nil { + // Clean up the socks inbound we just created + client.DeleteInbound(socksID) + return failProgress(ctx, err) + } + ctx.Output.Success(fmt.Sprintf("Inbounds created (SOCKS5: #%d, Tunnel: #%d)", socksID, tunnelID)) + + // Step 5: Update xray template (portal + routing) + ctx.Output.Step(5, 7, "Configuring reverse portal") + xrayCfg, err := client.GetXrayConfig() + if err != nil { + return failProgress(ctx, err) + } + xui.AddPortalAndRouting(xrayCfg, socksTag, tunnelTag) + if err := client.SaveXrayConfig(xrayCfg); err != nil { + return failProgress(ctx, err) + } + if err := client.RestartXray(); err != nil { + ctx.Output.Warning(fmt.Sprintf("Failed to restart xray: %v", err)) + } + ctx.Output.Success("Reverse portal configured") + + // Step 6: Configure firewall + ctx.Output.Step(6, 7, "Configuring firewall") + fm := firewall.DetectFirewall() + ports := []uint16{uint16(socksPort), uint16(tunnelPort)} + if err := firewall.AllowPorts(fm, ports, "tcp", "nethopper"); err != nil { + ctx.Output.Warning(fmt.Sprintf("Firewall configuration failed: %v", err)) + } else { + ctx.Output.Success(fmt.Sprintf("Firewall configured (%s)", fm.Type())) + } + + // Step 7: Save config + ctx.Output.Step(7, 7, "Saving configuration") + serverCfg := &config.ServerConfig{ + SocksPort: socksPort, + TunnelPort: tunnelPort, + UUID: uuid, + XUIMode: true, + XUISocksInboundID: socksID, + XUITunnelInboundID: tunnelID, + XUISocksTag: socksTag, + XUITunnelTag: tunnelTag, + XUIPortalTag: xui.NHPortalTag, + } + if err := config.SaveJSON(config.ServerConfigPath(), serverCfg); err != nil { + return failProgress(ctx, fmt.Errorf("failed to save config: %w", err)) + } + ctx.Output.Success("Configuration saved") + + endProgress(ctx) + return nil +} + +func handleStandaloneInstall(ctx *actions.Context) error { beginProgress(ctx, "Installing Nethopper Server") // Step 1: Download/ensure xray binary @@ -44,9 +174,26 @@ func HandleServerInstall(ctx *actions.Context) error { return failProgress(ctx, fmt.Errorf("failed to generate UUID: %w", err)) } + socksPort := ctx.GetInt("socks-port") + tunnelPort := ctx.GetInt("tunnel-port") + if socksPort == 0 { + if p, err := randomAvailablePort(); err == nil { + socksPort = p + } else { + socksPort = 1080 + } + } + if tunnelPort == 0 { + if p, err := randomAvailablePort(); err == nil { + tunnelPort = p + } else { + tunnelPort = 2083 + } + } + serverCfg := &config.ServerConfig{ - SocksPort: 1080, - TunnelPort: 2083, + SocksPort: socksPort, + TunnelPort: tunnelPort, UUID: uuid, } @@ -62,16 +209,14 @@ func HandleServerInstall(ctx *actions.Context) error { if err := writeFile(config.ServerXrayConfigPath(), xrayCfg); err != nil { return failProgress(ctx, fmt.Errorf("failed to write xray config: %w", err)) } - // Set ownership so the nethopper service user can read config exec.Command("chown", "-R", "nethopper:nethopper", config.ServerConfigDir).Run() - ctx.Output.Success("Configuration saved") // Step 3: Configure firewall ctx.Output.Step(3, 5, "Configuring firewall") fm := firewall.DetectFirewall() - ports := []uint16{uint16(serverCfg.SocksPort), uint16(serverCfg.TunnelPort)} - if err := firewall.AllowPorts(fm, ports, "tcp", "nethopper"); err != nil { + fwPorts := []uint16{uint16(serverCfg.SocksPort), uint16(serverCfg.TunnelPort)} + if err := firewall.AllowPorts(fm, fwPorts, "tcp", "nethopper"); err != nil { ctx.Output.Warning(fmt.Sprintf("Firewall configuration failed: %v", err)) } else { ctx.Output.Success(fmt.Sprintf("Firewall configured (%s)", fm.Type())) diff --git a/internal/handlers/server_status.go b/internal/handlers/server_status.go index d12a28a..5b3775a 100644 --- a/internal/handlers/server_status.go +++ b/internal/handlers/server_status.go @@ -7,30 +7,65 @@ import ( "github.com/net2share/nethopper/internal/binary" "github.com/net2share/nethopper/internal/config" "github.com/net2share/nethopper/internal/service" + "github.com/net2share/nethopper/internal/xui" ) func HandleServerStatus(ctx *actions.Context) error { var rows []actions.InfoRow + var connStr string - // Binary status - mgr := binary.NewServerManager() - xrayPath, err := mgr.ResolvePath(binary.XrayDef) - if err != nil { - rows = append(rows, actions.InfoRow{Key: "Xray", Value: "not installed"}) - } else { - rows = append(rows, actions.InfoRow{Key: "Xray", Value: xrayPath}) - } - - // Config status var serverCfg config.ServerConfig - var connStr string if err := config.LoadJSON(config.ServerConfigPath(), &serverCfg); err == nil { + if serverCfg.XUIMode { + rows = append(rows, actions.InfoRow{Key: "Mode", Value: "x-ui integration"}) + + // x-ui service status + if xui.IsXUIRunning() { + rows = append(rows, actions.InfoRow{Key: "x-ui Service", Value: "active"}) + } else { + rows = append(rows, actions.InfoRow{Key: "x-ui Service", Value: "inactive"}) + } + + rows = append(rows, + actions.InfoRow{Key: "SOCKS5 Inbound", Value: fmt.Sprintf("#%d (%s)", serverCfg.XUISocksInboundID, serverCfg.XUISocksTag)}, + actions.InfoRow{Key: "Tunnel Inbound", Value: fmt.Sprintf("#%d (%s)", serverCfg.XUITunnelInboundID, serverCfg.XUITunnelTag)}, + ) + } else { + rows = append(rows, actions.InfoRow{Key: "Mode", Value: "standalone"}) + + // Binary status + mgr := binary.NewServerManager() + xrayPath, err := mgr.ResolvePath(binary.XrayDef) + if err != nil { + rows = append(rows, actions.InfoRow{Key: "Xray", Value: "not installed"}) + } else { + rows = append(rows, actions.InfoRow{Key: "Xray", Value: xrayPath}) + } + } + + // Common fields rows = append(rows, actions.InfoRow{Key: "SOCKS5 Port", Value: fmt.Sprintf("%d", serverCfg.SocksPort)}, actions.InfoRow{Key: "Tunnel Port", Value: fmt.Sprintf("%d", serverCfg.TunnelPort)}, actions.InfoRow{Key: "UUID", Value: serverCfg.UUID}, ) + // Service status (standalone only) + if !serverCfg.XUIMode { + sysMgr := service.NewSystemdManager() + if sysMgr.IsInstalled() { + status, err := sysMgr.Status() + if err == nil { + rows = append(rows, + actions.InfoRow{Key: "Service", Value: status.Active}, + actions.InfoRow{Key: "Enabled", Value: fmt.Sprintf("%v", status.Enabled)}, + ) + } + } else { + rows = append(rows, actions.InfoRow{Key: "Service", Value: "not installed"}) + } + } + // Connection string serverIP := detectServerIP() cs := config.NewConnectionString(serverIP, &serverCfg) @@ -41,20 +76,6 @@ func HandleServerStatus(ctx *actions.Context) error { rows = append(rows, actions.InfoRow{Key: "Config", Value: "not configured"}) } - // Service status - sysMgr := service.NewSystemdManager() - if sysMgr.IsInstalled() { - status, err := sysMgr.Status() - if err == nil { - rows = append(rows, - actions.InfoRow{Key: "Service", Value: status.Active}, - actions.InfoRow{Key: "Enabled", Value: fmt.Sprintf("%v", status.Enabled)}, - ) - } - } else { - rows = append(rows, actions.InfoRow{Key: "Service", Value: "not installed"}) - } - if connStr != "" { rows = append(rows, actions.InfoRow{Key: "Connection String", Value: connStr}) } diff --git a/internal/handlers/server_uninstall.go b/internal/handlers/server_uninstall.go index 9b295a3..d9680e9 100644 --- a/internal/handlers/server_uninstall.go +++ b/internal/handlers/server_uninstall.go @@ -9,10 +9,108 @@ import ( "github.com/net2share/nethopper/internal/config" "github.com/net2share/nethopper/internal/firewall" "github.com/net2share/nethopper/internal/service" + "github.com/net2share/nethopper/internal/xui" ) func HandleServerUninstall(ctx *actions.Context) error { - // Check if there's anything to uninstall + var serverCfg config.ServerConfig + hasConfig := config.LoadJSON(config.ServerConfigPath(), &serverCfg) == nil + + if hasConfig && serverCfg.XUIMode { + return handleXUIUninstall(ctx, &serverCfg) + } + return handleStandaloneUninstall(ctx) +} + +func handleXUIUninstall(ctx *actions.Context, serverCfg *config.ServerConfig) error { + keepXUI := ctx.GetBool("keep-xui") + xuiRunning := xui.IsXUIRunning() + + beginProgress(ctx, "Uninstalling Nethopper Server (x-ui mode)") + + step, total := 1, 4 + if keepXUI { + total = 2 + } + + // Remove x-ui entries (unless --keep-xui or x-ui is down) + if !keepXUI { + if !xuiRunning { + ctx.Output.Warning("x-ui is not running, skipping x-ui cleanup") + } else { + user := ctx.GetString("xui-user") + pass := ctx.GetString("xui-pass") + if !ctx.IsInteractive && (user == "" || pass == "") { + ctx.Output.Warning("x-ui credentials not provided, skipping x-ui cleanup") + } else { + // Step: Authenticate and remove from x-ui + ctx.Output.Step(step, total, "Removing inbounds from x-ui") + panelInfo, err := xui.ReadPanelInfo() + if err != nil { + ctx.Output.Warning(fmt.Sprintf("Failed to read x-ui settings: %v", err)) + } else { + client := xui.NewClient(panelInfo) + if err := client.Login(user, pass); err != nil { + ctx.Output.Warning(fmt.Sprintf("Failed to authenticate: %v", err)) + } else { + // Delete inbounds + if err := client.DeleteInbound(serverCfg.XUISocksInboundID); err != nil { + ctx.Output.Warning(fmt.Sprintf("Failed to delete SOCKS5 inbound: %v", err)) + } + if err := client.DeleteInbound(serverCfg.XUITunnelInboundID); err != nil { + ctx.Output.Warning(fmt.Sprintf("Failed to delete tunnel inbound: %v", err)) + } + ctx.Output.Success("Inbounds removed") + + // Remove portal and routing + step++ + ctx.Output.Step(step, total, "Removing portal and routing rules") + xrayCfg, err := client.GetXrayConfig() + if err != nil { + ctx.Output.Warning(fmt.Sprintf("Failed to fetch xray config: %v", err)) + } else { + xui.RemovePortalAndRouting(xrayCfg) + if err := client.SaveXrayConfig(xrayCfg); err != nil { + ctx.Output.Warning(fmt.Sprintf("Failed to save xray config: %v", err)) + } + if err := client.RestartXray(); err != nil { + ctx.Output.Warning(fmt.Sprintf("Failed to restart xray: %v", err)) + } + ctx.Output.Success("Portal and routing rules removed") + } + } + } + } + } + step++ + } + + // Remove firewall rules + ctx.Output.Step(step, total, "Removing firewall rules") + if serverCfg.SocksPort > 0 { + fm := firewall.DetectFirewall() + ports := []uint16{uint16(serverCfg.SocksPort), uint16(serverCfg.TunnelPort)} + if err := firewall.RemovePorts(fm, ports, "tcp"); err != nil { + ctx.Output.Warning(fmt.Sprintf("Failed to remove firewall rules: %v", err)) + } else { + ctx.Output.Success("Firewall rules removed") + } + } + step++ + + // Remove nethopper config (NOT xray binary — it belongs to x-ui) + ctx.Output.Step(step, total, "Removing configuration") + os.Remove(config.ServerConfigPath()) + os.Remove(config.ServerXrayConfigPath()) + os.RemoveAll(config.ServerConfigDir) + os.RemoveAll(config.ServerStateDir) + ctx.Output.Success("Configuration removed") + + endProgress(ctx) + return nil +} + +func handleStandaloneUninstall(ctx *actions.Context) error { sysMgr := service.NewSystemdManager() mgr := binary.NewServerManager() hasService := sysMgr.IsInstalled() @@ -25,7 +123,6 @@ func HandleServerUninstall(ctx *actions.Context) error { beginProgress(ctx, "Uninstalling Nethopper Server") - // Load config to get ports for firewall cleanup var serverCfg config.ServerConfig _ = config.LoadJSON(config.ServerConfigPath(), &serverCfg) diff --git a/internal/menu/adapter.go b/internal/menu/adapter.go index 93bb47c..2e79383 100644 --- a/internal/menu/adapter.go +++ b/internal/menu/adapter.go @@ -6,8 +6,10 @@ import ( "github.com/net2share/go-corelib/tui" "github.com/net2share/nethopper/internal/actions" + "github.com/net2share/nethopper/internal/config" "github.com/net2share/nethopper/internal/handlers" "github.com/net2share/nethopper/internal/network" + "github.com/net2share/nethopper/internal/xui" ) // RunAction executes an action in interactive mode. @@ -59,6 +61,17 @@ func RunAction(actionID string, inline ...bool) error { // collectInputs collects action inputs interactively via TUI forms. func collectInputs(ctx *actions.Context, action *actions.Action) error { + // Pre-populate detection state for ShowIf functions + switch action.ID { + case actions.ActionServerInstall: + ctx.Set("xui-detected", xui.IsXUIRunning()) + case actions.ActionServerConfigure, actions.ActionServerUninstall: + var cfg config.ServerConfig + if config.LoadJSON(config.ServerConfigPath(), &cfg) == nil { + ctx.Set("xui-mode", cfg.XUIMode) + } + } + for _, input := range action.Inputs { if input.ShowIf != nil && !input.ShowIf(ctx) { continue diff --git a/internal/menu/main.go b/internal/menu/main.go index bd1d242..bdcb03b 100644 --- a/internal/menu/main.go +++ b/internal/menu/main.go @@ -62,10 +62,10 @@ func runServerMenu() error { state := getServerState() var options []tui.MenuOption - if !state.hasBinary { + if !state.hasConfig { options = append(options, tui.MenuOption{Label: "Install", Value: actions.ActionServerInstall}) } - if state.hasBinary { + if state.hasConfig { options = append(options, tui.MenuOption{Label: "Configure", Value: actions.ActionServerConfigure}) } if state.isInstalled() { diff --git a/internal/menu/state.go b/internal/menu/state.go index 125f122..c9cf83f 100644 --- a/internal/menu/state.go +++ b/internal/menu/state.go @@ -6,27 +6,41 @@ import ( "github.com/net2share/nethopper/internal/binary" "github.com/net2share/nethopper/internal/config" "github.com/net2share/nethopper/internal/service" + "github.com/net2share/nethopper/internal/xui" ) // serverState checks what's installed on the server. type serverState struct { - hasService bool - hasConfig bool - hasBinary bool + hasService bool + hasConfig bool + hasBinary bool + xuiDetected bool + xuiMode bool } func getServerState() serverState { sysMgr := service.NewSystemdManager() mgr := binary.NewServerManager() - return serverState{ - hasService: sysMgr.IsInstalled(), - hasConfig: fileExists(config.ServerConfigPath()), - hasBinary: mgr.IsInstalled(binary.XrayDef), + + state := serverState{ + hasService: sysMgr.IsInstalled(), + hasConfig: fileExists(config.ServerConfigPath()), + hasBinary: mgr.IsInstalled(binary.XrayDef), + xuiDetected: xui.IsXUIRunning(), + } + + if state.hasConfig { + var cfg config.ServerConfig + if config.LoadJSON(config.ServerConfigPath(), &cfg) == nil { + state.xuiMode = cfg.XUIMode + } } + + return state } func (s serverState) isInstalled() bool { - return s.hasService || s.hasConfig || s.hasBinary + return s.hasService || s.hasConfig || s.hasBinary || s.xuiMode } // clientState checks what's installed on the client. diff --git a/internal/xui/client.go b/internal/xui/client.go new file mode 100644 index 0000000..2013090 --- /dev/null +++ b/internal/xui/client.go @@ -0,0 +1,111 @@ +package xui + +import ( + "crypto/tls" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/cookiejar" + "net/url" + "strings" +) + +// apiResponse is the standard x-ui API response format. +type apiResponse struct { + Success bool `json:"success"` + Msg string `json:"msg"` + Obj json.RawMessage `json:"obj"` +} + +// Client is an authenticated HTTP client for the x-ui panel API. +type Client struct { + baseURL string + httpClient *http.Client +} + +// NewClient creates a new x-ui API client. +func NewClient(panelInfo *PanelInfo) *Client { + jar, _ := cookiejar.New(nil) + transport := &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: true, // localhost, self-signed certs + }, + } + return &Client{ + baseURL: strings.TrimSuffix(panelInfo.BaseURL, "/"), + httpClient: &http.Client{ + Jar: jar, + Transport: transport, + }, + } +} + +// Login authenticates with the x-ui panel. +func (c *Client) Login(username, password string) error { + data := url.Values{ + "username": {username}, + "password": {password}, + } + + resp, err := c.postForm("login", data) + if err != nil { + return fmt.Errorf("login request failed: %w", err) + } + + if !resp.Success { + return fmt.Errorf("login failed: %s", resp.Msg) + } + return nil +} + +// postForm sends a form-encoded POST request to the given path. +func (c *Client) postForm(path string, data url.Values) (*apiResponse, error) { + reqURL := c.baseURL + "/" + path + resp, err := c.httpClient.PostForm(reqURL, data) + if err != nil { + return nil, fmt.Errorf("request to %s failed: %w", path, err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + if resp.StatusCode == http.StatusNotFound { + return nil, fmt.Errorf("endpoint not found: %s (check panel base path)", path) + } + + var apiResp apiResponse + if err := json.Unmarshal(body, &apiResp); err != nil { + return nil, fmt.Errorf("failed to parse response: %w (body: %s)", err, string(body)) + } + return &apiResp, nil +} + +// post sends a POST request with the given body. +func (c *Client) post(path string) (*apiResponse, error) { + return c.postForm(path, nil) +} + +// get sends a GET request to the given path. +func (c *Client) get(path string) (*apiResponse, error) { + reqURL := c.baseURL + "/" + path + resp, err := c.httpClient.Get(reqURL) + if err != nil { + return nil, fmt.Errorf("request to %s failed: %w", path, err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + var apiResp apiResponse + if err := json.Unmarshal(body, &apiResp); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + return &apiResp, nil +} diff --git a/internal/xui/config.go b/internal/xui/config.go new file mode 100644 index 0000000..c563d4b --- /dev/null +++ b/internal/xui/config.go @@ -0,0 +1,194 @@ +package xui + +import ( + "encoding/json" + "fmt" + "net/url" +) + +const ( + NHPortalTag = "nh-portal" + NHPortalDomain = "nethopper.internal" +) + +// GetXrayConfig fetches the current xray template config from x-ui. +func (c *Client) GetXrayConfig() (map[string]interface{}, error) { + resp, err := c.post("panel/xray/") + if err != nil { + return nil, fmt.Errorf("failed to fetch xray config: %w", err) + } + if !resp.Success { + return nil, fmt.Errorf("failed to fetch xray config: %s", resp.Msg) + } + + // Response obj is a JSON string (double-encoded) + var objStr string + if err := json.Unmarshal(resp.Obj, &objStr); err != nil { + return nil, fmt.Errorf("failed to parse xray config response: %w", err) + } + + var wrapper map[string]json.RawMessage + if err := json.Unmarshal([]byte(objStr), &wrapper); err != nil { + return nil, fmt.Errorf("failed to parse xray config wrapper: %w", err) + } + + xraySettingRaw, ok := wrapper["xraySetting"] + if !ok { + return nil, fmt.Errorf("xraySetting not found in response") + } + + var cfg map[string]interface{} + if err := json.Unmarshal(xraySettingRaw, &cfg); err != nil { + return nil, fmt.Errorf("failed to parse xray config: %w", err) + } + + return cfg, nil +} + +// SaveXrayConfig saves a modified xray template config to x-ui. +func (c *Client) SaveXrayConfig(cfg map[string]interface{}) error { + cfgJSON, err := json.Marshal(cfg) + if err != nil { + return fmt.Errorf("failed to marshal xray config: %w", err) + } + + data := url.Values{ + "xraySetting": {string(cfgJSON)}, + } + + resp, err := c.postForm("panel/xray/update", data) + if err != nil { + return fmt.Errorf("failed to save xray config: %w", err) + } + if !resp.Success { + return fmt.Errorf("failed to save xray config: %s", resp.Msg) + } + return nil +} + +// RestartXray restarts the xray service through x-ui. +func (c *Client) RestartXray() error { + resp, err := c.post("panel/api/server/restartXrayService") + if err != nil { + return fmt.Errorf("failed to restart xray: %w", err) + } + if !resp.Success { + return fmt.Errorf("failed to restart xray: %s", resp.Msg) + } + return nil +} + +// AddPortalAndRouting adds the nh-portal entry and routing rules for the given inbound tags. +func AddPortalAndRouting(cfg map[string]interface{}, socksTag, tunnelTag string) { + // Add reverse.portals + reverse, _ := cfg["reverse"].(map[string]interface{}) + if reverse == nil { + reverse = map[string]interface{}{} + cfg["reverse"] = reverse + } + + portals, _ := reverse["portals"].([]interface{}) + portals = append(portals, map[string]interface{}{ + "tag": NHPortalTag, + "domain": NHPortalDomain, + }) + reverse["portals"] = portals + + // Add routing rules + routing, _ := cfg["routing"].(map[string]interface{}) + if routing == nil { + routing = map[string]interface{}{} + cfg["routing"] = routing + } + + rules, _ := routing["rules"].([]interface{}) + + // Rule: socks inbound -> portal + rules = append(rules, map[string]interface{}{ + "type": "field", + "inboundTag": []interface{}{socksTag}, + "outboundTag": NHPortalTag, + }) + + // Rule: tunnel inbound -> portal + rules = append(rules, map[string]interface{}{ + "type": "field", + "inboundTag": []interface{}{tunnelTag}, + "outboundTag": NHPortalTag, + }) + + routing["rules"] = rules +} + +// RemovePortalAndRouting removes the nh-portal entry and its associated routing rules. +func RemovePortalAndRouting(cfg map[string]interface{}) { + // Remove portal + if reverse, ok := cfg["reverse"].(map[string]interface{}); ok { + if portals, ok := reverse["portals"].([]interface{}); ok { + var filtered []interface{} + for _, p := range portals { + if pm, ok := p.(map[string]interface{}); ok { + if pm["tag"] == NHPortalTag { + continue + } + } + filtered = append(filtered, p) + } + reverse["portals"] = filtered + } + } + + // Remove routing rules that reference nh-portal + if routing, ok := cfg["routing"].(map[string]interface{}); ok { + if rules, ok := routing["rules"].([]interface{}); ok { + var filtered []interface{} + for _, r := range rules { + if rm, ok := r.(map[string]interface{}); ok { + if rm["outboundTag"] == NHPortalTag { + continue + } + } + filtered = append(filtered, r) + } + routing["rules"] = filtered + } + } +} + +// UpdateRoutingTags updates routing rules: removes rules with old tags and adds new ones. +func UpdateRoutingTags(cfg map[string]interface{}, oldSocksTag, oldTunnelTag, newSocksTag, newTunnelTag string) { + // Remove old routing rules for nh-portal + RemovePortalAndRouting(cfg) + + // Re-add portal (it was removed above) + reverse, _ := cfg["reverse"].(map[string]interface{}) + if reverse == nil { + reverse = map[string]interface{}{} + cfg["reverse"] = reverse + } + portals, _ := reverse["portals"].([]interface{}) + portals = append(portals, map[string]interface{}{ + "tag": NHPortalTag, + "domain": NHPortalDomain, + }) + reverse["portals"] = portals + + // Add new routing rules + routing, _ := cfg["routing"].(map[string]interface{}) + if routing == nil { + routing = map[string]interface{}{} + cfg["routing"] = routing + } + rules, _ := routing["rules"].([]interface{}) + rules = append(rules, map[string]interface{}{ + "type": "field", + "inboundTag": []interface{}{newSocksTag}, + "outboundTag": NHPortalTag, + }) + rules = append(rules, map[string]interface{}{ + "type": "field", + "inboundTag": []interface{}{newTunnelTag}, + "outboundTag": NHPortalTag, + }) + routing["rules"] = rules +} diff --git a/internal/xui/detect.go b/internal/xui/detect.go new file mode 100644 index 0000000..0f9cfa1 --- /dev/null +++ b/internal/xui/detect.go @@ -0,0 +1,90 @@ +package xui + +import ( + "fmt" + "os/exec" + "strings" +) + +const ( + xuiServiceName = "x-ui" + xuiBinaryPath = "/usr/local/x-ui/x-ui" +) + +// PanelInfo holds detected x-ui panel configuration. +type PanelInfo struct { + Port int + BasePath string + CertFile string + KeyFile string + UseHTTPS bool + BaseURL string +} + +// IsXUIRunning checks if the x-ui systemd service is active and running. +func IsXUIRunning() bool { + out, err := exec.Command("systemctl", "is-active", xuiServiceName).Output() + if err != nil { + return false + } + return strings.TrimSpace(string(out)) == "active" +} + +// ReadPanelInfo reads x-ui panel settings using the x-ui binary directly. +// Runs: /usr/local/x-ui/x-ui setting -show true (non-interactive) +// and: /usr/local/x-ui/x-ui setting -getCert true (for TLS detection) +func ReadPanelInfo() (*PanelInfo, error) { + info := &PanelInfo{} + + // Get port and basePath + out, err := exec.Command(xuiBinaryPath, "setting", "-show", "true").Output() + if err != nil { + return nil, fmt.Errorf("failed to read x-ui settings: %w", err) + } + + for _, line := range strings.Split(string(out), "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "port:") { + fmt.Sscanf(strings.TrimPrefix(line, "port:"), "%d", &info.Port) + } else if strings.HasPrefix(line, "webBasePath:") { + info.BasePath = strings.TrimSpace(strings.TrimPrefix(line, "webBasePath:")) + } + } + + if info.Port == 0 { + return nil, fmt.Errorf("could not detect x-ui panel port") + } + + // Normalize basePath + if info.BasePath == "" { + info.BasePath = "/" + } + if !strings.HasPrefix(info.BasePath, "/") { + info.BasePath = "/" + info.BasePath + } + if !strings.HasSuffix(info.BasePath, "/") { + info.BasePath = info.BasePath + "/" + } + + // Get cert info for TLS detection + certOut, err := exec.Command(xuiBinaryPath, "setting", "-getCert", "true").Output() + if err == nil { + for _, line := range strings.Split(string(certOut), "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "cert:") { + info.CertFile = strings.TrimSpace(strings.TrimPrefix(line, "cert:")) + } else if strings.HasPrefix(line, "key:") { + info.KeyFile = strings.TrimSpace(strings.TrimPrefix(line, "key:")) + } + } + } + + info.UseHTTPS = info.CertFile != "" + scheme := "http" + if info.UseHTTPS { + scheme = "https" + } + info.BaseURL = fmt.Sprintf("%s://127.0.0.1:%d%s", scheme, info.Port, info.BasePath) + + return info, nil +} diff --git a/internal/xui/inbound.go b/internal/xui/inbound.go new file mode 100644 index 0000000..39c3e4a --- /dev/null +++ b/internal/xui/inbound.go @@ -0,0 +1,81 @@ +package xui + +import ( + "encoding/json" + "fmt" + "net/url" +) + +// Inbound represents an x-ui inbound as returned by the API. +type Inbound struct { + ID int `json:"id"` + Port int `json:"port"` + Protocol string `json:"protocol"` + Tag string `json:"tag"` + Remark string `json:"remark"` + Enable bool `json:"enable"` +} + +// AddSocksInbound adds a SOCKS5 inbound via x-ui API. +// Returns the inbound ID and auto-generated tag. +func (c *Client) AddSocksInbound(port int) (int, string, error) { + settings := `{"auth":"noauth","accounts":[],"udp":true,"ip":"0.0.0.0"}` + streamSettings := `{"network":"tcp","security":"none","tcpSettings":{"header":{"type":"none"}}}` + sniffing := `{"enabled":true,"destOverride":["http","tls"]}` + + return c.addInbound("NH-SOCKS5", port, "socks", settings, streamSettings, sniffing) +} + +// AddVLESSInbound adds a VLESS tunnel inbound via x-ui API. +// Returns the inbound ID and auto-generated tag. +func (c *Client) AddVLESSInbound(port int, uuid string) (int, string, error) { + settings := fmt.Sprintf(`{"clients":[{"id":"%s","flow":"","email":"nethopper","limitIp":0,"totalGB":0,"expiryTime":0,"enable":true}],"decryption":"none","fallbacks":[]}`, uuid) + streamSettings := `{"network":"tcp","security":"none","tcpSettings":{"header":{"type":"none"}}}` + sniffing := `{"enabled":true,"destOverride":["http","tls"]}` + + return c.addInbound("NH-Tunnel", port, "vless", settings, streamSettings, sniffing) +} + +// DeleteInbound deletes an inbound by its database ID. +func (c *Client) DeleteInbound(id int) error { + resp, err := c.post(fmt.Sprintf("panel/api/inbounds/del/%d", id)) + if err != nil { + return fmt.Errorf("failed to delete inbound %d: %w", id, err) + } + if !resp.Success { + return fmt.Errorf("failed to delete inbound %d: %s", id, resp.Msg) + } + return nil +} + +func (c *Client) addInbound(remark string, port int, protocol, settings, streamSettings, sniffing string) (int, string, error) { + data := url.Values{ + "up": {"0"}, + "down": {"0"}, + "total": {"0"}, + "remark": {remark}, + "enable": {"true"}, + "expiryTime": {"0"}, + "listen": {""}, + "port": {fmt.Sprintf("%d", port)}, + "protocol": {protocol}, + "settings": {settings}, + "streamSettings": {streamSettings}, + "sniffing": {sniffing}, + } + + resp, err := c.postForm("panel/api/inbounds/add", data) + if err != nil { + return 0, "", fmt.Errorf("failed to add %s inbound: %w", protocol, err) + } + if !resp.Success { + return 0, "", fmt.Errorf("failed to add %s inbound: %s", protocol, resp.Msg) + } + + var inbound Inbound + if err := json.Unmarshal(resp.Obj, &inbound); err != nil { + return 0, "", fmt.Errorf("failed to parse inbound response: %w", err) + } + + return inbound.ID, inbound.Tag, nil +} From 814f11ed2ea012a42dd5f34a5e1271a9b716546e Mon Sep 17 00:00:00 2001 From: crazydi4mond <255249920+crazydi4mond@users.noreply.github.com> Date: Fri, 13 Mar 2026 21:15:38 +0100 Subject: [PATCH 2/2] docs: update README for x-ui integration and dynamic ports --- README.md | 55 ++++++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 40 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 200c750..426cb7e 100644 --- a/README.md +++ b/README.md @@ -17,12 +17,11 @@ Nethopper enables users in a restricted network to access the internet through a │ (SOCKS5 client) (Linux VM) (Bridge PC) │ │ │ │ │ │ │ │ SOCKS5 │ VLESS Reverse │ │ -│ │ :1080 │ Tunnel :2083 │ │ -│ ▼ │ │ │ +│ ▼ │ Tunnel │ │ │ ┌────────────────────────────────┤ │ │ │ │ RESTRICTED NETWORK │◄───────────────────────┤ │ │ │ │ restricted interface │ │ -│ │ Users connect to Server:1080 │ │ │ +│ │ Users connect to server │ │ │ │ │ via SOCKS5 proxy │ │ │ │ └────────────────────────────────┤ │ │ │ │ │ │ @@ -36,8 +35,8 @@ Nethopper enables users in a restricted network to access the internet through a └─────────────────────────────────────────────────────────────────────┘ Data flow: -1. App → SOCKS5 (:1080) → nhserver -2. nhserver (Portal) → VLESS reverse tunnel (:2083) → nhclient (Bridge) +1. App → SOCKS5 → nhserver +2. nhserver (Portal) → VLESS reverse tunnel → nhclient (Bridge) 3. nhclient → free interface → Internet ``` @@ -45,18 +44,25 @@ Data flow: | Component | Binary | Platforms | Description | |-----------|--------|-----------|-------------| -| Server | `nhserver` | Linux | Xray portal + SOCKS5 inbound, runs as systemd service | +| Server | `nhserver` | Linux | Xray portal + SOCKS5 inbound (standalone or via x-ui) | | Client | `nhclient` | Linux, macOS, Windows | Xray bridge, connects to server, routes via free interface | -Xray-core is downloaded automatically on first install. Set `NETHOPPER_XRAY_PATH` to use a local binary instead: +### Server Modes + +- **Standalone**: Downloads Xray, creates a systemd service, and manages everything independently. +- **x-ui integration**: Delegates inbound and Xray management to an existing [3x-ui](https://github.com/MHSanaei/3x-ui) panel. Nethopper creates SOCKS5 and VLESS inbounds via the x-ui API and adds portal/routing rules to x-ui's Xray config. No separate Xray binary or systemd service is needed. + +If x-ui is detected on the system, the TUI will prompt you to choose the mode. In CLI mode, x-ui integration is used automatically unless `--standalone` is passed. + +Ports are assigned randomly by default. You can override them with `--socks-port` and `--tunnel-port`. + +In standalone mode, Xray-core is downloaded automatically on first install. Set `NETHOPPER_XRAY_PATH` to use a local binary instead: ```bash sudo NETHOPPER_XRAY_PATH=/path/to/xray nhserver install NETHOPPER_XRAY_PATH=/path/to/xray nhclient install ``` -On the server, the binary is copied to `/usr/local/bin/xray` so the systemd service can access it regardless of where the original is located. - ## Requirements ### Server (nhserver) @@ -82,12 +88,19 @@ Both `nhserver` and `nhclient` can be used in two ways: ### 1. Install and Set Up the Server ```bash +# Standalone (or auto-detect x-ui) sudo nhserver install + +# Explicitly standalone even if x-ui is present +sudo nhserver install --standalone + +# x-ui integration with custom ports +sudo nhserver install --xui-user admin --xui-pass secret --socks-port 8080 --tunnel-port 3000 ``` Or via TUI: run `sudo nhserver` and select **Install**. -This downloads Xray, generates config with a random UUID, creates a systemd service, and configures the firewall. +Standalone mode downloads Xray, generates config with a random UUID, creates a systemd service, and configures the firewall. x-ui mode creates inbounds and routing rules through the panel API instead. ### 2. Get the Connection String @@ -127,7 +140,7 @@ Or via TUI: run `nhclient` and select **Run**. ### 6. Use the Proxy -Configure apps to use the SOCKS5 proxy at `:1080`. +Configure apps to use the SOCKS5 proxy at `:`. Check `sudo nhserver status` for the assigned port. ## CLI Reference @@ -135,11 +148,16 @@ Configure apps to use the SOCKS5 proxy at `:1080`. ```bash sudo nhserver # Launch interactive TUI -sudo nhserver install # Download xray, create service, configure firewall +sudo nhserver install # Install (auto-detects x-ui) +sudo nhserver install --standalone # Force standalone mode +sudo nhserver install --xui-user admin --xui-pass secret # x-ui mode with credentials +sudo nhserver install --socks-port 8080 --tunnel-port 3000 # Custom ports sudo nhserver configure # Update ports interactively sudo nhserver configure --socks-port 8080 --tunnel-port 3000 +sudo nhserver configure --xui-user admin --xui-pass secret # Required in x-ui mode sudo nhserver status # Show status and connection string sudo nhserver uninstall --force # Remove everything +sudo nhserver uninstall --keep-xui # Remove nethopper config but keep x-ui inbounds ``` ### Client Commands @@ -161,15 +179,15 @@ The `nh://` connection string encodes server details for easy sharing: { "v": 1, "s": "192.168.1.100", - "p": 2083, - "sp": 1080, + "p": 54321, + "sp": 12345, "u": "uuid-here" } ``` ## File Locations -### Server (Linux, root) +### Server — Standalone (Linux, root) | File | Path | |------|------| | Xray binary | `/usr/local/bin/xray` | @@ -177,6 +195,13 @@ The `nh://` connection string encodes server details for easy sharing: | Xray config | `/etc/nethopper/xray.json` | | Systemd service | `/etc/systemd/system/nethopper.service` | +### Server — x-ui mode (Linux, root) +| File | Path | +|------|------| +| Server config | `/etc/nethopper/server.json` | + +Xray binary and service are managed by x-ui. Inbounds and routing rules live in x-ui's database/config. + ### Client (user-level) | Platform | Binary | Config | |----------|--------|--------|