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 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
+2 -2
View File
@@ -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 }
+88 -14
View File
@@ -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)
}
}
+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 {
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()
}
+1
View File
@@ -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
View File
@@ -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")
+2
View File
@@ -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
View File
@@ -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:
+2
View File
@@ -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"
+6 -3
View File
@@ -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')
+21 -5
View File
@@ -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) {