Parcourir la source

Implement SSH CLI on port 2222, Service management subsystem, full CLI feature parity with UI, and logging engine

Proxmox Router il y a 6 jours
Parent
commit
0700dcbe43

BIN
bin/debtik-cli


BIN
bin/proxmox-router


+ 106 - 9
cmd/server/main.go

@@ -20,8 +20,10 @@ import (
 	"proxmox-router/internal/dns"
 	"proxmox-router/internal/dynamicrouting"
 	"proxmox-router/internal/firewall"
+	"proxmox-router/internal/logging"
 	"proxmox-router/internal/network"
 	"proxmox-router/internal/routing"
+	"proxmox-router/internal/services"
 	"proxmox-router/internal/tools"
 	"proxmox-router/internal/vpn"
 	"proxmox-router/internal/vrrp"
@@ -34,6 +36,9 @@ func main() {
 	metaPath := flag.String("meta", "/etc/proxmox-router/metadata.json", "Path to interface metadata storage file")
 	flag.Parse()
 
+	// Initialize Logging Ring Buffer
+	_ = logging.GetLogger()
+
 	// Initialize components
 	metaMgr := network.NewMetadataManager(*metaPath)
 	netMgr := network.NewManager(metaMgr)
@@ -52,17 +57,108 @@ func main() {
 	defer bwtestMgr.Stop()
 	dnsMgr := dns.NewManager()
 	authMgr := auth.NewManager("")
+	servicesMgr := services.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)
+	// Build Unified RouterOS CLI Executor
+	cliExecutor := cli.NewExecutor(
+		authMgr,
+		netMgr,
+		routingMgr,
+		dynRoutingMgr,
+		fwMgr,
+		dhcpMgr,
+		dhcpServerMgr,
+		dnsMgr,
+		bridgeMgr,
+		vpnMgr,
+		vrrpMgr,
+		bwtestMgr,
+		servicesMgr,
+	)
+
+	// Determine Telnet & SSH addresses from services config or flags
+	telnetAddr := *cliAddr
+	if tSvc, ok := servicesMgr.GetService("telnet"); ok && tSvc.Port > 0 {
+		telnetAddr = fmt.Sprintf(":%d", tSvc.Port)
+	}
+
+	sshAddr := ":2222"
+	if sSvc, ok := servicesMgr.GetService("ssh"); ok && sSvc.Port > 0 {
+		sshAddr = fmt.Sprintf(":%d", sSvc.Port)
+	}
+
+	cliTelnetServer := cli.NewServer(telnetAddr, authMgr, cliExecutor)
+	cliSSHServer := cli.NewSSHServer(sshAddr, authMgr, cliExecutor)
+
+	// Register hooks for dynamic service start/stop
+	servicesMgr.RegisterHook("telnet", func(serviceName string, port int, disabled bool) error {
+		if disabled {
+			cliTelnetServer.Stop()
+		} else {
+			cliTelnetServer.SetAddr(fmt.Sprintf(":%d", port))
+			go func() {
+				if err := cliTelnetServer.Start(); err != nil {
+					log.Printf("[CLI-Telnet] Listener error: %v", err)
+				}
+			}()
 		}
-	}()
-	defer cliServer.Stop()
+		return nil
+	})
+
+	servicesMgr.RegisterHook("ssh", func(serviceName string, port int, disabled bool) error {
+		if disabled {
+			cliSSHServer.Stop()
+		} else {
+			cliSSHServer.SetAddr(fmt.Sprintf(":%d", port))
+			go func() {
+				if err := cliSSHServer.Start(); err != nil {
+					log.Printf("[CLI-SSH] Listener error: %v", err)
+				}
+			}()
+		}
+		return nil
+	})
+
+	// Start enabled CLI servers
+	if tSvc, ok := servicesMgr.GetService("telnet"); !ok || !tSvc.Disabled {
+		go func() {
+			if err := cliTelnetServer.Start(); err != nil {
+				log.Printf("[CLI-Telnet] Server listener error: %v", err)
+			}
+		}()
+	}
+	defer cliTelnetServer.Stop()
+
+	if sSvc, ok := servicesMgr.GetService("ssh"); !ok || !sSvc.Disabled {
+		go func() {
+			if err := cliSSHServer.Start(); err != nil {
+				log.Printf("[CLI-SSH] Server listener error: %v", err)
+			}
+		}()
+	}
+	defer cliSSHServer.Stop()
 
-	srv := api.NewServer(netMgr, persistMgr, metaMgr, vpnMgr, fwMgr, routingMgr, dynRoutingMgr, bridgeMgr, toolsMgr, dhcpMgr, dhcpServerMgr, vrrpMgr, bwtestMgr, dnsMgr, authMgr, webHandler)
+	// API & Web UI Server
+	srv := api.NewServer(
+		netMgr,
+		persistMgr,
+		metaMgr,
+		vpnMgr,
+		fwMgr,
+		routingMgr,
+		dynRoutingMgr,
+		bridgeMgr,
+		toolsMgr,
+		dhcpMgr,
+		dhcpServerMgr,
+		vrrpMgr,
+		bwtestMgr,
+		dnsMgr,
+		authMgr,
+		servicesMgr,
+		webHandler,
+	)
 
 	httpServer := &http.Server{
 		Addr:         *addr,
@@ -77,7 +173,8 @@ func main() {
 	fmt.Println("      ⚡ Proxmox RouterOS Appliance Controller ⚡        ")
 	fmt.Println("========================================================")
 	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(" CLI Telnet Service  : 0.0.0.0%s (debtik-cli / telnet)\n", telnetAddr)
+	fmt.Printf(" CLI SSH Service     : 0.0.0.0%s (ssh admin@<router-ip> -p 2222)\n", sshAddr)
 	fmt.Printf(" Metadata Storage    : %s\n", *metaPath)
 	fmt.Printf(" Debian Config Path  : %s\n", network.DebianVlansCfgPath)
 
@@ -88,7 +185,7 @@ func main() {
 		}
 	}
 	fmt.Println("========================================================")
-	fmt.Println(" Web UI & API ready. Press Ctrl+C to terminate.")
+	fmt.Println(" DebTik Appliance ready. Press Ctrl+C to terminate.")
 
 	// Graceful shutdown handling
 	stopChan := make(chan os.Signal, 1)

+ 22 - 1
internal/api/api.go

@@ -17,6 +17,7 @@ import (
 	"proxmox-router/internal/firewall"
 	"proxmox-router/internal/network"
 	"proxmox-router/internal/routing"
+	"proxmox-router/internal/services"
 	"proxmox-router/internal/system"
 	"proxmox-router/internal/tools"
 	"proxmox-router/internal/vpn"
@@ -39,11 +40,12 @@ type Server struct {
 	bwtestMgr     *bwtest.Manager
 	dnsMgr        *dns.Manager
 	authMgr       *auth.Manager
+	servicesMgr   *services.Manager
 	mux           *http.ServeMux
 	fsHandler     http.Handler
 }
 
-func NewServer(netMgr *network.Manager, persistMgr *network.PersistenceManager, metaMgr *network.MetadataManager, vpnMgr *vpn.Manager, fwMgr *firewall.FirewallManager, routingMgr *routing.RoutingManager, dynRoutingMgr *dynamicrouting.Manager, bridgeMgr *bridge.BridgeManager, toolsMgr *tools.Manager, dhcpMgr *dhcp.Manager, dhcpServerMgr *dhcp.ServerManager, vrrpMgr *vrrp.Manager, bwtestMgr *bwtest.Manager, dnsMgr *dns.Manager, authMgr *auth.Manager, fsHandler http.Handler) *Server {
+func NewServer(netMgr *network.Manager, persistMgr *network.PersistenceManager, metaMgr *network.MetadataManager, vpnMgr *vpn.Manager, fwMgr *firewall.FirewallManager, routingMgr *routing.RoutingManager, dynRoutingMgr *dynamicrouting.Manager, bridgeMgr *bridge.BridgeManager, toolsMgr *tools.Manager, dhcpMgr *dhcp.Manager, dhcpServerMgr *dhcp.ServerManager, vrrpMgr *vrrp.Manager, bwtestMgr *bwtest.Manager, dnsMgr *dns.Manager, authMgr *auth.Manager, servicesMgr *services.Manager, fsHandler http.Handler) *Server {
 	s := &Server{
 		netMgr:        netMgr,
 		persistMgr:    persistMgr,
@@ -60,6 +62,7 @@ func NewServer(netMgr *network.Manager, persistMgr *network.PersistenceManager,
 		bwtestMgr:     bwtestMgr,
 		dnsMgr:        dnsMgr,
 		authMgr:       authMgr,
+		servicesMgr:   servicesMgr,
 		mux:           http.NewServeMux(),
 		fsHandler:     fsHandler,
 	}
@@ -416,6 +419,14 @@ func (s *Server) registerRoutes() {
 	s.mux.HandleFunc("GET /api/system/settings", s.handleGetSystemSettings)
 	s.mux.HandleFunc("POST /api/system/settings", s.handleSaveSystemSettings)
 
+	// Management Services (/ip service)
+	s.mux.HandleFunc("GET /api/system/services", s.handleListServices)
+	s.mux.HandleFunc("POST /api/system/services/{name}", s.handleUpdateService)
+	s.mux.HandleFunc("POST /api/system/services/{name}/state", s.handleSetServiceState)
+
+	// Logging (/log)
+	s.mux.HandleFunc("GET /api/system/logs", s.handleListLogs)
+
 	// Authentication & User Management
 	s.mux.HandleFunc("POST /api/auth/login", s.handleLogin)
 	s.mux.HandleFunc("POST /api/auth/logout", s.handleLogout)
@@ -435,6 +446,16 @@ func (s *Server) registerRoutes() {
 }
 
 func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	// If www service is disabled, reject HTTP requests with 503
+	if s.servicesMgr != nil {
+		if www, ok := s.servicesMgr.GetService("www"); ok && www.Disabled {
+			w.Header().Set("Content-Type", "text/plain; charset=utf-8")
+			w.WriteHeader(http.StatusServiceUnavailable)
+			_, _ = w.Write([]byte("503 Service Unavailable: Web service is disabled via /ip service disable www\n"))
+			return
+		}
+	}
+
 	// Enable CORS for local development/testing
 	w.Header().Set("Access-Control-Allow-Origin", "*")
 	w.Header().Set("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS, PUT")

+ 84 - 0
internal/api/services_handlers.go

@@ -0,0 +1,84 @@
+package api
+
+import (
+	"encoding/json"
+	"net/http"
+	"strconv"
+
+	"proxmox-router/internal/logging"
+)
+
+func (s *Server) handleListServices(w http.ResponseWriter, r *http.Request) {
+	if s.servicesMgr == nil {
+		writeError(w, http.StatusServiceUnavailable, "services manager not configured")
+		return
+	}
+	list := s.servicesMgr.ListServices()
+	writeJSON(w, http.StatusOK, list)
+}
+
+func (s *Server) handleUpdateService(w http.ResponseWriter, r *http.Request) {
+	if s.servicesMgr == nil {
+		writeError(w, http.StatusServiceUnavailable, "services manager not configured")
+		return
+	}
+	name := r.PathValue("name")
+	var req struct {
+		Port     int    `json:"port"`
+		Disabled bool   `json:"disabled"`
+		Comment  string `json:"comment"`
+	}
+	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+		writeError(w, http.StatusBadRequest, "invalid request body")
+		return
+	}
+
+	if err := s.servicesMgr.SetService(name, req.Port, req.Disabled); err != nil {
+		writeError(w, http.StatusBadRequest, err.Error())
+		return
+	}
+	svc, _ := s.servicesMgr.GetService(name)
+	writeJSON(w, http.StatusOK, svc)
+}
+
+func (s *Server) handleSetServiceState(w http.ResponseWriter, r *http.Request) {
+	if s.servicesMgr == nil {
+		writeError(w, http.StatusServiceUnavailable, "services manager not configured")
+		return
+	}
+	name := r.PathValue("name")
+	var req struct {
+		Disabled bool `json:"disabled"`
+	}
+	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+		writeError(w, http.StatusBadRequest, "invalid request body")
+		return
+	}
+
+	if err := s.servicesMgr.SetServiceState(name, req.Disabled); err != nil {
+		writeError(w, http.StatusBadRequest, err.Error())
+		return
+	}
+	svc, _ := s.servicesMgr.GetService(name)
+	writeJSON(w, http.StatusOK, svc)
+}
+
+func (s *Server) handleListLogs(w http.ResponseWriter, r *http.Request) {
+	limit := 100
+	if lStr := r.URL.Query().Get("limit"); lStr != "" {
+		if lVal, err := strconv.Atoi(lStr); err == nil && lVal > 0 {
+			limit = lVal
+		}
+	}
+	filter := r.URL.Query().Get("filter")
+	if filter == "" {
+		filter = r.URL.Query().Get("where")
+	}
+
+	logger := logging.GetLogger()
+	logs := logger.List(limit, filter)
+	writeJSON(w, http.StatusOK, map[string]any{
+		"total": len(logs),
+		"logs":  logs,
+	})
+}

Fichier diff supprimé car celui-ci est trop grand
+ 2014 - 327
internal/cli/executor.go


+ 4 - 0
internal/cli/formatter.go

@@ -53,6 +53,10 @@ func (t *TableWriter) AddRow(cols ...string) {
 	t.rows = append(t.rows, cols)
 }
 
+func (t *TableWriter) String() string {
+	return t.Render()
+}
+
 func (t *TableWriter) Render() string {
 	if len(t.headers) == 0 {
 		return ""

+ 31 - 16
internal/cli/server.go

@@ -12,11 +12,6 @@ import (
 	"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"
 )
 
@@ -27,38 +22,54 @@ type Server struct {
 	listener net.Listener
 	mu       sync.Mutex
 	stopChan chan struct{}
+	running  bool
 }
 
 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,
+	executor *Executor,
 ) *Server {
 	if addr == "" {
 		addr = ":8282"
 	}
-	exec := NewExecutor(authMgr, netMgr, routingMgr, fwMgr, dhcpMgr, dhcpServMgr, dnsMgr)
 	return &Server{
 		addr:     addr,
 		authMgr:  authMgr,
-		executor: exec,
+		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.mu.Lock()
 	s.listener = l
+	s.running = true
+	stopCh := s.stopChan
 	s.mu.Unlock()
 
 	log.Printf("[CLI] RouterOS-compatible CLI server listening on %s (TCP/Telnet)", s.addr)
@@ -67,10 +78,9 @@ func (s *Server) Start() error {
 		conn, err := l.Accept()
 		if err != nil {
 			select {
-			case <-s.stopChan:
+			case <-stopCh:
 				return nil
 			default:
-				log.Printf("[CLI] Accept error: %v", err)
 				continue
 			}
 		}
@@ -83,10 +93,15 @@ 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) {

+ 328 - 0
internal/cli/ssh.go

@@ -0,0 +1,328 @@
+package cli
+
+import (
+	"crypto/ed25519"
+	"crypto/rand"
+	"encoding/pem"
+	"errors"
+	"fmt"
+	"io"
+	"log"
+	"net"
+	"os"
+	"path/filepath"
+	"strings"
+	"sync"
+
+	"golang.org/x/crypto/ssh"
+	"golang.org/x/crypto/ssh/terminal"
+
+	"proxmox-router/internal/auth"
+	"proxmox-router/internal/logging"
+	"proxmox-router/internal/system"
+)
+
+const (
+	DefaultSSHHostKeyPath = "/etc/proxmox-router/ssh_host_ed25519_key"
+)
+
+type SSHServer struct {
+	addr     string
+	authMgr  *auth.Manager
+	executor *Executor
+	listener net.Listener
+	mu       sync.Mutex
+	stopChan chan struct{}
+	running  bool
+}
+
+func NewSSHServer(
+	addr string,
+	authMgr *auth.Manager,
+	executor *Executor,
+) *SSHServer {
+	if addr == "" {
+		addr = ":2222"
+	}
+	return &SSHServer{
+		addr:     addr,
+		authMgr:  authMgr,
+		executor: executor,
+		stopChan: make(chan struct{}),
+	}
+}
+
+func (s *SSHServer) SetAddr(addr string) {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	s.addr = addr
+}
+
+func (s *SSHServer) IsRunning() bool {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	return s.running
+}
+
+func (s *SSHServer) Start() error {
+	s.mu.Lock()
+	if s.running {
+		s.mu.Unlock()
+		return nil
+	}
+	s.stopChan = make(chan struct{})
+
+	signer, err := getOrCreateHostKey()
+	if err != nil {
+		s.mu.Unlock()
+		return fmt.Errorf("failed to load SSH host key: %w", err)
+	}
+
+	config := &ssh.ServerConfig{
+		PasswordCallback: func(conn ssh.ConnMetadata, password []byte) (*ssh.Permissions, error) {
+			user := conn.User()
+			sess, err := s.authMgr.Authenticate(user, string(password))
+			if err != nil {
+				logging.Log("auth,warning", "SSH login failed for user '%s' from %s: %v", user, conn.RemoteAddr(), err)
+				return nil, errors.New("authentication failed")
+			}
+			logging.Log("auth,info", "SSH login successful for user '%s' from %s (role: %s)", user, conn.RemoteAddr(), sess.Role)
+			return &ssh.Permissions{
+				Extensions: map[string]string{
+					"username": user,
+					"role":     string(sess.Role),
+				},
+			}, nil
+		},
+	}
+	config.AddHostKey(signer)
+
+	l, err := net.Listen("tcp", s.addr)
+	if err != nil {
+		s.mu.Unlock()
+		return fmt.Errorf("failed to bind SSH server on %s: %w", s.addr, err)
+	}
+
+	s.listener = l
+	s.running = true
+	stopCh := s.stopChan
+	s.mu.Unlock()
+
+	log.Printf("[CLI-SSH] RouterOS SSH server listening on %s", s.addr)
+
+	for {
+		rawConn, err := l.Accept()
+		if err != nil {
+			select {
+			case <-stopCh:
+				return nil
+			default:
+				continue
+			}
+		}
+
+		go s.handleSSHConn(rawConn, config)
+	}
+}
+
+func (s *SSHServer) 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-SSH] SSH server stopped")
+}
+
+func (s *SSHServer) handleSSHConn(rawConn net.Conn, config *ssh.ServerConfig) {
+	defer rawConn.Close()
+
+	sshConn, chans, reqs, err := ssh.NewServerConn(rawConn, config)
+	if err != nil {
+		return
+	}
+	defer sshConn.Close()
+
+	// Discard out-of-band global requests
+	go ssh.DiscardRequests(reqs)
+
+	username := sshConn.Permissions.Extensions["username"]
+	pubUser, err := s.authMgr.GetPublicUser(username)
+	if err != nil || pubUser == nil {
+		return
+	}
+
+	for newChan := range chans {
+		if newChan.ChannelType() != "session" {
+			_ = newChan.Reject(ssh.UnknownChannelType, "only session channels supported")
+			continue
+		}
+
+		channel, requests, err := newChan.Accept()
+		if err != nil {
+			continue
+		}
+
+		go s.handleSession(channel, requests, pubUser)
+	}
+}
+
+func (s *SSHServer) handleSession(channel ssh.Channel, requests <-chan *ssh.Request, user *auth.PublicUser) {
+	defer channel.Close()
+
+	term := terminal.NewTerminal(channel, "")
+	term.SetSize(120, 40)
+
+	for req := range requests {
+		switch req.Type {
+		case "pty-req":
+			termWidth := 120
+			termHeight := 40
+			if len(req.Payload) >= 8 {
+				// uint32 cols, uint32 rows
+				cols := int(req.Payload[3]) | int(req.Payload[2])<<8
+				rows := int(req.Payload[7]) | int(req.Payload[6])<<8
+				if cols > 20 && rows > 5 {
+					termWidth = cols
+					termHeight = rows
+				}
+			}
+			term.SetSize(termWidth, termHeight)
+			_ = req.Reply(true, nil)
+
+		case "window-change":
+			if len(req.Payload) >= 8 {
+				cols := int(req.Payload[3]) | int(req.Payload[2])<<8
+				rows := int(req.Payload[7]) | int(req.Payload[6])<<8
+				if cols > 20 && rows > 5 {
+					term.SetSize(cols, rows)
+				}
+			}
+			_ = req.Reply(true, nil)
+
+		case "shell":
+			_ = req.Reply(true, nil)
+			s.runInteractiveShell(term, user)
+			return
+
+		case "exec":
+			_ = req.Reply(true, nil)
+			cmdStr := ""
+			if len(req.Payload) > 4 {
+				cmdLen := int(req.Payload[3]) | int(req.Payload[2])<<8
+				if 4+cmdLen <= len(req.Payload) {
+					cmdStr = string(req.Payload[4 : 4+cmdLen])
+				} else {
+					cmdStr = string(req.Payload[4:])
+				}
+			}
+			output, _ := s.executor.Execute(cmdStr, user)
+			_, _ = channel.Write([]byte(output))
+			// Send exit status 0
+			_, _ = channel.SendRequest("exit-status", false, []byte{0, 0, 0, 0})
+			return
+
+		default:
+			_ = req.Reply(false, nil)
+		}
+	}
+}
+
+func (s *SSHServer) runInteractiveShell(term *terminal.Terminal, user *auth.PublicUser) {
+	st, _ := system.GetStatus()
+	hostname := "DebTik"
+	if st != nil && st.Hostname != "" {
+		hostname = st.Hostname
+	}
+
+	banner := Banner("1.0", user.Username, string(user.Role), hostname)
+	_, _ = term.Write([]byte(banner))
+
+	prompt := fmt.Sprintf("[%s%s%s@%s%s%s] > ", ColorGreen+ColorBold, user.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
+		}
+
+		if trimmed == "password" {
+			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))
+				continue
+			}
+			confirmPass, err := term.ReadPassword("Confirm new password: ")
+			if err != nil {
+				return
+			}
+			if newPass != confirmPass {
+				_, _ = term.Write([]byte(ColorRed + "Passwords do not match\n" + ColorReset))
+				continue
+			}
+			if err := s.authMgr.ChangePassword(user.Username, currPass, newPass, false); err != nil {
+				_, _ = term.Write([]byte(ColorRed + "Error: " + err.Error() + "\n" + ColorReset))
+				continue
+			}
+			_, _ = term.Write([]byte(ColorGreen + "Password changed successfully!\n" + ColorReset))
+			continue
+		}
+
+		output, shouldExit := s.executor.Execute(trimmed, user)
+		if output != "" {
+			_, _ = term.Write([]byte(output))
+		}
+		if shouldExit {
+			break
+		}
+	}
+}
+
+func getOrCreateHostKey() (ssh.Signer, error) {
+	keyPath := DefaultSSHHostKeyPath
+	if data, err := os.ReadFile(keyPath); err == nil {
+		signer, err := ssh.ParsePrivateKey(data)
+		if err == nil {
+			return signer, nil
+		}
+	}
+
+	// Generate persistent ED25519 host key
+	_ = os.MkdirAll(filepath.Dir(keyPath), 0755)
+	_, priv, err := ed25519.GenerateKey(rand.Reader)
+	if err != nil {
+		return nil, err
+	}
+
+	privBytes, err := ssh.MarshalPrivateKey(priv, "")
+	if err != nil {
+		return nil, err
+	}
+
+	pemBlock := pem.EncodeToMemory(privBytes)
+	_ = os.WriteFile(keyPath, pemBlock, 0600)
+
+	return ssh.ParsePrivateKey(pemBlock)
+}

+ 130 - 0
internal/logging/logger.go

@@ -0,0 +1,130 @@
+package logging
+
+import (
+	"fmt"
+	"io"
+	"log"
+	"os"
+	"strings"
+	"sync"
+	"sync/atomic"
+	"time"
+)
+
+type LogEntry struct {
+	ID        int64     `json:"id"`
+	Time      time.Time `json:"time"`
+	Topics    string    `json:"topics"` // e.g. "system,info", "auth,warning"
+	Message   string    `json:"message"`
+}
+
+type Logger struct {
+	mu      sync.RWMutex
+	maxSize int
+	entries []LogEntry
+	nextID  int64
+}
+
+var (
+	defaultLogger *Logger
+	once          sync.Once
+)
+
+func GetLogger() *Logger {
+	once.Do(func() {
+		defaultLogger = &Logger{
+			maxSize: 2000,
+			entries: make([]LogEntry, 0, 2000),
+		}
+
+		// Also hook standard library log output so existing logs are captured
+		writer := &logCaptureWriter{logger: defaultLogger}
+		log.SetOutput(io.MultiWriter(os.Stderr, writer))
+	})
+	return defaultLogger
+}
+
+type logCaptureWriter struct {
+	logger *Logger
+}
+
+func (w *logCaptureWriter) Write(p []byte) (n int, err error) {
+	msg := strings.TrimSpace(string(p))
+	if msg == "" {
+		return len(p), nil
+	}
+
+	// Remove leading timestamp from standard log format if present
+	if len(msg) > 20 && msg[4] == '/' && msg[7] == '/' {
+		if idx := strings.Index(msg, " "); idx != -1 {
+			if idx2 := strings.Index(msg[idx+1:], " "); idx2 != -1 {
+				msg = strings.TrimSpace(msg[idx+1+idx2:])
+			}
+		}
+	}
+
+	topics := "system,info"
+	lower := strings.ToLower(msg)
+	if strings.Contains(lower, "auth") || strings.Contains(lower, "login") {
+		topics = "auth,info"
+	} else if strings.Contains(lower, "dhcp") {
+		topics = "dhcp,info"
+	} else if strings.Contains(lower, "route") {
+		topics = "route,info"
+	} else if strings.Contains(lower, "firewall") || strings.Contains(lower, "iptables") {
+		topics = "firewall,info"
+	} else if strings.Contains(lower, "error") || strings.Contains(lower, "fail") {
+		topics = "system,error"
+	}
+
+	w.logger.Add(topics, msg)
+	return len(p), nil
+}
+
+func (l *Logger) Add(topics, message string) {
+	l.mu.Lock()
+	defer l.mu.Unlock()
+
+	id := atomic.AddInt64(&l.nextID, 1)
+	entry := LogEntry{
+		ID:      id,
+		Time:    time.Now(),
+		Topics:  topics,
+		Message: message,
+	}
+
+	if len(l.entries) >= l.maxSize {
+		// Evict oldest
+		l.entries = append(l.entries[1:], entry)
+	} else {
+		l.entries = append(l.entries, entry)
+	}
+}
+
+func Log(topics, format string, args ...any) {
+	GetLogger().Add(topics, fmt.Sprintf(format, args...))
+}
+
+func (l *Logger) List(limit int, filterTopic string) []LogEntry {
+	l.mu.RLock()
+	defer l.mu.RUnlock()
+
+	if limit <= 0 || limit > len(l.entries) {
+		limit = len(l.entries)
+	}
+
+	var matched []LogEntry
+	filterLower := strings.ToLower(filterTopic)
+
+	for i := len(l.entries) - 1; i >= 0; i-- {
+		e := l.entries[i]
+		if filterLower != "" && !strings.Contains(strings.ToLower(e.Topics), filterLower) && !strings.Contains(strings.ToLower(e.Message), filterLower) {
+			continue
+		}
+		matched = append([]LogEntry{e}, matched...)
+		if len(matched) >= limit {
+			break
+		}
+	}
+	return matched
+}

+ 225 - 0
internal/services/manager.go

@@ -0,0 +1,225 @@
+package services
+
+import (
+	"encoding/json"
+	"fmt"
+	"os"
+	"path/filepath"
+	"strings"
+	"sync"
+	"time"
+
+	"proxmox-router/internal/logging"
+)
+
+const (
+	DefaultServicesPath = "/etc/proxmox-router/services.json"
+
+	ServiceTelnet = "telnet"
+	ServiceSSH    = "ssh"
+	ServiceWWW    = "www"
+)
+
+type Service struct {
+	Name     string `json:"name"`               // "telnet", "ssh", "www"
+	Port     int    `json:"port"`               // Port number
+	Disabled bool   `json:"disabled"`           // Active status
+	Comment  string `json:"comment,omitempty"`  // Description
+}
+
+type ServiceHook func(serviceName string, port int, disabled bool) error
+
+type Manager struct {
+	mu       sync.RWMutex
+	filePath string
+	services map[string]Service
+	hooks    map[string]ServiceHook
+}
+
+func NewManager(customPath string) *Manager {
+	p := customPath
+	if p == "" {
+		p = DefaultServicesPath
+	}
+
+	m := &Manager{
+		filePath: p,
+		services: make(map[string]Service),
+		hooks:    make(map[string]ServiceHook),
+	}
+
+	m.initDefaults()
+	_ = m.load()
+	return m
+}
+
+func (m *Manager) initDefaults() {
+	m.services[ServiceWWW] = Service{
+		Name:     ServiceWWW,
+		Port:     80,
+		Disabled: false,
+		Comment:  "HTTP Web Management & REST API",
+	}
+	m.services[ServiceTelnet] = Service{
+		Name:     ServiceTelnet,
+		Port:     8282,
+		Disabled: false,
+		Comment:  "RouterOS CLI Console over TCP/Telnet",
+	}
+	m.services[ServiceSSH] = Service{
+		Name:     ServiceSSH,
+		Port:     2222,
+		Disabled: false,
+		Comment:  "RouterOS CLI Secure Shell (SSH)",
+	}
+}
+
+func (m *Manager) RegisterHook(serviceName string, hook ServiceHook) {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+	m.hooks[strings.ToLower(serviceName)] = hook
+}
+
+func (m *Manager) load() error {
+	data, err := os.ReadFile(m.filePath)
+	if err != nil {
+		if os.IsNotExist(err) {
+			return m.saveLocked()
+		}
+		return err
+	}
+
+	var list []Service
+	if err := json.Unmarshal(data, &list); err != nil {
+		return err
+	}
+
+	for _, s := range list {
+		name := strings.ToLower(s.Name)
+		if cur, ok := m.services[name]; ok {
+			cur.Port = s.Port
+			cur.Disabled = s.Disabled
+			if s.Comment != "" {
+				cur.Comment = s.Comment
+			}
+			m.services[name] = cur
+		} else {
+			m.services[name] = s
+		}
+	}
+	return nil
+}
+
+func (m *Manager) saveLocked() error {
+	dir := filepath.Dir(m.filePath)
+	if err := os.MkdirAll(dir, 0755); err != nil {
+		return err
+	}
+
+	list := make([]Service, 0, len(m.services))
+	// Keep ordered: www, telnet, ssh
+	order := []string{ServiceWWW, ServiceTelnet, ServiceSSH}
+	for _, name := range order {
+		if s, ok := m.services[name]; ok {
+			list = append(list, s)
+		}
+	}
+	for name, s := range m.services {
+		if name != ServiceWWW && name != ServiceTelnet && name != ServiceSSH {
+			list = append(list, s)
+		}
+	}
+
+	data, err := json.MarshalIndent(list, "", "  ")
+	if err != nil {
+		return err
+	}
+
+	tmp := filepath.Join(dir, fmt.Sprintf(".services.%d.tmp", time.Now().UnixNano()))
+	if err := os.WriteFile(tmp, data, 0644); err != nil {
+		return err
+	}
+	return os.Rename(tmp, m.filePath)
+}
+
+func (m *Manager) ListServices() []Service {
+	m.mu.RLock()
+	defer m.mu.RUnlock()
+
+	var list []Service
+	order := []string{ServiceWWW, ServiceTelnet, ServiceSSH}
+	for _, name := range order {
+		if s, ok := m.services[name]; ok {
+			list = append(list, s)
+		}
+	}
+	return list
+}
+
+func (m *Manager) GetService(name string) (Service, bool) {
+	m.mu.RLock()
+	defer m.mu.RUnlock()
+
+	s, ok := m.services[strings.ToLower(name)]
+	return s, ok
+}
+
+func (m *Manager) SetServiceState(name string, disabled bool) error {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	key := strings.ToLower(name)
+	svc, ok := m.services[key]
+	if !ok {
+		return fmt.Errorf("service %q not found", name)
+	}
+
+	svc.Disabled = disabled
+	m.services[key] = svc
+
+	if err := m.saveLocked(); err != nil {
+		return err
+	}
+
+	logging.Log("system,info", "Service %s state changed: disabled=%t", key, disabled)
+
+	// Trigger hook if registered
+	if hook, exists := m.hooks[key]; exists {
+		go func() {
+			_ = hook(key, svc.Port, svc.Disabled)
+		}()
+	}
+
+	return nil
+}
+
+func (m *Manager) SetService(name string, port int, disabled bool) error {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	key := strings.ToLower(name)
+	svc, ok := m.services[key]
+	if !ok {
+		return fmt.Errorf("service %q not found", name)
+	}
+
+	if port > 0 && port < 65536 {
+		svc.Port = port
+	}
+	svc.Disabled = disabled
+	m.services[key] = svc
+
+	if err := m.saveLocked(); err != nil {
+		return err
+	}
+
+	logging.Log("system,info", "Service %s updated: port=%d, disabled=%t", key, svc.Port, svc.Disabled)
+
+	if hook, exists := m.hooks[key]; exists {
+		go func() {
+			_ = hook(key, svc.Port, svc.Disabled)
+		}()
+	}
+
+	return nil
+}

+ 165 - 1
internal/web/static/app.js

@@ -329,7 +329,9 @@ function navigate(tabName) {
     tools: { title: 'Diagnostic & Troubleshooting Tools', sub: 'Network latency analysis, ICMP/MTR telemetry, path tracing, DNS resolution and TCP probing' },
     persistence: { title: 'Debian Configuration', sub: 'Native /etc/network/interfaces.d/ persistence manager' },
     settings: { title: 'Router Settings & DNS', sub: 'System DNS forwarder, upstream resolvers, static host mappings, and appliance identity' },
-    users: { title: 'Local User Manager', sub: 'Manage local system administrator credentials and access permissions' }
+    users: { title: 'Local User Manager', sub: 'Manage local system administrator credentials and access permissions' },
+    services: { title: 'RouterOS Management Services (/ip service)', sub: 'Enable or disable Web, SSH, and Telnet management listeners and listening ports' },
+    logs: { title: 'System Activity Logs (/log)', sub: 'Real-time circular buffer log viewer and topic-based event monitoring' }
   };
 
   const header = titles[tabName] || titles.dashboard;
@@ -381,6 +383,8 @@ function navigate(tabName) {
   if (tabName === 'bwtest') loadBwTestView();
   if (tabName === 'settings') loadSettingsData();
   if (tabName === 'users') loadUsers();
+  if (tabName === 'services') loadServices();
+  if (tabName === 'logs') loadLogs();
   setTimeout(initTableSorting, 50);
 }
 
@@ -16431,6 +16435,166 @@ async function submitSelfPassword(event) {
   }
 }
 
+// ==================== MANAGEMENT SERVICES (/ip service) ====================
+state.services = [];
+
+async function loadServices(manual = false) {
+  try {
+    const res = await fetch(`${API_BASE}/system/services`);
+    if (!res.ok) throw new Error('Failed to load services');
+    const data = await res.json();
+    state.services = data || [];
+    renderServicesTable();
+    if (manual) showToast('Services Updated', 'Service status synchronized', 'success');
+  } catch (err) {
+    if (manual) showToast('Error', err.message, 'error');
+  }
+}
+
+function renderServicesTable() {
+  const tbody = document.getElementById('services-table-body');
+  if (!tbody) return;
+
+  if (!state.services || state.services.length === 0) {
+    tbody.innerHTML = '<tr><td colspan="5" class="py-6 text-center text-slate-500 text-xs">No services configured.</td></tr>';
+    return;
+  }
+
+  tbody.innerHTML = state.services.map(s => {
+    const isOff = s.disabled;
+    const statusBadge = isOff
+      ? '<span class="px-2 py-0.5 rounded text-[10px] bg-rose-500/20 text-rose-300 border border-rose-500/30 font-bold uppercase">Disabled</span>'
+      : '<span class="px-2 py-0.5 rounded text-[10px] bg-emerald-500/20 text-emerald-300 border border-emerald-500/30 font-bold uppercase">Active</span>';
+
+    const toggleBtn = isOff
+      ? `<button onclick="toggleServiceState('${s.name}', false)" class="px-2.5 py-1 rounded-lg bg-emerald-500/20 text-emerald-300 hover:bg-emerald-500/30 border border-emerald-500/40 text-[11px] font-bold transition">Enable</button>`
+      : `<button onclick="toggleServiceState('${s.name}', true)" class="px-2.5 py-1 rounded-lg bg-rose-500/20 text-rose-300 hover:bg-rose-500/30 border border-rose-500/40 text-[11px] font-bold transition">Disable</button>`;
+
+    return `
+      <tr class="hover:bg-slate-800/40 transition">
+        <td class="py-3 px-4 font-bold text-white uppercase">${s.name}</td>
+        <td class="py-3 px-4 text-cyan-400 font-mono">${s.port}</td>
+        <td class="py-3 px-4">${statusBadge}</td>
+        <td class="py-3 px-4 text-slate-400 text-xs font-sans">${s.comment || '-'}</td>
+        <td class="py-3 px-4 text-right">
+          ${toggleBtn}
+        </td>
+      </tr>
+    `;
+  }).join('');
+}
+
+async function toggleServiceState(serviceName, newDisabled) {
+  if (serviceName === 'www' && newDisabled) {
+    if (!confirm("WARNING: Disabling the Web service ('www') will turn off this Web UI and REST API. You will only be able to re-enable it via SSH (port 2222) or Telnet (port 8282) using 'ip service enable www'. Continue?")) {
+      return;
+    }
+  }
+
+  try {
+    const res = await fetch(`${API_BASE}/system/services/${serviceName}/state`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ disabled: newDisabled })
+    });
+    if (!res.ok) {
+      const errData = await res.json();
+      throw new Error(errData.message || 'Failed to update service');
+    }
+    showToast('Service Updated', `Service ${serviceName} is now ${newDisabled ? 'disabled' : 'enabled'}`, 'success');
+    await loadServices();
+  } catch (err) {
+    showToast('Error', err.message, 'error');
+  }
+}
+
+// ==================== SYSTEM LOGS (/log) ====================
+state.logs = [];
+let logsTimer = null;
+
+async function loadLogs(manual = false) {
+  const limitEl = document.getElementById('logs-limit-select');
+  const limit = limitEl ? limitEl.value : '100';
+
+  try {
+    const res = await fetch(`${API_BASE}/system/logs?limit=${limit}`);
+    if (!res.ok) throw new Error('Failed to load logs');
+    const data = await res.json();
+    state.logs = data.logs || [];
+    renderLogsTable();
+    if (manual) showToast('Logs Refreshed', `Fetched ${state.logs.length} log records`, 'success');
+  } catch (err) {
+    if (manual) showToast('Error', err.message, 'error');
+  }
+
+  // Setup auto-refresh if enabled and on logs tab
+  if (!logsTimer) {
+    logsTimer = setInterval(() => {
+      if (state.activeTab === 'logs') {
+        const auto = document.getElementById('logs-auto-refresh');
+        if (auto && auto.checked) {
+          loadLogs(false);
+        }
+      }
+    }, 3000);
+  }
+}
+
+function renderLogsTable() {
+  const tbody = document.getElementById('logs-table-body');
+  if (!tbody) return;
+
+  const search = (document.getElementById('logs-search-input')?.value || '').toLowerCase();
+  const topicFilter = (document.getElementById('logs-topic-filter')?.value || '').toLowerCase();
+
+  const filtered = state.logs.filter(entry => {
+    if (topicFilter && !entry.topics.toLowerCase().includes(topicFilter)) {
+      return false;
+    }
+    if (search && !entry.message.toLowerCase().includes(search) && !entry.topics.toLowerCase().includes(search)) {
+      return false;
+    }
+    return true;
+  });
+
+  if (filtered.length === 0) {
+    tbody.innerHTML = '<tr><td colspan="4" class="py-8 text-center text-slate-500 text-xs">No matching log entries found.</td></tr>';
+    return;
+  }
+
+  tbody.innerHTML = filtered.map(l => {
+    let topicColor = 'bg-slate-800 text-slate-300 border-slate-700';
+    const topLower = l.topics.toLowerCase();
+    if (topLower.includes('auth')) {
+      topicColor = 'bg-amber-500/20 text-amber-300 border-amber-500/30';
+    } else if (topLower.includes('dhcp')) {
+      topicColor = 'bg-cyan-500/20 text-cyan-300 border-cyan-500/30';
+    } else if (topLower.includes('route')) {
+      topicColor = 'bg-blue-500/20 text-blue-300 border-blue-500/30';
+    } else if (topLower.includes('firewall')) {
+      topicColor = 'bg-rose-500/20 text-rose-300 border-rose-500/30';
+    } else if (topLower.includes('error')) {
+      topicColor = 'bg-red-500/20 text-red-300 border-red-500/30';
+    } else if (topLower.includes('system')) {
+      topicColor = 'bg-emerald-500/20 text-emerald-300 border-emerald-500/30';
+    }
+
+    const d = new Date(l.time);
+    const timeStr = d.toLocaleDateString('en-US', { month: 'short', day: '2-digit' }) + ' ' + d.toLocaleTimeString('en-US', { hour12: false });
+
+    return `
+      <tr class="hover:bg-slate-800/40 transition">
+        <td class="py-2 px-3 text-slate-500 font-mono">${l.id}</td>
+        <td class="py-2 px-3 text-slate-400 font-mono whitespace-nowrap">${timeStr}</td>
+        <td class="py-2 px-3 whitespace-nowrap">
+          <span class="px-2 py-0.5 rounded text-[10px] font-bold border ${topicColor}">${l.topics}</span>
+        </td>
+        <td class="py-2 px-3 text-slate-200 font-mono break-all">${escapeHtml(l.message)}</td>
+      </tr>
+    `;
+  }).join('');
+}
+
 
 
 

+ 119 - 0
internal/web/static/index.html

@@ -446,6 +446,28 @@
           <span id="badge-users-count" class="text-[10px] bg-rose-950 text-rose-300 border border-rose-800/40 px-1.5 py-0.2 rounded-full font-mono">1</span>
         </a>
 
+        <a href="#services" onclick="navigate('services')" id="nav-services"
+           class="nav-link flex items-center justify-between px-3 py-2 rounded-lg text-xs font-medium transition-all group text-slate-300 hover:bg-slate-800/80 hover:text-white">
+          <div class="flex items-center space-x-2.5">
+            <svg class="w-4 h-4 text-emerald-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+              <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" />
+            </svg>
+            <span>Services (Telnet/SSH/Web)</span>
+          </div>
+          <span id="badge-services-status" class="w-2 h-2 rounded-full bg-emerald-500"></span>
+        </a>
+
+        <a href="#logs" onclick="navigate('logs')" id="nav-logs"
+           class="nav-link flex items-center justify-between px-3 py-2 rounded-lg text-xs font-medium transition-all group text-slate-300 hover:bg-slate-800/80 hover:text-white">
+          <div class="flex items-center space-x-2.5">
+            <svg class="w-4 h-4 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+              <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
+            </svg>
+            <span>System Logs</span>
+          </div>
+          <span class="text-[10px] bg-blue-950 text-blue-300 border border-blue-800/40 px-1.5 py-0.2 rounded-full font-mono">LOG</span>
+        </a>
+
       </nav>
 
       <!-- Sidebar Footer: Host System Metrics -->
@@ -4555,6 +4577,103 @@
           </div>
         </section>
 
+        <!-- ================= SECTION: SERVICES (/ip service) ================= -->
+        <section id="section-services" class="hidden space-y-6">
+          <div class="bg-slate-900/80 border border-slate-800 rounded-2xl p-5 shadow-lg flex items-center justify-between">
+            <div>
+              <h3 class="text-base font-bold text-white flex items-center gap-2">
+                <svg class="w-5 h-5 text-emerald-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" /></svg>
+                RouterOS Management Services (/ip service)
+              </h3>
+              <p class="text-xs text-slate-400 mt-1">Control management service listeners and listening ports across Web, SSH, and Telnet.</p>
+            </div>
+            <button onclick="loadServices(true)" class="px-3 py-1.5 rounded-lg bg-slate-800 hover:bg-slate-700 text-xs text-white flex items-center gap-1.5 transition">
+              <svg class="w-4 h-4 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>
+              <span>Refresh</span>
+            </button>
+          </div>
+
+          <div class="bg-slate-900/80 border border-slate-800 rounded-2xl overflow-hidden shadow-xl">
+            <div class="overflow-x-auto">
+              <table class="w-full text-left text-xs">
+                <thead class="bg-slate-950/60 text-slate-400 uppercase font-mono text-[10px] tracking-wider border-b border-slate-800">
+                  <tr>
+                    <th class="py-3 px-4">Service</th>
+                    <th class="py-3 px-4">Port</th>
+                    <th class="py-3 px-4">Status</th>
+                    <th class="py-3 px-4">Description</th>
+                    <th class="py-3 px-4 text-right">Actions</th>
+                  </tr>
+                </thead>
+                <tbody id="services-table-body" class="divide-y divide-slate-800 font-mono">
+                  <!-- Rendered dynamically -->
+                </tbody>
+              </table>
+            </div>
+          </div>
+        </section>
+
+        <!-- ================= SECTION: SYSTEM LOGS (/log) ================= -->
+        <section id="section-logs" class="hidden space-y-6">
+          <div class="bg-slate-900/80 border border-slate-800 rounded-2xl p-4 shadow-lg flex flex-wrap items-center justify-between gap-4">
+            <div class="flex items-center gap-3">
+              <div class="relative">
+                <span class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none text-slate-500">
+                  <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>
+                </span>
+                <input type="text" id="logs-search-input" oninput="renderLogsTable()" placeholder="Search logs..."
+                       class="bg-slate-950/80 border border-slate-800 rounded-xl pl-9 pr-3 py-1.5 text-xs text-white placeholder-slate-500 font-mono focus:outline-none focus:border-cyan-500 w-64">
+              </div>
+
+              <select id="logs-topic-filter" onchange="renderLogsTable()" class="bg-slate-950/80 border border-slate-800 rounded-xl px-3 py-1.5 text-xs text-white font-mono focus:outline-none focus:border-cyan-500">
+                <option value="">All Topics</option>
+                <option value="system">System</option>
+                <option value="auth">Auth & Security</option>
+                <option value="dhcp">DHCP</option>
+                <option value="route">Routing</option>
+                <option value="firewall">Firewall</option>
+                <option value="error">Errors</option>
+              </select>
+
+              <select id="logs-limit-select" onchange="loadLogs(true)" class="bg-slate-950/80 border border-slate-800 rounded-xl px-3 py-1.5 text-xs text-white font-mono focus:outline-none focus:border-cyan-500">
+                <option value="50">50 entries</option>
+                <option value="100" selected>100 entries</option>
+                <option value="250">250 entries</option>
+                <option value="500">500 entries</option>
+              </select>
+            </div>
+
+            <div class="flex items-center gap-3">
+              <label class="flex items-center gap-2 text-xs text-slate-400 cursor-pointer">
+                <input type="checkbox" id="logs-auto-refresh" checked class="rounded bg-slate-800 border-slate-700 text-cyan-500 focus:ring-0">
+                <span>Auto-refresh</span>
+              </label>
+              <button onclick="loadLogs(true)" class="px-3 py-1.5 rounded-lg bg-slate-800 hover:bg-slate-700 text-xs text-white flex items-center gap-1.5 transition">
+                <svg class="w-4 h-4 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>
+                <span>Refresh</span>
+              </button>
+            </div>
+          </div>
+
+          <div class="bg-slate-900/80 border border-slate-800 rounded-2xl overflow-hidden shadow-xl">
+            <div class="overflow-x-auto max-h-[650px] overflow-y-auto">
+              <table class="w-full text-left text-xs">
+                <thead class="sticky top-0 bg-slate-950 text-slate-400 uppercase font-mono text-[10px] tracking-wider border-b border-slate-800 z-10">
+                  <tr>
+                    <th class="py-2.5 px-3 w-16">#</th>
+                    <th class="py-2.5 px-3 w-44">Time</th>
+                    <th class="py-2.5 px-3 w-36">Topics</th>
+                    <th class="py-2.5 px-3">Message</th>
+                  </tr>
+                </thead>
+                <tbody id="logs-table-body" class="divide-y divide-slate-800/60 font-mono text-[11px]">
+                  <!-- Rendered dynamically -->
+                </tbody>
+              </table>
+            </div>
+          </div>
+        </section>
+
       </main>
     </div>
   </div>

Certains fichiers n'ont pas été affichés car il y a eu trop de fichiers modifiés dans ce diff