feat: harden console sessions + complete protocol support
Server Deploy / deploy (push) Failing after 1m9s
Agent Release / build (push) Successful in 1m52s
Agent Release / msi (push) Failing after 52s

- single-use session tokens (atomic ConsumeSessionToken) + user-bound tunnel (actor must match session opener)
- wire VNC end-to-end (stash/consume password, connect+tunnel, frontend password field)
- passphrase-protected SSH keys: passphrase_enc on Key model, capture on upload, decrypt + pass to guacd
This commit is contained in:
2026-07-17 12:19:07 +01:00
parent 2fe08ad7e9
commit a9d602d021
16 changed files with 179 additions and 732 deletions
+20 -5
View File
@@ -45,7 +45,7 @@ func consoleConnect(c *gin.Context) {
return
}
if body.Protocol == "rdp" && (body.RDPUsername != "" || body.RDPPassword != "") {
if (body.Protocol == "rdp" || body.Protocol == "vnc") && (body.RDPUsername != "" || body.RDPPassword != "") {
if err := services.StashConsoleRDPCreds(sess.SessionID, body.RDPUsername, body.RDPPassword); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -82,31 +82,46 @@ func consoleTunnel(c *gin.Context) {
c.JSON(http.StatusNotFound, gin.H{"error": "session not found"})
return
}
// User-bound: the caller (authenticated via session cookie) must be the same
// user who opened the session. Blocks a leaked token being used by someone else.
if actor := actorFromCtx(c); actor != sess.User {
c.JSON(http.StatusForbidden, gin.H{"error": "session belongs to another user"})
return
}
// Single-use: atomically spend the token so a replay within its TTL is rejected.
if err := services.ConsumeSessionToken(sessionID); err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "token already used"})
return
}
srv, err := services.GetServer(sess.ServerID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
return
}
// Decrypt private key in-memory only (ssh).
var privKey string
// Decrypt private key + passphrase in-memory only (ssh).
var privKey, passphrase string
if sess.Protocol == "ssh" && sess.KeyID != "" {
privKey, err = services.GetPrivateKey(sess.KeyID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "selected key has no private material"})
return
}
passphrase, _ = services.GetPassphrase(sess.KeyID)
}
var rdpUser, rdpPass string
if sess.Protocol == "rdp" {
if sess.Protocol == "rdp" || sess.Protocol == "vnc" {
rdpUser, rdpPass, err = services.ConsumeConsoleRDPCreds(sessionID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not load credentials"})
return
}
}
gp, err := services.BuildGuacParams(srv, sess.Protocol, sess.SSHUsername, privKey, rdpUser, rdpPass)
gp, err := services.BuildGuacParams(srv, sess.Protocol, sess.SSHUsername, privKey, passphrase, rdpUser, rdpPass)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
+2 -1
View File
@@ -222,13 +222,14 @@ func createKey(c *gin.Context) {
Label string `json:"label" binding:"required"`
PublicKey string `json:"public_key" binding:"required"`
PrivateKey string `json:"private_key"`
Passphrase string `json:"passphrase"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
key, err := services.CreateKey(body.Label, body.PublicKey, "uploaded", "", body.PrivateKey)
key, err := services.CreateKey(body.Label, body.PublicKey, "uploaded", "", body.PrivateKey, body.Passphrase)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
+2 -1
View File
@@ -57,7 +57,8 @@ func (s *vantageServer) UploadGeneratedKey(ctx context.Context, req *pb.UploadKe
return nil, status.Errorf(codes.Unauthenticated, "invalid agent token")
}
key, err := services.CreateKey(req.Label, req.PublicKey, "generated", srv.ServerID, req.PrivateKey)
// Agent-generated keys carry no passphrase over the wire (proto has no field).
key, err := services.CreateKey(req.Label, req.PublicKey, "generated", srv.ServerID, req.PrivateKey, "")
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to store key: %v", err)
}
@@ -17,6 +17,10 @@ type ConsoleSession struct {
EndedAt *time.Time `bson:"ended_at,omitempty" json:"ended_at,omitempty"`
ClientIP string `bson:"client_ip,omitempty" json:"client_ip,omitempty"`
// TokenConsumedAt marks the one-time session token as spent. Set atomically
// when the tunnel opens; a second open with the same token is rejected.
TokenConsumedAt *time.Time `bson:"token_consumed_at,omitempty" json:"-"`
SSHUsername string `bson:"ssh_username,omitempty" json:"ssh_username,omitempty"`
RDPUserEnc string `bson:"rdp_user_enc,omitempty" json:"-"`
+2
View File
@@ -16,5 +16,7 @@ type Key struct {
GeneratedByServerID string `bson:"generated_by_server_id,omitempty" json:"generated_by_server_id,omitempty"`
PrivateKeyEncrypted string `bson:"private_key_enc,omitempty" json:"-"`
HasPrivateKey bool `bson:"-" json:"has_private_key"`
PassphraseEncrypted string `bson:"passphrase_enc,omitempty" json:"-"`
HasPassphrase bool `bson:"-" json:"has_passphrase"`
CreatedAt time.Time `bson:"created_at" json:"created_at"`
}
+27 -3
View File
@@ -87,9 +87,10 @@ 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, sshUser, privateKey, rdpUser, rdpPass string) (*GuacParams, error) {
// privateKey/passphrase are the decrypted SSH private key and its optional
// passphrase (ssh only); rdpUser/rdpPass are used for rdp, and rdpPass carries
// the password for vnc. None of these values are persisted or logged by the caller.
func BuildGuacParams(srv *models.Server, protocol, sshUser, privateKey, passphrase, rdpUser, rdpPass string) (*GuacParams, error) {
host := srv.IPAddress
switch protocol {
case "ssh":
@@ -104,6 +105,9 @@ func BuildGuacParams(srv *models.Server, protocol, sshUser, privateKey, rdpUser,
if privateKey != "" {
p["private-key"] = privateKey
}
if passphrase != "" {
p["passphrase"] = passphrase
}
return &GuacParams{Protocol: "ssh", Params: p}, nil
case "rdp":
return &GuacParams{Protocol: "rdp", Params: map[string]string{
@@ -214,6 +218,26 @@ func SetConsoleSSHUser(sessionID, username string) error {
return err
}
// ConsumeSessionToken atomically marks a session's one-time token as spent.
// It returns an error if the token was already consumed (replay) or the session
// does not exist, so the tunnel can be opened at most once per issued token.
func ConsumeSessionToken(sessionID string) error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
now := time.Now()
res, err := db.Col("console_sessions").UpdateOne(ctx,
bson.M{"session_id": sessionID, "token_consumed_at": nil},
bson.M{"$set": bson.M{"token_consumed_at": now}},
)
if err != nil {
return err
}
if res.MatchedCount == 0 {
return fmt.Errorf("session token already used")
}
return nil
}
func EndConsoleSession(sessionID string) error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
+28 -3
View File
@@ -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)
}
@@ -66,7 +66,7 @@ func TestBuildGuacParamsSSH(t *testing.T) {
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)
}
@@ -80,7 +80,32 @@ 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")
}
}
func TestBuildGuacParamsSSHPassphrase(t *testing.T) {
srv := &models.Server{IPAddress: "10.0.0.5", SSHPort: 22}
p, err := BuildGuacParams(srv, "ssh", "deploy", "PK", "s3cret-phrase", "", "")
if err != nil {
t.Fatalf("err: %v", err)
}
if p.Params["username"] != "deploy" {
t.Fatalf("username %q", p.Params["username"])
}
if p.Params["passphrase"] != "s3cret-phrase" {
t.Fatalf("missing passphrase: %+v", p.Params)
}
}
func TestBuildGuacParamsVNC(t *testing.T) {
srv := &models.Server{IPAddress: "10.0.0.7"}
p, err := BuildGuacParams(srv, "vnc", "", "", "", "", "vncpass")
if err != nil {
t.Fatalf("err: %v", err)
}
if p.Protocol != "vnc" || p.Params["hostname"] != "10.0.0.7" || p.Params["port"] != "5900" || p.Params["password"] != "vncpass" {
t.Fatalf("bad vnc params: %+v", p.Params)
}
}
+25 -1
View File
@@ -33,9 +33,10 @@ func computeFingerprint(pubKey string) string {
func setKeyMeta(k *models.Key) {
k.HasPrivateKey = k.PrivateKeyEncrypted != ""
k.HasPassphrase = k.PassphraseEncrypted != ""
}
func CreateKey(label, publicKey, source, generatedByServerID, privateKey string) (*models.Key, error) {
func CreateKey(label, publicKey, source, generatedByServerID, privateKey, passphrase string) (*models.Key, error) {
key := &models.Key{
KeyID: uuid.NewString(),
Label: label,
@@ -52,6 +53,13 @@ func CreateKey(label, publicKey, source, generatedByServerID, privateKey string)
}
key.PrivateKeyEncrypted = enc
}
if passphrase != "" {
enc, err := encryptString(passphrase)
if err != nil {
return nil, fmt.Errorf("encrypt passphrase: %w", err)
}
key.PassphraseEncrypted = enc
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
@@ -90,6 +98,22 @@ func GetPrivateKey(keyID string) (string, error) {
return decryptPrivateKey(key.PrivateKeyEncrypted)
}
// GetPassphrase returns the decrypted passphrase for a key, or an empty string
// if the key has none stored.
func GetPassphrase(keyID string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var key models.Key
if err := db.Col("keys").FindOne(ctx, bson.M{"key_id": keyID}).Decode(&key); err != nil {
return "", err
}
if key.PassphraseEncrypted == "" {
return "", nil
}
return decryptString(key.PassphraseEncrypted)
}
type KeyWithCount struct {
models.Key `bson:",inline"`
AssignedCount int `bson:"-" json:"assigned_count"`