diff --git a/server/internal/api/console.go b/server/internal/api/console.go index 71618c5..f57ea1f 100644 --- a/server/internal/api/console.go +++ b/server/internal/api/console.go @@ -21,6 +21,7 @@ func consoleConnect(c *gin.Context) { KeyID string `json:"key_id"` RDPUsername string `json:"rdp_username"` RDPPassword string `json:"rdp_password"` + SSHUsername string `json:"ssh_username"` } if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) @@ -51,6 +52,13 @@ func consoleConnect(c *gin.Context) { } } + if body.Protocol == "ssh" { + if err := services.SetConsoleSSHUser(sess.SessionID, body.SSHUsername); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + } + services.LogEvent("console.opened", actorFromCtx(c), srv.ServerID, "", "console session opened ("+body.Protocol+")") @@ -98,7 +106,7 @@ func consoleTunnel(c *gin.Context) { return } } - gp, err := services.BuildGuacParams(srv, sess.Protocol, privKey, rdpUser, rdpPass) + gp, err := services.BuildGuacParams(srv, sess.Protocol, sess.SSHUsername, privKey, rdpUser, rdpPass) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return diff --git a/server/internal/models/console_session.go b/server/internal/models/console_session.go index 9df54ea..107b106 100644 --- a/server/internal/models/console_session.go +++ b/server/internal/models/console_session.go @@ -17,6 +17,8 @@ type ConsoleSession struct { EndedAt *time.Time `bson:"ended_at,omitempty" json:"ended_at,omitempty"` ClientIP string `bson:"client_ip,omitempty" json:"client_ip,omitempty"` + SSHUsername string `bson:"ssh_username,omitempty" json:"ssh_username,omitempty"` + RDPUserEnc string `bson:"rdp_user_enc,omitempty" json:"-"` RDPPassEnc string `bson:"rdp_pass_enc,omitempty" json:"-"` } diff --git a/server/internal/services/console.go b/server/internal/services/console.go index 793c9d1..20ad0e9 100644 --- a/server/internal/services/console.go +++ b/server/internal/services/console.go @@ -89,7 +89,7 @@ func portOr(v, def int) string { // BuildGuacParams assembles the guacd connection parameter map for a protocol. // privateKey is the decrypted SSH private key (ssh only); rdpUser/rdpPass are // used for rdp. None of these values are persisted or logged by the caller. -func BuildGuacParams(srv *models.Server, protocol, privateKey, rdpUser, rdpPass string) (*GuacParams, error) { +func BuildGuacParams(srv *models.Server, protocol, sshUser, privateKey, rdpUser, rdpPass string) (*GuacParams, error) { host := srv.IPAddress switch protocol { case "ssh": @@ -97,6 +97,10 @@ func BuildGuacParams(srv *models.Server, protocol, privateKey, rdpUser, rdpPass "hostname": host, "port": portOr(srv.SSHPort, 22), } + if sshUser == "" { + sshUser = "root" + } + p["username"] = sshUser if privateKey != "" { p["private-key"] = privateKey } @@ -200,6 +204,16 @@ func ConsumeConsoleRDPCreds(sessionID string) (username, password string, err er return username, password, nil } +// SetConsoleSSHUser persists the SSH username to use on the session doc. +func SetConsoleSSHUser(sessionID, username string) error { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _, err := db.Col("console_sessions").UpdateOne(ctx, + bson.M{"session_id": sessionID}, + bson.M{"$set": bson.M{"ssh_username": username}}) + return err +} + func EndConsoleSession(sessionID string) error { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() diff --git a/server/internal/services/console_test.go b/server/internal/services/console_test.go index 25f598b..01eb1c1 100644 --- a/server/internal/services/console_test.go +++ b/server/internal/services/console_test.go @@ -46,7 +46,7 @@ func TestSessionTokenTampered(t *testing.T) { func TestBuildGuacParamsSSH(t *testing.T) { srv := &models.Server{IPAddress: "10.0.0.5", SSHPort: 22} - p, err := BuildGuacParams(srv, "ssh", "PRIVATE-KEY-DATA", "", "") + p, err := BuildGuacParams(srv, "ssh", "", "PRIVATE-KEY-DATA", "", "") if err != nil { t.Fatalf("err: %v", err) } @@ -59,11 +59,14 @@ func TestBuildGuacParamsSSH(t *testing.T) { if p.Params["private-key"] != "PRIVATE-KEY-DATA" { t.Fatalf("missing private-key") } + if p.Params["username"] != "root" { + t.Fatalf("expected default username root, got %q", p.Params["username"]) + } } func TestBuildGuacParamsRDP(t *testing.T) { srv := &models.Server{IPAddress: "10.0.0.9", RDPPort: 3389} - p, err := BuildGuacParams(srv, "rdp", "", "administrator", "s3cret") + p, err := BuildGuacParams(srv, "rdp", "", "", "administrator", "s3cret") if err != nil { t.Fatalf("err: %v", err) } @@ -77,7 +80,7 @@ func TestBuildGuacParamsRDP(t *testing.T) { func TestBuildGuacParamsUnknownProtocol(t *testing.T) { srv := &models.Server{IPAddress: "10.0.0.9"} - if _, err := BuildGuacParams(srv, "telnet", "", "", ""); err == nil { + if _, err := BuildGuacParams(srv, "telnet", "", "", "", ""); err == nil { t.Fatalf("expected error for unknown protocol") } } diff --git a/web/app/servers/[id]/console/page.tsx b/web/app/servers/[id]/console/page.tsx index 9a7ba0f..3c1007f 100644 --- a/web/app/servers/[id]/console/page.tsx +++ b/web/app/servers/[id]/console/page.tsx @@ -19,6 +19,7 @@ export default function ServerConsolePage() { const [protocol, setProtocol] = useState(searchParams.get("protocol") || ""); const [keyId, setKeyId] = useState(""); + const [sshUsername, setSshUsername] = useState("root"); const [rdpUsername, setRdpUsername] = useState(""); const [rdpPassword, setRdpPassword] = useState(""); const [connecting, setConnecting] = useState(false); @@ -72,6 +73,7 @@ export default function ServerConsolePage() { }; if (protocol === "ssh") { body.key_id = keyId || undefined; + body.ssh_username = sshUsername || undefined; } else if (protocol === "rdp") { body.rdp_username = rdpUsername || undefined; body.rdp_password = rdpPassword || undefined; @@ -152,6 +154,14 @@ export default function ServerConsolePage() { {protocol === "ssh" && (
+ + setSshUsername(e.target.value)} + placeholder="root" + className="mb-3 w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30" + />