diff --git a/config/config.go b/config/config.go index b52f12e..83be4b1 100644 --- a/config/config.go +++ b/config/config.go @@ -96,15 +96,16 @@ type AuthConfig struct { } type AppConfig struct { - Interfaces map[string]*InterfaceConfig `yaml:"interfaces"` - DHCP DHCPConfig `yaml:"dhcp"` - NAT NATConfig `yaml:"nat"` - Firewall FirewallConfig `yaml:"firewall"` - KnownDevices []KnownDevice `yaml:"known_devices"` - Mihomo MihomoConfig `yaml:"mihomo"` - ClientPolicy ClientPolicyConfig `yaml:"client_policy,omitempty"` - Connectivity ConnectivityConfig `yaml:"connectivity,omitempty" json:"connectivity,omitempty"` - Auth AuthConfig `yaml:"auth,omitempty"` + Interfaces map[string]*InterfaceConfig `yaml:"interfaces"` + DHCP DHCPConfig `yaml:"dhcp"` + NAT NATConfig `yaml:"nat"` + Firewall FirewallConfig `yaml:"firewall"` + KnownDevices []KnownDevice `yaml:"known_devices"` + Mihomo MihomoConfig `yaml:"mihomo"` + ClientPolicy ClientPolicyConfig `yaml:"client_policy,omitempty"` + Connectivity ConnectivityConfig `yaml:"connectivity,omitempty" json:"connectivity,omitempty"` + Auth AuthConfig `yaml:"auth,omitempty"` + ListenAddresses []string `yaml:"listen_addresses,omitempty" json:"listen_addresses,omitempty"` } var ( diff --git a/main.go b/main.go index 6b28bc9..b81ee68 100644 --- a/main.go +++ b/main.go @@ -20,6 +20,7 @@ import ( "nano-router/monitor" "nano-router/nat" "nano-router/network" + "nano-router/setup" "nano-router/traffic" ) @@ -27,6 +28,11 @@ import ( var publicFS embed.FS func main() { + if len(os.Args) > 1 && os.Args[1] == "setup" { + setup.Run() + return + } + cfg, err := config.Load() if err != nil { log.Fatalf("load config.yaml: %v", err) @@ -164,8 +170,23 @@ func main() { handlers.StartPolicySync(30 * time.Second) log.Printf("Config file: %s", config.GetPath()) - log.Printf("Network Manager listening on http://0.0.0.0:%s", port) - log.Fatal(http.ListenAndServe(":"+port, auth.PublicAuthMiddleware(mux))) + + handler := auth.PublicAuthMiddleware(mux) + if len(cfg.ListenAddresses) > 0 { + servers := make([]*http.Server, len(cfg.ListenAddresses)) + errCh := make(chan error, len(cfg.ListenAddresses)) + for i, addr := range cfg.ListenAddresses { + bind := addr + ":" + port + srv := &http.Server{Addr: bind, Handler: handler} + servers[i] = srv + log.Printf("Network Manager listening on http://%s", bind) + go func(s *http.Server) { errCh <- s.ListenAndServe() }(srv) + } + log.Fatal(<-errCh) + } else { + log.Printf("Network Manager listening on http://0.0.0.0:%s", port) + log.Fatal(http.ListenAndServe(":"+port, handler)) + } } func importSystemState() *config.AppConfig { diff --git a/nano-router b/nano-router index 4a88b69..98ef640 100755 Binary files a/nano-router and b/nano-router differ diff --git a/setup/setup.go b/setup/setup.go new file mode 100644 index 0000000..9904393 --- /dev/null +++ b/setup/setup.go @@ -0,0 +1,631 @@ +package setup + +import ( + "bufio" + "fmt" + "net" + "os" + "os/exec" + "path/filepath" + "sort" + "strconv" + "strings" + + "nano-router/auth" + "nano-router/config" + "nano-router/network" +) + +const serviceName = "network-manager" +const servicePath = "/etc/init.d/" + serviceName +const binInstallPath = "/usr/local/bin/network-manager" + +func Run() { + reader := bufio.NewReader(os.Stdin) + + printBanner() + fmt.Println() + fmt.Println(" This wizard will guide you through initial router configuration.") + fmt.Println(" Press Enter to accept the default value shown in brackets.") + fmt.Println() + + ifaces, err := network.GetInterfaces() + if err != nil { + fmt.Fprintf(os.Stderr, " [ERROR] Cannot list network interfaces: %v\n", err) + os.Exit(1) + } + + physicalIfaces := filterPhysicalIfaces(ifaces) + if len(physicalIfaces) < 2 { + fmt.Fprintf(os.Stderr, " [ERROR] At least 2 physical interfaces are required, found %d.\n", len(physicalIfaces)) + os.Exit(1) + } + + fmt.Println(" ── Step 1: WAN Interface ──────────────────────────────────────────") + fmt.Println() + + wanIface := selectInterface(reader, physicalIfaces, "Select WAN interface") + fmt.Println() + + fmt.Printf(" Configuring WAN interface: %s\n", wanIface) + fmt.Println() + + wanMode := selectWANMode(reader) + fmt.Println() + + var wanAddress, wanNetmask, wanGateway string + if wanMode == "static" { + wanStats, _ := network.GetInterfaceStats(wanIface) + defAddr := "" + if wanStats != nil && wanStats.IPv4 != "" { + defAddr = wanStats.IPv4 + } + defMask := "255.255.255.0" + if wanStats != nil && wanStats.IPv4Mask != "" { + defMask = wanStats.IPv4Mask + } + defGW := "" + if wanStats != nil && wanStats.Gateway != "" { + defGW = wanStats.Gateway + } + + wanAddress = readIP(reader, " WAN IP address", defAddr) + wanNetmask = readNetmask(reader, " WAN netmask", defMask) + wanGateway = readIPOrDefault(reader, " WAN gateway", defGW) + } + + fmt.Println() + fmt.Println(" ── Step 2: LAN Interface ──────────────────────────────────────────") + fmt.Println() + + remainingIfaces := removeItem(physicalIfaces, wanIface) + lanIface := selectInterface(reader, remainingIfaces, "Select LAN interface") + fmt.Println() + + lanStats, _ := network.GetInterfaceStats(lanIface) + + defLanAddr := "192.168.1.1" + defLanMask := "255.255.255.0" + if lanStats != nil && lanStats.IPv4 != "" { + defLanAddr = lanStats.IPv4 + } + if lanStats != nil && lanStats.IPv4Mask != "" { + defLanMask = lanStats.IPv4Mask + } + + fmt.Printf(" Configuring LAN interface: %s\n", lanIface) + fmt.Println() + + lanAddress := readIP(reader, " LAN IP address", defLanAddr) + lanNetmask := readNetmask(reader, " LAN netmask", defLanMask) + + lanSubnet := computeSubnet(lanAddress, lanNetmask) + + fmt.Println() + + enableDHCP := readYesNo(reader, " Enable DHCP server on LAN?", true) + fmt.Println() + + var dhcpRangeStart, dhcpRangeEnd string + var dhcpLeaseTime int + enableDHCPPool := false + if enableDHCP { + defStart := incrementIP(lanAddress, 100) + defEnd := incrementIP(lanAddress, 200) + if lanStats != nil && lanStats.IPv4 != "" { + defStart = incrementIP(lanStats.IPv4, 100) + defEnd = incrementIP(lanStats.IPv4, 200) + } + + fmt.Println(" ── DHCP Pool Configuration ─────────────────────────────────────────") + fmt.Println() + dhcpRangeStart = readIP(reader, " DHCP range start", defStart) + dhcpRangeEnd = readIP(reader, " DHCP range end", defEnd) + dhcpLeaseTime = readInt(reader, " Lease time (seconds)", 86400) + enableDHCPPool = true + fmt.Println() + } + + fmt.Println(" ── Step 3: Administrator Account ──────────────────────────────────") + fmt.Println() + + adminUsername := readNonEmpty(reader, " Administrator login", "admin") + adminPassword := readPassword(reader, " Administrator password") + fmt.Println() + + fmt.Println() + fmt.Println(" ── Generating Configuration ────────────────────────────────────────") + fmt.Println() + + cfg := buildConfig(wanIface, wanMode, wanAddress, wanNetmask, wanGateway, + lanIface, lanAddress, lanNetmask, lanSubnet, + enableDHCP, enableDHCPPool, dhcpRangeStart, dhcpRangeEnd, dhcpLeaseTime, + adminUsername, adminPassword) + + if err := config.Save(cfg); err != nil { + fmt.Fprintf(os.Stderr, " [ERROR] Cannot save config: %v\n", err) + os.Exit(1) + } + fmt.Printf(" Configuration saved to %s\n", config.GetPath()) + fmt.Println() + + fmt.Println(" ── Installing Service ──────────────────────────────────────────────") + fmt.Println() + + if err := installService(); err != nil { + fmt.Fprintf(os.Stderr, " [ERROR] Cannot install service: %v\n", err) + os.Exit(1) + } + fmt.Println() + + fmt.Println(" ── Setup Complete ──────────────────────────────────────────────────") + fmt.Println() + fmt.Printf(" Admin panel: http://%s:8080\n", lanAddress) + fmt.Printf(" Login: %s\n", adminUsername) + fmt.Printf(" Password: %s\n", adminPassword) + fmt.Println() + fmt.Println(" The service has been installed and enabled for autostart.") + fmt.Println(" Run 'rc-service network-manager start' or reboot to activate.") + fmt.Println() +} + +func printBanner() { + fmt.Println() + fmt.Println(" ┌──────────────────────────────────────────────────────────────────┐") + fmt.Println(" │ Alpine Router -- Initial Setup Wizard │") + fmt.Println(" └──────────────────────────────────────────────────────────────────┘") +} + +func selectInterface(reader *bufio.Reader, ifaces []string, prompt string) string { + fmt.Printf(" %s:\n", prompt) + fmt.Println() + for i, name := range ifaces { + stats, _ := network.GetInterfaceStats(name) + extra := "" + if stats != nil { + parts := []string{} + if stats.IPv4 != "" { + parts = append(parts, stats.IPv4) + } + if stats.State != "" { + parts = append(parts, "["+stats.State+"]") + } + if len(parts) > 0 { + extra = " " + strings.Join(parts, " ") + } + } + fmt.Printf(" %2d) %s%s\n", i+1, name, extra) + } + fmt.Println() + + for { + fmt.Print(" Enter number: ") + input := strings.TrimSpace(readLine(reader)) + num, err := strconv.Atoi(input) + if err != nil || num < 1 || num > len(ifaces) { + fmt.Printf(" [!] Please enter a number between 1 and %d\n", len(ifaces)) + continue + } + selected := ifaces[num-1] + fmt.Printf(" Selected: %s\n", selected) + return selected + } +} + +func selectWANMode(reader *bufio.Reader) string { + fmt.Println(" WAN addressing mode:") + fmt.Println() + fmt.Println(" 1) DHCP ( automatic )") + fmt.Println(" 2) Static ( manual IP configuration )") + fmt.Println() + + for { + fmt.Print(" Select mode (1-2) [1]: ") + input := strings.TrimSpace(readLine(reader)) + if input == "" || input == "1" { + fmt.Println(" Selected: DHCP") + return "dhcp" + } + if input == "2" { + fmt.Println(" Selected: Static") + return "static" + } + fmt.Println(" [!] Enter 1 or 2") + } +} + +func readIP(reader *bufio.Reader, prompt, def string) string { + for { + promptStr := fmt.Sprintf(" %s", prompt) + if def != "" { + promptStr += fmt.Sprintf(" [%s]", def) + } + promptStr += ": " + fmt.Print(promptStr) + input := strings.TrimSpace(readLine(reader)) + if input == "" && def != "" { + input = def + } + if net.ParseIP(input) == nil { + fmt.Println(" [!] Invalid IP address, try again") + continue + } + return input + } +} + +func readIPOrDefault(reader *bufio.Reader, prompt, def string) string { + for { + promptStr := fmt.Sprintf(" %s", prompt) + if def != "" { + promptStr += fmt.Sprintf(" [%s]", def) + } + promptStr += " (or leave empty to skip): " + fmt.Print(promptStr) + input := strings.TrimSpace(readLine(reader)) + if input == "" { + if def != "" { + return def + } + return "" + } + if net.ParseIP(input) == nil { + fmt.Println(" [!] Invalid IP address, try again") + continue + } + return input + } +} + +func readNetmask(reader *bufio.Reader, prompt, def string) string { + for { + promptStr := fmt.Sprintf(" %s", prompt) + if def != "" { + promptStr += fmt.Sprintf(" [%s]", def) + } + promptStr += ": " + fmt.Print(promptStr) + input := strings.TrimSpace(readLine(reader)) + if input == "" && def != "" { + input = def + } + if isValidNetmask(input) { + return input + } + fmt.Println(" [!] Invalid netmask, try again (e.g. 255.255.255.0)") + } +} + +func readYesNo(reader *bufio.Reader, prompt string, def bool) bool { + defStr := "Y/n" + if !def { + defStr = "y/N" + } + for { + fmt.Printf(" %s [%s]: ", prompt, defStr) + input := strings.TrimSpace(strings.ToLower(readLine(reader))) + if input == "" { + return def + } + switch input { + case "y", "yes": + return true + case "n", "no": + return false + default: + fmt.Println(" [!] Enter y or n") + } + } +} + +func readNonEmpty(reader *bufio.Reader, prompt, def string) string { + for { + promptStr := fmt.Sprintf(" %s", prompt) + if def != "" { + promptStr += fmt.Sprintf(" [%s]", def) + } + promptStr += ": " + fmt.Print(promptStr) + input := strings.TrimSpace(readLine(reader)) + if input == "" && def != "" { + return def + } + if input != "" { + return input + } + fmt.Println(" [!] Value cannot be empty") + } +} + +func readPassword(reader *bufio.Reader, prompt string) string { + fd := int(os.Stdin.Fd()) + oldState, canMute := termiosGetState(fd) + + for { + fmt.Printf(" %s: ", prompt) + if canMute { + termiosDisableEcho(fd) + } + pw1 := readLineRaw() + if canMute { + termiosRestore(fd, oldState) + fmt.Println() + } + if len(pw1) < 4 { + fmt.Println(" [!] Password must be at least 4 characters") + continue + } + + fmt.Print(" Confirm password: ") + if canMute { + termiosDisableEcho(fd) + } + pw2 := readLineRaw() + if canMute { + termiosRestore(fd, oldState) + fmt.Println() + } + if pw1 != pw2 { + fmt.Println(" [!] Passwords do not match, try again") + continue + } + return pw1 + } +} + +func readLineRaw() string { + var line []byte + buf := make([]byte, 1) + for { + n, err := os.Stdin.Read(buf) + if n > 0 { + if buf[0] == '\n' || buf[0] == '\r' { + break + } + line = append(line, buf[0]) + } + if err != nil { + break + } + } + return string(line) +} + +func readInt(reader *bufio.Reader, prompt string, def int) int { + for { + fmt.Printf(" %s [%d]: ", prompt, def) + input := strings.TrimSpace(readLine(reader)) + if input == "" { + return def + } + num, err := strconv.Atoi(input) + if err != nil || num <= 0 { + fmt.Println(" [!] Enter a positive integer") + continue + } + return num + } +} + +func readLine(reader *bufio.Reader) string { + line, _ := reader.ReadString('\n') + return strings.TrimRight(line, "\r\n") +} + +func filterPhysicalIfaces(ifaces []string) []string { + skip := map[string]bool{"lo": true} + var result []string + for _, name := range ifaces { + if skip[name] { + continue + } + if network.IsVLAN(name) { + continue + } + result = append(result, name) + } + sort.Strings(result) + return result +} + +func removeItem(xs []string, item string) []string { + var result []string + for _, x := range xs { + if x != item { + result = append(result, x) + } + } + return result +} + +func isValidNetmask(mask string) bool { + m := net.ParseIP(mask) + if m == nil { + return false + } + m4 := m.To4() + if m4 == nil { + return false + } + var n uint32 + for _, b := range m4 { + n = (n << 8) | uint32(b) + } + if n == 0 { + return false + } + complement := ^n + return complement&((complement)+1) == 0 +} + +func computeSubnet(ipStr, maskStr string) string { + ip := net.ParseIP(ipStr) + mask := net.ParseIP(maskStr) + if ip == nil || mask == nil { + return ipStr + } + ip4 := ip.To4() + mask4 := mask.To4() + if ip4 == nil || mask4 == nil { + return ipStr + } + ipMask := net.IPMask(mask4) + subnet := net.IPNet{ + IP: ip4.Mask(ipMask), + Mask: ipMask, + } + return subnet.String() +} + +func incrementIP(ipStr string, offset int) string { + ip := net.ParseIP(ipStr) + if ip == nil { + return ipStr + } + ip4 := ip.To4() + if ip4 == nil { + return ipStr + } + val := uint32(ip4[0])<<24 | uint32(ip4[1])<<16 | uint32(ip4[2])<<8 | uint32(ip4[3]) + val += uint32(offset) + return fmt.Sprintf("%d.%d.%d.%d", + (val>>24)&0xFF, (val>>16)&0xFF, (val>>8)&0xFF, val&0xFF) +} + +func buildConfig( + wanIface, wanMode, wanAddress, wanNetmask, wanGateway, + lanIface, lanAddress, lanNetmask, lanSubnet string, + enableDHCP, enableDHCPPool bool, + dhcpRangeStart, dhcpRangeEnd string, + dhcpLeaseTime int, + adminUsername, adminPassword string, +) *config.AppConfig { + cfg := &config.AppConfig{ + Interfaces: map[string]*config.InterfaceConfig{ + wanIface: { + Type: "wan", + Auto: true, + Mode: wanMode, + Address: wanAddress, + Netmask: wanNetmask, + Gateway: wanGateway, + }, + lanIface: { + Type: "lan", + Auto: true, + Mode: "static", + Address: lanAddress, + Netmask: lanNetmask, + }, + }, + NAT: config.NATConfig{ + Interfaces: []string{lanIface}, + }, + Firewall: config.FirewallConfig{ + VLANIsolation: true, + Rules: []config.FirewallRule{}, + }, + DHCP: config.DHCPConfig{ + Enabled: enableDHCP, + Pools: []config.DHCPPool{}, + }, + KnownDevices: []config.KnownDevice{}, + Mihomo: config.MihomoConfig{Enabled: false}, + ClientPolicy: config.ClientPolicyConfig{Default: "direct"}, + Auth: config.AuthConfig{ + Username: adminUsername, + PasswordHash: auth.HashPassword(adminPassword), + }, + ListenAddresses: []string{lanAddress}, + } + + if enableDHCP && enableDHCPPool { + parts := strings.Split(lanSubnet, "/") + subnetPart := lanSubnet + if len(parts) == 2 { + subnetPart = parts[0] + "/" + parts[1] + } + + dnsServers := []string{lanAddress} + if wanMode == "static" && wanGateway != "" { + dnsServers = []string{lanAddress, wanGateway} + } + + cfg.DHCP.Pools = append(cfg.DHCP.Pools, config.DHCPPool{ + Interface: lanIface, + Enabled: true, + Subnet: subnetPart, + Netmask: lanNetmask, + RangeStart: dhcpRangeStart, + RangeEnd: dhcpRangeEnd, + Router: lanAddress, + DNS: dnsServers, + LeaseTime: dhcpLeaseTime, + }) + } + + return cfg +} + +func installService() error { + exe, err := os.Executable() + if err != nil { + return fmt.Errorf("detect executable path: %w", err) + } + resolved, err := filepath.EvalSymlinks(exe) + if err != nil { + resolved = exe + } + + if _, err := os.Stat(servicePath); err == nil { + fmt.Println(" Removing existing service...") + _ = exec.Command("rc-service", serviceName, "stop").Run() + _ = exec.Command("rc-update", "delete", serviceName).Run() + _ = os.Remove(servicePath) + } + + fmt.Printf(" Installing binary to %s...\n", binInstallPath) + if err := os.MkdirAll(filepath.Dir(binInstallPath), 0755); err != nil { + return fmt.Errorf("create bin directory: %w", err) + } + + if out, err := exec.Command("cp", resolved, binInstallPath).CombinedOutput(); err != nil { + return fmt.Errorf("copy binary: %s: %w", strings.TrimSpace(string(out)), err) + } + + if err := os.Chmod(binInstallPath, 0755); err != nil { + return fmt.Errorf("chmod binary: %w", err) + } + fmt.Println(" Binary installed and made executable") + + serviceContent := `#!/sbin/openrc-run + +description="Network Manager Web Panel" +command="` + binInstallPath + `" +command_background=true +pidfile="/run/${RC_SVCNAME}.pid" +output_log="/var/log/network-manager.log" +error_log="/var/log/network-manager.log" + +depend() { + need net + after firewall +} + +start_pre() { + checkpath --directory /var/log +} +` + + if err := os.WriteFile(servicePath, []byte(serviceContent), 0755); err != nil { + return fmt.Errorf("write service file: %w", err) + } + fmt.Printf(" Service file created: %s\n", servicePath) + + if out, err := exec.Command("rc-update", "add", serviceName, "default").CombinedOutput(); err != nil { + return fmt.Errorf("enable service: %s: %w", strings.TrimSpace(string(out)), err) + } + fmt.Println(" Service enabled for autostart (default runlevel)") + + return nil +} \ No newline at end of file diff --git a/setup/termios_linux.go b/setup/termios_linux.go new file mode 100644 index 0000000..94e1c23 --- /dev/null +++ b/setup/termios_linux.go @@ -0,0 +1,45 @@ +package setup + +import ( + "syscall" + "unsafe" +) + +type termios struct { + Iflag uint32 + Oflag uint32 + Cflag uint32 + Lflag uint32 + Cc [20]byte + Ispeed uint32 + Ospeed uint32 +} + +const ( + tcgetattr = 0x5401 + tcsetattr = 0x5402 + TCSAFLUSH = 2 + ECHO = 0x8 + ICANON = 0x100 +) + +func termiosGetState(fd int) (*termios, bool) { + var state termios + if _, _, err := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), uintptr(tcgetattr), uintptr(unsafe.Pointer(&state))); err != 0 { + return nil, false + } + return &state, true +} + +func termiosDisableEcho(fd int) { + var state termios + if _, _, err := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), uintptr(tcgetattr), uintptr(unsafe.Pointer(&state))); err != 0 { + return + } + state.Lflag &^= ECHO | ICANON + syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), uintptr(tcsetattr), uintptr(unsafe.Pointer(&state))) +} + +func termiosRestore(fd int, state *termios) { + syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), uintptr(tcsetattr), uintptr(unsafe.Pointer(state))) +} \ No newline at end of file