Compare commits
3 Commits
b5e8e9d5e0
...
001f0487e2
| Author | SHA1 | Date | |
|---|---|---|---|
| 001f0487e2 | |||
| fd28f0b722 | |||
| 7c21b910b1 |
@@ -20,7 +20,7 @@ func (s *Store) ListSSHServers() ([]models.SSHServer, error) {
|
||||
var item models.SSHServer
|
||||
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
|
||||
ORDER BY name, host, port`)
|
||||
if err != nil {
|
||||
@@ -29,7 +29,7 @@ func (s *Store) ListSSHServers() ([]models.SSHServer, error) {
|
||||
defer rows.Close()
|
||||
|
||||
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
|
||||
}
|
||||
@@ -52,10 +52,10 @@ func (s *Store) GetSSHServer(id string) (models.SSHServer, error) {
|
||||
var tagsJSON string
|
||||
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
|
||||
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 {
|
||||
return item, err
|
||||
}
|
||||
@@ -75,7 +75,7 @@ func (s *Store) ListSSHServersForUser(userID string) ([]models.SSHServer, error)
|
||||
var trimmedUserID string
|
||||
|
||||
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
|
||||
WHERE created_by_kind = 'user' AND created_by_subject_id = ?
|
||||
ORDER BY name, host, port`, trimmedUserID)
|
||||
@@ -85,7 +85,7 @@ func (s *Store) ListSSHServersForUser(userID string) ([]models.SSHServer, error)
|
||||
defer rows.Close()
|
||||
|
||||
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
|
||||
}
|
||||
@@ -150,6 +150,7 @@ func (s *Store) CreateSSHServer(item models.SSHServer) (models.SSHServer, error)
|
||||
if err != nil { return item, err }
|
||||
}
|
||||
if item.Port <= 0 { item.Port = 22 }
|
||||
item.HostKeyPolicy = normalizeSSHServerHostKeyPolicy(item.HostKeyPolicy)
|
||||
item.Tags = normalizeStringList(item.Tags)
|
||||
tagsJSON, err = encodeStringList(item.Tags)
|
||||
if err != nil { return item, err }
|
||||
@@ -165,12 +166,13 @@ func (s *Store) CreateSSHServer(item models.SSHServer) (models.SSHServer, error)
|
||||
description,
|
||||
tags_json,
|
||||
enabled,
|
||||
host_key_policy,
|
||||
created_by_kind,
|
||||
created_by_subject_id,
|
||||
created_by_subject_name,
|
||||
created_at,
|
||||
updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
item.ID,
|
||||
strings.TrimSpace(item.Name),
|
||||
strings.TrimSpace(item.Host),
|
||||
@@ -178,6 +180,7 @@ func (s *Store) CreateSSHServer(item models.SSHServer) (models.SSHServer, error)
|
||||
strings.TrimSpace(item.Description),
|
||||
tagsJSON,
|
||||
item.Enabled,
|
||||
item.HostKeyPolicy,
|
||||
strings.TrimSpace(item.CreatedByKind),
|
||||
strings.TrimSpace(item.CreatedBySubjectID),
|
||||
strings.TrimSpace(item.CreatedBySubjectName),
|
||||
@@ -192,6 +195,7 @@ func (s *Store) UpdateSSHServer(item models.SSHServer) (models.SSHServer, error)
|
||||
var tagsJSON string
|
||||
|
||||
if item.Port <= 0 { item.Port = 22 }
|
||||
item.HostKeyPolicy = normalizeSSHServerHostKeyPolicy(item.HostKeyPolicy)
|
||||
item.Tags = normalizeStringList(item.Tags)
|
||||
tagsJSON, err = encodeStringList(item.Tags)
|
||||
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.
|
||||
_, 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 = ?`,
|
||||
strings.TrimSpace(item.Name),
|
||||
strings.TrimSpace(item.Host),
|
||||
@@ -207,6 +211,7 @@ func (s *Store) UpdateSSHServer(item models.SSHServer) (models.SSHServer, error)
|
||||
strings.TrimSpace(item.Description),
|
||||
tagsJSON,
|
||||
item.Enabled,
|
||||
item.HostKeyPolicy,
|
||||
item.UpdatedAt,
|
||||
strings.TrimSpace(item.ID),
|
||||
)
|
||||
@@ -281,6 +286,7 @@ func (s *Store) ListSSHAccessProfiles() ([]models.SSHAccessProfile, error) {
|
||||
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,
|
||||
@@ -338,6 +344,7 @@ func (s *Store) ListSSHAccessProfiles() ([]models.SSHAccessProfile, error) {
|
||||
&item.Server.Description,
|
||||
&serverTagsJSON,
|
||||
&item.Server.Enabled,
|
||||
&item.Server.HostKeyPolicy,
|
||||
&item.Server.CreatedByKind,
|
||||
&item.Server.CreatedBySubjectID,
|
||||
&item.Server.CreatedBySubjectName,
|
||||
@@ -415,6 +422,7 @@ func (s *Store) GetSSHAccessProfile(id string) (models.SSHAccessProfile, error)
|
||||
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,
|
||||
@@ -468,6 +476,7 @@ func (s *Store) GetSSHAccessProfile(id string) (models.SSHAccessProfile, error)
|
||||
&item.Server.Description,
|
||||
&serverTagsJSON,
|
||||
&item.Server.Enabled,
|
||||
&item.Server.HostKeyPolicy,
|
||||
&item.Server.CreatedByKind,
|
||||
&item.Server.CreatedBySubjectID,
|
||||
&item.Server.CreatedBySubjectName,
|
||||
@@ -857,6 +866,7 @@ func (s *Store) ListSSHAccessProfilesForUser(userID string) ([]models.SSHAccessP
|
||||
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,
|
||||
@@ -934,6 +944,7 @@ func (s *Store) ListSSHAccessProfilesForUser(userID string) ([]models.SSHAccessP
|
||||
&item.Server.Description,
|
||||
&serverTagsJSON,
|
||||
&item.Server.Enabled,
|
||||
&item.Server.HostKeyPolicy,
|
||||
&item.Server.CreatedByKind,
|
||||
&item.Server.CreatedBySubjectID,
|
||||
&item.Server.CreatedBySubjectName,
|
||||
@@ -1501,6 +1512,14 @@ func emptyStringToNil(raw string) any {
|
||||
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 {
|
||||
var out []string
|
||||
|
||||
|
||||
@@ -72,7 +72,7 @@ func (s *Store) ListSSHServersForGroup(groupID string) ([]models.SSHServer, erro
|
||||
var tagsJSON string
|
||||
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
|
||||
JOIN ssh_servers s ON s.public_id = gm.server_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 }
|
||||
defer rows.Close()
|
||||
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 }
|
||||
item.Tags, err = decodeStringList(tagsJSON)
|
||||
if err != nil { return nil, err }
|
||||
|
||||
@@ -23,12 +23,13 @@ import "codit/internal/models"
|
||||
|
||||
const logIDSSHBroker string = "ssh-broker"
|
||||
type sshServerRequest struct {
|
||||
Name string `json:"name"`
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
Description string `json:"description"`
|
||||
Tags []string `json:"tags"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Name string `json:"name"`
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
Description string `json:"description"`
|
||||
Tags []string `json:"tags"`
|
||||
Enabled bool `json:"enabled"`
|
||||
HostKeyPolicy string `json:"host_key_policy"`
|
||||
}
|
||||
|
||||
type sshCredentialRequest struct {
|
||||
@@ -124,10 +125,11 @@ type sshSessionStreamMessage struct {
|
||||
}
|
||||
|
||||
type sshSessionPrepared struct {
|
||||
Session models.SSHSession
|
||||
Profile models.SSHAccessProfile
|
||||
HostKeys []models.SSHServerHostKey
|
||||
AuthMethods []ssh.AuthMethod
|
||||
Session models.SSHSession
|
||||
Profile models.SSHAccessProfile
|
||||
HostKeys []models.SSHServerHostKey
|
||||
AuthMethods []ssh.AuthMethod
|
||||
PinHostKeyOnFirstUse bool
|
||||
}
|
||||
|
||||
type sshWorkspaceAttachment struct {
|
||||
@@ -318,6 +320,7 @@ func (api *API) CreateSSHServerAdmin(w http.ResponseWriter, r *http.Request, _ m
|
||||
Description: req.Description,
|
||||
Tags: normalizeSSHBrokerTags(req.Tags),
|
||||
Enabled: req.Enabled,
|
||||
HostKeyPolicy: normalizeSSHServerHostKeyPolicy(req.HostKeyPolicy),
|
||||
CreatedByKind: actorKind,
|
||||
CreatedBySubjectID: actorID,
|
||||
CreatedBySubjectName: actorName,
|
||||
@@ -373,6 +376,7 @@ func (api *API) UpdateSSHServerAdmin(w http.ResponseWriter, r *http.Request, par
|
||||
item.Description = req.Description
|
||||
item.Tags = normalizeSSHBrokerTags(req.Tags)
|
||||
item.Enabled = req.Enabled
|
||||
item.HostKeyPolicy = normalizeSSHServerHostKeyPolicy(req.HostKeyPolicy)
|
||||
item, err = api.store(r).UpdateSSHServer(item)
|
||||
if err != nil {
|
||||
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,
|
||||
Tags: normalizeSSHBrokerTags(req.Tags),
|
||||
Enabled: req.Enabled,
|
||||
HostKeyPolicy: normalizeSSHServerHostKeyPolicy(req.HostKeyPolicy),
|
||||
CreatedByKind: "user",
|
||||
CreatedBySubjectID: user.ID,
|
||||
CreatedBySubjectName: user.Username,
|
||||
@@ -1147,6 +1152,7 @@ func (api *API) UpdateSSHServerForSelf(w http.ResponseWriter, r *http.Request, p
|
||||
item.Description = req.Description
|
||||
item.Tags = normalizeSSHBrokerTags(req.Tags)
|
||||
item.Enabled = req.Enabled
|
||||
item.HostKeyPolicy = normalizeSSHServerHostKeyPolicy(req.HostKeyPolicy)
|
||||
item, err = api.store(r).UpdateSSHServer(item)
|
||||
if err != nil {
|
||||
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 prepared sshSessionPrepared
|
||||
var prepareStage string
|
||||
var hostKeyFingerprint string
|
||||
var belongs bool
|
||||
var err error
|
||||
|
||||
@@ -1654,6 +1661,9 @@ func (api *API) CreateSSHSessionForSelf(w http.ResponseWriter, r *http.Request,
|
||||
api.SSHPreparedSessionStore.Put(item.ID, prepared)
|
||||
}
|
||||
})
|
||||
if len(prepared.HostKeys) > 0 {
|
||||
hostKeyFingerprint = prepared.HostKeys[0].Fingerprint
|
||||
}
|
||||
response = sshSessionConnectResponse{
|
||||
SessionID: item.ID,
|
||||
Status: item.Status,
|
||||
@@ -1662,7 +1672,7 @@ func (api *API) CreateSSHSessionForSelf(w http.ResponseWriter, r *http.Request,
|
||||
Host: profile.Server.Host,
|
||||
Port: profile.Server.Port,
|
||||
RemoteUsername: profile.RemoteUsername,
|
||||
HostKeyFingerprint: prepared.HostKeys[0].Fingerprint,
|
||||
HostKeyFingerprint: hostKeyFingerprint,
|
||||
}
|
||||
WriteJSON(w, http.StatusCreated, response)
|
||||
}
|
||||
@@ -2156,6 +2166,14 @@ func normalizeSSHBrokerTags(raw []string) []string {
|
||||
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 {
|
||||
var out []string
|
||||
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
|
||||
}
|
||||
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)
|
||||
@@ -2539,8 +2560,12 @@ func (api *API) runSSHSessionStream(store *db.Store, user models.User, sessionIt
|
||||
var closeReason string
|
||||
var err error
|
||||
var connectedFingerprint string
|
||||
var connectedHostKey ssh.PublicKey
|
||||
var connectedAt int64
|
||||
var now int64
|
||||
var pinnedHostKey models.SSHServerHostKey
|
||||
var reloadedHostKeys []models.SSHServerHostKey
|
||||
var i int
|
||||
var shellErrCh chan error
|
||||
var done bool
|
||||
var waitErr error
|
||||
@@ -2629,7 +2654,7 @@ func (api *API) runSSHSessionStream(store *db.Store, user models.User, sessionIt
|
||||
sshConfig = &ssh.ClientConfig{
|
||||
User: profile.RemoteUsername,
|
||||
Auth: authMethods,
|
||||
HostKeyCallback: sshHostKeyCallback(hostKeys, &connectedFingerprint),
|
||||
HostKeyCallback: sshHostKeyCallback(hostKeys, &connectedFingerprint, &connectedHostKey, prepared.PinHostKeyOnFirstUse),
|
||||
Timeout: 15 * time.Second,
|
||||
}
|
||||
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
|
||||
}
|
||||
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)
|
||||
sshSession, err = sshClient.NewSession()
|
||||
if err != nil {
|
||||
@@ -3015,7 +3087,7 @@ func (api *API) resolveSSHAccessProfilePrincipals(store *db.Store, profile model
|
||||
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 i int
|
||||
|
||||
@@ -3027,7 +3099,9 @@ func sshHostKeyCallback(items []models.SSHServerHostKey, connectedFingerprint *s
|
||||
var fingerprint string
|
||||
fingerprint = strings.TrimSpace(ssh.FingerprintSHA256(key))
|
||||
if connectedFingerprint != nil { *connectedFingerprint = fingerprint }
|
||||
if connectedHostKey != nil { *connectedHostKey = key }
|
||||
if allowed[fingerprint] { return nil}
|
||||
if trustOnFirstUse && len(items) == 0 { return nil }
|
||||
return errors.New("host key mismatch: " + fingerprint)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,29 +144,18 @@ func (api *API) SetupMyTOTP(w http.ResponseWriter, r *http.Request, _ map[string
|
||||
}
|
||||
|
||||
func (api *API) totpIssuer(r *http.Request) string {
|
||||
var raw string
|
||||
var parsed *url.URL
|
||||
var err error
|
||||
var host string
|
||||
|
||||
raw = strings.TrimSpace(api.Cfg.PublicBaseURL)
|
||||
if raw != "" {
|
||||
parsed, err = url.Parse(raw)
|
||||
if err == nil {
|
||||
host = strings.TrimSpace(parsed.Host)
|
||||
if host != "" {
|
||||
return host
|
||||
}
|
||||
}
|
||||
}
|
||||
// if the site name is set, use it as a totp issuer
|
||||
host = api.siteName()
|
||||
if host != "" { return host }
|
||||
|
||||
// use the actual access host name as a totp issuer
|
||||
host = cleanTOTPHost(r.Header.Get("X-Forwarded-Host"))
|
||||
if host != "" {
|
||||
return host
|
||||
}
|
||||
if host != "" { return host }
|
||||
host = cleanTOTPHost(r.Host)
|
||||
if host != "" {
|
||||
return host
|
||||
}
|
||||
if host != "" { return host }
|
||||
|
||||
return api.serverTitle()
|
||||
}
|
||||
|
||||
|
||||
@@ -526,6 +526,7 @@ type SSHServer struct {
|
||||
Description string `json:"description"`
|
||||
Tags []string `json:"tags"`
|
||||
Enabled bool `json:"enabled"`
|
||||
HostKeyPolicy string `json:"host_key_policy"`
|
||||
CreatedByKind string `json:"created_by_kind"`
|
||||
CreatedBySubjectID string `json:"created_by_subject_id"`
|
||||
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
@@ -15,6 +15,7 @@ import "log"
|
||||
import "net"
|
||||
import "net/http"
|
||||
import "net/netip"
|
||||
import "net/url"
|
||||
import "os"
|
||||
import "path/filepath"
|
||||
import "sync"
|
||||
@@ -412,13 +413,23 @@ func newServerWithInternalConfig(cfg *codit_config.Config, logger Logger, identi
|
||||
cfg.DockerHTTPPrefix = codit_config.NormalizeHTTPPrefix(cfg.DockerHTTPPrefix, "/v2")
|
||||
cfg.GitHTTPPrefix = codit_config.NormalizeHTTPPrefix(cfg.GitHTTPPrefix, "/git")
|
||||
cfg.RPMHTTPPrefix = codit_config.NormalizeHTTPPrefix(cfg.RPMHTTPPrefix, "/rpm")
|
||||
|
||||
app.cfg = cfg
|
||||
app.logger = logger
|
||||
app.serverId = NormalizeServerId(identifier)
|
||||
if cfg.App.SiteName == "" {
|
||||
app.siteName = TitleFromServerId(app.serverId)
|
||||
} else {
|
||||
app.siteName = cfg.App.SiteName
|
||||
app.siteName = strings.TrimSpace(cfg.App.SiteName)
|
||||
if app.siteName == "" {
|
||||
// get the host from the public base url if set.
|
||||
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")
|
||||
|
||||
@@ -521,9 +521,11 @@ Important fields:
|
||||
- `name`, `host`, `port`
|
||||
- `description`, `tags_json`
|
||||
- `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 = '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`
|
||||
|
||||
|
||||
+40
-8
@@ -3922,11 +3922,11 @@ paths:
|
||||
operationId: CreateSSHServerAdmin
|
||||
x-codit-handler: CreateSSHServerAdmin
|
||||
requestBody:
|
||||
required: false
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RequestObject'
|
||||
$ref: '#/components/schemas/SSHServerRequest'
|
||||
responses:
|
||||
'200':
|
||||
description: Successful response.
|
||||
@@ -3977,11 +3977,11 @@ paths:
|
||||
operationId: UpdateSSHServerAdmin
|
||||
x-codit-handler: UpdateSSHServerAdmin
|
||||
requestBody:
|
||||
required: false
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RequestObject'
|
||||
$ref: '#/components/schemas/SSHServerRequest'
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
@@ -4838,11 +4838,11 @@ paths:
|
||||
operationId: CreateSSHServerForSelf
|
||||
x-codit-handler: CreateSSHServerForSelf
|
||||
requestBody:
|
||||
required: false
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RequestObject'
|
||||
$ref: '#/components/schemas/SSHServerRequest'
|
||||
responses:
|
||||
'200':
|
||||
description: Successful response.
|
||||
@@ -4889,11 +4889,11 @@ paths:
|
||||
operationId: UpdateSSHServerForSelf
|
||||
x-codit-handler: UpdateSSHServerForSelf
|
||||
requestBody:
|
||||
required: false
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RequestObject'
|
||||
$ref: '#/components/schemas/SSHServerRequest'
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
@@ -9515,9 +9515,41 @@ components:
|
||||
type: string
|
||||
enabled:
|
||||
type: boolean
|
||||
host_key_policy:
|
||||
type: string
|
||||
enum:
|
||||
- strict
|
||||
- trust_on_first_use
|
||||
editable:
|
||||
type: boolean
|
||||
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:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
@@ -911,6 +911,7 @@ export interface SSHServer {
|
||||
description: string
|
||||
tags: string[]
|
||||
enabled: boolean
|
||||
host_key_policy: 'strict' | 'trust_on_first_use'
|
||||
created_by_kind: string
|
||||
created_by_subject_id: string
|
||||
created_by_subject_name: string
|
||||
@@ -991,6 +992,7 @@ export type SSHServerUpsertPayload = {
|
||||
description: string
|
||||
tags: string[]
|
||||
enabled: boolean
|
||||
host_key_policy: 'strict' | 'trust_on_first_use'
|
||||
}
|
||||
|
||||
export type SSHAccessProfileTargetPayload = {
|
||||
|
||||
@@ -20,6 +20,13 @@ function fmt(value: number): string {
|
||||
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) {
|
||||
const item: SSHServer | null = props.item
|
||||
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="Port" value={String(item?.port || 0)} 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 }} />
|
||||
{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}
|
||||
|
||||
@@ -6,6 +6,7 @@ import Dialog from '@mui/material/Dialog'
|
||||
import DialogActions from '@mui/material/DialogActions'
|
||||
import DialogTitle from '@mui/material/DialogTitle'
|
||||
import FormControlLabel from '@mui/material/FormControlLabel'
|
||||
import MenuItem from '@mui/material/MenuItem'
|
||||
import TextField from '@mui/material/TextField'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import FormDialogContent from './FormDialogContent'
|
||||
@@ -17,6 +18,7 @@ export type SSHServerFormState = {
|
||||
description: string
|
||||
tagsText: string
|
||||
enabled: boolean
|
||||
hostKeyPolicy: 'strict' | 'trust_on_first_use'
|
||||
}
|
||||
|
||||
type SSHServerFormDialogProps = {
|
||||
@@ -67,6 +69,16 @@ export default function SSHServerFormDialog(props: SSHServerFormDialogProps) {
|
||||
onChange={(event) => props.setForm((prev) => ({ ...prev, tagsText: event.target.value }))}
|
||||
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
|
||||
control={<Checkbox checked={props.form.enabled} onChange={(event) => props.setForm((prev) => ({ ...prev, enabled: event.target.checked }))} />}
|
||||
label="Enabled"
|
||||
|
||||
@@ -27,7 +27,8 @@ const emptyForm = (): SSHServerFormState => ({
|
||||
port: 22,
|
||||
description: '',
|
||||
tagsText: '',
|
||||
enabled: true
|
||||
enabled: true,
|
||||
hostKeyPolicy: 'strict'
|
||||
})
|
||||
|
||||
const emptyGroupForm = (): SSHServerGroupFormState => ({
|
||||
@@ -249,7 +250,8 @@ export default function AdminSSHServersPage() {
|
||||
port: item.port,
|
||||
description: item.description || '',
|
||||
tagsText: (item.tags || []).join(', '),
|
||||
enabled: item.enabled
|
||||
enabled: item.enabled,
|
||||
hostKeyPolicy: item.host_key_policy || 'strict'
|
||||
})
|
||||
setDialogError(null)
|
||||
setDialogOpen(true)
|
||||
@@ -272,7 +274,8 @@ export default function AdminSSHServersPage() {
|
||||
port: Number(form.port) || 0,
|
||||
description: form.description.trim(),
|
||||
tags: parseTags(form.tagsText),
|
||||
enabled: form.enabled
|
||||
enabled: form.enabled,
|
||||
host_key_policy: form.hostKeyPolicy
|
||||
}
|
||||
if (!payload.name) {
|
||||
setDialogError('Name is required')
|
||||
|
||||
@@ -50,7 +50,8 @@ const emptyServerForm = (): SSHServerFormState => ({
|
||||
port: 22,
|
||||
description: '',
|
||||
tagsText: '',
|
||||
enabled: true
|
||||
enabled: true,
|
||||
hostKeyPolicy: 'trust_on_first_use'
|
||||
})
|
||||
|
||||
export default function SSHServersPage() {
|
||||
@@ -252,7 +253,8 @@ export default function SSHServersPage() {
|
||||
port: item.port,
|
||||
description: item.description || '',
|
||||
tagsText: (item.tags || []).join(', '),
|
||||
enabled: item.enabled
|
||||
enabled: item.enabled,
|
||||
hostKeyPolicy: item.host_key_policy || 'strict'
|
||||
})
|
||||
setServerDialogError(null)
|
||||
setServerDialogOpen(true)
|
||||
@@ -331,6 +333,18 @@ export default function SSHServersPage() {
|
||||
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) => {
|
||||
let message: string
|
||||
let sessions: string[] = []
|
||||
@@ -349,11 +363,11 @@ export default function SSHServersPage() {
|
||||
sessions = [...sessions, session.session_id]
|
||||
} catch (err) {
|
||||
message = err instanceof Error ? err.message : 'Failed to connect'
|
||||
failures = [...failures, message]
|
||||
failures = [...failures, `${getConnectServerLabel(item, serverID)}: ${message}`]
|
||||
}
|
||||
}
|
||||
if (sessions.length === 0) {
|
||||
throw new Error(failures[0] || 'Failed to connect')
|
||||
throw new Error(failures.join('; ') || 'Failed to connect')
|
||||
}
|
||||
clearConnectServerSelection()
|
||||
closeConnectPasswordPrompt()
|
||||
@@ -366,6 +380,7 @@ export default function SSHServersPage() {
|
||||
if (password !== undefined || otpCode !== undefined) {
|
||||
setConnectPasswordError(message)
|
||||
} else {
|
||||
clearConnectServerSelection()
|
||||
setError(message)
|
||||
}
|
||||
} finally {
|
||||
@@ -489,7 +504,8 @@ export default function SSHServersPage() {
|
||||
port: Number(serverForm.port) || 0,
|
||||
description: serverForm.description.trim(),
|
||||
tags: parseTags(serverForm.tagsText),
|
||||
enabled: serverForm.enabled
|
||||
enabled: serverForm.enabled,
|
||||
host_key_policy: serverForm.hostKeyPolicy
|
||||
}
|
||||
|
||||
if (!payload.name) {
|
||||
|
||||
Reference in New Issue
Block a user