Compare commits

..

3 Commits

14 changed files with 233 additions and 63 deletions
+27 -8
View File
@@ -20,7 +20,7 @@ func (s *Store) ListSSHServers() ([]models.SSHServer, error) {
var item models.SSHServer var item models.SSHServer
var tagsJSON string var tagsJSON string
rows, err = s.Query(`SELECT public_id, name, host, port, description, tags_json, enabled, created_by_kind, created_by_subject_id, created_by_subject_name, created_at, updated_at rows, err = s.Query(`SELECT public_id, name, host, port, description, tags_json, enabled, host_key_policy, created_by_kind, created_by_subject_id, created_by_subject_name, created_at, updated_at
FROM ssh_servers FROM ssh_servers
ORDER BY name, host, port`) ORDER BY name, host, port`)
if err != nil { if err != nil {
@@ -29,7 +29,7 @@ func (s *Store) ListSSHServers() ([]models.SSHServer, error) {
defer rows.Close() defer rows.Close()
for rows.Next() { for rows.Next() {
err = rows.Scan(&item.ID, &item.Name, &item.Host, &item.Port, &item.Description, &tagsJSON, &item.Enabled, &item.CreatedByKind, &item.CreatedBySubjectID, &item.CreatedBySubjectName, &item.CreatedAt, &item.UpdatedAt) err = rows.Scan(&item.ID, &item.Name, &item.Host, &item.Port, &item.Description, &tagsJSON, &item.Enabled, &item.HostKeyPolicy, &item.CreatedByKind, &item.CreatedBySubjectID, &item.CreatedBySubjectName, &item.CreatedAt, &item.UpdatedAt)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -52,10 +52,10 @@ func (s *Store) GetSSHServer(id string) (models.SSHServer, error) {
var tagsJSON string var tagsJSON string
var err error var err error
row = s.QueryRow(`SELECT public_id, name, host, port, description, tags_json, enabled, created_by_kind, created_by_subject_id, created_by_subject_name, created_at, updated_at row = s.QueryRow(`SELECT public_id, name, host, port, description, tags_json, enabled, host_key_policy, created_by_kind, created_by_subject_id, created_by_subject_name, created_at, updated_at
FROM ssh_servers FROM ssh_servers
WHERE public_id = ?`, strings.TrimSpace(id)) WHERE public_id = ?`, strings.TrimSpace(id))
err = row.Scan(&item.ID, &item.Name, &item.Host, &item.Port, &item.Description, &tagsJSON, &item.Enabled, &item.CreatedByKind, &item.CreatedBySubjectID, &item.CreatedBySubjectName, &item.CreatedAt, &item.UpdatedAt) err = row.Scan(&item.ID, &item.Name, &item.Host, &item.Port, &item.Description, &tagsJSON, &item.Enabled, &item.HostKeyPolicy, &item.CreatedByKind, &item.CreatedBySubjectID, &item.CreatedBySubjectName, &item.CreatedAt, &item.UpdatedAt)
if err != nil { if err != nil {
return item, err return item, err
} }
@@ -75,7 +75,7 @@ func (s *Store) ListSSHServersForUser(userID string) ([]models.SSHServer, error)
var trimmedUserID string var trimmedUserID string
trimmedUserID = strings.TrimSpace(userID) trimmedUserID = strings.TrimSpace(userID)
rows, err = s.Query(`SELECT public_id, name, host, port, description, tags_json, enabled, created_by_kind, created_by_subject_id, created_by_subject_name, created_at, updated_at rows, err = s.Query(`SELECT public_id, name, host, port, description, tags_json, enabled, host_key_policy, created_by_kind, created_by_subject_id, created_by_subject_name, created_at, updated_at
FROM ssh_servers FROM ssh_servers
WHERE created_by_kind = 'user' AND created_by_subject_id = ? WHERE created_by_kind = 'user' AND created_by_subject_id = ?
ORDER BY name, host, port`, trimmedUserID) ORDER BY name, host, port`, trimmedUserID)
@@ -85,7 +85,7 @@ func (s *Store) ListSSHServersForUser(userID string) ([]models.SSHServer, error)
defer rows.Close() defer rows.Close()
for rows.Next() { for rows.Next() {
err = rows.Scan(&item.ID, &item.Name, &item.Host, &item.Port, &item.Description, &tagsJSON, &item.Enabled, &item.CreatedByKind, &item.CreatedBySubjectID, &item.CreatedBySubjectName, &item.CreatedAt, &item.UpdatedAt) err = rows.Scan(&item.ID, &item.Name, &item.Host, &item.Port, &item.Description, &tagsJSON, &item.Enabled, &item.HostKeyPolicy, &item.CreatedByKind, &item.CreatedBySubjectID, &item.CreatedBySubjectName, &item.CreatedAt, &item.UpdatedAt)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -150,6 +150,7 @@ func (s *Store) CreateSSHServer(item models.SSHServer) (models.SSHServer, error)
if err != nil { return item, err } if err != nil { return item, err }
} }
if item.Port <= 0 { item.Port = 22 } if item.Port <= 0 { item.Port = 22 }
item.HostKeyPolicy = normalizeSSHServerHostKeyPolicy(item.HostKeyPolicy)
item.Tags = normalizeStringList(item.Tags) item.Tags = normalizeStringList(item.Tags)
tagsJSON, err = encodeStringList(item.Tags) tagsJSON, err = encodeStringList(item.Tags)
if err != nil { return item, err } if err != nil { return item, err }
@@ -165,12 +166,13 @@ func (s *Store) CreateSSHServer(item models.SSHServer) (models.SSHServer, error)
description, description,
tags_json, tags_json,
enabled, enabled,
host_key_policy,
created_by_kind, created_by_kind,
created_by_subject_id, created_by_subject_id,
created_by_subject_name, created_by_subject_name,
created_at, created_at,
updated_at updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
item.ID, item.ID,
strings.TrimSpace(item.Name), strings.TrimSpace(item.Name),
strings.TrimSpace(item.Host), strings.TrimSpace(item.Host),
@@ -178,6 +180,7 @@ func (s *Store) CreateSSHServer(item models.SSHServer) (models.SSHServer, error)
strings.TrimSpace(item.Description), strings.TrimSpace(item.Description),
tagsJSON, tagsJSON,
item.Enabled, item.Enabled,
item.HostKeyPolicy,
strings.TrimSpace(item.CreatedByKind), strings.TrimSpace(item.CreatedByKind),
strings.TrimSpace(item.CreatedBySubjectID), strings.TrimSpace(item.CreatedBySubjectID),
strings.TrimSpace(item.CreatedBySubjectName), strings.TrimSpace(item.CreatedBySubjectName),
@@ -192,6 +195,7 @@ func (s *Store) UpdateSSHServer(item models.SSHServer) (models.SSHServer, error)
var tagsJSON string var tagsJSON string
if item.Port <= 0 { item.Port = 22 } if item.Port <= 0 { item.Port = 22 }
item.HostKeyPolicy = normalizeSSHServerHostKeyPolicy(item.HostKeyPolicy)
item.Tags = normalizeStringList(item.Tags) item.Tags = normalizeStringList(item.Tags)
tagsJSON, err = encodeStringList(item.Tags) tagsJSON, err = encodeStringList(item.Tags)
if err != nil { return item, err } if err != nil { return item, err }
@@ -199,7 +203,7 @@ func (s *Store) UpdateSSHServer(item models.SSHServer) (models.SSHServer, error)
// TODO: need to protect it with tx. // TODO: need to protect it with tx.
_, err = s.Exec(`UPDATE ssh_servers _, err = s.Exec(`UPDATE ssh_servers
SET name = ?, host = ?, port = ?, description = ?, tags_json = ?, enabled = ?, updated_at = ? SET name = ?, host = ?, port = ?, description = ?, tags_json = ?, enabled = ?, host_key_policy = ?, updated_at = ?
WHERE public_id = ?`, WHERE public_id = ?`,
strings.TrimSpace(item.Name), strings.TrimSpace(item.Name),
strings.TrimSpace(item.Host), strings.TrimSpace(item.Host),
@@ -207,6 +211,7 @@ func (s *Store) UpdateSSHServer(item models.SSHServer) (models.SSHServer, error)
strings.TrimSpace(item.Description), strings.TrimSpace(item.Description),
tagsJSON, tagsJSON,
item.Enabled, item.Enabled,
item.HostKeyPolicy,
item.UpdatedAt, item.UpdatedAt,
strings.TrimSpace(item.ID), strings.TrimSpace(item.ID),
) )
@@ -281,6 +286,7 @@ func (s *Store) ListSSHAccessProfiles() ([]models.SSHAccessProfile, error) {
s.description, s.description,
s.tags_json, s.tags_json,
s.enabled, s.enabled,
s.host_key_policy,
s.created_by_kind, s.created_by_kind,
s.created_by_subject_id, s.created_by_subject_id,
s.created_by_subject_name, s.created_by_subject_name,
@@ -338,6 +344,7 @@ func (s *Store) ListSSHAccessProfiles() ([]models.SSHAccessProfile, error) {
&item.Server.Description, &item.Server.Description,
&serverTagsJSON, &serverTagsJSON,
&item.Server.Enabled, &item.Server.Enabled,
&item.Server.HostKeyPolicy,
&item.Server.CreatedByKind, &item.Server.CreatedByKind,
&item.Server.CreatedBySubjectID, &item.Server.CreatedBySubjectID,
&item.Server.CreatedBySubjectName, &item.Server.CreatedBySubjectName,
@@ -415,6 +422,7 @@ func (s *Store) GetSSHAccessProfile(id string) (models.SSHAccessProfile, error)
s.description, s.description,
s.tags_json, s.tags_json,
s.enabled, s.enabled,
s.host_key_policy,
s.created_by_kind, s.created_by_kind,
s.created_by_subject_id, s.created_by_subject_id,
s.created_by_subject_name, s.created_by_subject_name,
@@ -468,6 +476,7 @@ func (s *Store) GetSSHAccessProfile(id string) (models.SSHAccessProfile, error)
&item.Server.Description, &item.Server.Description,
&serverTagsJSON, &serverTagsJSON,
&item.Server.Enabled, &item.Server.Enabled,
&item.Server.HostKeyPolicy,
&item.Server.CreatedByKind, &item.Server.CreatedByKind,
&item.Server.CreatedBySubjectID, &item.Server.CreatedBySubjectID,
&item.Server.CreatedBySubjectName, &item.Server.CreatedBySubjectName,
@@ -857,6 +866,7 @@ func (s *Store) ListSSHAccessProfilesForUser(userID string) ([]models.SSHAccessP
s.description, s.description,
s.tags_json, s.tags_json,
s.enabled, s.enabled,
s.host_key_policy,
s.created_by_kind, s.created_by_kind,
s.created_by_subject_id, s.created_by_subject_id,
s.created_by_subject_name, s.created_by_subject_name,
@@ -934,6 +944,7 @@ func (s *Store) ListSSHAccessProfilesForUser(userID string) ([]models.SSHAccessP
&item.Server.Description, &item.Server.Description,
&serverTagsJSON, &serverTagsJSON,
&item.Server.Enabled, &item.Server.Enabled,
&item.Server.HostKeyPolicy,
&item.Server.CreatedByKind, &item.Server.CreatedByKind,
&item.Server.CreatedBySubjectID, &item.Server.CreatedBySubjectID,
&item.Server.CreatedBySubjectName, &item.Server.CreatedBySubjectName,
@@ -1501,6 +1512,14 @@ func emptyStringToNil(raw string) any {
return value return value
} }
func normalizeSSHServerHostKeyPolicy(value string) string {
value = strings.TrimSpace(value)
if value == "trust_on_first_use" {
return value
}
return "strict"
}
func NormalizeSSHServerTags(items []string) []string { func NormalizeSSHServerTags(items []string) []string {
var out []string var out []string
+2 -2
View File
@@ -72,7 +72,7 @@ func (s *Store) ListSSHServersForGroup(groupID string) ([]models.SSHServer, erro
var tagsJSON string var tagsJSON string
var err error var err error
rows, err = s.Query(`SELECT s.public_id, s.name, s.host, s.port, s.description, s.tags_json, s.enabled, s.created_by_kind, s.created_by_subject_id, s.created_by_subject_name, s.created_at, s.updated_at rows, err = s.Query(`SELECT s.public_id, s.name, s.host, s.port, s.description, s.tags_json, s.enabled, s.host_key_policy, s.created_by_kind, s.created_by_subject_id, s.created_by_subject_name, s.created_at, s.updated_at
FROM ssh_server_group_members gm FROM ssh_server_group_members gm
JOIN ssh_servers s ON s.public_id = gm.server_public_id JOIN ssh_servers s ON s.public_id = gm.server_public_id
WHERE gm.group_public_id = ? WHERE gm.group_public_id = ?
@@ -80,7 +80,7 @@ func (s *Store) ListSSHServersForGroup(groupID string) ([]models.SSHServer, erro
if err != nil { return nil, err } if err != nil { return nil, err }
defer rows.Close() defer rows.Close()
for rows.Next() { for rows.Next() {
err = rows.Scan(&item.ID, &item.Name, &item.Host, &item.Port, &item.Description, &tagsJSON, &item.Enabled, &item.CreatedByKind, &item.CreatedBySubjectID, &item.CreatedBySubjectName, &item.CreatedAt, &item.UpdatedAt) err = rows.Scan(&item.ID, &item.Name, &item.Host, &item.Port, &item.Description, &tagsJSON, &item.Enabled, &item.HostKeyPolicy, &item.CreatedByKind, &item.CreatedBySubjectID, &item.CreatedBySubjectName, &item.CreatedAt, &item.UpdatedAt)
if err != nil { return nil, err } if err != nil { return nil, err }
item.Tags, err = decodeStringList(tagsJSON) item.Tags, err = decodeStringList(tagsJSON)
if err != nil { return nil, err } if err != nil { return nil, err }
+88 -14
View File
@@ -23,12 +23,13 @@ import "codit/internal/models"
const logIDSSHBroker string = "ssh-broker" const logIDSSHBroker string = "ssh-broker"
type sshServerRequest struct { type sshServerRequest struct {
Name string `json:"name"` Name string `json:"name"`
Host string `json:"host"` Host string `json:"host"`
Port int `json:"port"` Port int `json:"port"`
Description string `json:"description"` Description string `json:"description"`
Tags []string `json:"tags"` Tags []string `json:"tags"`
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
HostKeyPolicy string `json:"host_key_policy"`
} }
type sshCredentialRequest struct { type sshCredentialRequest struct {
@@ -124,10 +125,11 @@ type sshSessionStreamMessage struct {
} }
type sshSessionPrepared struct { type sshSessionPrepared struct {
Session models.SSHSession Session models.SSHSession
Profile models.SSHAccessProfile Profile models.SSHAccessProfile
HostKeys []models.SSHServerHostKey HostKeys []models.SSHServerHostKey
AuthMethods []ssh.AuthMethod AuthMethods []ssh.AuthMethod
PinHostKeyOnFirstUse bool
} }
type sshWorkspaceAttachment struct { type sshWorkspaceAttachment struct {
@@ -318,6 +320,7 @@ func (api *API) CreateSSHServerAdmin(w http.ResponseWriter, r *http.Request, _ m
Description: req.Description, Description: req.Description,
Tags: normalizeSSHBrokerTags(req.Tags), Tags: normalizeSSHBrokerTags(req.Tags),
Enabled: req.Enabled, Enabled: req.Enabled,
HostKeyPolicy: normalizeSSHServerHostKeyPolicy(req.HostKeyPolicy),
CreatedByKind: actorKind, CreatedByKind: actorKind,
CreatedBySubjectID: actorID, CreatedBySubjectID: actorID,
CreatedBySubjectName: actorName, CreatedBySubjectName: actorName,
@@ -373,6 +376,7 @@ func (api *API) UpdateSSHServerAdmin(w http.ResponseWriter, r *http.Request, par
item.Description = req.Description item.Description = req.Description
item.Tags = normalizeSSHBrokerTags(req.Tags) item.Tags = normalizeSSHBrokerTags(req.Tags)
item.Enabled = req.Enabled item.Enabled = req.Enabled
item.HostKeyPolicy = normalizeSSHServerHostKeyPolicy(req.HostKeyPolicy)
item, err = api.store(r).UpdateSSHServer(item) item, err = api.store(r).UpdateSSHServer(item)
if err != nil { if err != nil {
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
@@ -1086,6 +1090,7 @@ func (api *API) CreateSSHServerForSelf(w http.ResponseWriter, r *http.Request, _
Description: req.Description, Description: req.Description,
Tags: normalizeSSHBrokerTags(req.Tags), Tags: normalizeSSHBrokerTags(req.Tags),
Enabled: req.Enabled, Enabled: req.Enabled,
HostKeyPolicy: normalizeSSHServerHostKeyPolicy(req.HostKeyPolicy),
CreatedByKind: "user", CreatedByKind: "user",
CreatedBySubjectID: user.ID, CreatedBySubjectID: user.ID,
CreatedBySubjectName: user.Username, CreatedBySubjectName: user.Username,
@@ -1147,6 +1152,7 @@ func (api *API) UpdateSSHServerForSelf(w http.ResponseWriter, r *http.Request, p
item.Description = req.Description item.Description = req.Description
item.Tags = normalizeSSHBrokerTags(req.Tags) item.Tags = normalizeSSHBrokerTags(req.Tags)
item.Enabled = req.Enabled item.Enabled = req.Enabled
item.HostKeyPolicy = normalizeSSHServerHostKeyPolicy(req.HostKeyPolicy)
item, err = api.store(r).UpdateSSHServer(item) item, err = api.store(r).UpdateSSHServer(item)
if err != nil { if err != nil {
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
@@ -1549,6 +1555,7 @@ func (api *API) CreateSSHSessionForSelf(w http.ResponseWriter, r *http.Request,
var response sshSessionConnectResponse var response sshSessionConnectResponse
var prepared sshSessionPrepared var prepared sshSessionPrepared
var prepareStage string var prepareStage string
var hostKeyFingerprint string
var belongs bool var belongs bool
var err error var err error
@@ -1654,6 +1661,9 @@ func (api *API) CreateSSHSessionForSelf(w http.ResponseWriter, r *http.Request,
api.SSHPreparedSessionStore.Put(item.ID, prepared) api.SSHPreparedSessionStore.Put(item.ID, prepared)
} }
}) })
if len(prepared.HostKeys) > 0 {
hostKeyFingerprint = prepared.HostKeys[0].Fingerprint
}
response = sshSessionConnectResponse{ response = sshSessionConnectResponse{
SessionID: item.ID, SessionID: item.ID,
Status: item.Status, Status: item.Status,
@@ -1662,7 +1672,7 @@ func (api *API) CreateSSHSessionForSelf(w http.ResponseWriter, r *http.Request,
Host: profile.Server.Host, Host: profile.Server.Host,
Port: profile.Server.Port, Port: profile.Server.Port,
RemoteUsername: profile.RemoteUsername, RemoteUsername: profile.RemoteUsername,
HostKeyFingerprint: prepared.HostKeys[0].Fingerprint, HostKeyFingerprint: hostKeyFingerprint,
} }
WriteJSON(w, http.StatusCreated, response) WriteJSON(w, http.StatusCreated, response)
} }
@@ -2156,6 +2166,14 @@ func normalizeSSHBrokerTags(raw []string) []string {
return db.NormalizeSSHServerTags(raw) return db.NormalizeSSHServerTags(raw)
} }
func normalizeSSHServerHostKeyPolicy(value string) string {
value = strings.TrimSpace(value)
if value == "trust_on_first_use" {
return value
}
return "strict"
}
func normalizeSSHBrokerNames(raw []string) []string { func normalizeSSHBrokerNames(raw []string) []string {
var out []string var out []string
var seen map[string]bool var seen map[string]bool
@@ -2357,7 +2375,10 @@ func (api *API) prepareSSHSession(store *db.Store, user models.User, sessionItem
return prepared, "load_host_keys", err return prepared, "load_host_keys", err
} }
if len(prepared.HostKeys) == 0 { if len(prepared.HostKeys) == 0 {
return prepared, "host_key_missing", errors.New("no pinned host key for this server") if normalizeSSHServerHostKeyPolicy(prepared.Profile.Server.HostKeyPolicy) != "trust_on_first_use" {
return prepared, "host_key_missing", errors.New("no pinned host key for " + prepared.Profile.Server.Name)
}
prepared.PinHostKeyOnFirstUse = true
} }
prepared.AuthMethods, err = api.buildSSHSessionAuth(store, prepared.Profile, promptedPassword, otpCode) prepared.AuthMethods, err = api.buildSSHSessionAuth(store, prepared.Profile, promptedPassword, otpCode)
@@ -2539,8 +2560,12 @@ func (api *API) runSSHSessionStream(store *db.Store, user models.User, sessionIt
var closeReason string var closeReason string
var err error var err error
var connectedFingerprint string var connectedFingerprint string
var connectedHostKey ssh.PublicKey
var connectedAt int64 var connectedAt int64
var now int64 var now int64
var pinnedHostKey models.SSHServerHostKey
var reloadedHostKeys []models.SSHServerHostKey
var i int
var shellErrCh chan error var shellErrCh chan error
var done bool var done bool
var waitErr error var waitErr error
@@ -2629,7 +2654,7 @@ func (api *API) runSSHSessionStream(store *db.Store, user models.User, sessionIt
sshConfig = &ssh.ClientConfig{ sshConfig = &ssh.ClientConfig{
User: profile.RemoteUsername, User: profile.RemoteUsername,
Auth: authMethods, Auth: authMethods,
HostKeyCallback: sshHostKeyCallback(hostKeys, &connectedFingerprint), HostKeyCallback: sshHostKeyCallback(hostKeys, &connectedFingerprint, &connectedHostKey, prepared.PinHostKeyOnFirstUse),
Timeout: 15 * time.Second, Timeout: 15 * time.Second,
} }
sshClient, err = ssh.Dial("tcp", net.JoinHostPort(profile.Server.Host, fmt.Sprintf("%d", profile.Server.Port)), sshConfig) sshClient, err = ssh.Dial("tcp", net.JoinHostPort(profile.Server.Host, fmt.Sprintf("%d", profile.Server.Port)), sshConfig)
@@ -2643,6 +2668,53 @@ func (api *API) runSSHSessionStream(store *db.Store, user models.User, sessionIt
return return
} }
defer sshClient.Close() defer sshClient.Close()
if prepared.PinHostKeyOnFirstUse {
if connectedHostKey == nil {
err = errors.New("failed to capture ssh host key")
api.logSSHSessionConnectFailure(sessionItem, profile, user, "pin_host_key", err)
if sendStatus != nil {
sendStatus(sshSessionStreamMessage{Type: "status", Status: "error", Message: err.Error()})
}
now = time.Now().UTC().Unix()
_ = store.UpdateSSHSessionStatus(sessionItem.ID, "error", connectedFingerprint, 0, now, err.Error())
return
}
pinnedHostKey = models.SSHServerHostKey{
ServerID: sessionItem.ServerID,
Algorithm: connectedHostKey.Type(),
PublicKey: strings.TrimSpace(string(ssh.MarshalAuthorizedKey(connectedHostKey))),
Fingerprint: connectedFingerprint,
}
pinnedHostKey, err = store.CreateSSHServerHostKey(pinnedHostKey)
if err != nil {
pinnedHostKey = models.SSHServerHostKey{}
reloadedHostKeys, err = store.ListSSHServerHostKeys(sessionItem.ServerID)
if err == nil {
for i = 0; i < len(reloadedHostKeys); i++ {
if strings.TrimSpace(reloadedHostKeys[i].Fingerprint) == connectedFingerprint {
pinnedHostKey = reloadedHostKeys[i]
break
}
}
}
if pinnedHostKey.ID == "" {
api.logSSHSessionConnectFailure(sessionItem, profile, user, "pin_host_key", err)
if sendStatus != nil {
sendStatus(sshSessionStreamMessage{Type: "status", Status: "error", Message: "failed to pin host key: " + err.Error()})
}
now = time.Now().UTC().Unix()
_ = store.UpdateSSHSessionStatus(sessionItem.ID, "error", connectedFingerprint, 0, now, "failed to pin host key: " + err.Error())
return
}
}
api.Logger.Write(logIDSSHBroker, codit_logger.LOG_INFO,
"host key pinned session=%s server=%s server_name=%q fingerprint=%s algorithm=%s",
sessionItem.ID,
profile.Server.ID,
profile.Server.Name,
pinnedHostKey.Fingerprint,
pinnedHostKey.Algorithm)
}
runtimeSession.SetResources(transportWS, sshClient, nil, nil) runtimeSession.SetResources(transportWS, sshClient, nil, nil)
sshSession, err = sshClient.NewSession() sshSession, err = sshClient.NewSession()
if err != nil { if err != nil {
@@ -3015,7 +3087,7 @@ func (api *API) resolveSSHAccessProfilePrincipals(store *db.Store, profile model
return principals, nil return principals, nil
} }
func sshHostKeyCallback(items []models.SSHServerHostKey, connectedFingerprint *string) ssh.HostKeyCallback { func sshHostKeyCallback(items []models.SSHServerHostKey, connectedFingerprint *string, connectedHostKey *ssh.PublicKey, trustOnFirstUse bool) ssh.HostKeyCallback {
var allowed map[string]bool var allowed map[string]bool
var i int var i int
@@ -3027,7 +3099,9 @@ func sshHostKeyCallback(items []models.SSHServerHostKey, connectedFingerprint *s
var fingerprint string var fingerprint string
fingerprint = strings.TrimSpace(ssh.FingerprintSHA256(key)) fingerprint = strings.TrimSpace(ssh.FingerprintSHA256(key))
if connectedFingerprint != nil { *connectedFingerprint = fingerprint } if connectedFingerprint != nil { *connectedFingerprint = fingerprint }
if connectedHostKey != nil { *connectedHostKey = key }
if allowed[fingerprint] { return nil} if allowed[fingerprint] { return nil}
if trustOnFirstUse && len(items) == 0 { return nil }
return errors.New("host key mismatch: " + fingerprint) return errors.New("host key mismatch: " + fingerprint)
} }
} }
+8 -19
View File
@@ -144,29 +144,18 @@ func (api *API) SetupMyTOTP(w http.ResponseWriter, r *http.Request, _ map[string
} }
func (api *API) totpIssuer(r *http.Request) string { func (api *API) totpIssuer(r *http.Request) string {
var raw string
var parsed *url.URL
var err error
var host string var host string
raw = strings.TrimSpace(api.Cfg.PublicBaseURL) // if the site name is set, use it as a totp issuer
if raw != "" { host = api.siteName()
parsed, err = url.Parse(raw) if host != "" { return host }
if err == nil {
host = strings.TrimSpace(parsed.Host) // use the actual access host name as a totp issuer
if host != "" {
return host
}
}
}
host = cleanTOTPHost(r.Header.Get("X-Forwarded-Host")) host = cleanTOTPHost(r.Header.Get("X-Forwarded-Host"))
if host != "" { if host != "" { return host }
return host
}
host = cleanTOTPHost(r.Host) host = cleanTOTPHost(r.Host)
if host != "" { if host != "" { return host }
return host
}
return api.serverTitle() return api.serverTitle()
} }
+1
View File
@@ -526,6 +526,7 @@ type SSHServer struct {
Description string `json:"description"` Description string `json:"description"`
Tags []string `json:"tags"` Tags []string `json:"tags"`
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
HostKeyPolicy string `json:"host_key_policy"`
CreatedByKind string `json:"created_by_kind"` CreatedByKind string `json:"created_by_kind"`
CreatedBySubjectID string `json:"created_by_subject_id"` CreatedBySubjectID string `json:"created_by_subject_id"`
CreatedBySubjectName string `json:"created_by_subject_name"` CreatedBySubjectName string `json:"created_by_subject_name"`
@@ -0,0 +1 @@
ALTER TABLE ssh_servers ADD COLUMN host_key_policy TEXT NOT NULL DEFAULT 'strict';
+15 -4
View File
@@ -15,6 +15,7 @@ import "log"
import "net" import "net"
import "net/http" import "net/http"
import "net/netip" import "net/netip"
import "net/url"
import "os" import "os"
import "path/filepath" import "path/filepath"
import "sync" import "sync"
@@ -412,13 +413,23 @@ func newServerWithInternalConfig(cfg *codit_config.Config, logger Logger, identi
cfg.DockerHTTPPrefix = codit_config.NormalizeHTTPPrefix(cfg.DockerHTTPPrefix, "/v2") cfg.DockerHTTPPrefix = codit_config.NormalizeHTTPPrefix(cfg.DockerHTTPPrefix, "/v2")
cfg.GitHTTPPrefix = codit_config.NormalizeHTTPPrefix(cfg.GitHTTPPrefix, "/git") cfg.GitHTTPPrefix = codit_config.NormalizeHTTPPrefix(cfg.GitHTTPPrefix, "/git")
cfg.RPMHTTPPrefix = codit_config.NormalizeHTTPPrefix(cfg.RPMHTTPPrefix, "/rpm") cfg.RPMHTTPPrefix = codit_config.NormalizeHTTPPrefix(cfg.RPMHTTPPrefix, "/rpm")
app.cfg = cfg app.cfg = cfg
app.logger = logger app.logger = logger
app.serverId = NormalizeServerId(identifier) app.serverId = NormalizeServerId(identifier)
if cfg.App.SiteName == "" { app.siteName = strings.TrimSpace(cfg.App.SiteName)
app.siteName = TitleFromServerId(app.serverId) if app.siteName == "" {
} else { // get the host from the public base url if set.
app.siteName = cfg.App.SiteName var tmp string
tmp = strings.TrimSpace(cfg.PublicBaseURL)
if tmp != "" {
var parsed *url.URL
parsed, err = url.Parse(tmp)
if err == nil && parsed != nil {
app.siteName = strings.TrimSpace(parsed.Host)
}
}
if app.siteName == "" { app.siteName = TitleFromServerId(app.serverId) }
} }
app.docker_base_dir = filepath.Join(cfg.DataDir, "docker") app.docker_base_dir = filepath.Join(cfg.DataDir, "docker")
+2
View File
@@ -521,9 +521,11 @@ Important fields:
- `name`, `host`, `port` - `name`, `host`, `port`
- `description`, `tags_json` - `description`, `tags_json`
- `enabled` - `enabled`
- `host_key_policy`: `strict` requires an existing pinned host key; `trust_on_first_use` allows the first connection to pin the observed host key.
- `created_by_kind`, `created_by_subject_id`, `created_by_subject_name` - `created_by_kind`, `created_by_subject_id`, `created_by_subject_name`
`created_by_kind = 'user'` indicates a personal server; admin-created servers are shared through access profiles and targets. `created_by_kind = 'user'` indicates a personal server; admin-created servers are shared through access profiles and targets.
When `host_key_policy = 'trust_on_first_use'`, the first successful SSH connection writes the observed key into `ssh_server_host_keys`; later connections use the pinned-key check.
### `ssh_server_host_keys` ### `ssh_server_host_keys`
+40 -8
View File
@@ -3922,11 +3922,11 @@ paths:
operationId: CreateSSHServerAdmin operationId: CreateSSHServerAdmin
x-codit-handler: CreateSSHServerAdmin x-codit-handler: CreateSSHServerAdmin
requestBody: requestBody:
required: false required: true
content: content:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/RequestObject' $ref: '#/components/schemas/SSHServerRequest'
responses: responses:
'200': '200':
description: Successful response. description: Successful response.
@@ -3977,11 +3977,11 @@ paths:
operationId: UpdateSSHServerAdmin operationId: UpdateSSHServerAdmin
x-codit-handler: UpdateSSHServerAdmin x-codit-handler: UpdateSSHServerAdmin
requestBody: requestBody:
required: false required: true
content: content:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/RequestObject' $ref: '#/components/schemas/SSHServerRequest'
parameters: parameters:
- name: id - name: id
in: path in: path
@@ -4838,11 +4838,11 @@ paths:
operationId: CreateSSHServerForSelf operationId: CreateSSHServerForSelf
x-codit-handler: CreateSSHServerForSelf x-codit-handler: CreateSSHServerForSelf
requestBody: requestBody:
required: false required: true
content: content:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/RequestObject' $ref: '#/components/schemas/SSHServerRequest'
responses: responses:
'200': '200':
description: Successful response. description: Successful response.
@@ -4889,11 +4889,11 @@ paths:
operationId: UpdateSSHServerForSelf operationId: UpdateSSHServerForSelf
x-codit-handler: UpdateSSHServerForSelf x-codit-handler: UpdateSSHServerForSelf
requestBody: requestBody:
required: false required: true
content: content:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/RequestObject' $ref: '#/components/schemas/SSHServerRequest'
parameters: parameters:
- name: id - name: id
in: path in: path
@@ -9515,9 +9515,41 @@ components:
type: string type: string
enabled: enabled:
type: boolean type: boolean
host_key_policy:
type: string
enum:
- strict
- trust_on_first_use
editable: editable:
type: boolean type: boolean
additionalProperties: true additionalProperties: true
SSHServerRequest:
type: object
description: 'Request payload for creating or updating an SSH server. host_key_policy defaults to strict if omitted or invalid.'
properties:
name:
type: string
host:
type: string
port:
type: integer
default: 22
description:
type: string
tags:
type: array
items:
type: string
enabled:
type: boolean
host_key_policy:
type: string
enum:
- strict
- trust_on_first_use
default: strict
description: 'strict requires a pinned host key before connecting. trust_on_first_use accepts the first observed host key and stores it as a pinned key.'
additionalProperties: false
SSHServerHostKey: SSHServerHostKey:
type: object type: object
properties: properties:
+2
View File
@@ -911,6 +911,7 @@ export interface SSHServer {
description: string description: string
tags: string[] tags: string[]
enabled: boolean enabled: boolean
host_key_policy: 'strict' | 'trust_on_first_use'
created_by_kind: string created_by_kind: string
created_by_subject_id: string created_by_subject_id: string
created_by_subject_name: string created_by_subject_name: string
@@ -991,6 +992,7 @@ export type SSHServerUpsertPayload = {
description: string description: string
tags: string[] tags: string[]
enabled: boolean enabled: boolean
host_key_policy: 'strict' | 'trust_on_first_use'
} }
export type SSHAccessProfileTargetPayload = { export type SSHAccessProfileTargetPayload = {
@@ -20,6 +20,13 @@ function fmt(value: number): string {
return new Date(value * 1000).toLocaleString() return new Date(value * 1000).toLocaleString()
} }
function formatHostKeyPolicy(value: string | undefined): string {
if (value === 'trust_on_first_use') {
return 'Trust on first use'
}
return 'Strict'
}
export default function SSHServerDetailsDialog(props: SSHServerDetailsDialogProps) { export default function SSHServerDetailsDialog(props: SSHServerDetailsDialogProps) {
const item: SSHServer | null = props.item const item: SSHServer | null = props.item
const showAdminFields: boolean = Boolean(props.showAdminFields) const showAdminFields: boolean = Boolean(props.showAdminFields)
@@ -34,6 +41,7 @@ export default function SSHServerDetailsDialog(props: SSHServerDetailsDialogProp
<TextField label="Host" value={item?.host || ''} InputProps={{ readOnly: true }} /> <TextField label="Host" value={item?.host || ''} InputProps={{ readOnly: true }} />
<TextField label="Port" value={String(item?.port || 0)} InputProps={{ readOnly: true }} /> <TextField label="Port" value={String(item?.port || 0)} InputProps={{ readOnly: true }} />
<TextField label="Enabled" value={item?.enabled ? 'yes' : 'no'} InputProps={{ readOnly: true }} /> <TextField label="Enabled" value={item?.enabled ? 'yes' : 'no'} InputProps={{ readOnly: true }} />
<TextField label="Host Key Policy" value={formatHostKeyPolicy(item?.host_key_policy)} InputProps={{ readOnly: true }} />
<TextField label="Tags" value={(item?.tags || []).join(', ') || '-'} InputProps={{ readOnly: true }} /> <TextField label="Tags" value={(item?.tags || []).join(', ') || '-'} InputProps={{ readOnly: true }} />
{showAdminFields ? <TextField label="Created By Kind" value={item?.created_by_kind || '-'} InputProps={{ readOnly: true }} /> : null} {showAdminFields ? <TextField label="Created By Kind" value={item?.created_by_kind || '-'} InputProps={{ readOnly: true }} /> : null}
{showAdminFields ? <TextField label="Created By Subject ID" value={item?.created_by_subject_id || '-'} InputProps={{ readOnly: true }} /> : null} {showAdminFields ? <TextField label="Created By Subject ID" value={item?.created_by_subject_id || '-'} InputProps={{ readOnly: true }} /> : null}
@@ -6,6 +6,7 @@ import Dialog from '@mui/material/Dialog'
import DialogActions from '@mui/material/DialogActions' import DialogActions from '@mui/material/DialogActions'
import DialogTitle from '@mui/material/DialogTitle' import DialogTitle from '@mui/material/DialogTitle'
import FormControlLabel from '@mui/material/FormControlLabel' import FormControlLabel from '@mui/material/FormControlLabel'
import MenuItem from '@mui/material/MenuItem'
import TextField from '@mui/material/TextField' import TextField from '@mui/material/TextField'
import Typography from '@mui/material/Typography' import Typography from '@mui/material/Typography'
import FormDialogContent from './FormDialogContent' import FormDialogContent from './FormDialogContent'
@@ -17,6 +18,7 @@ export type SSHServerFormState = {
description: string description: string
tagsText: string tagsText: string
enabled: boolean enabled: boolean
hostKeyPolicy: 'strict' | 'trust_on_first_use'
} }
type SSHServerFormDialogProps = { type SSHServerFormDialogProps = {
@@ -67,6 +69,16 @@ export default function SSHServerFormDialog(props: SSHServerFormDialogProps) {
onChange={(event) => props.setForm((prev) => ({ ...prev, tagsText: event.target.value }))} onChange={(event) => props.setForm((prev) => ({ ...prev, tagsText: event.target.value }))}
helperText="Comma-separated tags." helperText="Comma-separated tags."
/> />
<TextField
select
label="Host Key Policy"
value={props.form.hostKeyPolicy}
onChange={(event) => props.setForm((prev) => ({ ...prev, hostKeyPolicy: event.target.value as 'strict' | 'trust_on_first_use' }))}
helperText="Strict requires a pinned key. Trust on first use pins the first observed key."
>
<MenuItem value="strict">Strict - require pinned host key</MenuItem>
<MenuItem value="trust_on_first_use">Trust on first use - pin first observed host key</MenuItem>
</TextField>
<FormControlLabel <FormControlLabel
control={<Checkbox checked={props.form.enabled} onChange={(event) => props.setForm((prev) => ({ ...prev, enabled: event.target.checked }))} />} control={<Checkbox checked={props.form.enabled} onChange={(event) => props.setForm((prev) => ({ ...prev, enabled: event.target.checked }))} />}
label="Enabled" label="Enabled"
+6 -3
View File
@@ -27,7 +27,8 @@ const emptyForm = (): SSHServerFormState => ({
port: 22, port: 22,
description: '', description: '',
tagsText: '', tagsText: '',
enabled: true enabled: true,
hostKeyPolicy: 'strict'
}) })
const emptyGroupForm = (): SSHServerGroupFormState => ({ const emptyGroupForm = (): SSHServerGroupFormState => ({
@@ -249,7 +250,8 @@ export default function AdminSSHServersPage() {
port: item.port, port: item.port,
description: item.description || '', description: item.description || '',
tagsText: (item.tags || []).join(', '), tagsText: (item.tags || []).join(', '),
enabled: item.enabled enabled: item.enabled,
hostKeyPolicy: item.host_key_policy || 'strict'
}) })
setDialogError(null) setDialogError(null)
setDialogOpen(true) setDialogOpen(true)
@@ -272,7 +274,8 @@ export default function AdminSSHServersPage() {
port: Number(form.port) || 0, port: Number(form.port) || 0,
description: form.description.trim(), description: form.description.trim(),
tags: parseTags(form.tagsText), tags: parseTags(form.tagsText),
enabled: form.enabled enabled: form.enabled,
host_key_policy: form.hostKeyPolicy
} }
if (!payload.name) { if (!payload.name) {
setDialogError('Name is required') setDialogError('Name is required')
+21 -5
View File
@@ -50,7 +50,8 @@ const emptyServerForm = (): SSHServerFormState => ({
port: 22, port: 22,
description: '', description: '',
tagsText: '', tagsText: '',
enabled: true enabled: true,
hostKeyPolicy: 'trust_on_first_use'
}) })
export default function SSHServersPage() { export default function SSHServersPage() {
@@ -252,7 +253,8 @@ export default function SSHServersPage() {
port: item.port, port: item.port,
description: item.description || '', description: item.description || '',
tagsText: (item.tags || []).join(', '), tagsText: (item.tags || []).join(', '),
enabled: item.enabled enabled: item.enabled,
hostKeyPolicy: item.host_key_policy || 'strict'
}) })
setServerDialogError(null) setServerDialogError(null)
setServerDialogOpen(true) setServerDialogOpen(true)
@@ -331,6 +333,18 @@ export default function SSHServersPage() {
navigate(`/ssh-sessions?sessions=${encodedIDs}`) navigate(`/ssh-sessions?sessions=${encodedIDs}`)
} }
const getConnectServerLabel = (item: SSHAccessProfile, serverID: string): string => {
const server: SSHServer | undefined = connectServerOptions.find((candidate: SSHServer) => candidate.id === serverID)
if (server) {
return `${server.name} (${server.host}:${server.port})`
}
if (!serverID && item.server) {
return `${item.server.name} (${item.server.host}:${item.server.port})`
}
return serverID || item.server_id || 'server'
}
const connectToProfileServers = async (item: SSHAccessProfile, serverIDs: string[], password?: string, otpCode?: string) => { const connectToProfileServers = async (item: SSHAccessProfile, serverIDs: string[], password?: string, otpCode?: string) => {
let message: string let message: string
let sessions: string[] = [] let sessions: string[] = []
@@ -349,11 +363,11 @@ export default function SSHServersPage() {
sessions = [...sessions, session.session_id] sessions = [...sessions, session.session_id]
} catch (err) { } catch (err) {
message = err instanceof Error ? err.message : 'Failed to connect' message = err instanceof Error ? err.message : 'Failed to connect'
failures = [...failures, message] failures = [...failures, `${getConnectServerLabel(item, serverID)}: ${message}`]
} }
} }
if (sessions.length === 0) { if (sessions.length === 0) {
throw new Error(failures[0] || 'Failed to connect') throw new Error(failures.join('; ') || 'Failed to connect')
} }
clearConnectServerSelection() clearConnectServerSelection()
closeConnectPasswordPrompt() closeConnectPasswordPrompt()
@@ -366,6 +380,7 @@ export default function SSHServersPage() {
if (password !== undefined || otpCode !== undefined) { if (password !== undefined || otpCode !== undefined) {
setConnectPasswordError(message) setConnectPasswordError(message)
} else { } else {
clearConnectServerSelection()
setError(message) setError(message)
} }
} finally { } finally {
@@ -489,7 +504,8 @@ export default function SSHServersPage() {
port: Number(serverForm.port) || 0, port: Number(serverForm.port) || 0,
description: serverForm.description.trim(), description: serverForm.description.trim(),
tags: parseTags(serverForm.tagsText), tags: parseTags(serverForm.tagsText),
enabled: serverForm.enabled enabled: serverForm.enabled,
host_key_policy: serverForm.hostKeyPolicy
} }
if (!payload.name) { if (!payload.name) {