Преглед на файлове

feat(cli): implement RouterOS-compatible CLI server on port 8282 and debtik-cli terminal command

Proxmox Router преди 6 дни
родител
ревизия
12bdbe22e3
променени са 10 файла, в които са добавени 1288 реда и са изтрити 5 реда
  1. BIN
      bin/debtik-cli
  2. BIN
      bin/proxmox-router
  3. 124 0
      cmd/debtik-cli/main.go
  4. 12 1
      cmd/server/main.go
  5. 7 3
      go.mod
  6. 2 1
      go.sum
  7. 655 0
      internal/cli/executor.go
  8. 134 0
      internal/cli/formatter.go
  9. 216 0
      internal/cli/server.go
  10. 138 0
      internal/cli/telnet.go

BIN
bin/debtik-cli


BIN
bin/proxmox-router


+ 124 - 0
cmd/debtik-cli/main.go

@@ -0,0 +1,124 @@
+package main
+
+import (
+	"bufio"
+	"flag"
+	"fmt"
+	"io"
+	"net"
+	"os"
+	"os/signal"
+	"strings"
+	"syscall"
+	"time"
+
+	"golang.org/x/crypto/ssh/terminal"
+)
+
+func main() {
+	addr := flag.String("addr", "127.0.0.1:8282", "DebTik CLI server address")
+	username := flag.String("u", "", "Username for automatic authentication")
+	password := flag.String("p", "", "Password for automatic authentication")
+	cmdFlag := flag.String("c", "", "Execute a single command non-interactively and exit")
+	flag.Parse()
+
+	conn, err := net.DialTimeout("tcp", *addr, 3*time.Second)
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "\033[31mError: Could not connect to DebTik CLI service on %s\033[0m\n", *addr)
+		fmt.Fprintf(os.Stderr, "Ensure proxmox-router service is active and listening on port 8282.\n")
+		os.Exit(1)
+	}
+	defer conn.Close()
+
+	// If one-shot command execution is requested:
+	if *cmdFlag != "" {
+		runOneShot(conn, *username, *password, *cmdFlag)
+		return
+	}
+
+	// Interactive Mode
+	runInteractive(conn, *username, *password)
+}
+
+func runOneShot(conn net.Conn, username, password, command string) {
+	reader := bufio.NewReader(conn)
+	// If credentials were provided, handle initial prompt
+	if username != "" && password != "" {
+		_ = autoLogin(conn, reader, username, password)
+	}
+
+	// Send command followed by quit
+	_, _ = fmt.Fprintf(conn, "%s\nquit\n", command)
+
+	// Read until EOF
+	_, _ = io.Copy(os.Stdout, reader)
+}
+
+func runInteractive(conn net.Conn, username, password string) {
+	fd := int(os.Stdin.Fd())
+	isTerm := terminal.IsTerminal(fd)
+
+	if isTerm {
+		oldState, err := terminal.MakeRaw(fd)
+		if err == nil {
+			defer func() {
+				_ = terminal.Restore(fd, oldState)
+			}()
+		}
+	}
+
+	// Setup clean signal handling to restore terminal on SIGINT/SIGTERM
+	sigChan := make(chan os.Signal, 1)
+	signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
+	go func() {
+		<-sigChan
+		if isTerm {
+			_ = terminal.Restore(fd, nil)
+		}
+		os.Exit(0)
+	}()
+
+	errChan := make(chan error, 2)
+
+	// Copy from Remote Server -> Local Terminal
+	go func() {
+		_, err := io.Copy(os.Stdout, conn)
+		errChan <- err
+	}()
+
+	// Copy from Local Terminal -> Remote Server
+	go func() {
+		_, err := io.Copy(conn, os.Stdin)
+		errChan <- err
+	}()
+
+	// Wait for connection to close
+	<-errChan
+}
+
+func autoLogin(conn net.Conn, reader *bufio.Reader, username, password string) error {
+	// Read until "login: "
+	for {
+		line, err := reader.ReadString(':')
+		if err != nil {
+			return err
+		}
+		if strings.Contains(strings.ToLower(line), "login") {
+			_, _ = fmt.Fprintf(conn, "%s\n", username)
+			break
+		}
+	}
+
+	// Read until "Password: "
+	for {
+		line, err := reader.ReadString(':')
+		if err != nil {
+			return err
+		}
+		if strings.Contains(strings.ToLower(line), "password") {
+			_, _ = fmt.Fprintf(conn, "%s\n", password)
+			break
+		}
+	}
+	return nil
+}

+ 12 - 1
cmd/server/main.go

@@ -15,6 +15,7 @@ import (
 	"proxmox-router/internal/auth"
 	"proxmox-router/internal/bridge"
 	"proxmox-router/internal/bwtest"
+	"proxmox-router/internal/cli"
 	"proxmox-router/internal/dhcp"
 	"proxmox-router/internal/dns"
 	"proxmox-router/internal/dynamicrouting"
@@ -29,6 +30,7 @@ import (
 
 func main() {
 	addr := flag.String("addr", ":8080", "HTTP service address (e.g. :8080 or :80)")
+	cliAddr := flag.String("cli-addr", ":8282", "CLI service address for Telnet and debtik-cli (e.g. :8282)")
 	metaPath := flag.String("meta", "/etc/proxmox-router/metadata.json", "Path to interface metadata storage file")
 	flag.Parse()
 
@@ -52,6 +54,14 @@ func main() {
 	authMgr := auth.NewManager("")
 	webHandler := web.Handler()
 
+	cliServer := cli.NewServer(*cliAddr, authMgr, netMgr, routingMgr, fwMgr, dhcpMgr, dhcpServerMgr, dnsMgr)
+	go func() {
+		if err := cliServer.Start(); err != nil {
+			log.Printf("[CLI] Server listener error: %v", err)
+		}
+	}()
+	defer cliServer.Stop()
+
 	srv := api.NewServer(netMgr, persistMgr, metaMgr, vpnMgr, fwMgr, routingMgr, dynRoutingMgr, bridgeMgr, toolsMgr, dhcpMgr, dhcpServerMgr, vrrpMgr, bwtestMgr, dnsMgr, authMgr, webHandler)
 
 	httpServer := &http.Server{
@@ -66,7 +76,8 @@ func main() {
 	fmt.Println("========================================================")
 	fmt.Println("      ⚡ Proxmox RouterOS Appliance Controller ⚡        ")
 	fmt.Println("========================================================")
-	fmt.Printf(" Listening on        : http://0.0.0.0%s\n", *addr)
+	fmt.Printf(" Web UI & API        : http://0.0.0.0%s\n", *addr)
+	fmt.Printf(" CLI Service         : 0.0.0.0%s (debtik-cli / telnet)\n", *cliAddr)
 	fmt.Printf(" Metadata Storage    : %s\n", *metaPath)
 	fmt.Printf(" Debian Config Path  : %s\n", network.DebianVlansCfgPath)
 

+ 7 - 3
go.mod

@@ -3,9 +3,13 @@ module proxmox-router
 go 1.26.0
 
 require (
-	github.com/pierrec/lz4/v4 v4.1.33 // indirect
-	github.com/vishvananda/netlink v1.3.1 // indirect
+	github.com/pierrec/lz4/v4 v4.1.33
+	github.com/vishvananda/netlink v1.3.1
+	golang.org/x/crypto v0.58.0
+)
+
+require (
 	github.com/vishvananda/netns v0.0.5 // indirect
-	golang.org/x/crypto v0.58.0 // indirect
 	golang.org/x/sys v0.49.0 // indirect
+	golang.org/x/term v0.47.0 // indirect
 )

+ 2 - 1
go.sum

@@ -7,7 +7,8 @@ github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZla
 golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54=
 golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w=
 golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.10.0 h1:SqMFp9UcQJZa+pmYuAKjd9xq1f0j5rLcDIk0mj4qAsA=
 golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
 golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
+golang.org/x/term v0.47.0 h1:cpyOwQkr2Nzxe2p6KnrTG7Ei2wtzjsuSjhpXXepZ/BM=
+golang.org/x/term v0.47.0/go.mod h1:efAfAuG6RKABvg1ArGebRkvX6LXul14SM67o2e4mUCY=

+ 655 - 0
internal/cli/executor.go

@@ -0,0 +1,655 @@
+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 <name> 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 <name> 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=<iface>\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=<iface>\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=<username> password=<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 <username>\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 <username>\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=<new-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 <host> [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 <host>\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 <name> 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 <name>                            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=<name>               Set router hostname\n")
+	sb.WriteString("  system reboot                                 Reboot system\n")
+	sb.WriteString("  ping <host> [count=N]                         Send ICMP echo requests\n")
+	sb.WriteString("  traceroute <host>                             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
+}

+ 134 - 0
internal/cli/formatter.go

@@ -0,0 +1,134 @@
+package cli
+
+import (
+	"fmt"
+	"strings"
+)
+
+const (
+	ColorReset   = "\033[0m"
+	ColorBold    = "\033[1m"
+	ColorDim     = "\033[2m"
+	ColorRed     = "\033[31m"
+	ColorGreen   = "\033[32m"
+	ColorYellow  = "\033[33m"
+	ColorBlue    = "\033[34m"
+	ColorMagenta = "\033[35m"
+	ColorCyan    = "\033[36m"
+	ColorWhite   = "\033[37m"
+)
+
+func Banner(version, username, role, hostname string) string {
+	var sb strings.Builder
+	sb.WriteString("\n")
+	sb.WriteString(ColorCyan + ColorBold)
+	sb.WriteString("  ____       _   _____ _ _    \n")
+	sb.WriteString(" |  _ \\  ___| |_|_   _(_) | __\n")
+	sb.WriteString(" | | | |/ _ \\ '_ \\| | | | |/ /\n")
+	sb.WriteString(" | |_| |  __/ |_) | | | |   < \n")
+	sb.WriteString(" |____/ \\___|_.__/|_| |_|_|\\_\\\n")
+	sb.WriteString(ColorReset)
+	sb.WriteString(ColorBold + " DebTik RouterOS Network Appliance Controller" + ColorReset + "\n")
+	sb.WriteString(fmt.Sprintf(ColorDim+" Appliance Hostname : %s%s\n", hostname, ColorReset))
+	sb.WriteString(fmt.Sprintf(ColorDim+" Authenticated User : %s%s%s (Role: %s%s%s)\n", ColorGreen, username, ColorDim, ColorYellow, strings.ToUpper(role), ColorDim))
+	sb.WriteString(ColorDim + " Type 'help' or '?' for available commands.\n" + ColorReset)
+	sb.WriteString("\n")
+	return sb.String()
+}
+
+// TableWriter formats aligned text tables with custom columns
+type TableWriter struct {
+	headers []string
+	rows    [][]string
+}
+
+func NewTable(headers ...string) *TableWriter {
+	return &TableWriter{
+		headers: headers,
+		rows:    make([][]string, 0),
+	}
+}
+
+func (t *TableWriter) AddRow(cols ...string) {
+	t.rows = append(t.rows, cols)
+}
+
+func (t *TableWriter) Render() string {
+	if len(t.headers) == 0 {
+		return ""
+	}
+
+	colWidths := make([]int, len(t.headers))
+	for i, h := range t.headers {
+		colWidths[i] = len(h)
+	}
+
+	for _, row := range t.rows {
+		for i, cell := range row {
+			if i < len(colWidths) {
+				cleanLen := len(stripANSI(cell))
+				if cleanLen > colWidths[i] {
+					colWidths[i] = cleanLen
+				}
+			}
+		}
+	}
+
+	var sb strings.Builder
+	// Render Header
+	sb.WriteString(ColorCyan + ColorBold)
+	for i, h := range t.headers {
+		pad := colWidths[i] - len(h)
+		sb.WriteString(fmt.Sprintf(" %s%s ", h, strings.Repeat(" ", max(0, pad))))
+	}
+	sb.WriteString(ColorReset + "\n")
+
+	// Render separator
+	sb.WriteString(ColorDim)
+	for i := range t.headers {
+		sb.WriteString(fmt.Sprintf(" %s ", strings.Repeat("-", colWidths[i])))
+	}
+	sb.WriteString(ColorReset + "\n")
+
+	// Render Rows
+	for _, row := range t.rows {
+		for i := range t.headers {
+			cell := ""
+			if i < len(row) {
+				cell = row[i]
+			}
+			cleanLen := len(stripANSI(cell))
+			pad := colWidths[i] - cleanLen
+			sb.WriteString(fmt.Sprintf(" %s%s ", cell, strings.Repeat(" ", max(0, pad))))
+		}
+		sb.WriteString("\n")
+	}
+
+	return sb.String()
+}
+
+func stripANSI(str string) string {
+	var clean strings.Builder
+	inEsc := false
+	for _, r := range str {
+		if r == 0x1b { // ESC
+			inEsc = true
+			continue
+		}
+		if inEsc {
+			if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') {
+				inEsc = false
+			}
+			continue
+		}
+		clean.WriteRune(r)
+	}
+	return clean.String()
+}
+
+func max(a, b int) int {
+	if a > b {
+		return a
+	}
+	return b
+}

+ 216 - 0
internal/cli/server.go

@@ -0,0 +1,216 @@
+package cli
+
+import (
+	"errors"
+	"fmt"
+	"io"
+	"log"
+	"net"
+	"strings"
+	"sync"
+
+	"golang.org/x/crypto/ssh/terminal"
+
+	"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 Server struct {
+	addr     string
+	authMgr  *auth.Manager
+	executor *Executor
+	listener net.Listener
+	mu       sync.Mutex
+	stopChan chan struct{}
+}
+
+func NewServer(
+	addr string,
+	authMgr *auth.Manager,
+	netMgr *network.Manager,
+	routingMgr *routing.RoutingManager,
+	fwMgr *firewall.FirewallManager,
+	dhcpMgr *dhcp.Manager,
+	dhcpServMgr *dhcp.ServerManager,
+	dnsMgr *dns.Manager,
+) *Server {
+	if addr == "" {
+		addr = ":8282"
+	}
+	exec := NewExecutor(authMgr, netMgr, routingMgr, fwMgr, dhcpMgr, dhcpServMgr, dnsMgr)
+	return &Server{
+		addr:     addr,
+		authMgr:  authMgr,
+		executor: exec,
+		stopChan: make(chan struct{}),
+	}
+}
+
+func (s *Server) Start() error {
+	l, err := net.Listen("tcp", s.addr)
+	if err != nil {
+		return fmt.Errorf("failed to bind CLI server on %s: %w", s.addr, err)
+	}
+
+	s.mu.Lock()
+	s.listener = l
+	s.mu.Unlock()
+
+	log.Printf("[CLI] RouterOS-compatible CLI server listening on %s (TCP/Telnet)", s.addr)
+
+	for {
+		conn, err := l.Accept()
+		if err != nil {
+			select {
+			case <-s.stopChan:
+				return nil
+			default:
+				log.Printf("[CLI] Accept error: %v", err)
+				continue
+			}
+		}
+
+		go s.handleConn(conn)
+	}
+}
+
+func (s *Server) Stop() {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+
+	close(s.stopChan)
+	if s.listener != nil {
+		_ = s.listener.Close()
+	}
+}
+
+func (s *Server) handleConn(rawConn net.Conn) {
+	defer rawConn.Close()
+
+	telnet := NewTelnetConn(rawConn)
+	term := terminal.NewTerminal(telnet, "")
+	term.SetSize(120, 40)
+
+	// Authentication Loop (up to 3 attempts)
+	var authenticatedUser *auth.PublicUser
+	for attempt := 1; attempt <= 3; attempt++ {
+		term.SetPrompt("DebTik login: ")
+		username, err := term.ReadLine()
+		if err != nil {
+			return
+		}
+		username = strings.TrimSpace(username)
+
+		password, err := term.ReadPassword("Password: ")
+		if err != nil {
+			return
+		}
+
+		if username == "" {
+			continue
+		}
+
+		sess, authErr := s.authMgr.Authenticate(username, password)
+		if authErr == nil && sess != nil {
+			pub, getErr := s.authMgr.GetPublicUser(username)
+			if getErr == nil && pub != nil {
+				authenticatedUser = pub
+				break
+			}
+		}
+
+		if errors.Is(authErr, auth.ErrUserDisabled) {
+			_, _ = term.Write([]byte(ColorRed + "Login failed: user account is disabled\n\n" + ColorReset))
+			return
+		}
+
+		_, _ = term.Write([]byte(ColorRed + "Login failed: invalid username or password\n\n" + ColorReset))
+	}
+
+	if authenticatedUser == nil {
+		_, _ = term.Write([]byte(ColorRed + "Too many failed login attempts. Disconnecting.\n" + ColorReset))
+		return
+	}
+
+	// Successful login: display welcome banner
+	st, _ := system.GetStatus()
+	hostname := "DebTik"
+	if st != nil && st.Hostname != "" {
+		hostname = st.Hostname
+	}
+
+	banner := Banner("1.0", authenticatedUser.Username, string(authenticatedUser.Role), hostname)
+	_, _ = term.Write([]byte(banner))
+
+	// Interactive Shell Prompt
+	prompt := fmt.Sprintf("[%s%s%s@%s%s%s] > ", ColorGreen+ColorBold, authenticatedUser.Username, ColorReset, ColorCyan+ColorBold, hostname, ColorReset)
+	term.SetPrompt(prompt)
+
+	for {
+		line, err := term.ReadLine()
+		if err != nil {
+			if err == io.EOF {
+				break
+			}
+			return
+		}
+
+		trimmed := strings.TrimSpace(line)
+		if trimmed == "" {
+			continue
+		}
+
+		// Special self password change command
+		if trimmed == "password" {
+			s.handleInteractivePasswordChange(term, authenticatedUser)
+			continue
+		}
+
+		output, shouldExit := s.executor.Execute(trimmed, authenticatedUser)
+		if output != "" {
+			_, _ = term.Write([]byte(output))
+		}
+
+		if shouldExit {
+			break
+		}
+	}
+}
+
+func (s *Server) handleInteractivePasswordChange(term *terminal.Terminal, user *auth.PublicUser) {
+	currPass, err := term.ReadPassword("Current password: ")
+	if err != nil {
+		return
+	}
+
+	newPass, err := term.ReadPassword("New password: ")
+	if err != nil {
+		return
+	}
+	if len(newPass) < 4 {
+		_, _ = term.Write([]byte(ColorRed + "Password must be at least 4 characters\n" + ColorReset))
+		return
+	}
+
+	confirmPass, err := term.ReadPassword("Confirm new password: ")
+	if err != nil {
+		return
+	}
+
+	if newPass != confirmPass {
+		_, _ = term.Write([]byte(ColorRed + "Passwords do not match\n" + ColorReset))
+		return
+	}
+
+	if err := s.authMgr.ChangePassword(user.Username, currPass, newPass, false); err != nil {
+		_, _ = term.Write([]byte(ColorRed + "Error: " + err.Error() + "\n" + ColorReset))
+		return
+	}
+
+	_, _ = term.Write([]byte(ColorGreen + "Password changed successfully!\n" + ColorReset))
+}

+ 138 - 0
internal/cli/telnet.go

@@ -0,0 +1,138 @@
+package cli
+
+import (
+	"bytes"
+	"net"
+)
+
+const (
+	iac  = 255 // Interpret As Command
+	dont = 254
+	do   = 253
+	wont = 252
+	will = 251
+	sb   = 250
+	se   = 240
+
+	optEcho             = 1
+	optSuppressGoAhead  = 3
+	optTerminalType     = 24
+	optWindowSize       = 31
+	optLinemode         = 34
+)
+
+// TelnetConn wraps a net.Conn and handles RFC 854 Telnet negotiations
+// while presenting a clean stream to terminal.NewTerminal.
+type TelnetConn struct {
+	net.Conn
+	subnegotiation bool
+}
+
+func NewTelnetConn(conn net.Conn) *TelnetConn {
+	tc := &TelnetConn{Conn: conn}
+	tc.initNegotiation()
+	return tc
+}
+
+func (tc *TelnetConn) initNegotiation() {
+	// Request client to suppress go-ahead and handle character-at-a-time / local echo off
+	// IAC WILL ECHO, IAC WILL SUPPRESS_GO_AHEAD, IAC DONT LINEMODE
+	initSeq := []byte{
+		iac, will, optEcho,
+		iac, will, optSuppressGoAhead,
+		iac, dont, optLinemode,
+	}
+	_, _ = tc.Conn.Write(initSeq)
+}
+
+func (tc *TelnetConn) Read(p []byte) (n int, err error) {
+	buf := make([]byte, len(p))
+	rawN, err := tc.Conn.Read(buf)
+	if err != nil {
+		return 0, err
+	}
+
+	var clean bytes.Buffer
+	i := 0
+	for i < rawN {
+		b := buf[i]
+		if b == iac {
+			if i+1 >= rawN {
+				break
+			}
+			cmd := buf[i+1]
+			switch cmd {
+			case iac: // Escaped 255
+				clean.WriteByte(iac)
+				i += 2
+			case will, wont, do, dont:
+				if i+2 < rawN {
+					opt := buf[i+2]
+					tc.handleOption(cmd, opt)
+					i += 3
+				} else {
+					i = rawN
+				}
+			case sb: // Subnegotiation start
+				tc.subnegotiation = true
+				i += 2
+			case se: // Subnegotiation end
+				tc.subnegotiation = false
+				i += 2
+			default:
+				i += 2
+			}
+			continue
+		}
+
+		if tc.subnegotiation {
+			i++
+			continue
+		}
+
+		// Normalize CR NUL or CR LF into LF for standard terminal handling
+		if b == '\r' {
+			if i+1 < rawN && (buf[i+1] == '\n' || buf[i+1] == 0) {
+				clean.WriteByte('\n')
+				i += 2
+				continue
+			}
+			clean.WriteByte('\n')
+			i++
+			continue
+		}
+
+		clean.WriteByte(b)
+		i++
+	}
+
+	res := clean.Bytes()
+	copy(p, res)
+	return len(res), nil
+}
+
+func (tc *TelnetConn) handleOption(cmd, opt byte) {
+	// Respond to DO ECHO or DO SUPPRESS_GO_AHEAD with WILL
+	switch cmd {
+	case do:
+		if opt == optEcho || opt == optSuppressGoAhead {
+			_, _ = tc.Conn.Write([]byte{iac, will, opt})
+		} else {
+			_, _ = tc.Conn.Write([]byte{iac, wont, opt})
+		}
+	case dont:
+		_, _ = tc.Conn.Write([]byte{iac, wont, opt})
+	}
+}
+
+func (tc *TelnetConn) Write(p []byte) (n int, err error) {
+	// Ensure \n is transmitted as \r\n for telnet standard compatibility
+	var out bytes.Buffer
+	for _, b := range p {
+		if b == '\n' {
+			out.WriteByte('\r')
+		}
+		out.WriteByte(b)
+	}
+	return tc.Conn.Write(out.Bytes())
+}