package cli import ( "errors" "fmt" "io" "log" "net" "strings" "sync" "golang.org/x/crypto/ssh/terminal" "proxmox-router/internal/auth" "proxmox-router/internal/system" ) type Server struct { addr string authMgr *auth.Manager executor *Executor listener net.Listener mu sync.Mutex stopChan chan struct{} running bool } func NewServer( addr string, authMgr *auth.Manager, executor *Executor, ) *Server { if addr == "" { addr = ":8282" } return &Server{ addr: addr, authMgr: authMgr, executor: executor, stopChan: make(chan struct{}), } } func (s *Server) SetAddr(addr string) { s.mu.Lock() defer s.mu.Unlock() s.addr = addr } func (s *Server) IsRunning() bool { s.mu.Lock() defer s.mu.Unlock() return s.running } func (s *Server) Start() error { s.mu.Lock() if s.running { s.mu.Unlock() return nil } s.stopChan = make(chan struct{}) l, err := net.Listen("tcp", s.addr) if err != nil { s.mu.Unlock() return fmt.Errorf("failed to bind CLI server on %s: %w", s.addr, err) } s.listener = l s.running = true stopCh := s.stopChan s.mu.Unlock() log.Printf("[CLI] DebTik CLI server listening on %s (TCP/Telnet)", s.addr) for { conn, err := l.Accept() if err != nil { select { case <-stopCh: return nil default: continue } } go s.handleConn(conn) } } func (s *Server) Stop() { s.mu.Lock() defer s.mu.Unlock() if !s.running { return } s.running = false close(s.stopChan) if s.listener != nil { _ = s.listener.Close() } log.Printf("[CLI] Telnet CLI server stopped") } 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) completer := NewCompleter(term, s.executor) term.AutoCompleteCallback = completer.Callback 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)) }