package cli import ( "fmt" "os/exec" "strconv" "strings" "proxmox-router/internal/auth" "proxmox-router/internal/dhcp" "proxmox-router/internal/dns" "proxmox-router/internal/firewall" "proxmox-router/internal/network" "proxmox-router/internal/routing" "proxmox-router/internal/system" ) type Executor struct { authMgr *auth.Manager netMgr *network.Manager routingMgr *routing.RoutingManager fwMgr *firewall.FirewallManager dhcpMgr *dhcp.Manager dhcpServMgr *dhcp.ServerManager dnsMgr *dns.Manager } func NewExecutor( authMgr *auth.Manager, netMgr *network.Manager, routingMgr *routing.RoutingManager, fwMgr *firewall.FirewallManager, dhcpMgr *dhcp.Manager, dhcpServMgr *dhcp.ServerManager, dnsMgr *dns.Manager, ) *Executor { return &Executor{ authMgr: authMgr, netMgr: netMgr, routingMgr: routingMgr, fwMgr: fwMgr, dhcpMgr: dhcpMgr, dhcpServMgr: dhcpServMgr, dnsMgr: dnsMgr, } } func (e *Executor) Execute(rawCmd string, user *auth.PublicUser) (string, bool) { cmd := strings.TrimSpace(rawCmd) if cmd == "" { return "", false } // Remove leading slash if present (RouterOS compatibility e.g. /ip route print) cmd = strings.TrimPrefix(cmd, "/") parts := strings.Fields(cmd) if len(parts) == 0 { return "", false } mainCmd := strings.ToLower(parts[0]) switch mainCmd { case "quit", "exit": return ColorYellow + "Connection closed. Goodbye!\n" + ColorReset, true case "help", "?": return e.showHelp(), false case "clear": return "\033[2J\033[H", false case "ping": return e.handlePing(parts[1:]), false case "traceroute": return e.handleTraceroute(parts[1:]), false case "system": return e.handleSystem(parts[1:], user), false case "interface": return e.handleInterface(parts[1:], user), false case "ip": return e.handleIP(parts[1:], user), false case "user": return e.handleUser(parts[1:], user), false default: return fmt.Sprintf(ColorRed+"Unknown command or syntax error: %q. Type 'help' or '?' for command list.\n"+ColorReset, cmd), false } } func (e *Executor) handleInterface(parts []string, user *auth.PublicUser) string { if len(parts) == 0 || parts[0] == "print" || parts[0] == "pr" || parts[0] == "show" || parts[0] == "list" { ifaces, err := e.netMgr.ListInterfaces() if err != nil { return ColorRed + "Error listing interfaces: " + err.Error() + ColorReset + "\n" } tbl := NewTable("#", "NAME", "TYPE", "STATE", "MTU", "MAC", "IP ADDRESSES") for idx, iface := range ifaces { stateColor := ColorGreen if iface.State != "UP" { stateColor = ColorRed } stateStr := fmt.Sprintf("%s[%s]%s", stateColor, iface.State, ColorReset) var ips []string for _, a := range iface.IPv4 { ips = append(ips, a) } tbl.AddRow( fmt.Sprintf("%d", idx), ColorBold+iface.Name+ColorReset, iface.Type, stateStr, fmt.Sprintf("%d", iface.MTU), iface.MAC, strings.Join(ips, ", "), ) } return tbl.Render() } if parts[0] == "set" { if user.Role == auth.RoleRead { return ColorRed + "Permission denied: read-only user cannot modify interface state\n" + ColorReset } kv := parseKeyValues(parts[1:]) name := "" if len(parts) > 1 && !strings.Contains(parts[1], "=") { name = parts[1] } if n, ok := kv["name"]; ok { name = n } if name == "" { return ColorRed + "Usage: interface set disabled=yes|no\n" + ColorReset } if dis, ok := kv["disabled"]; ok { up := strings.ToLower(dis) != "yes" if err := e.netMgr.SetInterfaceState(name, up); err != nil { return ColorRed + "Error: " + err.Error() + ColorReset + "\n" } return ColorGreen + fmt.Sprintf("Interface %s state updated\n", name) + ColorReset } } return ColorYellow + "Usage: interface print | interface set disabled=yes|no\n" + ColorReset } func (e *Executor) handleIP(parts []string, user *auth.PublicUser) string { if len(parts) == 0 { return ColorYellow + "Usage: ip [address|route|dhcp-client|dhcp-server|firewall|dns] print\n" + ColorReset } sub := strings.ToLower(parts[0]) switch sub { case "address", "addr": return e.handleIPAddress(parts[1:], user) case "route": return e.handleIPRoute(parts[1:], user) case "dhcp-client": return e.handleDHCPClient(parts[1:], user) case "dhcp-server": return e.handleDHCPServer(parts[1:], user) case "firewall": return e.handleFirewall(parts[1:], user) case "dns": return e.handleDNS(parts[1:], user) default: return ColorRed + fmt.Sprintf("Unknown ip subsystem: %q. Try 'ip address', 'ip route', 'ip firewall', etc.\n", sub) + ColorReset } } func (e *Executor) handleIPAddress(parts []string, user *auth.PublicUser) string { if len(parts) == 0 || parts[0] == "print" || parts[0] == "pr" || parts[0] == "show" { ifaces, err := e.netMgr.ListInterfaces() if err != nil { return ColorRed + "Error: " + err.Error() + ColorReset + "\n" } tbl := NewTable("#", "INTERFACE", "ADDRESS", "TYPE", "STATUS") idx := 0 for _, iface := range ifaces { for _, ip := range iface.IPv4 { tbl.AddRow( fmt.Sprintf("%d", idx), ColorBold+iface.Name+ColorReset, ColorGreen+ip+ColorReset, iface.Type, iface.State, ) idx++ } } return tbl.Render() } if parts[0] == "add" { if user.Role == auth.RoleRead { return ColorRed + "Permission denied: read-only user cannot add IP address\n" + ColorReset } kv := parseKeyValues(parts[1:]) addr := kv["address"] iface := kv["interface"] if addr == "" || iface == "" { return ColorYellow + "Usage: ip address add address=X.X.X.X/YY interface=\n" + ColorReset } if err := e.netMgr.AddInterfaceIP(iface, addr); err != nil { return ColorRed + "Failed to add address: " + err.Error() + ColorReset + "\n" } return ColorGreen + fmt.Sprintf("Assigned %s to %s successfully\n", addr, iface) + ColorReset } if parts[0] == "remove" || parts[0] == "del" { if user.Role == auth.RoleRead { return ColorRed + "Permission denied: read-only user cannot remove IP address\n" + ColorReset } kv := parseKeyValues(parts[1:]) addr := kv["address"] iface := kv["interface"] if addr == "" || iface == "" { return ColorYellow + "Usage: ip address remove address=X.X.X.X/YY interface=\n" + ColorReset } if err := e.netMgr.DeleteInterfaceIP(iface, addr); err != nil { return ColorRed + "Failed to remove address: " + err.Error() + ColorReset + "\n" } return ColorGreen + fmt.Sprintf("Removed %s from %s successfully\n", addr, iface) + ColorReset } return ColorYellow + "Usage: ip address print | ip address add address=... interface=... | ip address remove address=... interface=...\n" + ColorReset } func (e *Executor) handleIPRoute(parts []string, user *auth.PublicUser) string { if len(parts) == 0 || parts[0] == "print" || parts[0] == "pr" || parts[0] == "show" { routes := e.routingMgr.ListRoutes() tbl := NewTable("#", "DST-ADDRESS", "GATEWAY", "INTERFACE", "METRIC", "TABLE", "FLAGS") for idx, r := range routes { flags := "DAC" // Dynamic Active Connected if r.Protocol == "static" { flags = "DAS" // Dynamic Active Static } else if r.Protocol == "dhcp" { flags = "DAD" // Dynamic Active DHCP } tbl.AddRow( fmt.Sprintf("%d", idx), ColorBold+r.DstAddress+ColorReset, r.Gateway, r.Interface, fmt.Sprintf("%d", r.Distance), r.RoutingTable, ColorGreen+flags+ColorReset, ) } return tbl.Render() } if parts[0] == "add" { if user.Role == auth.RoleRead { return ColorRed + "Permission denied: read-only user cannot add routes\n" + ColorReset } kv := parseKeyValues(parts[1:]) dst := kv["dst-address"] if dst == "" { dst = kv["dst"] } gw := kv["gateway"] if gw == "" { gw = kv["gw"] } dist := 1 if dStr, ok := kv["distance"]; ok { if parsed, err := strconv.Atoi(dStr); err == nil && parsed > 0 { dist = parsed } } if dst == "" || gw == "" { return ColorYellow + "Usage: ip route add dst-address=X.X.X.X/YY gateway=Z.Z.Z.Z [distance=N]\n" + ColorReset } newRoute := routing.RouteItem{ DstAddress: dst, Gateway: gw, Distance: dist, RoutingTable: "main", Active: true, } if err := e.routingMgr.SaveRoute(newRoute); err != nil { return ColorRed + "Failed to add route: " + err.Error() + ColorReset + "\n" } return ColorGreen + fmt.Sprintf("Added route to %s via %s (distance %d)\n", dst, gw, dist) + ColorReset } return ColorYellow + "Usage: ip route print | ip route add dst-address=... gateway=... [distance=...]\n" + ColorReset } func (e *Executor) handleDHCPClient(parts []string, user *auth.PublicUser) string { clients := e.dhcpMgr.ListClients() tbl := NewTable("#", "INTERFACE", "STATUS", "IP ADDRESS", "GATEWAY", "DHCP-SERVER", "EXPIRES") for idx, c := range clients { stColor := ColorYellow if c.Status == "bound" { stColor = ColorGreen } else if c.Status == "disabled" || c.Status == "stopped" { stColor = ColorRed } tbl.AddRow( fmt.Sprintf("%d", idx), ColorBold+c.Interface+ColorReset, stColor+c.Status+ColorReset, c.IPAddress, c.Gateway, c.DHCPServer, c.ExpiresIn, ) } return tbl.Render() } func (e *Executor) handleDHCPServer(parts []string, user *auth.PublicUser) string { if len(parts) > 0 && parts[0] == "lease" { leases := e.dhcpServMgr.ListLeases() tbl := NewTable("#", "ADDRESS", "MAC-ADDRESS", "HOSTNAME", "EXPIRES-IN", "SERVER") for idx, l := range leases { tbl.AddRow( fmt.Sprintf("%d", idx), ColorGreen+l.Address+ColorReset, l.MACAddress, l.HostName, l.ExpiresIn, l.Server, ) } return tbl.Render() } servers := e.dhcpServMgr.ListServers() tbl := NewTable("#", "INTERFACE", "SUBNET", "IP-POOL", "LEASE-TIME", "STATUS") for idx, s := range servers { st := ColorGreen + "running" + ColorReset if s.Disabled { st = ColorRed + "disabled" + ColorReset } tbl.AddRow( fmt.Sprintf("%d", idx), ColorBold+s.Interface+ColorReset, s.Network, s.AddressPool, s.LeaseTime, st, ) } return tbl.Render() } func (e *Executor) handleFirewall(parts []string, user *auth.PublicUser) string { if len(parts) == 0 { return ColorYellow + "Usage: ip firewall [filter|nat|mangle] print\n" + ColorReset } sub := strings.ToLower(parts[0]) switch sub { case "filter": rules := e.fwMgr.ListFilterRules() tbl := NewTable("#", "CHAIN", "ACTION", "PROTOCOL", "SRC-ADDRESS", "DST-ADDRESS", "IN-IFACE", "OUT-IFACE", "COMMENT") for idx, r := range rules { tbl.AddRow( fmt.Sprintf("%d", idx), r.Chain, ColorBold+r.Action+ColorReset, r.Protocol, r.SrcAddress, r.DstAddress, r.InInterface, r.OutInterface, ColorDim+r.Comment+ColorReset, ) } return tbl.Render() case "nat": rules := e.fwMgr.ListNATRules() tbl := NewTable("#", "CHAIN", "ACTION", "OUT-INTERFACE", "TO-ADDRESS", "COMMENT") for idx, r := range rules { out := r.OutInterface if r.OutInterfaceList != "" { out = fmt.Sprintf("list:%s", r.OutInterfaceList) } tbl.AddRow( fmt.Sprintf("%d", idx), r.Chain, ColorGreen+r.Action+ColorReset, out, r.ToAddresses, ColorDim+r.Comment+ColorReset, ) } return tbl.Render() default: return ColorYellow + "Usage: ip firewall filter print | ip firewall nat print\n" + ColorReset } } func (e *Executor) handleDNS(parts []string, user *auth.PublicUser) string { if len(parts) > 0 && parts[0] == "cache" { if len(parts) > 1 && parts[1] == "flush" { if user.Role == auth.RoleRead { return ColorRed + "Permission denied: read-only user cannot flush DNS cache\n" + ColorReset } if err := e.dnsMgr.FlushCache(); err != nil { return ColorRed + "Error flushing DNS cache: " + err.Error() + ColorReset + "\n" } return ColorGreen + "DNS Cache flushed successfully\n" + ColorReset } } settings := e.dnsMgr.GetSettings() var sb strings.Builder sb.WriteString(ColorCyan + ColorBold + "DNS Settings:\n" + ColorReset) sb.WriteString(fmt.Sprintf(" Upstream Servers : %s\n", strings.Join(settings.Servers, ", "))) sb.WriteString(fmt.Sprintf(" Dynamic Servers : %s\n", strings.Join(settings.DynamicServers, ", "))) sb.WriteString(fmt.Sprintf(" Domain : %s\n", settings.Domain)) sb.WriteString(fmt.Sprintf(" Cache Size : %d entries\n", settings.CacheSize)) sb.WriteString(fmt.Sprintf(" Allow Remote Reqs : %t\n", settings.AllowRemoteRequests)) return sb.String() } func (e *Executor) handleUser(parts []string, user *auth.PublicUser) string { if len(parts) == 0 || parts[0] == "print" || parts[0] == "pr" { if user.Role != auth.RoleFull { return ColorRed + "Permission denied: administrative privileges required\n" + ColorReset } users := e.authMgr.ListUsers() tbl := NewTable("#", "USERNAME", "ROLE", "DISABLED", "COMMENT", "LAST LOGIN") for idx, u := range users { disStr := ColorGreen + "no" + ColorReset if u.Disabled { disStr = ColorRed + "yes" + ColorReset } last := ColorDim + "never" + ColorReset if !u.LastLoginAt.IsZero() { last = u.LastLoginAt.Format("2006-01-02 15:04") } tbl.AddRow( fmt.Sprintf("%d", idx), ColorBold+u.Username+ColorReset, ColorYellow+strings.ToUpper(string(u.Role))+ColorReset, disStr, u.Comment, last, ) } return tbl.Render() } if parts[0] == "add" { if user.Role != auth.RoleFull { return ColorRed + "Permission denied: administrative privileges required\n" + ColorReset } kv := parseKeyValues(parts[1:]) name := kv["name"] pass := kv["password"] role := auth.Role(kv["role"]) if role == "" { role = auth.RoleRead } comment := kv["comment"] if name == "" || pass == "" { return ColorYellow + "Usage: user add name= password= [role=full|write|read] [comment=\"...\"]\n" + ColorReset } req := auth.CreateUserRequest{ Username: name, Password: pass, Role: role, Comment: comment, } if err := e.authMgr.CreateUser(req); err != nil { return ColorRed + "Failed to create user: " + err.Error() + ColorReset + "\n" } return ColorGreen + fmt.Sprintf("User %q (%s) created successfully\n", name, role) + ColorReset } if parts[0] == "remove" || parts[0] == "del" { if user.Role != auth.RoleFull { return ColorRed + "Permission denied: administrative privileges required\n" + ColorReset } if len(parts) < 2 { return ColorYellow + "Usage: user remove \n" + ColorReset } target := parts[1] if err := e.authMgr.DeleteUser(target, user.Username); err != nil { return ColorRed + "Failed to remove user: " + err.Error() + ColorReset + "\n" } return ColorGreen + fmt.Sprintf("User %q removed successfully\n", target) + ColorReset } return ColorYellow + "Usage: user print | user add name=... password=... [role=...] | user remove \n" + ColorReset } func (e *Executor) handleSystem(parts []string, user *auth.PublicUser) string { if len(parts) == 0 { return ColorYellow + "Usage: system [resource|identity|reboot]\n" + ColorReset } sub := strings.ToLower(parts[0]) switch sub { case "resource": st, err := system.GetStatus() if err != nil { return ColorRed + "Error getting system status: " + err.Error() + ColorReset + "\n" } var sb strings.Builder sb.WriteString(ColorCyan + ColorBold + "System Resources:\n" + ColorReset) sb.WriteString(fmt.Sprintf(" Appliance Hostname : %s\n", st.Hostname)) sb.WriteString(fmt.Sprintf(" Operating System : %s\n", st.OS)) sb.WriteString(fmt.Sprintf(" Kernel Version : %s\n", st.Kernel)) sb.WriteString(fmt.Sprintf(" Uptime : %s\n", st.UptimeHuman)) sb.WriteString(fmt.Sprintf(" CPU Cores : %d cores\n", st.CPUCores)) sb.WriteString(fmt.Sprintf(" CPU Utilization : %.1f%%\n", st.CPUPct)) sb.WriteString(fmt.Sprintf(" Load Average : %s\n", strings.Join(st.CPULoad, ", "))) sb.WriteString(fmt.Sprintf(" Total Memory : %d MB\n", st.MemTotalMB)) sb.WriteString(fmt.Sprintf(" Used Memory : %d MB (%.1f%%)\n", st.MemUsedMB, st.MemUsagePct)) sb.WriteString(fmt.Sprintf(" Default Gateway : %s\n", st.DefaultGateway)) return sb.String() case "identity": st, _ := system.GetStatus() if len(parts) > 1 && parts[1] == "set" { if user.Role == auth.RoleRead { return ColorRed + "Permission denied: read-only user cannot change identity\n" + ColorReset } kv := parseKeyValues(parts[2:]) newName := kv["name"] if newName == "" { return ColorYellow + "Usage: system identity set name=\n" + ColorReset } _ = exec.Command("hostnamectl", "set-hostname", newName).Run() return ColorGreen + fmt.Sprintf("Identity set to %q\n", newName) + ColorReset } return fmt.Sprintf(ColorCyan+"name: %s%s\n", st.Hostname, ColorReset) case "reboot": if user.Role != auth.RoleFull { return ColorRed + "Permission denied: full administrative privileges required to reboot\n" + ColorReset } _ = exec.Command("/sbin/reboot").Start() return ColorRed + ColorBold + "System is going down for reboot NOW...\n" + ColorReset default: return ColorYellow + "Usage: system resource print | system identity print | system reboot\n" + ColorReset } } func (e *Executor) handlePing(args []string) string { if len(args) == 0 { return ColorYellow + "Usage: ping [count=N]\n" + ColorReset } host := args[0] count := "4" for _, a := range args[1:] { if strings.HasPrefix(a, "count=") { count = strings.TrimPrefix(a, "count=") } } cmd := exec.Command("ping", "-c", count, "-W", "2", host) out, err := cmd.CombinedOutput() if err != nil { return fmt.Sprintf("%s%s%s\n", ColorRed, string(out), ColorReset) } return string(out) } func (e *Executor) handleTraceroute(args []string) string { if len(args) == 0 { return ColorYellow + "Usage: traceroute \n" + ColorReset } host := args[0] cmd := exec.Command("traceroute", "-m", "15", "-w", "1", host) out, err := cmd.CombinedOutput() if err != nil { // Fallback to ping/tracepath if traceroute not found return fmt.Sprintf("%s%s%s\n", ColorRed, string(out), ColorReset) } return string(out) } func (e *Executor) showHelp() string { var sb strings.Builder sb.WriteString(ColorCyan + ColorBold + "\n--- DebTik RouterOS CLI Commands Reference ---\n" + ColorReset) sb.WriteString(ColorBold + "Interfaces:\n" + ColorReset) sb.WriteString(" interface print List all interfaces, MACs and IPs\n") sb.WriteString(" interface set disabled=yes|no Enable or disable interface\n") sb.WriteString(ColorBold + "IP & Routing:\n" + ColorReset) sb.WriteString(" ip address print List configured IP addresses\n") sb.WriteString(" ip address add address=X/Y interface=DEV Add IP address to interface\n") sb.WriteString(" ip address remove address=X/Y interface=DEV Remove IP address\n") sb.WriteString(" ip route print Display routing table\n") sb.WriteString(" ip route add dst-address=X/Y gateway=Z Add static route\n") sb.WriteString(ColorBold + "DHCP & Services:\n" + ColorReset) sb.WriteString(" ip dhcp-client print Show DHCP client leases\n") sb.WriteString(" ip dhcp-server print Show DHCP server instances\n") sb.WriteString(" ip dhcp-server lease print Show active client leases\n") sb.WriteString(" ip dns print Show DNS cache and forwarders\n") sb.WriteString(" ip dns cache flush Flush DNS cache\n") sb.WriteString(ColorBold + "Firewall:\n" + ColorReset) sb.WriteString(" ip firewall filter print List Filter rules\n") sb.WriteString(" ip firewall nat print List NAT rules\n") sb.WriteString(ColorBold + "Users & Management:\n" + ColorReset) sb.WriteString(" user print List system users (Admin)\n") sb.WriteString(" user add name=X password=Y role=full|write Add local user\n") sb.WriteString(" user remove Delete local user\n") sb.WriteString(" password Change current password\n") sb.WriteString(ColorBold + "System & Utilities:\n" + ColorReset) sb.WriteString(" system resource print CPU, RAM, Uptime and load\n") sb.WriteString(" system identity print Show router hostname\n") sb.WriteString(" system identity set name= Set router hostname\n") sb.WriteString(" system reboot Reboot system\n") sb.WriteString(" ping [count=N] Send ICMP echo requests\n") sb.WriteString(" traceroute Trace route to target\n") sb.WriteString(" clear Clear screen\n") sb.WriteString(" exit / quit Disconnect from session\n") sb.WriteString("\n") return sb.String() } func parseKeyValues(parts []string) map[string]string { res := make(map[string]string) for _, p := range parts { if eq := strings.Index(p, "="); eq != -1 { k := strings.ToLower(strings.TrimSpace(p[:eq])) v := strings.TrimSpace(p[eq+1:]) v = strings.Trim(v, "\"") res[k] = v } } return res }