diff --git a/core/api-server/api-server.go b/core/api-server/api-server.go index 4a48dcc499..12ac0f0d65 100644 --- a/core/api-server/api-server.go +++ b/core/api-server/api-server.go @@ -137,6 +137,12 @@ func main() { api.GET("/node/:node_id/task/:task_id/context", methods.GetNodeTaskContext) api.POST("/node/:node_id/tasks", methods.CreateNodeTask) + // Authorize a terminal and mint a one-shot ticket. The body carries + // {"action": "open-terminal"} so the Authorizator matches it against + // the grants of the node taken from the URL. No credentials here: the + // SSH handshake happens on the /ws/terminal channel. + api.POST("/node/:node_id/terminal-sessions", socket.CreateTerminalSession) + // module api.GET("/modules", methods.GetModules) api.GET("/module/:module_id/tasks", methods.GetModuleTasks) @@ -162,6 +168,18 @@ func main() { socketConnection.HandleRequest(c.Writer, c.Request) }) + // Terminal sessions use a dedicated melody instance: the shared one above + // keeps melody's 512 byte default read limit, which a paste would exceed, + // and raising it there would raise it for that endpoint's clients too. + // The client address is resolved by gin so that the trusted proxy logic + // applies to the ticket check as well. + terminalConnection := socket.TerminalInstance() + ws.GET("/terminal", func(c *gin.Context) { + terminalConnection.HandleRequestWithKeys(c.Writer, c.Request, map[string]any{ + "client_ip": c.ClientIP(), + }) + }) + // handle missing endpoint router.NoRoute(func(c *gin.Context) { c.JSON(http.StatusNotFound, structs.Map(response.StatusNotFound{ diff --git a/core/api-server/go.mod b/core/api-server/go.mod index 34d333001a..d4617666d6 100644 --- a/core/api-server/go.mod +++ b/core/api-server/go.mod @@ -15,6 +15,7 @@ require ( github.com/olahol/melody v1.4.0 github.com/pkg/errors v0.9.1 github.com/spf13/cobra v1.8.1 + golang.org/x/crypto v0.53.0 golang.org/x/time v0.12.0 ) @@ -50,7 +51,6 @@ require ( github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect golang.org/x/arch v0.22.0 // indirect - golang.org/x/crypto v0.53.0 // indirect golang.org/x/net v0.56.0 // indirect golang.org/x/sys v0.46.0 // indirect golang.org/x/text v0.39.0 // indirect diff --git a/core/api-server/go.sum b/core/api-server/go.sum index 1a3e1e09c0..d5083b150e 100644 --- a/core/api-server/go.sum +++ b/core/api-server/go.sum @@ -174,6 +174,8 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= +golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus= diff --git a/core/api-server/response/response.go b/core/api-server/response/response.go index 6bb3d0b0bc..29ff915120 100644 --- a/core/api-server/response/response.go +++ b/core/api-server/response/response.go @@ -47,6 +47,12 @@ type StatusNotFound struct { Data any `json:"data" structs:"data"` } +type StatusConflict struct { + Code int `json:"code" example:"409" structs:"code"` + Message string `json:"message" example:"Conflict" structs:"message"` + Data any `json:"data" structs:"data"` +} + type StatusInternalServerError struct { Code int `json:"code" example:"500" structs:"code"` Message string `json:"message" example:"Internal server error" structs:"message"` diff --git a/core/api-server/socket/terminal.go b/core/api-server/socket/terminal.go new file mode 100644 index 0000000000..b372c8b2be --- /dev/null +++ b/core/api-server/socket/terminal.go @@ -0,0 +1,430 @@ +/* + * Copyright (C) 2026 Nethesis S.r.l. + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package socket + +import ( + "context" + "crypto/rand" + "crypto/subtle" + "encoding/hex" + "encoding/json" + "net/http" + "sync" + "time" + + jwt "github.com/appleboy/gin-jwt/v2" + "github.com/fatih/structs" + "github.com/gin-gonic/gin" + "github.com/olahol/melody" + + "github.com/NethServer/ns8-core/core/api-server/audit" + "github.com/NethServer/ns8-core/core/api-server/models" + "github.com/NethServer/ns8-core/core/api-server/redis" + "github.com/NethServer/ns8-core/core/api-server/response" + "github.com/NethServer/ns8-core/core/api-server/utils" +) + +const ( + // Time allowed to attach the WebSocket with the ticket obtained from the + // REST call, and then to complete the login prompts on that channel. The + // login budget is generous because a human is typing, but bounded: the + // ticket holds the node reserved, so an abandoned tab would otherwise lock + // the terminal for everyone else. + terminalAttachTimeout = 30 * time.Second + terminalLoginTimeout = 2 * time.Minute + + // Attempts offered at the login prompt before the session is closed, as on + // a console. Each attempt is a fresh ssh.Dial, so each one gets its own + // MaxAuthTries budget on the node. + terminalLoginAttempts = 3 + + // Bytes accepted on a single prompt line. A user name or a password longer + // than this is a runaway client, not a person typing. + terminalLineLimit = 128 + + // Ceiling on the two SSH round trips that have no watchdog of their own: + // the correlation record, which goes through the account's login shell, and + // the keepalive, whose loop also carries the expiry and idle checks. + terminalCorrelationTimeout = 5 * time.Second + terminalKeepaliveTimeout = 15 * time.Second + + terminalIdleTimeout = 15 * time.Minute + terminalMaxDuration = 8 * time.Hour + terminalKeepalive = 30 * time.Second + + // Bigger than the shared /ws instance, which keeps melody's 512 byte + // default: a paste would otherwise exceed the read limit and close the + // connection. Raising it there would raise it for that endpoint's clients. + terminalMaxMessageSize = 128 << 10 + terminalMessageBuffer = 1024 + terminalCoalesceWindow = 15 * time.Millisecond + terminalThrottleCeiling = 5 * time.Minute +) + +// Phases of a terminal WebSocket. A message that does not belong to the +// current phase closes the session instead of being ignored. +const ( + phaseTicket = "ticket" + phaseLogin = "login" + phaseRunning = "running" + phaseClosed = "closed" +) + +var terminalCtx = context.Background() + +var terminalConn *melody.Melody + +var terminalMu sync.Mutex + +// Outstanding tickets, keyed by their hex token. +var terminalTickets = map[string]*terminalTicket{} + +// Node currently held, enforcing one session per node. The reservation carries +// an id and not just the user name: a stale release must not be able to free a +// reservation that has since been handed to someone else, which would let two +// sessions run on the same node. +var terminalHolders = map[string]terminalHolder{} + +var terminalHolderSeq uint64 + +type terminalHolder struct { + user string + id uint64 +} + +type terminalTicket struct { + token []byte + nodeID string + holderID uint64 + user string + clientIP string + exp int64 + expiresAt time.Time +} + +// terminalControl is the typed shape of every inbound text frame. Credentials +// no longer travel here: the login prompts run inside the terminal, so the +// password arrives as ordinary binary keystrokes. Text frames are still never +// logged verbatim, because the ticket is a bearer token for the reservation. +type terminalControl struct { + Type string `json:"type"` + Ticket string `json:"ticket"` + Rows int `json:"rows"` + Cols int `json:"cols"` +} + +// TerminalInstance returns the melody instance dedicated to terminal sessions. +func TerminalInstance() *melody.Melody { + if terminalConn != nil { + return terminalConn + } + terminalConn = melody.New() + terminalConn.Config.MaxMessageSize = terminalMaxMessageSize + terminalConn.Config.MessageBufferSize = terminalMessageBuffer + + terminalConn.HandleConnect(onTerminalConnect) + terminalConn.HandleMessage(onTerminalControl) + terminalConn.HandleMessageBinary(onTerminalInput) + terminalConn.HandleDisconnect(onTerminalDisconnect) + + // Fail closed. melody drops outbound frames when the session buffer is + // full and Write reports nothing to the caller, so a lost frame towards + // the node would silently truncate a command line before Enter. + terminalConn.HandleError(func(s *melody.Session, err error) { + closeTerminal(s, "transport error: "+err.Error()) + }) + + return terminalConn +} + +/* + * CreateTerminalSession authorizes a terminal on a node and returns a one-shot + * ticket. It carries no credentials and opens no SSH connection: the handshake + * happens on the WebSocket channel, so a browser-held signer can replace the + * password later without touching this route. + * + * The request body is {"action": "open-terminal"} so the existing Authorizator + * matches it against the grants of the node taken from the URL. + */ +func CreateTerminalSession(c *gin.Context) { + nodeID := c.Param("node_id") + info := jwt.ExtractClaims(c) + user, _ := info["id"].(string) + exp, _ := info["exp"].(float64) + + redisConnection := redis.Instance() + defer redisConnection.Close() + + enabled, _ := redisConnection.HGet(terminalCtx, "node/"+nodeID+"/terminal", "enabled").Result() + if enabled != "1" { + c.JSON(http.StatusForbidden, structs.Map(response.StatusForbidden{ + Code: 403, + Message: "terminal is not enabled on this node", + Data: nil, + })) + return + } + + ticket, err := reserveTerminal(nodeID, user, c.ClientIP(), int64(exp)) + if err != nil { + c.JSON(http.StatusConflict, structs.Map(response.StatusConflict{ + Code: 409, + Message: err.Error(), + Data: nil, + })) + return + } + + auditTerminal(user, "terminal-open-requested", gin.H{ + "node": nodeID, + "client_ip": c.ClientIP(), + }) + + c.JSON(http.StatusCreated, structs.Map(response.StatusCreated{ + Code: 201, + Message: "terminal session ticket created", + Data: gin.H{"ticket": ticket}, + })) +} + +// reserveTerminal mints a ticket and marks the node busy. The reservation is +// taken here and must be released when the handshake fails, otherwise a single +// wrong password would lock the node until the ticket expires. +func reserveTerminal(nodeID string, user string, clientIP string, exp int64) (string, error) { + terminalMu.Lock() + defer terminalMu.Unlock() + + expireTicketsLocked() + + if holder, busy := terminalHolders[nodeID]; busy { + return "", errTerminalBusy(holder.user) + } + + raw := make([]byte, 32) + if _, err := rand.Read(raw); err != nil { + return "", err + } + token := hex.EncodeToString(raw) + + terminalHolderSeq++ + holderID := terminalHolderSeq + + terminalTickets[token] = &terminalTicket{ + token: raw, + nodeID: nodeID, + holderID: holderID, + user: user, + clientIP: clientIP, + exp: exp, + expiresAt: time.Now().Add(terminalAttachTimeout), + } + terminalHolders[nodeID] = terminalHolder{user: user, id: holderID} + + return token, nil +} + +// consumeTicket validates and removes a ticket. The source address must match +// the one that obtained it. +func consumeTicket(token string, clientIP string) (*terminalTicket, error) { + terminalMu.Lock() + defer terminalMu.Unlock() + + expireTicketsLocked() + + ticket, found := terminalTickets[token] + if !found { + return nil, errTerminalTicket("unknown or already used ticket") + } + + raw, err := hex.DecodeString(token) + if err != nil || subtle.ConstantTimeCompare(raw, ticket.token) != 1 { + return nil, errTerminalTicket("unknown or already used ticket") + } + + delete(terminalTickets, token) + + if ticket.clientIP != clientIP { + releaseTerminalLocked(ticket.nodeID, ticket.holderID) + return nil, errTerminalAddressChanged + } + + return ticket, nil +} + +func expireTicketsLocked() { + now := time.Now() + for token, ticket := range terminalTickets { + if now.After(ticket.expiresAt) { + delete(terminalTickets, token) + releaseTerminalLocked(ticket.nodeID, ticket.holderID) + } + } +} + +func releaseTerminal(nodeID string, holderID uint64) { + terminalMu.Lock() + defer terminalMu.Unlock() + releaseTerminalLocked(nodeID, holderID) +} + +// releaseTerminalLocked frees a node only for the reservation that asked. A +// late release from a session that has already ended must not drop the +// reservation of whoever took the node next. +func releaseTerminalLocked(nodeID string, holderID uint64) { + if holder, held := terminalHolders[nodeID]; held && holder.id != holderID { + return + } + delete(terminalHolders, nodeID) +} + +func auditTerminal(user string, action string, data gin.H) { + payload, _ := json.Marshal(data) + audit.Store(models.Audit{ + ID: 0, + User: user, + Action: action, + Data: string(payload), + Timestamp: time.Now().UTC(), + }) +} + +/* + * Phase handling + */ + +func onTerminalConnect(s *melody.Session) { + state := &terminalState{ + phase: phaseTicket, + opened: time.Now(), + rows: 24, + cols: 80, + loginInput: make(chan []byte, 64), + done: make(chan struct{}), + } + s.Set("terminal", state) + + go func() { + time.Sleep(terminalAttachTimeout) + if state.currentPhase() == phaseTicket { + closeTerminal(s, "ticket not presented in time") + } + }() +} + +func onTerminalControl(s *melody.Session, message []byte) { + var control terminalControl + err := json.Unmarshal(message, &control) + // Wipe the frame before doing anything else: it may hold the password. + for i := range message { + message[i] = 0 + } + if err != nil { + // Deliberately a fixed string: the frame must never be logged. + utils.LogError(errTerminalControl) + closeTerminal(s, "malformed control frame") + return + } + + state := terminalStateOf(s) + if state == nil { + closeTerminal(s, "no session state") + return + } + + switch control.Type { + case "ticket": + handleTicketFrame(s, state, control) + case "resize": + state.resize(control.Rows, control.Cols) + default: + closeTerminal(s, "unexpected control frame") + } +} + +func handleTicketFrame(s *melody.Session, state *terminalState, control terminalControl) { + if state.currentPhase() != phaseTicket { + closeTerminal(s, "ticket already presented") + return + } + + ticket, err := consumeTicket(control.Ticket, clientAddress(s)) + if err != nil { + writeTerminalControl(s, gin.H{"type": "auth-error", "message": err.Error()}) + closeTerminal(s, "ticket rejected") + return + } + + if !state.adopt(ticket) { + // The reservation was taken out by the ticket, and this session will + // never carry it, so free it here. + releaseTerminal(ticket.nodeID, ticket.holderID) + return + } + + writeTerminalControl(s, gin.H{"type": "ticket-accepted"}) + + // The login prompts need keystrokes, and melody calls its handlers one at a + // time on the read pump: waiting for input from inside a handler would stop + // that input from ever being read. + go runLoginFlow(s, state) +} + +func onTerminalInput(s *melody.Session, message []byte) { + state := terminalStateOf(s) + if state == nil { + return + } + + switch state.currentPhase() { + case phaseRunning: + state.write(message) + case phaseLogin: + // Copy before handing over: melody reuses its read buffer, and these + // bytes are the password being typed. + keys := make([]byte, len(message)) + copy(keys, message) + for i := range message { + message[i] = 0 + } + if !state.offerLoginInput(keys) { + closeTerminal(s, "input flood during login") + } + } +} + +func onTerminalDisconnect(s *melody.Session) { + closeTerminal(s, "client disconnected") +} + +func terminalStateOf(s *melody.Session) *terminalState { + value, found := s.Get("terminal") + if !found { + return nil + } + state, _ := value.(*terminalState) + return state +} + +// clientAddress returns the address gin resolved for the handshake. It is +// stored by the route through HandleRequestWithKeys so that the trusted proxy +// logic applies here too: SetTrustedProxies is limited to the loopback, so a +// forged X-Forwarded-For does not win. +func clientAddress(s *melody.Session) string { + value, found := s.Get("client_ip") + if !found { + return "" + } + address, _ := value.(string) + return address +} + +func writeTerminalControl(s *melody.Session, payload gin.H) { + frame, err := json.Marshal(payload) + if err != nil { + return + } + _ = s.Write(frame) +} diff --git a/core/api-server/socket/terminal_login.go b/core/api-server/socket/terminal_login.go new file mode 100644 index 0000000000..5f09e84dca --- /dev/null +++ b/core/api-server/socket/terminal_login.go @@ -0,0 +1,355 @@ +/* + * Copyright (C) 2026 Nethesis S.r.l. + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package socket + +import ( + "errors" + "fmt" + "strings" + "time" + + "github.com/gin-gonic/gin" + "github.com/olahol/melody" +) + +var ( + errLoginTimeout = errors.New("no answer at the login prompt") + errLoginAborted = errors.New("login cancelled") +) + +/* + * Login prompts + * + * SSH carries the user name inside the authentication request, so there is no + * such thing as a remote "login:" prompt: only the password questions come from + * the node, through keyboard-interactive. The user name prompt below is drawn + * by api-server, which is why this file has to implement line editing. + * + * Nothing here narrows who can read the password. It still crosses this process + * in clear on its way to the node, exactly as the credentials frame did. What + * changes is that the browser no longer holds it in a form field. + */ + +func runLoginFlow(s *melody.Session, state *terminalState) { + deadline := time.Now().Add(terminalLoginTimeout) + + // Resolved once: it depends on the node, not on the account being tried, and + // a node with no published host key must say so instead of looking like + // three rejected passwords. + target, hostKeys, err := nodeSSHTarget(state.nodeID) + if err != nil { + writeTerminalText(s, "\r\n"+sanitizePrompt(err.Error())+"\r\n") + closeTerminal(s, "target unavailable") + return + } + + // The keepalive loop only watches the disabled flag once a shell is up, so + // without this an operator revoking the terminal could not interrupt a + // session parked at the password prompt. + go watchDisabledDuringLogin(s, state) + + writeTerminalText(s, fmt.Sprintf("\r\nNS8 node %s\r\n", state.nodeID)) + + for attempt := 0; attempt < terminalLoginAttempts; attempt++ { + if state.currentPhase() != phaseLogin { + return + } + + writeTerminalText(s, "\r\nlogin: ") + raw, err := readLine(s, state, deadline, true) + if err != nil { + closeTerminal(s, err.Error()) + return + } + + sshUser := string(raw) + if !sshUsernamePattern.MatchString(sshUser) { + state.dropLoginRemainder() + writeTerminalText(s, "\r\ninvalid user name\r\n") + continue + } + + // Checked per attempt and not once per session: the operator may switch + // to an account that is already being guessed. + if wait := throttleRetryAfter(state.nodeID, sshUser, state.user); wait > 0 { + writeTerminalText(s, fmt.Sprintf( + "\r\ntoo many failed attempts, try again in %d seconds\r\n", int(wait.Seconds()))) + closeTerminal(s, "throttled") + return + } + + // Cancelling at the prompt surfaces as a failed handshake too, so it is + // recorded here rather than inferred from the dial error: a Ctrl-C is + // not a wrong password and must not feed the throttle or the audit. + var promptErr error + client, dialErr := dialNode(target, hostKeys, sshUser, + func(name string, instruction string, questions []string, echos []bool) ([]string, error) { + answers, err := answerPrompts(s, state, deadline, name, instruction, questions, echos) + if err != nil { + promptErr = err + } + return answers, err + }) + + if promptErr != nil { + closeTerminal(s, promptErr.Error()) + return + } + + if dialErr != nil { + throttleRecordFailure(state.nodeID, sshUser, state.user) + // Audited with the cluster-admin identity: lastb on the node only + // ever shows the leader address, so without this line an + // administrator guessing root passwords leaves no attributable + // trace. + auditTerminal(state.user, "terminal-auth-failed", gin.H{ + "node": state.nodeID, + "ssh_user": sshUser, + "client_ip": state.clientIP, + }) + state.dropLoginRemainder() + // Deliberately vague and uniform, as sshd is: telling the operator + // whether the account exists would be a gift to anyone guessing. + writeTerminalText(s, "\r\nLogin incorrect\r\n") + continue + } + + throttleRecordSuccess(state.nodeID, sshUser, state.user) + attachShell(s, state, sshUser, client) + return + } + + closeTerminal(s, "too many failed login attempts") +} + +func watchDisabledDuringLogin(s *melody.Session, state *terminalState) { + ticker := time.NewTicker(terminalKeepalive) + defer ticker.Stop() + + for { + select { + case <-state.done: + return + case <-ticker.C: + if state.currentPhase() != phaseLogin { + return + } + if terminalDisabled(state.nodeID) { + closeTerminal(s, "the terminal was disabled on this node") + return + } + } + } +} + +// answerPrompts renders the questions sshd sends over keyboard-interactive and +// collects the replies. The node writes these strings, so they are stripped of +// control characters before reaching the terminal. +func answerPrompts(s *melody.Session, state *terminalState, deadline time.Time, + name string, instruction string, questions []string, echos []bool) ([]string, error) { + + if text := sanitizePrompt(name); text != "" { + writeTerminalText(s, "\r\n"+text+"\r\n") + } + if text := sanitizePrompt(instruction); text != "" { + writeTerminalText(s, "\r\n"+text+"\r\n") + } + + answers := make([]string, len(questions)) + for i, question := range questions { + echo := i < len(echos) && echos[i] + + // "Password: " already ends with a space; a prompt that does not would + // otherwise have the cursor stuck against its last character. + prompt := sanitizePrompt(question) + if !strings.HasSuffix(prompt, " ") { + prompt += " " + } + + writeTerminalText(s, "\r\n"+prompt) + raw, err := readLine(s, state, deadline, echo) + if err != nil { + return nil, err + } + + // Same limit as the credentials frame had: the string is immutable, so + // this copy cannot be wiped. Our own buffer can, and is. + answers[i] = string(raw) + for j := range raw { + raw[j] = 0 + } + + if !echo { + writeTerminalText(s, "\r\n") + } + } + + return answers, nil +} + +/* + * Line editing + * + * The prompt runs before any shell exists, so no pty is echoing or handling + * erase for us. Keep it to what a login prompt needs, and make sure escape + * sequences from arrow keys never reach the buffer: a stray "\x1b[A" inside a + * user name would be invisible on screen and rejected with a puzzling error. + */ + +func readLine(s *melody.Session, state *terminalState, deadline time.Time, echo bool) ([]byte, error) { + // Allocated once at full size so append never moves the buffer: a + // reallocation would leave a copy of the password behind for the collector. + line := make([]byte, 0, terminalLineLimit) + + const ( + escNone = iota + escSeen + escCSI + ) + escape := escNone + + wipe := func(buffer []byte) { + for i := range buffer { + buffer[i] = 0 + } + } + + for { + // Whatever the previous prompt left behind comes first, before waiting + // on the socket again. + chunk := state.takeLoginRemainder() + + if len(chunk) == 0 { + remaining := time.Until(deadline) + if remaining <= 0 { + wipe(line) + return nil, errLoginTimeout + } + + timer := time.NewTimer(remaining) + select { + case chunk = <-state.loginInput: + timer.Stop() + case <-state.done: + timer.Stop() + wipe(line) + return nil, errLoginAborted + case <-timer.C: + wipe(line) + return nil, errLoginTimeout + } + } + + for index := 0; index < len(chunk); index++ { + character := chunk[index] + switch escape { + case escSeen: + // Only CSI and SS3 carry parameters; anything else was a lone + // Escape followed by an ordinary key. + if character == '[' || character == 'O' { + escape = escCSI + } else { + escape = escNone + } + continue + case escCSI: + if character >= '@' && character <= '~' { + escape = escNone + } + continue + } + + switch character { + case '\r', '\n': + // A paste can hold the user name and the password in one + // frame. Keep the tail for the next prompt instead of dropping + // it, and swallow the LF of a CRLF pair so it does not read as + // an empty answer there. + rest := chunk[index+1:] + if character == '\r' && len(rest) > 0 && rest[0] == '\n' { + rest = rest[1:] + } + if len(rest) > 0 { + keep := make([]byte, len(rest)) + copy(keep, rest) + state.stashLoginRemainder(keep) + } + + wipe(chunk) + answer := make([]byte, len(line)) + copy(answer, line) + wipe(line) + return answer, nil + + case 0x7f, 0x08: + if len(line) > 0 { + line = line[:len(line)-1] + if echo { + writeTerminalText(s, "\b \b") + } + } + + case 0x03: + wipe(chunk) + wipe(line) + return nil, errLoginAborted + + case 0x04: + if len(line) == 0 { + wipe(chunk) + return nil, errLoginAborted + } + + case 0x15: + if echo { + writeTerminalText(s, strings.Repeat("\b \b", len(line))) + } + line = line[:0] + + case 0x1b: + escape = escSeen + + default: + // Bytes above 0x7f are kept as they come: a UTF-8 password + // arrives as several of them and must be reassembled untouched. + if character < 0x20 || len(line) >= terminalLineLimit { + continue + } + line = append(line, character) + if echo { + writeTerminalRaw(s, []byte{character}) + } + } + } + + wipe(chunk) + } +} + +// sanitizePrompt keeps a hostile or misconfigured node from driving the +// operator's terminal through the text of a PAM prompt. +func sanitizePrompt(text string) string { + var clean strings.Builder + for _, character := range text { + if character == '\t' { + clean.WriteRune(character) + continue + } + if character < 0x20 || character == 0x7f { + continue + } + clean.WriteRune(character) + } + return clean.String() +} + +func writeTerminalRaw(s *melody.Session, data []byte) { + _ = s.WriteBinary(data) +} + +func writeTerminalText(s *melody.Session, text string) { + _ = s.WriteBinary([]byte(text)) +} diff --git a/core/api-server/socket/terminal_ssh.go b/core/api-server/socket/terminal_ssh.go new file mode 100644 index 0000000000..dcfa2bb815 --- /dev/null +++ b/core/api-server/socket/terminal_ssh.go @@ -0,0 +1,736 @@ +/* + * Copyright (C) 2026 Nethesis S.r.l. + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package socket + +import ( + "crypto/subtle" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "regexp" + "strings" + "sync" + "time" + + "github.com/gin-gonic/gin" + "github.com/olahol/melody" + "golang.org/x/crypto/ssh" + + "github.com/NethServer/ns8-core/core/api-server/redis" +) + +const terminalHandshakeTimeout = 15 * time.Second + +var ( + errTerminalControl = errors.New("terminal: malformed control frame") + errTerminalAddressChanged = errors.New("the browser address changed since the terminal was requested, open it again") +) + +// sshUsernamePattern matches cluster/validator-definitions.json +// strict-username-string, the only shape cluster/actions/add-user accepts. +var sshUsernamePattern = regexp.MustCompile(`^[a-z_][a-z0-9_-]*\$?$`) + +func errTerminalBusy(holder string) error { + return fmt.Errorf("a terminal is already open on this node by %s", holder) +} + +func errTerminalTicket(reason string) error { + return errors.New(reason) +} + +type terminalState struct { + mu sync.Mutex + phase string + opened time.Time + + nodeID string + holderID uint64 + user string + sshUser string + clientIP string + exp int64 + + // Kept outside the SSH session: the browser sizes the pane as soon as it is + // mounted, which is now before the shell exists. + rows int + cols int + + // Keystrokes waiting to be consumed by the login prompts. Buffered so a + // paste does not block melody's read pump. + loginInput chan []byte + + // What followed the newline in the frame the previous prompt stopped on. A + // paste can carry the user name and the password in one frame, and those + // trailing bytes belong to the next prompt. + loginRemainder []byte + + // Closed once by closeTerminal. Without it a login goroutine would sit on + // its prompt until the login deadline after the browser has gone, and could + // still bring up a shell on a session nobody is reading. + done chan struct{} + + client *ssh.Client + session *ssh.Session + stdin io.WriteCloser + localPort string + lastIO time.Time + closed bool +} + +func (state *terminalState) currentPhase() string { + state.mu.Lock() + defer state.mu.Unlock() + return state.phase +} + +func (state *terminalState) setPhase(phase string) { + state.mu.Lock() + defer state.mu.Unlock() + state.phase = phase +} + +// adopt binds a consumed ticket to the session and enters the login phase in a +// single step. It reports false when the session was closed meanwhile: melody +// runs closeTerminal from its write pump, so a close can land between consuming +// the ticket and installing it. Installing it anyway would revive a closed +// session whose later closeTerminal calls all return at the closed guard, +// leaving the node reserved for good. +func (state *terminalState) adopt(ticket *terminalTicket) bool { + state.mu.Lock() + defer state.mu.Unlock() + if state.closed { + return false + } + state.nodeID = ticket.nodeID + state.holderID = ticket.holderID + state.user = ticket.user + state.clientIP = ticket.clientIP + state.exp = ticket.exp + state.phase = phaseLogin + return true +} + +func (state *terminalState) touch() { + state.mu.Lock() + defer state.mu.Unlock() + state.lastIO = time.Now() +} + +func (state *terminalState) idleFor() time.Duration { + state.mu.Lock() + defer state.mu.Unlock() + if state.lastIO.IsZero() { + return 0 + } + return time.Since(state.lastIO) +} + +func (state *terminalState) write(data []byte) { + state.mu.Lock() + stdin := state.stdin + state.lastIO = time.Now() + state.mu.Unlock() + if stdin == nil { + return + } + _, _ = stdin.Write(data) +} + +// resize records the size in every phase and forwards it only once a shell +// exists: the pane is mounted for the login prompts, so the first frames land +// before there is any session to notify. +func (state *terminalState) resize(rows int, cols int) { + if rows <= 0 || cols <= 0 { + return + } + state.mu.Lock() + state.rows = rows + state.cols = cols + session := state.session + state.mu.Unlock() + if session == nil { + return + } + _ = session.WindowChange(rows, cols) +} + +func (state *terminalState) currentSize() (int, int) { + state.mu.Lock() + defer state.mu.Unlock() + return state.rows, state.cols +} + +func (state *terminalState) takeLoginRemainder() []byte { + state.mu.Lock() + defer state.mu.Unlock() + rest := state.loginRemainder + state.loginRemainder = nil + return rest +} + +func (state *terminalState) stashLoginRemainder(data []byte) { + state.mu.Lock() + defer state.mu.Unlock() + state.loginRemainder = data +} + +// dropLoginRemainder throws away buffered input after a failed attempt. A paste +// that went wrong must not spill its second line into the next prompt, where +// the user name echoes and a password would land in the scrollback. +func (state *terminalState) dropLoginRemainder() { + state.mu.Lock() + defer state.mu.Unlock() + for i := range state.loginRemainder { + state.loginRemainder[i] = 0 + } + state.loginRemainder = nil +} + +// offerLoginInput queues keystrokes for the login prompts. It reports false +// when the queue is saturated, which no human typing can do. +func (state *terminalState) offerLoginInput(keys []byte) bool { + select { + case state.loginInput <- keys: + return true + default: + return false + } +} + +/* + * Handshake + */ + +// dialNode authenticates against a node and returns the live client. It neither +// closes the WebSocket nor records anything: only the caller can tell a refused +// password from an operator who pressed Ctrl-C at the prompt. +func dialNode(target string, hostKeys []ssh.PublicKey, sshUser string, ask ssh.KeyboardInteractiveChallenge) (*ssh.Client, error) { + config := &ssh.ClientConfig{ + User: sshUser, + // One method only: x/crypto/ssh tries every method listed and each + // failure counts against the server's MaxAuthTries. Every retry is a + // fresh Dial, so each one starts from a clean budget on the node. + Auth: []ssh.AuthMethod{ssh.KeyboardInteractive(ask)}, + HostKeyCallback: publishedHostKeyCallback(hostKeys), + HostKeyAlgorithms: publishedHostKeyAlgorithms(hostKeys), + Timeout: terminalHandshakeTimeout, + } + + return ssh.Dial("tcp", target, config) +} + +// attachShell takes an authenticated client and puts the session in service. +func attachShell(s *melody.Session, state *terminalState, sshUser string, client *ssh.Client) { + // The browser can vanish while the handshake is in flight. Opening a shell + // then would resurrect a closed session and leak the SSH client. + if state.currentPhase() != phaseLogin { + _ = client.Close() + return + } + + rows, cols := state.currentSize() + + localPort := "" + if _, port, splitErr := net.SplitHostPort(client.LocalAddr().String()); splitErr == nil { + localPort = port + } + + session, stdout, stdin, err := openTerminalShell(client, rows, cols) + if err != nil { + _ = client.Close() + writeTerminalControl(s, gin.H{"type": "auth-error", "message": "could not open a shell: " + err.Error()}) + closeTerminal(s, "shell refused") + return + } + + // Best effort, and after the shell is up: the operating system + // administrator has no access to audit.db, so this is the only place the + // cluster-admin identity shows up on the node itself, but it must not delay + // a session that is otherwise ready. + correlated := writeCorrelationRecord(client, state.user, state.clientIP) == nil + + state.mu.Lock() + state.sshUser = sshUser + state.client = client + state.session = session + state.stdin = stdin + state.localPort = localPort + state.lastIO = time.Now() + state.phase = phaseRunning + state.mu.Unlock() + + auditTerminal(state.user, "terminal-open", gin.H{ + "node": state.nodeID, + "ssh_user": sshUser, + "client_ip": state.clientIP, + "local_port": localPort, + "correlated": correlated, + }) + + writeTerminalControl(s, gin.H{"type": "ready"}) + + go pumpTerminalOutput(s, state, stdout) + go terminalKeepaliveLoop(s, state) +} + +func openTerminalShell(client *ssh.Client, rows int, cols int) (*ssh.Session, io.Reader, io.WriteCloser, error) { + session, err := client.NewSession() + if err != nil { + return nil, nil, nil, err + } + + if rows <= 0 { + rows = 24 + } + if cols <= 0 { + cols = 80 + } + + modes := ssh.TerminalModes{ + ssh.ECHO: 1, + ssh.TTY_OP_ISPEED: 38400, + ssh.TTY_OP_OSPEED: 38400, + } + if err := session.RequestPty("xterm-256color", rows, cols, modes); err != nil { + _ = session.Close() + return nil, nil, nil, err + } + + stdin, err := session.StdinPipe() + if err != nil { + _ = session.Close() + return nil, nil, nil, err + } + stdout, err := session.StdoutPipe() + if err != nil { + _ = session.Close() + return nil, nil, nil, err + } + if err := session.Shell(); err != nil { + _ = session.Close() + return nil, nil, nil, err + } + return session, stdout, stdin, nil +} + +/* + * Target resolution. The browser only ever sends a node identifier: taking a + * host and port from the client would turn api-server into an arbitrary SSH + * proxy. + */ + +type publishedHostKey struct { + Type string `json:"type"` + Key string `json:"key"` +} + +func nodeSSHTarget(nodeID string) (string, []ssh.PublicKey, error) { + redisConnection := redis.Instance() + defer redisConnection.Close() + + address, err := redisConnection.HGet(terminalCtx, "node/"+nodeID+"/vpn", "ip_address").Result() + if err != nil || address == "" { + return "", nil, fmt.Errorf("no VPN address published for node/%s", nodeID) + } + + port, _ := redisConnection.HGet(terminalCtx, "node/"+nodeID+"/ssh", "port").Result() + if port == "" { + port = "22" + } + + raw, _ := redisConnection.HGet(terminalCtx, "node/"+nodeID+"/ssh", "host_keys").Result() + keys, err := parsePublishedHostKeys(raw) + if err != nil { + return "", nil, err + } + + return net.JoinHostPort(address, port), keys, nil +} + +func parsePublishedHostKeys(raw string) ([]ssh.PublicKey, error) { + if raw == "" { + return nil, errors.New("this node published no SSH host key, run probe-terminal-access on it") + } + + var published []publishedHostKey + if err := json.Unmarshal([]byte(raw), &published); err != nil { + return nil, errors.New("the SSH host keys published by this node are unreadable") + } + + var keys []ssh.PublicKey + for _, entry := range published { + parsed, _, _, _, err := ssh.ParseAuthorizedKey([]byte(entry.Type + " " + entry.Key)) + if err != nil { + continue + } + keys = append(keys, parsed) + } + if len(keys) == 0 { + return nil, errors.New("this node published no usable SSH host key") + } + return keys, nil +} + +/* + * publishedHostKeyCallback accepts any key the node published over the + * cluster's authenticated channel, Redis ACL plus WireGuard. This is not a + * pinning: a legitimately regenerated key is accepted as soon as it is + * republished, and so would a substituted one if an attacker controlled the + * node -- a case where the node is lost anyway. + */ +func publishedHostKeyCallback(keys []ssh.PublicKey) ssh.HostKeyCallback { + return func(hostname string, remote net.Addr, offered ssh.PublicKey) error { + offeredBytes := offered.Marshal() + for _, known := range keys { + if subtle.ConstantTimeCompare(offeredBytes, known.Marshal()) == 1 { + return nil + } + } + return errors.New("the SSH host key does not match the keys published by this node") + } +} + +// publishedHostKeyAlgorithms restricts negotiation to the published key types. +// Without it, a server offering RSA before a published Ed25519 key produces a +// refusal that looks like a host key mismatch. +func publishedHostKeyAlgorithms(keys []ssh.PublicKey) []string { + var algorithms []string + seen := map[string]bool{} + add := func(name string) { + if !seen[name] { + seen[name] = true + algorithms = append(algorithms, name) + } + } + for _, key := range keys { + if key.Type() == ssh.KeyAlgoRSA { + // An ssh-rsa host key also serves the SHA-2 signature algorithms, + // and modern servers refuse the SHA-1 one. + add(ssh.KeyAlgoRSASHA512) + add(ssh.KeyAlgoRSASHA256) + } + add(key.Type()) + } + return algorithms +} + +/* + * Relay + */ + +func pumpTerminalOutput(s *melody.Session, state *terminalState, stdout io.Reader) { + chunks := make(chan []byte, 64) + + go func() { + defer close(chunks) + buffer := make([]byte, 32<<10) + for { + read, err := stdout.Read(buffer) + if read > 0 { + chunk := make([]byte, read) + copy(chunk, buffer[:read]) + chunks <- chunk + } + if err != nil { + return + } + } + }() + + var pending []byte + flush := func() { + if len(pending) == 0 { + return + } + _ = s.WriteBinary(pending) + pending = nil + } + + // Coalesce so that a burst of small reads does not fill melody's outbound + // buffer, which drops frames rather than blocking. + ticker := time.NewTicker(terminalCoalesceWindow) + defer ticker.Stop() + + for { + select { + case chunk, open := <-chunks: + if !open { + flush() + closeTerminal(s, "remote shell closed") + return + } + pending = append(pending, chunk...) + state.touch() + if len(pending) >= terminalMaxMessageSize/2 { + flush() + } + case <-ticker.C: + flush() + } + } +} + +func terminalKeepaliveLoop(s *melody.Session, state *terminalState) { + ticker := time.NewTicker(terminalKeepalive) + defer ticker.Stop() + + for range ticker.C { + if state.currentPhase() != phaseRunning { + return + } + + state.mu.Lock() + client := state.client + exp := state.exp + nodeID := state.nodeID + opened := state.opened + state.mu.Unlock() + + // x/crypto/ssh has no keepalive of its own. Bounded because it opens + // this loop, and the loop is what enforces the expiry, idle, duration + // and disabled checks below: a black-holed node would otherwise leave a + // live shell that none of them can ever reach. + alive := make(chan error, 1) + go func() { + _, _, err := client.SendRequest("keepalive@openssh.com", true, nil) + alive <- err + }() + + select { + case err := <-alive: + if err != nil { + closeTerminal(s, "lost the connection to the node") + return + } + case <-time.After(terminalKeepaliveTimeout): + closeTerminal(s, "the node stopped answering") + return + } + + if exp > 0 && time.Now().Unix() > exp { + closeTerminal(s, "session token expired") + return + } + if state.idleFor() > terminalIdleTimeout { + closeTerminal(s, "idle for too long") + return + } + if time.Since(opened) > terminalMaxDuration { + closeTerminal(s, "maximum session duration reached") + return + } + // Re-read the flag so that disable-node-terminal, a cluster restore or + // a manual change closes live sessions. A cluster action cannot reach + // these connections, which live in this process. + if terminalDisabled(nodeID) { + closeTerminal(s, "the terminal was disabled on this node") + return + } + } +} + +// terminalDisabled only reports a disabled terminal on positive information: a +// Redis hiccup must not tear down every established session. +func terminalDisabled(nodeID string) bool { + redisConnection := redis.Instance() + defer redisConnection.Close() + + enabled, err := redisConnection.HGet(terminalCtx, "node/"+nodeID+"/terminal", "enabled").Result() + if err != nil { + return false + } + return enabled != "1" +} + +func closeTerminal(s *melody.Session, reason string) { + state := terminalStateOf(s) + if state == nil { + _ = s.CloseWithMsg(melody.FormatCloseMessage(1000, "Bye")) + return + } + + state.mu.Lock() + if state.closed { + state.mu.Unlock() + return + } + state.closed = true + state.phase = phaseClosed + if state.done != nil { + close(state.done) + } + client := state.client + session := state.session + nodeID := state.nodeID + holderID := state.holderID + user := state.user + sshUser := state.sshUser + localPort := state.localPort + opened := state.opened + started := client != nil + state.mu.Unlock() + + if session != nil { + _ = session.Close() + } + if client != nil { + _ = client.Close() + } + if nodeID != "" { + releaseTerminal(nodeID, holderID) + } + + if started { + auditTerminal(user, "terminal-close", gin.H{ + "node": nodeID, + "ssh_user": sshUser, + "local_port": localPort, + "reason": reason, + "seconds": int(time.Since(opened).Seconds()), + }) + } + + writeTerminalControl(s, gin.H{"type": "closed", "reason": reason}) + // melody writes the close frame straight to the socket while queued + // messages are still in the session buffer, so closing immediately would + // drop the reason the browser needs to display. + time.Sleep(50 * time.Millisecond) + _ = s.CloseWithMsg(melody.FormatCloseMessage(1000, "Bye")) +} + +/* + * Correlation record + */ + +func writeCorrelationRecord(client *ssh.Client, adminUser string, clientIP string) error { + // SSH exec carries no argv: sshd hands the string to the user's shell, so + // the identity is interpolated there. add-user constrains cluster-admin + // names today, but that guarantee lives in another component's schema, so + // validate here too and quote. + if !sshUsernamePattern.MatchString(adminUser) { + return errors.New("cluster-admin name outside the expected character set") + } + + session, err := client.NewSession() + if err != nil { + return err + } + defer session.Close() + + message := fmt.Sprintf("session opened by %s from %s", adminUser, clientIP) + + // Bounded on purpose. This runs a command through the account's login + // shell, and a shell that never returns would otherwise hang the caller + // with no timeout of its own watching over it. The deferred Close unblocks + // the goroutine, and the buffered channel keeps it from leaking. + done := make(chan error, 1) + go func() { + done <- session.Run("logger -t ns8-terminal -- " + shellSingleQuote(message)) + }() + + select { + case err := <-done: + return err + case <-time.After(terminalCorrelationTimeout): + return errors.New("timed out writing the correlation record") + } +} + +func shellSingleQuote(value string) string { + return "'" + strings.ReplaceAll(value, "'", `'\''`) + "'" +} + +/* + * Throttle. Resistance to password guessing rests entirely here: pam_faillock + * has even_deny_root disabled by default, so root is not locked out node-side, + * and faillock is not in Debian's default stack. + * + * Counted per node and SSH account, and per cluster-admin user. Kept in this + * map rather than on the WebSocket session so that reconnecting does not reset + * the counter. + */ + +type throttleEntry struct { + failures int + notBefore time.Time +} + +var throttleMu sync.Mutex +var throttleByAccount = map[string]*throttleEntry{} +var throttleByUser = map[string]*throttleEntry{} + +func throttleDelay(failures int) time.Duration { + if failures <= 1 { + return 0 + } + delay := time.Duration(1< terminalThrottleCeiling { + return terminalThrottleCeiling + } + return delay +} + +func throttleRetryAfter(nodeID string, sshUser string, clusterUser string) time.Duration { + throttleMu.Lock() + defer throttleMu.Unlock() + + pruneThrottleLocked() + + longest := time.Duration(0) + for _, entry := range []*throttleEntry{ + throttleByAccount[nodeID+"/"+sshUser], + throttleByUser[clusterUser], + } { + if entry == nil { + continue + } + if wait := time.Until(entry.notBefore); wait > longest { + longest = wait + } + } + return longest +} + +func throttleRecordFailure(nodeID string, sshUser string, clusterUser string) { + throttleMu.Lock() + defer throttleMu.Unlock() + + for _, bucket := range []struct { + table map[string]*throttleEntry + key string + }{ + {throttleByAccount, nodeID + "/" + sshUser}, + {throttleByUser, clusterUser}, + } { + entry := bucket.table[bucket.key] + if entry == nil { + entry = &throttleEntry{} + bucket.table[bucket.key] = entry + } + entry.failures++ + entry.notBefore = time.Now().Add(throttleDelay(entry.failures)) + } +} + +func throttleRecordSuccess(nodeID string, sshUser string, clusterUser string) { + throttleMu.Lock() + defer throttleMu.Unlock() + delete(throttleByAccount, nodeID+"/"+sshUser) + delete(throttleByUser, clusterUser) +} + +func pruneThrottleLocked() { + horizon := time.Now().Add(-terminalThrottleCeiling) + for _, table := range []map[string]*throttleEntry{throttleByAccount, throttleByUser} { + for key, entry := range table { + if entry.notBefore.Before(horizon) { + delete(table, key) + } + } + } +} diff --git a/core/imageroot/etc/systemd/system/api-server.service b/core/imageroot/etc/systemd/system/api-server.service index beccaa4466..c384309e72 100644 --- a/core/imageroot/etc/systemd/system/api-server.service +++ b/core/imageroot/etc/systemd/system/api-server.service @@ -13,6 +13,9 @@ ExecStart=/usr/local/bin/api-server ExecStartPost=-+/usr/local/bin/api-server-motd ExecStopPost=+rm -vf /etc/issue.d/api-server.issue /etc/motd.d/api-server Restart=always +# The terminal relay handles system passwords in memory: no core dump can carry +# them to disk. +LimitCORE=0 User=api-server RuntimeDirectory=api-server/tokens RuntimeDirectoryMode=0700 diff --git a/core/imageroot/var/lib/nethserver/cluster/actions/disable-node-terminal/50disable b/core/imageroot/var/lib/nethserver/cluster/actions/disable-node-terminal/50disable new file mode 100755 index 0000000000..2921eb24dd --- /dev/null +++ b/core/imageroot/var/lib/nethserver/cluster/actions/disable-node-terminal/50disable @@ -0,0 +1,35 @@ +#!/usr/bin/env python3 + +# +# Copyright (C) 2026 Nethesis S.r.l. +# SPDX-License-Identifier: GPL-3.0-or-later +# + +"""Disable the cluster-admin terminal on a node. + +Clears the flag first: that closes the api-server door immediately, and +api-server drops live sessions on the next keepalive tick when it re-reads the +flag. Removing the sshd drop-in comes second, and does not wait -- the node may +well be unreachable, which is precisely the case this ordering handles. The +node//tasks queue is durable, so the node converges when it comes back. +""" + +import json +import sys + +import agent +import agent.tasks + +request = json.load(sys.stdin) +node_id = request['node_id'] + +rdb = agent.redis_connect(privileged=True) + +# Only the flag: see the note in enable-node-terminal. +rdb.hset(f'node/{node_id}/terminal', 'enabled', '0') + +agent.tasks.runp_nowait([{ + 'agent_id': f'node/{node_id}', + 'action': 'set-terminal-sshd', + 'data': {}, +}], endpoint='redis://cluster-leader') diff --git a/core/imageroot/var/lib/nethserver/cluster/actions/disable-node-terminal/validate-input.json b/core/imageroot/var/lib/nethserver/cluster/actions/disable-node-terminal/validate-input.json new file mode 100644 index 0000000000..4c0b0b09e3 --- /dev/null +++ b/core/imageroot/var/lib/nethserver/cluster/actions/disable-node-terminal/validate-input.json @@ -0,0 +1,23 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "disable-node-terminal input", + "$id": "http://schema.nethserver.org/cluster/disable-node-terminal-input.json", + "description": "Forbid the cluster-admin terminal on a node. Closes live sessions and removes the sshd drop-in.", + "examples": [ + { + "node_id": 2 + } + ], + "type": "object", + "additionalProperties": false, + "required": [ + "node_id" + ], + "properties": { + "node_id": { + "title": "Node identifier", + "type": "integer", + "minimum": 1 + } + } +} diff --git a/core/imageroot/var/lib/nethserver/cluster/actions/enable-node-terminal/50enable b/core/imageroot/var/lib/nethserver/cluster/actions/enable-node-terminal/50enable new file mode 100755 index 0000000000..00c243354d --- /dev/null +++ b/core/imageroot/var/lib/nethserver/cluster/actions/enable-node-terminal/50enable @@ -0,0 +1,90 @@ +#!/usr/bin/env python3 + +# +# Copyright (C) 2026 Nethesis S.r.l. +# SPDX-License-Identifier: GPL-3.0-or-later +# + +"""Enable the cluster-admin terminal on a node. + +Writes the flag first, then asks the node to converge its sshd drop-in. The +transient state that follows -- flag on, drop-in not yet written -- makes the +handshake fail visibly and opens nothing. The opposite order would risk leaving +sshd accepting root passwords from the VPN while the interface reports the +terminal as disabled. + +This action runs on the leader, so it must never write sshd files itself: the +target node does that through its own set-terminal-sshd action. +""" + +import json +import sys + +import agent +import agent.tasks + +AGENT_MAX_IDLE = 8 + + +def agent_is_alive(rdb, agent_id): + """Mirror api-server's liveness check: the agent sets its Redis client name. + + Needed because the task wait loop polls forever with no timeout, so pushing + a subtask to a powered-off node would hang this action indefinitely. + + An agent holds several connections and they do not go idle together, so the + freshest one decides. Answering from whichever came first in the list made + this a coin flip that declared a running node dead. Pubsub connections are + left out, as api-server does: a subscriber is idle by nature. + """ + for client in rdb.client_list(_type='normal'): + if client.get('name') != agent_id: + continue + try: + if int(client.get('idle', AGENT_MAX_IDLE + 1)) <= AGENT_MAX_IDLE: + return True + except ValueError: + continue + return False + + +request = json.load(sys.stdin) +node_id = request['node_id'] + +rdb = agent.redis_connect(privileged=True) +if not rdb.exists(f'node/{node_id}/vpn'): + print(f'node/{node_id} is not a cluster member', file=sys.stderr) + sys.exit(2) + +# Fail before touching the flag: enabling a terminal on an unreachable node +# cannot succeed, and leaving the flag set would be misleading. +if not agent_is_alive(rdb, f'node/{node_id}'): + print(f'node/{node_id} is not responding, cannot reconfigure its sshd', + file=sys.stderr) + sys.exit(2) + +previous_enabled = rdb.hget(f'node/{node_id}/terminal', 'enabled') or '0' + +# Only the flag. Who flipped it and when belongs in audit.db, which keeps the +# whole history and which modules cannot read: node/* is world-readable inside +# the cluster through their %R~node/* grant, so an administrator name written +# here would be handed to every installed module. +rdb.hset(f'node/{node_id}/terminal', 'enabled', '1') + +# Wait for the node: enabling a terminal on an unreachable node is pointless, +# and a rejected sshd configuration must be loud rather than leave the cluster +# believing the terminal is usable. +errors = agent.tasks.runp_brief([{ + 'agent_id': f'node/{node_id}', + 'action': 'set-terminal-sshd', + 'data': {}, +}], endpoint='redis://cluster-leader') + +if errors: + # Roll the flag back. The UI reads it to advertise a working terminal, so + # leaving it set would send operators to a node whose sshd refuses them, + # and three attempts each is enough to throttle them on every node. + rdb.hset(f'node/{node_id}/terminal', 'enabled', previous_enabled) + print(f'node/{node_id} did not converge: sshd was not reconfigured, so the ' + 'terminal stays disabled', file=sys.stderr) + sys.exit(3) diff --git a/core/imageroot/var/lib/nethserver/cluster/actions/enable-node-terminal/validate-input.json b/core/imageroot/var/lib/nethserver/cluster/actions/enable-node-terminal/validate-input.json new file mode 100644 index 0000000000..20f361754b --- /dev/null +++ b/core/imageroot/var/lib/nethserver/cluster/actions/enable-node-terminal/validate-input.json @@ -0,0 +1,23 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "enable-node-terminal input", + "$id": "http://schema.nethserver.org/cluster/enable-node-terminal-input.json", + "description": "Allow the cluster-admin terminal on a node. Installs an sshd drop-in that accepts password authentication from the cluster VPN only.", + "examples": [ + { + "node_id": 2 + } + ], + "type": "object", + "additionalProperties": false, + "required": [ + "node_id" + ], + "properties": { + "node_id": { + "title": "Node identifier", + "type": "integer", + "minimum": 1 + } + } +} diff --git a/core/imageroot/var/lib/nethserver/cluster/actions/list-nodes/10list_nodes b/core/imageroot/var/lib/nethserver/cluster/actions/list-nodes/10list_nodes index 48a41406d8..d44e975720 100755 --- a/core/imageroot/var/lib/nethserver/cluster/actions/list-nodes/10list_nodes +++ b/core/imageroot/var/lib/nethserver/cluster/actions/list-nodes/10list_nodes @@ -74,6 +74,7 @@ def main(): node_metrics.update(get_identity(node_id)) node_metrics["node_id"] = int(node_id) node_metrics["ui_name"] = rdb.get(f"node/{node_id}/ui_name") or "" + node_metrics["terminal_enabled"] = rdb.hget(f"node/{node_id}/terminal", "enabled") == "1" node_list.append(node_metrics) print(json.dumps({"nodes":node_list})) diff --git a/core/imageroot/var/lib/nethserver/cluster/actions/list-nodes/validate-output.json b/core/imageroot/var/lib/nethserver/cluster/actions/list-nodes/validate-output.json index 3de9c08957..dd29d5fb0f 100644 --- a/core/imageroot/var/lib/nethserver/cluster/actions/list-nodes/validate-output.json +++ b/core/imageroot/var/lib/nethserver/cluster/actions/list-nodes/validate-output.json @@ -113,6 +113,10 @@ "ui_name": { "type": "string" }, + "terminal_enabled": { + "type": "boolean", + "description": "Whether the cluster-admin terminal is allowed on this node" + }, "vpn_endpoint": { "type": "string" }, diff --git a/core/imageroot/var/lib/nethserver/node/actions/probe-terminal-access/50probe b/core/imageroot/var/lib/nethserver/node/actions/probe-terminal-access/50probe new file mode 100755 index 0000000000..61f38e2b01 --- /dev/null +++ b/core/imageroot/var/lib/nethserver/node/actions/probe-terminal-access/50probe @@ -0,0 +1,136 @@ +#!/usr/bin/env python3 + +# +# Copyright (C) 2026 Nethesis S.r.l. +# SPDX-License-Identifier: GPL-3.0-or-later +# + +"""Report whether the cluster-admin terminal can reach sshd on this node. + +Returns four values and nothing else. The task output is readable by any +authenticated user because GET requests bypass authorization in api-server, +so the raw sshd configuration must never be exposed here. + +As a side effect, publishes the host keys sshd actually serves into +node//ssh. The api-server trusts them because they arrive over the +cluster's authenticated channel (Redis ACL plus WireGuard); this is not a +pinning, and a legitimately regenerated key is accepted as soon as it is +published. +""" + +import json +import os +import subprocess +import sys + +import agent + +SSHD_BINARY = '/usr/sbin/sshd' +ANY_ADDRESSES = ('0.0.0.0', '::', '*') + + +def fail(message): + print(message, file=sys.stderr) + sys.exit(1) + + +def run_sshd_test(connection_spec=None): + """Return sshd's effective configuration as a dict of directive -> [values].""" + command = [SSHD_BINARY, '-T'] + if connection_spec: + command += ['-C', connection_spec] + probe = subprocess.run(command, capture_output=True, text=True) + if probe.returncode != 0: + fail(f'sshd -T failed: {probe.stderr.strip()}') + + config = {} + for line in probe.stdout.splitlines(): + directive, _, value = line.partition(' ') + config.setdefault(directive.lower(), []).append(value.strip()) + return config + + +def listen_host(entry): + """Host part of a sshd -T listenaddress entry. + + Entries look like 0.0.0.0:22, [::]:22 or 10.5.4.1:22, but sshd omits the + port when the configuration does. Splitting on the last colon is therefore + wrong for a bare IPv6 address: '::' would come out as ':' and the caller + would report no listener on a host that listens on every address. + """ + entry = entry.strip() + if entry.startswith('['): + # sshd brackets an IPv6 host only to append a port. + host, _, _ = entry[1:].partition(']') + return host + # One colon separates a port; several mean an unbracketed IPv6 address. + if entry.count(':') == 1: + return entry.split(':', 1)[0] + return entry + + +def listens_on(config, address): + """True when sshd listens on the given address, or on every address.""" + for entry in config.get('listenaddress', []): + host = listen_host(entry) + if host in ANY_ADDRESSES or host == address: + return True + return False + + +def served_host_keys(config): + """Public keys for the private keys sshd is configured to serve.""" + keys = [] + for path in config.get('hostkey', []): + try: + with open(path + '.pub') as pub: + fields = pub.read().split() + except OSError: + derive = subprocess.run(['ssh-keygen', '-y', '-f', path], + capture_output=True, text=True) + if derive.returncode != 0: + continue + fields = derive.stdout.split() + if len(fields) >= 2: + keys.append({'type': fields[0], 'key': fields[1]}) + return keys + + +if not os.path.isfile(SSHD_BINARY): + fail(f'{SSHD_BINARY} not found: is openssh-server installed?') + +# Privileged: the probe publishes the host keys into node//ssh, and the +# default Redis user is read-only. +rdb = agent.redis_connect(privileged=True) +node_id = os.environ['NODE_ID'] + +leader_id = rdb.hget('cluster/environment', 'NODE_ID') +leader_address = rdb.hget(f'node/{leader_id}/vpn', 'ip_address') +node_address = rdb.hget(f'node/{node_id}/vpn', 'ip_address') +if not leader_address or not node_address: + fail('missing VPN address for the leader or for this node') + +# The global pass gives the listening port, needed to build the connection spec. +global_config = run_sshd_test() +port = int(global_config.get('port', ['22'])[0]) + +# Without -C, sshd ignores every Match block, including the one the terminal +# opt-in installs, so the answer would always be the pre-activation policy. +# addr must stay a single address for the same reason cluster/network does. +effective = run_sshd_test( + f'user=root,addr={leader_address},laddr={node_address},lport={port}' +) + +host_keys = served_host_keys(global_config) +if host_keys: + rdb.hset(f'node/{node_id}/ssh', mapping={ + 'host_keys': json.dumps(host_keys), + 'port': port, + }) + +json.dump({ + 'permit_root_login': effective.get('permitrootlogin', ['no'])[0] == 'yes', + 'password_auth': effective.get('passwordauthentication', ['no'])[0] == 'yes', + 'listen_wg0': listens_on(global_config, node_address), + 'port': port, +}, fp=sys.stdout) diff --git a/core/imageroot/var/lib/nethserver/node/actions/probe-terminal-access/validate-output.json b/core/imageroot/var/lib/nethserver/node/actions/probe-terminal-access/validate-output.json new file mode 100644 index 0000000000..7ae6f830d5 --- /dev/null +++ b/core/imageroot/var/lib/nethserver/node/actions/probe-terminal-access/validate-output.json @@ -0,0 +1,42 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "probe-terminal-access output", + "$id": "http://schema.nethserver.org/node/probe-terminal-access-output.json", + "description": "Whether sshd on this node accepts the cluster-admin terminal. Deliberately limited to three booleans and the port: the task output is readable by any authenticated user, so the raw sshd configuration must not be exposed.", + "examples": [ + { + "permit_root_login": true, + "password_auth": true, + "listen_wg0": true, + "port": 22 + } + ], + "type": "object", + "additionalProperties": false, + "required": [ + "permit_root_login", + "password_auth", + "listen_wg0", + "port" + ], + "properties": { + "permit_root_login": { + "type": "boolean", + "description": "True when root may authenticate with a password from the cluster VPN, i.e. the effective PermitRootLogin is yes" + }, + "password_auth": { + "type": "boolean", + "description": "True when password authentication is enabled from the cluster VPN" + }, + "listen_wg0": { + "type": "boolean", + "description": "True when sshd listens on the node VPN address, or on every address" + }, + "port": { + "type": "integer", + "minimum": 1, + "maximum": 65535, + "description": "Effective sshd listening port" + } + } +} diff --git a/core/imageroot/var/lib/nethserver/node/actions/set-terminal-sshd/50converge b/core/imageroot/var/lib/nethserver/node/actions/set-terminal-sshd/50converge new file mode 100755 index 0000000000..df09af6976 --- /dev/null +++ b/core/imageroot/var/lib/nethserver/node/actions/set-terminal-sshd/50converge @@ -0,0 +1,148 @@ +#!/usr/bin/env python3 + +# +# Copyright (C) 2026 Nethesis S.r.l. +# SPDX-License-Identifier: GPL-3.0-or-later +# + +"""Converge the cluster-admin terminal sshd drop-in to the node flag. + +This action takes no parameter on purpose. The desired state is read from +Redis (node//terminal enabled), so a caller can only realign the node +with the cluster policy, never open password authentication on its own. +""" + +import os +import subprocess +import sys +import tempfile + +import agent + +DROPIN_PATH = '/etc/ssh/sshd_config.d/90-ns8-terminal.conf' +SSHD_CONFIG = '/etc/ssh/sshd_config' +SSHD_BINARY = '/usr/sbin/sshd' + + +def fail(message): + print(message, file=sys.stderr) + sys.exit(1) + + +def find_sshd_unit(): + """Return the sshd unit name: RHEL ships sshd.service, Debian ssh.service.""" + for unit in ('sshd.service', 'ssh.service'): + probe = subprocess.run(['systemctl', 'cat', '--', unit], capture_output=True) + if probe.returncode == 0: + return unit + fail('no sshd unit found (tried sshd.service and ssh.service): is openssh-server installed?') + + +def assert_include_present(): + """Refuse to write a drop-in that sshd would never read. + + Without an Include directive the file is inert, and reporting success + would leave the cluster believing the terminal is usable. + """ + try: + with open(SSHD_CONFIG) as config: + for line in config: + fields = line.split() + if len(fields) >= 2 and fields[0].lower() == 'include' and 'sshd_config.d' in fields[1]: + return + except OSError as ex: + fail(f'cannot read {SSHD_CONFIG}: {ex}') + fail(f'{SSHD_CONFIG} has no "Include /etc/ssh/sshd_config.d/*.conf" directive, ' + f'{DROPIN_PATH} would be ignored') + + +def dropin_content(vpn_network): + """Build the drop-in restricting password authentication to the cluster VPN. + + vpn_network is the cluster/network value, a single CIDR. Never derive it + from api-server's getClusterNetworks(), which prepends the loopback + addresses: the support tunnel DNATs port 22 to 127.0.0.1, so loopback here + would open root password login on a path that is key-only today. + """ + return ( + '# Managed by NS8, rewritten by the set-terminal-sshd action. Do not edit.\n' + f'Match Address {vpn_network}\n' + ' PermitRootLogin yes\n' + ' PasswordAuthentication yes\n' + ' KbdInteractiveAuthentication yes\n' + '# "Match all" closes the block above. Drop-ins are included at the top of\n' + '# sshd_config, so an unclosed Match would swallow the rest of the host policy.\n' + 'Match all\n' + ) + + +def read_dropin(): + try: + with open(DROPIN_PATH) as dropin: + return dropin.read() + except FileNotFoundError: + return None + except OSError as ex: + fail(f'cannot read {DROPIN_PATH}: {ex}') + + +def apply_state(content): + """Write the drop-in, or remove it when content is None.""" + if content is None: + try: + os.unlink(DROPIN_PATH) + except FileNotFoundError: + pass + return + + directory = os.path.dirname(DROPIN_PATH) + os.makedirs(directory, mode=0o755, exist_ok=True) + with tempfile.NamedTemporaryFile('w', dir=directory, delete=False) as tmp: + tmp.write(content) + tmp.flush() + os.fsync(tmp.fileno()) + os.fchmod(tmp.fileno(), 0o644) + os.replace(tmp.name, DROPIN_PATH) + + +rdb = agent.redis_connect() +node_id = os.environ['NODE_ID'] +enabled = rdb.hget(f'node/{node_id}/terminal', 'enabled') == '1' + +if enabled: + vpn_network = rdb.get('cluster/network') + if not vpn_network: + fail('cluster/network is empty: refusing to open password authentication ' + 'without a VPN network to restrict it to') + desired = dropin_content(vpn_network) +else: + desired = None + +previous = read_dropin() +if desired == previous: + sys.exit(0) + +# sshd is only required once there is something to change, so a node without +# openssh-server and with the terminal disabled stays a silent no-op. That +# matters because the update-core hook calls this action on every node. +if not os.path.isfile(SSHD_BINARY): + fail(f'{SSHD_BINARY} not found: is openssh-server installed?') + +if enabled: + assert_include_present() + +unit = find_sshd_unit() +apply_state(desired) + +# Validate before reloading: a syntax error would take sshd down on this node. +check = subprocess.run([SSHD_BINARY, '-t'], capture_output=True, text=True) +if check.returncode != 0: + apply_state(previous) + fail(f'sshd rejected the configuration, reverted {DROPIN_PATH}: {check.stderr.strip()}') + +subprocess.run(['systemctl', 'reload', unit], check=True) + +if desired is None: + print(f'{DROPIN_PATH} removed, password authentication closed', file=sys.stderr) +else: + print(f'{DROPIN_PATH} written for {vpn_network}', file=sys.stderr) diff --git a/core/imageroot/var/lib/nethserver/node/install-finalize.sh b/core/imageroot/var/lib/nethserver/node/install-finalize.sh index a635ade3d9..79a5c2d153 100755 --- a/core/imageroot/var/lib/nethserver/node/install-finalize.sh +++ b/core/imageroot/var/lib/nethserver/node/install-finalize.sh @@ -106,6 +106,12 @@ systemctl enable --now \ echo "Start node timers" systemctl enable --now password-warning.timer +# Publish the SSH host keys the cluster-admin terminal will check. A joining node +# publishes its own at the first probe, which always precedes a terminal session. +echo "Publish SSH host keys:" +runagent -m node /var/lib/nethserver/node/actions/probe-terminal-access/50probe >/dev/null \ + || echo "terminal: could not publish the SSH host keys" + echo "Grant initial permissions:" runagent python3 <<'EOF' import agent diff --git a/core/imageroot/var/lib/nethserver/node/uninstall.sh b/core/imageroot/var/lib/nethserver/node/uninstall.sh index ca5bb0dfdc..f8f473e020 100644 --- a/core/imageroot/var/lib/nethserver/node/uninstall.sh +++ b/core/imageroot/var/lib/nethserver/node/uninstall.sh @@ -67,6 +67,16 @@ if [[ -n "${wg0_cluster_network}" ]]; then firewall-cmd --permanent --zone=trusted --remove-source="${wg0_cluster_network}" >/dev/null fi +# Close the password authentication the cluster-admin terminal may have opened. +# The file is written at runtime, so it is not listed in coreimage.lst and would +# otherwise outlive the uninstall. +if [[ -f /etc/ssh/sshd_config.d/90-ns8-terminal.conf ]]; then + rm -f /etc/ssh/sshd_config.d/90-ns8-terminal.conf + if /usr/sbin/sshd -t; then + systemctl reload sshd.service 2>/dev/null || systemctl reload ssh.service || : + fi +fi + firewall-cmd --reload echo "Stopping the core services and timers" diff --git a/core/imageroot/var/lib/nethserver/node/update-core.d/55terminal_sshd b/core/imageroot/var/lib/nethserver/node/update-core.d/55terminal_sshd new file mode 100755 index 0000000000..c3676d353c --- /dev/null +++ b/core/imageroot/var/lib/nethserver/node/update-core.d/55terminal_sshd @@ -0,0 +1,26 @@ +#!/bin/bash + +# +# Copyright (C) 2026 Nethesis S.r.l. +# SPDX-License-Identifier: GPL-3.0-or-later +# + +exec 1>&2 + +# Refresh the published SSH host keys and realign the terminal sshd drop-in +# with node//terminal. Both steps are idempotent. +# +# This is the long-stop, not the main convergence path: enable-node-terminal and +# disable-node-terminal push the same action right after writing the flag, and +# the node//tasks queue is durable so a powered-off node converges when it +# returns. Weeks can pass between two update-core runs. +# +# Neither step may break the core update: a node without openssh-server is a +# legitimate configuration, and set-terminal-sshd is a no-op there as long as the +# terminal is disabled. + +runagent -m node /var/lib/nethserver/node/actions/probe-terminal-access/50probe >/dev/null \ + || echo "terminal: could not publish the SSH host keys" + +runagent -m node /var/lib/nethserver/node/actions/set-terminal-sshd/50converge \ + || echo "terminal: sshd drop-in is out of sync with node/*/terminal" diff --git a/core/ui/package.json b/core/ui/package.json index 71f901a85b..2806f194c7 100644 --- a/core/ui/package.json +++ b/core/ui/package.json @@ -41,7 +41,9 @@ "vue-text-highlight": "^2.0.10", "vue-toastification": "^1.7.11", "vue2-timepicker": "^1.1.6", - "vuex": "^3.4.0" + "vuex": "^3.4.0", + "xterm": "^5.3.0", + "xterm-addon-fit": "^0.8.0" }, "devDependencies": { "@babel/core": "^7.14.3", diff --git a/core/ui/public/i18n/en/translation.json b/core/ui/public/i18n/en/translation.json index 8330ac4fc2..e4ef7c51d7 100644 --- a/core/ui/public/i18n/en/translation.json +++ b/core/ui/public/i18n/en/translation.json @@ -446,7 +446,12 @@ "get-password-warning": "Get password warning", "list-mountpoints": "List mountpoints", "import-backup-destinations": "Import backup destinations", - "list-cluster-backup-endpoints": "List cluster backup endpoints" + "list-cluster-backup-endpoints": "List cluster backup endpoints", + "open-terminal": "Open terminal", + "probe-terminal-access": "Check terminal access", + "enable-node-terminal": "Enable node terminal", + "disable-node-terminal": "Disable node terminal", + "set-terminal-sshd": "Apply terminal sshd configuration" }, "network": { "title": "Network" @@ -718,11 +723,11 @@ "mail_from_placeholder": "E.g. user@domain.com", "mail_from_format": "Invalid email address format", "notification_enabled_on_my_nethesis": "Notifications enabled on my.nethesis.it", - "linked_to_an_active_subscription":"This system is linked to an active subscription, alert emails are already managed and delivered by my.nethesis.it portal.", + "linked_to_an_active_subscription": "This system is linked to an active subscription, alert emails are already managed and delivered by my.nethesis.it portal.", "disabled_email_notifications_description": "To configure alert notifications, email notifications must be enabled in the cluster settings.", "disabled_email_notification": "Email notifications disabled", "go_to_email_notifications": "Go to Email notifications" - }, + }, "settings_subscription": { "title": "Subscription", "cluster_subscription": "Cluster subscription", @@ -1120,7 +1125,7 @@ "restart_app": "Restarting the application {name} will interrupt its operation and may cause downtime. Proceed only if maintenance is needed and users are aware.", "restart_instance": "Restart instance", "restart_module_warning": "Proceed only if maintenance is needed and users are aware.", - "restarting":" Restarting...", + "restarting": " Restarting...", "restart_instance_name": "Restart instance {instance}", "restart_application": "Restart application", "app_categories": { @@ -1512,7 +1517,7 @@ "confirm_password": "Confirm password", "import_data": "Import data", "export_data": "Export data", - "password":"Password", + "password": "Password", "locked": "Locked", "must_change_password": "Must change password", "no_password_expiration": "No password expiration" @@ -1812,12 +1817,37 @@ "invalid_csv_format": "Invalid CSV format", "import_error": "Import error", "import": "Import", - "delete_your_file_after_import":"Delete your file after the import", - "delete_your_file_after_import_description":"If the file contains user passwords, it's recommended to delete the CSV after the import for security reasons.", + "delete_your_file_after_import": "Delete your file after the import", + "delete_your_file_after_import_description": "If the file contains user passwords, it's recommended to delete the CSV after the import for security reasons.", "no_import_users": "No users to import", "no_import_users_description": "The CSV file does not contain any user to import.", "invalid_csv_format_not_expected_columns": "Invalid CSV format: unexpected number of columns", "file_read_error": "File read error", "importing_data_on_domain_name": "Importing data on domain {name}" + }, + "terminal": { + "title": "Terminal", + "node": "Node", + "open": "Open terminal", + "close": "Close", + "close_confirm": "Close the terminal?", + "close_confirm_description": "The shell on the node is terminated with the session. Anything still running in it, including a command started from this terminal, is killed.", + "on": "Enabled", + "off": "Disabled", + "disabled_on_node": "The terminal is disabled on this node", + "enable_warning": "Enabling the terminal changes the sshd configuration of this node: password authentication is allowed from the cluster VPN only. On a node hardened with PasswordAuthentication no, this re-allows it for VPN connections.", + "cannot_change_state": "Cannot change the terminal state", + "toggle_failed": "The node did not apply the change. Its sshd configuration may not match the state shown here.", + "cannot_list_nodes": "Cannot retrieve the node list", + "cannot_open": "Cannot open the terminal", + "session_closed": "Session closed", + "transport_error": "The connection to the server was interrupted", + "probe_failed": "Cannot determine whether sshd accepts a terminal on this node", + "sshd_not_listening": "sshd does not listen on the cluster VPN address", + "sshd_not_listening_description": "The terminal reaches the node through the VPN. Check the ListenAddress directive on that node.", + "root_password_refused": "This node refuses root with a password", + "root_password_refused_description": "PermitRootLogin is not set to yes for VPN connections. Use an unprivileged account and sudo, or change the sshd configuration of the node.", + "password_auth_disabled": "Password authentication is disabled on this node", + "password_auth_disabled_description": "sshd refuses passwords for VPN connections. Check the sshd configuration of the node." } } diff --git a/core/ui/src/components/shell/SideMenuContent.vue b/core/ui/src/components/shell/SideMenuContent.vue index 1ea38a70ab..57681d58c6 100644 --- a/core/ui/src/components/shell/SideMenuContent.vue +++ b/core/ui/src/components/shell/SideMenuContent.vue @@ -58,6 +58,13 @@ + + + {{ $t("terminal.title") }} + + + + + + diff --git a/core/ui/src/router/index.js b/core/ui/src/router/index.js index 1679cfc836..d02cd1e78f 100644 --- a/core/ui/src/router/index.js +++ b/core/ui/src/router/index.js @@ -174,6 +174,12 @@ const routes = [ name: "SoftwareCenterAppInstances", component: SoftwareCenterAppInstances, }, + { + path: "/terminal", + name: "NodeTerminal", + component: () => + import(/* webpackChunkName: "terminal" */ "../views/NodeTerminal.vue"), + }, { path: "/system-logs", name: "SystemLogs", diff --git a/core/ui/src/views/NodeTerminal.vue b/core/ui/src/views/NodeTerminal.vue new file mode 100644 index 0000000000..f4c0e4dea5 --- /dev/null +++ b/core/ui/src/views/NodeTerminal.vue @@ -0,0 +1,629 @@ + + + + + + diff --git a/core/ui/yarn.lock b/core/ui/yarn.lock index fae5d81634..ab504b5b48 100644 --- a/core/ui/yarn.lock +++ b/core/ui/yarn.lock @@ -14818,6 +14818,8 @@ __metadata: vue-toastification: ^1.7.11 vue2-timepicker: ^1.1.6 vuex: ^3.4.0 + xterm: ^5.3.0 + xterm-addon-fit: ^0.8.0 languageName: unknown linkType: soft @@ -20676,6 +20678,22 @@ __metadata: languageName: node linkType: hard +"xterm-addon-fit@npm:^0.8.0": + version: 0.8.0 + resolution: "xterm-addon-fit@npm:0.8.0" + peerDependencies: + xterm: ^5.0.0 + checksum: 5af2041b442f7c804eda2e6f62e3b68b5159b0ae6bd96e2aa8d85b26441df57291cbfed653d1196d4af5d9b94bfc39993df8b409a25c35e0d36bdaf6f5cdfe5f + languageName: node + linkType: hard + +"xterm@npm:^5.3.0": + version: 5.3.0 + resolution: "xterm@npm:5.3.0" + checksum: 1bdfdfe4cae4412128376180d85e476b43fb021cdd1114b18acad821c9ea44b5b600e0d88febf2b3572f38fad7741e5161ce0178a44369617cf937222cc6e011 + languageName: node + linkType: hard + "y18n@npm:^4.0.0": version: 4.0.3 resolution: "y18n@npm:4.0.3" diff --git a/docs/core/terminal.md b/docs/core/terminal.md new file mode 100644 index 0000000000..656bf06163 --- /dev/null +++ b/docs/core/terminal.md @@ -0,0 +1,265 @@ +--- +layout: default +title: Terminal +nav_order: 18 +parent: Core +--- + +# Terminal + +Cluster administrators can open an interactive shell on any cluster node from +cluster-admin. The pseudo-terminal is created by `sshd` on the target node; +api-server opens the SSH connection over the WireGuard VPN and relays the byte +stream to the browser. + +* TOC +{:toc} + +## What the feature is + +**A jump host.** The `open-terminal` grant does not give root. It gives the right +to reach port 22 of a node through the cluster VPN, which is usually not routable +from an administrator's workstation. Authentication is still performed by `sshd` +with a system account and password supplied by the user. + +## Two administrator populations + +The design assumes that "NS8 cluster administrator" and "operating system +administrator" may be two different people. + +`open-terminal` can be granted on a single node to an operator who holds no other +privilege there, and conversely an NS8 `owner` cannot obtain a shell without +system credentials. This is why the authentication factor always comes from the +user and never from NS8, and why nothing in NS8 writes to `authorized_keys`. + +A key enrolment action was considered and rejected: `owner` holds the `*` action +pattern on every node, so it would hold any enrolment action by construction, +enrol itself and obtain root without a system factor. "Grantable to `owner` only" +is not a restriction, it is the maximal grant. + +## Enabling the terminal on a node + +The terminal is disabled on every node by default, and enabling it **modifies the +node's sshd configuration**. + +`enable-node-terminal` sets the `node//terminal` flag, then asks the node to +write `/etc/ssh/sshd_config.d/90-ns8-terminal.conf`: + +``` +Match Address + PermitRootLogin yes + PasswordAuthentication yes + KbdInteractiveAuthentication yes +Match all +``` + +`prohibit-password` has been OpenSSH's compiled-in default since 7.0, and it +refuses both password and keyboard-interactive authentication for root. Without +this block, root cannot log in with a password on either supported distribution +family. + +The block grants that access **from the cluster VPN only** — never from the LAN, +never from the Internet — and only on a node whose flag is set. It does not +weaken the two-population rule: it grants the right to *present* a system factor, +not access without one. + +On a node hardened with `PasswordAuthentication no`, this block re-allows password +authentication from the VPN. The settings switch states this. + +`disable-node-terminal` clears the flag and removes the file. + +### Address handling + +`cluster/network` holds a single CIDR. Do not build the `Match Address` value +from api-server's `getClusterNetworks()`, which prepends the loopback addresses: +the support tunnel DNATs port 22 to `127.0.0.1`, so loopback here would open root +password login on a path that is public-key only today. + +### Convergence + +`set-terminal-sshd` takes no parameter. It reads `node//terminal` and +converges the drop-in to it, so a caller can only realign the node with the +cluster policy, never open password authentication on its own — which matters +because `owner` holds that action anyway. + +The flag is written first in both directions. The worst transient state is "flag +set, drop-in not yet written": the handshake fails, visibly, and nothing is open. +The opposite order risks leaving `sshd` accepting root passwords while the +interface reports the terminal as disabled. + +Three triggers, fastest first: + +1. `enable-node-terminal` and `disable-node-terminal` push the node action right + after writing the flag; +2. the `node//tasks` queue is durable, so a powered-off node converges when it + comes back, with no timer; +3. the `update-core.d/55terminal_sshd` hook realigns. This is the long-stop, not + the main path: weeks can pass between two core updates. + +`sshd -t` runs before any reload, and the drop-in is reverted if validation +fails: a syntax error would otherwise take `sshd` down on that node. + +The action fails loudly when `sshd_config` has no +`Include /etc/ssh/sshd_config.d/*.conf` directive, because the drop-in would be +inert and reporting success would be a lie. + +## How a session starts + +The REST call only authorizes. It checks the `open-terminal` grant, reserves the +node — one session per node at a time — and returns a one-shot ticket bound to +the browser's address and valid for thirty seconds. The browser then opens the +WebSocket and presents that ticket. No credentials are involved so far, and the +browser sends only a node id: address and port come from Redis, because taking a +host from the client would turn api-server into an arbitrary SSH proxy. + +The login prompts then run inside the terminal itself. There is no credentials +form. + +**The user name prompt is drawn by api-server, not by the node.** SSH carries the +user name inside the authentication request, so there is no remote `login:` +prompt to relay: api-server prints one and does its own line editing until +Enter. Only the password questions are real, relayed from `sshd` over +keyboard-interactive, with the echo off as `sshd` asks. + +A wrong password gives another try, three in total, each one a fresh SSH +connection so each starts from a clean `MaxAuthTries` budget on the node. The +whole login phase is capped at two minutes: an abandoned prompt would otherwise +hold the node reserved against everyone else. Cancelling at the prompt is +distinguished from a refused password, so it feeds neither the throttle nor the +audit. + +This changes nothing about who can read the password. It still crosses +api-server in clear on its way to the node. What it removes is the password +field, and the browser vault entry that field invited. + +## Preconditions + +`probe-terminal-access` reports whether a session can succeed. The browser runs +it when a node is selected, so the warnings are on screen before the terminal is +opened and nobody types a root password for a handshake that cannot work. + +It also has a side effect the handshake depends on: it publishes the host keys +`sshd` serves into `node//ssh`. A node that has never been probed has no +published key, and the connection is refused for that reason alone. + +It returns three booleans and the port, and nothing else: task output is readable +by any authenticated user because GET requests bypass authorization, so the raw +`sshd` configuration must not be exposed. + +The probe runs `sshd -T -C user=root,addr=…,laddr=…,lport=…`. Without `-C`, +`sshd` ignores every `Match` block, including the one the opt-in installs, so the +answer would always be the pre-activation policy. + +Grant `probe-terminal-access` wherever `open-terminal` is granted: + +``` +grant-actions --action open-terminal --on node/2 --to +grant-actions --action probe-terminal-access --on node/2 --to +``` + +Note that `open-terminal` is an authorization name, not an action directory: +nothing executes it. It is the value api-server matches against the grants when +a session is requested, so it will not appear in `api-cli list-actions`. The +action name must stay outside the `get-*`, `list-*`, `show-*` and `read-*` +patterns, which the built-in `reader` role holds. + +## Host keys + +The same probe publishes the host keys `sshd` actually serves into +`node//ssh`. api-server accepts those keys and refuses to connect when none +is published. + +This is **not** pinning: a legitimately regenerated key is accepted as soon as it +is published, and so would a substituted key if an attacker controlled the node — +a case where the node is lost anyway. The property is that the key arrives over +the cluster's authenticated channel, Redis ACL plus WireGuard, which is stronger +than trust-on-first-use. + +`HostKeyAlgorithms` is set from the published key types, otherwise a server +offering RSA before a published Ed25519 key produces a phantom refusal. + +## Auditing + +`audit.db` records session open and close, failed handshakes, the cluster-admin +identity, the SSH username, the browser source address and the **local TCP port** +of the SSH connection. `sshd` logs `Accepted … port N` and `Failed password … +port N`, so that port makes the join with the node journal exact rather than +merely chronological. + +Node-side attribution is otherwise degraded: `last` and `lastb` show the leader's +address for every session. As a complement, api-server runs `logger` over a +second SSH channel, so the node journal carries the cluster-admin identity and +the browser address — the operating system administrator has no access to +`audit.db`. It runs once the pseudo-terminal is open and is bounded: it goes +through the account's login shell, and a shell that never returns would +otherwise hold up a session that is already usable. + +That line only exists for sessions that succeed. A failed handshake opens no SSH +session, so nothing can run `logger` and the node keeps only the `sshd` record, +which names the leader. + +Failed handshakes must be audited: without them, a cluster administrator guessing +root passwords is invisible both in `audit.db` and in `lastb`. + +## CrowdSec can ban the leader and cut a node out of the cluster + +Every terminal connection reaches a node from the leader's VPN address, so `sshd` +attributes failed logins to the leader and never to the real client. A +brute-force detector running on the node will therefore ban the leader. + +This was reproduced on a test cluster: four failed attempts produced eight +`crowdsecurity/ssh-bf` events and a ban of the leader's VPN address on the node. +The firewall bouncer drops the source address on **all ports**, not just SSH, and +the whole control plane travels that same address — the node agent, Redis +replication, log shipping. Redis replication went down with the terminal and had +to re-establish. The ban lasted a minute there; with CrowdSec's default duration +the node would leave the cluster for four hours, and the cluster UI cannot repair +it, because the UI drives the node through the connection that is blocked. + +The trigger is an administrator mistyping a root password. + +**CrowdSec must be configured never to ban the cluster VPN network.** That is the +only mitigation covering every source of failures coming from the leader, not +just this feature. Spacing the attempts on the api-server side lowers the rate +but does not close it. + +## What this does not protect + +api-server relays the stream, so it sees everything typed, including the system +password. That is inherent to any browser terminal: even without a login prompt +of ours, users type `sudo`, `mysql -p` or a nested `ssh` inside the shell. +End-to-end encryption would not change it, since api-server serves the JavaScript +and could substitute the key material without the browser having any anchor to +notice. + +Reading the stream is not the interesting power, and it is not one this feature +grants. api-server's Redis ACL covers every key and every channel and includes +`lpush`, and NS8 dispatches work by pushing onto `task//…`, so that access +already queues arbitrary actions on any node agent, which runs as root. Whoever +controls api-server holds the cluster without anyone's password. + +What the terminal adds is persistence rather than access. Cluster secrets are +rotated by rebuilding — JWT secret, Redis ACL passwords, WireGuard keys. A node +root password is not: it stays valid afterwards and carries outside NS8 wherever +it is reused. + +Passwords also live in the memory of processes that can be paged out: Traefik, +which terminates TLS, api-server, and Redis, which receives the cluster-admin +password as an `AUTH` argument. `LimitCORE=0` keeps them out of core dumps, but +nothing keeps them out of swap. Encrypted swap is the answer, and it is an +installation decision rather than a code one. This is not specific to the +terminal: the cluster-admin password already takes that path at every login. + +The `open-terminal` grant is also a password oracle: it allows testing system +passwords against `sshd` from an address that sits in firewalld's `trusted` zone, +bypassing the network restrictions an administrator may have set for external +access. Resistance rests **entirely** on api-server's throttle — `pam_faillock` +has `even_deny_root` disabled by default, so root is not locked out node-side, and +faillock is not in Debian's default stack. The throttle is counted per node and +SSH account, survives WebSocket reconnections, and grows its delay. + +The per-node flag is a policy switch, not a security boundary. The authoritative +boundary is `sshd` and the system password. + +Cluster backups list keys explicitly, so `node//terminal` is not saved and a +restore lands with the terminal disabled and no drop-in.