Compare commits

...

8 Commits

18 changed files with 4364 additions and 2905 deletions
+9 -9
View File
@@ -73,6 +73,7 @@ func (h *gitPathRewriteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
var repoStorageID int64
var err error
var newPath string
currentStore = requestStore(r, h.store)
path = strings.TrimPrefix(r.URL.Path, "/")
if path == "" {
@@ -412,6 +413,8 @@ func main() {
Uploads: uploadStore,
Logger: logger,
SSHSessionRegistry: handlers.NewSSHSessionRegistry(),
SSHPromptedPasswordStore: handlers.NewSSHPromptedPasswordStore(),
SSHPreparedSessionStore: handlers.NewSSHPreparedSessionStore(),
}
rpmMirror.Start()
@@ -423,6 +426,7 @@ func main() {
var gitServer http.Handler
var authFunc func(store *db.Store, username string, password string) (bool, error)
authFunc = func(store *db.Store, username string, password string) (bool, error) {
var user models.User
var principal models.ServicePrincipal
@@ -601,6 +605,7 @@ func main() {
router.Handle("GET", "/api/admin/ssh/access-profiles/:id", api.GetSSHAccessProfileAdmin)
router.Handle("PATCH", "/api/admin/ssh/access-profiles/:id", api.UpdateSSHAccessProfileAdmin)
router.Handle("DELETE", "/api/admin/ssh/access-profiles/:id", api.DeleteSSHAccessProfileAdmin)
router.Handle("GET", "/api/admin/ssh/sessions", api.ListSSHSessionsAdmin)
router.Handle("GET", "/api/ssh/user-cas", api.ListSSHUserCAsForSelf)
router.Handle("GET", "/api/ssh/user-cas/:id", api.GetSSHUserCAForSelf)
router.Handle("GET", "/api/ssh/user-cas/:id/public-key", api.DownloadSSHUserCAPublicKeyForSelf)
@@ -620,6 +625,7 @@ func main() {
router.Handle("DELETE", "/api/ssh/access-profiles/:id", api.DeleteSSHAccessProfileForSelf)
router.Handle("POST", "/api/ssh/access-profiles/:id/connect", api.CreateSSHSessionForSelf)
router.Handle("GET", "/api/ssh/sessions", api.ListSSHSessionsForSelf)
router.Handle("GET", "/api/ssh/stream", api.StreamSSHWorkspaceForSelf)
router.Handle("GET", "/api/ssh/sessions/:id", api.GetSSHSessionForSelf)
router.Handle("POST", "/api/ssh/sessions/:id/disconnect", api.DisconnectSSHSessionForSelf)
router.Handle("GET", "/api/ssh/sessions/:id/stream", api.StreamSSHSessionForSelf)
@@ -1160,10 +1166,7 @@ func requestClientCertFingerprint(r *http.Request) string {
}
func isClientCertAllowed(fp string, policy listenerAuthPolicy) bool {
if len(policy.CertAllowlist) == 0 {
return true
}
if len(policy.CertAllowlist) == 0 { return true }
return policy.CertAllowlist[fp]
}
@@ -1171,12 +1174,9 @@ func requestStore(r *http.Request, store *db.Store) *db.Store {
var requestStore *db.Store
var ok bool
if r == nil {
return store
}
if r =! nil {
requestStore, ok = middleware.StoreFromContext(r.Context())
if ok && requestStore != nil {
return requestStore
if ok && requestStore != nil { return requestStore }
}
return store
}
+5
View File
@@ -30,6 +30,11 @@ func Open(driver, dsn string) (*Store, error) {
goto oops
}
//if drv == "sqlite" {
// db.SetMaxOpenConns(10)
// db.SetMaxIdleConns(1)
//}
err = db.Ping()
if err != nil {
goto oops
+185 -125
View File
@@ -3,6 +3,7 @@ package db
import "database/sql"
import "encoding/json"
import "errors"
import "fmt"
import "sort"
import "strings"
import "time"
@@ -144,18 +145,12 @@ func (s *Store) CreateSSHServer(item models.SSHServer) (models.SSHServer, error)
if strings.TrimSpace(item.ID) == "" {
item.ID, err = util.NewID()
if err != nil {
return item, err
}
}
if item.Port <= 0 {
item.Port = 22
if err != nil { return item, err }
}
if item.Port <= 0 { item.Port = 22 }
item.Tags = normalizeStringList(item.Tags)
tagsJSON, err = encodeStringList(item.Tags)
if err != nil {
return item, err
}
if err != nil { return item, err }
now = time.Now().UTC().Unix()
item.CreatedAt = now
item.UpdatedAt = now
@@ -187,26 +182,20 @@ func (s *Store) CreateSSHServer(item models.SSHServer) (models.SSHServer, error)
item.CreatedAt,
item.UpdatedAt,
)
if err != nil {
return item, err
}
return item, nil
}
func (s *Store) UpdateSSHServer(item models.SSHServer) (models.SSHServer, error) {
var err error
var tagsJSON string
if item.Port <= 0 {
item.Port = 22
}
if item.Port <= 0 { item.Port = 22 }
item.Tags = normalizeStringList(item.Tags)
tagsJSON, err = encodeStringList(item.Tags)
if err != nil {
return item, err
}
if err != nil { return item, err }
item.UpdatedAt = time.Now().UTC().Unix()
// TODO: need to protect it with tx.
_, err = s.Exec(`UPDATE ssh_servers
SET name = ?, host = ?, port = ?, description = ?, tags_json = ?, enabled = ?, updated_at = ?
WHERE public_id = ?`,
@@ -219,19 +208,13 @@ func (s *Store) UpdateSSHServer(item models.SSHServer) (models.SSHServer, error)
item.UpdatedAt,
strings.TrimSpace(item.ID),
)
if err != nil {
return item, err
}
item, err = s.GetSSHServer(item.ID)
if err != nil {
return item, err
}
return item, nil
if err != nil { return item, err }
return s.GetSSHServer(item.ID)
}
func (s *Store) DeleteSSHServer(id string) error {
var err error
_, err = s.Exec(`DELETE FROM ssh_servers WHERE public_id = ?`, strings.TrimSpace(id))
return err
}
@@ -285,9 +268,7 @@ func (s *Store) ListSSHAccessProfiles() ([]models.SSHAccessProfile, error) {
FROM ssh_access_profiles p
JOIN ssh_servers s ON s.public_id = p.server_public_id
ORDER BY s.name, p.name`)
if err != nil {
return nil, err
}
if err != nil { return nil, err }
defer rows.Close()
for rows.Next() {
@@ -329,31 +310,24 @@ func (s *Store) ListSSHAccessProfiles() ([]models.SSHAccessProfile, error) {
&item.Server.CreatedAt,
&item.Server.UpdatedAt,
)
if err != nil {
return nil, err
}
if err != nil { return nil, err }
item.SSHPrincipals, err = decodeStringList(principalsJSON)
if err != nil {
return nil, err
}
if err != nil { return nil, err }
item.SSHPrincipalGrantIDs, err = decodeStringList(grantIDsJSON)
if err != nil {
return nil, err
}
if err != nil { return nil, err }
item.Server.Tags, err = decodeStringList(serverTagsJSON)
if err != nil {
return nil, err
}
if err != nil { return nil, err }
item.Targets, err = s.listSSHAccessProfileTargets(item.ID)
if err != nil {
return nil, err
}
if err != nil { return nil, err }
items = append(items, item)
}
err = rows.Err()
if err != nil {
return nil, err
}
if err != nil { return nil, err }
return items, nil
}
@@ -490,38 +464,32 @@ func (s *Store) CreateSSHAccessProfile(item models.SSHAccessProfile) (models.SSH
}
if strings.TrimSpace(item.ID) == "" {
item.ID, err = util.NewID()
if err != nil {
return item, err
}
}
if item.DefaultValidSeconds <= 0 {
item.DefaultValidSeconds = 3600
}
if item.MaxValidSeconds <= 0 {
item.MaxValidSeconds = item.DefaultValidSeconds
if err != nil { return item, err }
}
if item.DefaultValidSeconds <= 0 { item.DefaultValidSeconds = 3600 }
if item.MaxValidSeconds <= 0 { item.MaxValidSeconds = item.DefaultValidSeconds }
item.SSHPrincipals = normalizeStringList(item.SSHPrincipals)
item.SSHPrincipalGrantIDs = normalizeStringList(item.SSHPrincipalGrantIDs)
principalsJSON, err = encodeStringList(item.SSHPrincipals)
if err != nil {
return item, err
}
if err != nil { return item, err }
grantIDsJSON, err = encodeStringList(item.SSHPrincipalGrantIDs)
if err != nil {
return item, err
}
if err != nil { return item, err }
now = time.Now().UTC().Unix()
item.CreatedAt = now
item.UpdatedAt = now
tx, owned, err = s.begin()
if err != nil {
return item, err
}
if strings.TrimSpace(item.SecretPayload) != "" {
if err != nil { return item, err }
item.SecretPayload = strings.TrimSpace(item.SecretPayload)
item.SecretPassword = strings.TrimSpace(item.SecretPassword)
if item.SecretPayload != "" || item.SecretPassword != "" {
secret = models.SSHSecret{
ID: item.SecretID,
Kind: "private_key",
Payload: item.SecretPayload,
Password: item.SecretPassword,
CreatedByKind: item.CreatedByKind,
CreatedBySubjectID: item.CreatedBySubjectID,
CreatedBySubjectName: item.CreatedBySubjectName,
@@ -629,32 +597,30 @@ func (s *Store) UpdateSSHAccessProfile(item models.SSHAccessProfile) (models.SSH
if strings.TrimSpace(item.AuthMethod) == "" {
return item, errors.New("auth_method is required")
}
if item.DefaultValidSeconds <= 0 {
item.DefaultValidSeconds = 3600
}
if item.MaxValidSeconds <= 0 {
item.MaxValidSeconds = item.DefaultValidSeconds
}
if item.DefaultValidSeconds <= 0 { item.DefaultValidSeconds = 3600 }
if item.MaxValidSeconds <= 0 { item.MaxValidSeconds = item.DefaultValidSeconds }
item.SSHPrincipals = normalizeStringList(item.SSHPrincipals)
item.SSHPrincipalGrantIDs = normalizeStringList(item.SSHPrincipalGrantIDs)
principalsJSON, err = encodeStringList(item.SSHPrincipals)
if err != nil {
return item, err
}
if err != nil { return item, err }
grantIDsJSON, err = encodeStringList(item.SSHPrincipalGrantIDs)
if err != nil {
return item, err
}
if err != nil { return item, err }
item.UpdatedAt = time.Now().UTC().Unix()
tx, owned, err = s.begin()
if err != nil {
return item, err
}
if strings.TrimSpace(item.SecretPayload) != "" {
if err != nil { return item, err }
item.SecretPayload = strings.TrimSpace(item.SecretPayload)
item.SecretPassword = strings.TrimSpace(item.SecretPassword)
if item.SecretPayload != "" || item.SecretPassword != "" {
secret = models.SSHSecret{
ID: item.SecretID,
Kind: "private_key",
Payload: item.SecretPayload,
Password: item.SecretPassword,
CreatedByKind: item.CreatedByKind,
CreatedBySubjectID: item.CreatedBySubjectID,
CreatedBySubjectName: item.CreatedBySubjectName,
@@ -875,32 +841,26 @@ func (s *Store) ListSSHAccessProfilesForUser(userID string) ([]models.SSHAccessP
&item.Server.CreatedAt,
&item.Server.UpdatedAt,
)
if err != nil {
return nil, err
}
if err != nil { return nil, err }
item.SSHPrincipals, err = decodeStringList(principalsJSON)
if err != nil {
return nil, err
}
if err != nil { return nil, err }
item.SSHPrincipalGrantIDs, err = decodeStringList(grantIDsJSON)
if err != nil {
return nil, err
}
if err != nil { return nil, err }
item.Server.Tags, err = decodeStringList(serverTagsJSON)
if err != nil {
return nil, err
}
if err != nil { return nil, err }
item.Targets, err = s.listSSHAccessProfileTargets(item.ID)
if err != nil {
return nil, err
}
if err != nil { return nil, err }
item.Editable = item.OwnerScope == "user" && item.OwnerUserID == trimmedUserID
items = append(items, item)
}
err = rows.Err()
if err != nil {
return nil, err
}
if err != nil { return nil, err }
return items, nil
}
@@ -971,10 +931,8 @@ func (s *Store) GetSSHSecret(id string) (models.SSHSecret, error) {
var item models.SSHSecret
var err error
row = s.QueryRow(`SELECT public_id, kind, payload, metadata_json, created_by_kind, created_by_subject_id, created_by_subject_name, created_at, updated_at
FROM ssh_secrets
WHERE public_id = ?`, strings.TrimSpace(id))
err = row.Scan(&item.ID, &item.Kind, &item.Payload, &item.MetadataJSON, &item.CreatedByKind, &item.CreatedBySubjectID, &item.CreatedBySubjectName, &item.CreatedAt, &item.UpdatedAt)
row = s.QueryRow(`SELECT public_id, kind, payload, password, metadata_json, created_by_kind, created_by_subject_id, created_by_subject_name, created_at, updated_at FROM ssh_secrets WHERE public_id = ?`, strings.TrimSpace(id))
err = row.Scan(&item.ID, &item.Kind, &item.Payload, &item.Password, &item.MetadataJSON, &item.CreatedByKind, &item.CreatedBySubjectID, &item.CreatedBySubjectName, &item.CreatedAt, &item.UpdatedAt)
if err != nil {
return item, err
}
@@ -1132,38 +1090,137 @@ func (s *Store) GetSSHSession(id string) (models.SSHSession, error) {
}
func (s *Store) ListSSHSessionsForUser(userID string, limit int) ([]models.SSHSession, error) {
var items []models.SSHSession
var hasMore bool
var err error
items, hasMore, err = s.ListSSHSessionsForUserFiltered(userID, limit, 0, "", "")
if err != nil {
return nil, err
}
_ = hasMore
return items, nil
}
func (s *Store) ListSSHSessionsForUserFiltered(userID string, limit int, offset int, query string, status string) ([]models.SSHSession, bool, error) {
var rows *sql.Rows
var items []models.SSHSession
var item models.SSHSession
var whereParts []string
var args []any
var sqlQuery string
var like string
var err error
if limit <= 0 {
limit = 50
limit = 20
}
if limit > 500 {
limit = 500
if limit > 200 {
limit = 200
}
rows, err = s.Query(`SELECT public_id, profile_public_id, server_public_id, user_public_id, username, remote_username, auth_method, host, port, status, host_key_fingerprint, requested_term, requested_cols, requested_rows, started_at, connected_at, ended_at, remote_addr, user_agent, error
if offset < 0 {
offset = 0
}
query = strings.TrimSpace(query)
status = strings.ToLower(strings.TrimSpace(status))
whereParts = append(whereParts, "user_public_id = ?")
args = append(args, strings.TrimSpace(userID))
if query != "" {
like = "%" + query + "%"
whereParts = append(whereParts, "(public_id LIKE ? OR profile_public_id LIKE ? OR server_public_id LIKE ? OR user_public_id LIKE ? OR username LIKE ? OR remote_username LIKE ? OR host LIKE ? OR auth_method LIKE ? OR status LIKE ? OR host_key_fingerprint LIKE ? OR error LIKE ?)")
args = append(args, like, like, like, like, like, like, like, like, like, like, like)
}
if status != "" {
whereParts = append(whereParts, "status = ?")
args = append(args, status)
}
sqlQuery = fmt.Sprintf(`SELECT public_id, profile_public_id, server_public_id, user_public_id, username, remote_username, auth_method, host, port, status, host_key_fingerprint, requested_term, requested_cols, requested_rows, started_at, connected_at, ended_at, remote_addr, user_agent, error
FROM ssh_sessions
WHERE user_public_id = ?
WHERE %s
ORDER BY started_at DESC
LIMIT ?`, strings.TrimSpace(userID), limit)
LIMIT ? OFFSET ?`, strings.Join(whereParts, " AND "))
args = append(args, limit + 1, offset)
rows, err = s.Query(sqlQuery, args...)
if err != nil {
return nil, err
return nil, false, err
}
defer rows.Close()
for rows.Next() {
err = rows.Scan(&item.ID, &item.ProfileID, &item.ServerID, &item.UserID, &item.Username, &item.RemoteUsername, &item.AuthMethod, &item.Host, &item.Port, &item.Status, &item.HostKeyFingerprint, &item.RequestedTerm, &item.RequestedCols, &item.RequestedRows, &item.StartedAt, &item.ConnectedAt, &item.EndedAt, &item.RemoteAddr, &item.UserAgent, &item.Error)
if err != nil {
return nil, err
return nil, false, err
}
items = append(items, item)
}
err = rows.Err()
if err != nil {
return nil, err
return nil, false, err
}
return items, nil
if len(items) > limit {
items = items[:limit]
return items, true, nil
}
return items, false, nil
}
func (s *Store) ListSSHSessionsFiltered(limit int, offset int, query string, status string) ([]models.SSHSession, bool, error) {
var rows *sql.Rows
var items []models.SSHSession
var item models.SSHSession
var whereParts []string
var args []any
var sqlQuery string
var like string
var err error
if limit <= 0 {
limit = 20
}
if limit > 200 {
limit = 200
}
if offset < 0 {
offset = 0
}
query = strings.TrimSpace(query)
status = strings.ToLower(strings.TrimSpace(status))
if query != "" {
like = "%" + query + "%"
whereParts = append(whereParts, "(public_id LIKE ? OR profile_public_id LIKE ? OR server_public_id LIKE ? OR user_public_id LIKE ? OR username LIKE ? OR remote_username LIKE ? OR host LIKE ? OR auth_method LIKE ? OR status LIKE ? OR host_key_fingerprint LIKE ? OR error LIKE ?)")
args = append(args, like, like, like, like, like, like, like, like, like, like, like)
}
if status != "" {
whereParts = append(whereParts, "status = ?")
args = append(args, status)
}
sqlQuery = `SELECT public_id, profile_public_id, server_public_id, user_public_id, username, remote_username, auth_method, host, port, status, host_key_fingerprint, requested_term, requested_cols, requested_rows, started_at, connected_at, ended_at, remote_addr, user_agent, error
FROM ssh_sessions`
if len(whereParts) > 0 {
sqlQuery += "\n\t\tWHERE " + strings.Join(whereParts, " AND ")
}
sqlQuery += "\n\t\tORDER BY started_at DESC\n\t\tLIMIT ? OFFSET ?"
args = append(args, limit + 1, offset)
rows, err = s.Query(sqlQuery, args...)
if err != nil {
return nil, false, err
}
defer rows.Close()
for rows.Next() {
err = rows.Scan(&item.ID, &item.ProfileID, &item.ServerID, &item.UserID, &item.Username, &item.RemoteUsername, &item.AuthMethod, &item.Host, &item.Port, &item.Status, &item.HostKeyFingerprint, &item.RequestedTerm, &item.RequestedCols, &item.RequestedRows, &item.StartedAt, &item.ConnectedAt, &item.EndedAt, &item.RemoteAddr, &item.UserAgent, &item.Error)
if err != nil {
return nil, false, err
}
items = append(items, item)
}
err = rows.Err()
if err != nil {
return nil, false, err
}
if len(items) > limit {
items = items[:limit]
return items, true, nil
}
return items, false, nil
}
func (s *Store) UpdateSSHSessionStatus(id string, status string, hostKeyFingerprint string, connectedAt int64, endedAt int64, errorText string) error {
@@ -1199,16 +1256,18 @@ func createSSHSecretTx(tx *sql.Tx, item models.SSHSecret) (models.SSHSecret, err
public_id,
kind,
payload,
password,
metadata_json,
created_by_kind,
created_by_subject_id,
created_by_subject_name,
created_at,
updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
item.ID,
strings.TrimSpace(item.Kind),
item.Payload,
item.Password,
normalizeJSONText(item.MetadataJSON, "{}"),
strings.TrimSpace(item.CreatedByKind),
strings.TrimSpace(item.CreatedBySubjectID),
@@ -1216,29 +1275,30 @@ func createSSHSecretTx(tx *sql.Tx, item models.SSHSecret) (models.SSHSecret, err
item.CreatedAt,
item.UpdatedAt,
)
if err != nil {
return item, err
}
return item, nil
}
func updateSSHSecretTx(tx *sql.Tx, item models.SSHSecret) (models.SSHSecret, error) {
var err error
item.UpdatedAt = time.Now().UTC().Unix()
_, err = tx.Exec(`UPDATE ssh_secrets
SET kind = ?, payload = ?, metadata_json = ?, updated_at = ?
WHERE public_id = ?`,
_, err = tx.Exec(`UPDATE ssh_secrets SET kind = ?, payload = ?, metadata_json = ?, updated_at = ? WHERE public_id = ?`,
strings.TrimSpace(item.Kind),
item.Payload,
normalizeJSONText(item.MetadataJSON, "{}"),
item.UpdatedAt,
strings.TrimSpace(item.ID),
)
if err != nil {
return item, err
if err == nil && item.Password != "" {
// TODO: how can i update the password without security breach?
// currently, i perform the password update only if the given password is not blank
_, err = tx.Exec(`UPDATE ssh_secrets SET password = ?, updated_at = ? WHERE public_id = ?`,
item.Password,
item.UpdatedAt,
strings.TrimSpace(item.ID),
)
}
return item, nil
return item, err
}
func insertSSHAccessProfileTargetTx(tx *sql.Tx, profileID string, targetType string, targetID string, createdAt int64) error {
+2
View File
@@ -40,6 +40,8 @@ type API struct {
Uploads storage.FileStore
Logger *util.Logger
SSHSessionRegistry *SSHSessionRegistry
SSHPromptedPasswordStore *SSHPromptedPasswordStore
SSHPreparedSessionStore *SSHPreparedSessionStore
OnTLSListenersChanged func()
OnTLSListenerRuntimeStatus func() map[string]int
}
+1 -2
View File
@@ -1039,9 +1039,8 @@ func (api *API) ServePKICRL(w http.ResponseWriter, r *http.Request) {
var err error
path = strings.TrimPrefix(r.URL.Path, "/pki/crl/")
base = path
if strings.HasSuffix(base, ".pem") {
base = strings.TrimSuffix(base, ".pem")
}
caID = strings.TrimSpace(base)
if caID == "" {
w.WriteHeader(http.StatusNotFound)
File diff suppressed because it is too large Load Diff
@@ -2,6 +2,7 @@ package handlers
import "errors"
import "io"
import "strings"
import "sync"
import "golang.org/x/crypto/ssh"
@@ -22,6 +23,16 @@ type SSHSessionRegistry struct {
items map[string]*sshActiveSession
}
type SSHPromptedPasswordStore struct {
mu sync.Mutex
items map[string]string
}
type SSHPreparedSessionStore struct {
mu sync.Mutex
items map[string]sshSessionPrepared
}
func NewSSHSessionRegistry() *SSHSessionRegistry {
var registry *SSHSessionRegistry
@@ -31,6 +42,129 @@ func NewSSHSessionRegistry() *SSHSessionRegistry {
return registry
}
func NewSSHPromptedPasswordStore() *SSHPromptedPasswordStore {
var store *SSHPromptedPasswordStore
store = &SSHPromptedPasswordStore{
items: map[string]string{},
}
return store
}
func NewSSHPreparedSessionStore() *SSHPreparedSessionStore {
var store *SSHPreparedSessionStore
store = &SSHPreparedSessionStore{
items: map[string]sshSessionPrepared{},
}
return store
}
func (s *SSHPromptedPasswordStore) Put(id string, password string) {
var trimmedID string
if s == nil { return }
trimmedID = strings.TrimSpace(id)
if trimmedID == "" { return }
s.mu.Lock()
s.items[trimmedID] = password
s.mu.Unlock()
}
func (s *SSHPromptedPasswordStore) Take(id string) string {
var trimmedID string
var password string
if s == nil { return "" }
trimmedID = strings.TrimSpace(id)
if trimmedID == "" { return "" }
s.mu.Lock()
password = s.items[trimmedID]
delete(s.items, trimmedID)
s.mu.Unlock()
return password
}
func (s *SSHPromptedPasswordStore) Delete(id string) {
var trimmedID string
if s == nil { return}
trimmedID = strings.TrimSpace(id)
if trimmedID == "" { return }
s.mu.Lock()
delete(s.items, trimmedID)
s.mu.Unlock()
}
func (s *SSHPreparedSessionStore) Put(id string, item sshSessionPrepared) {
var trimmedID string
if s == nil {
return
}
trimmedID = strings.TrimSpace(id)
if trimmedID == "" {
return
}
s.mu.Lock()
s.items[trimmedID] = item
s.mu.Unlock()
}
func (s *SSHPreparedSessionStore) Get(id string) (sshSessionPrepared, bool) {
var trimmedID string
var item sshSessionPrepared
var ok bool
if s == nil {
return item, false
}
trimmedID = strings.TrimSpace(id)
if trimmedID == "" {
return item, false
}
s.mu.Lock()
item, ok = s.items[trimmedID]
s.mu.Unlock()
return item, ok
}
func (s *SSHPreparedSessionStore) Take(id string) (sshSessionPrepared, bool) {
var trimmedID string
var item sshSessionPrepared
var ok bool
if s == nil {
return item, false
}
trimmedID = strings.TrimSpace(id)
if trimmedID == "" {
return item, false
}
s.mu.Lock()
item, ok = s.items[trimmedID]
if ok {
delete(s.items, trimmedID)
}
s.mu.Unlock()
return item, ok
}
func (s *SSHPreparedSessionStore) Delete(id string) {
var trimmedID string
if s == nil {
return
}
trimmedID = strings.TrimSpace(id)
if trimmedID == "" {
return
}
s.mu.Lock()
delete(s.items, trimmedID)
s.mu.Unlock()
}
func (r *SSHSessionRegistry) Register(id string, item *sshActiveSession) error {
if r == nil || item == nil {
return nil
+6 -15
View File
@@ -201,13 +201,9 @@ func RegisterAfterCommit(ctx context.Context, fn func()) {
var holder *afterCommitHolder
var ok bool
if ctx == nil || fn == nil {
return
}
if ctx == nil || fn == nil { return }
holder, ok = ctx.Value(afterCommitKey).(*afterCommitHolder)
if !ok || holder == nil {
return
}
if !ok || holder == nil { return}
holder.Fns = append(holder.Fns, fn)
}
@@ -217,19 +213,14 @@ func runAfterCommit(ctx context.Context) {
var fns []func()
var i int
if ctx == nil {
return
}
if ctx == nil { return }
holder, ok = ctx.Value(afterCommitKey).(*afterCommitHolder)
if !ok || holder == nil || len(holder.Fns) == 0 {
return
}
if !ok || holder == nil || len(holder.Fns) == 0 { return }
fns = append(fns, holder.Fns...)
holder.Fns = nil
for i = 0; i < len(fns); i++ {
if fns[i] != nil {
fns[i]()
}
if fns[i] != nil { fns[i]()}
}
}
+2
View File
@@ -539,6 +539,7 @@ type SSHSecret struct {
ID string `json:"id"`
Kind string `json:"kind"`
Payload string `json:"-"`
Password string `json:"-"`
MetadataJSON string `json:"-"`
CreatedByKind string `json:"created_by_kind"`
CreatedBySubjectID string `json:"created_by_subject_id"`
@@ -561,6 +562,7 @@ type SSHAccessProfile struct {
Enabled bool `json:"enabled"`
SecretID string `json:"-"`
SecretPayload string `json:"-"`
SecretPassword string `json:"="`
AuthPublicKey string `json:"auth_public_key"`
AuthPublicKeyFingerprint string `json:"auth_public_key_fingerprint"`
SSHUserCAID string `json:"ssh_user_ca_id"`
+1
View File
@@ -36,6 +36,7 @@ CREATE TABLE IF NOT EXISTS ssh_secrets (
public_id TEXT NOT NULL UNIQUE,
kind TEXT NOT NULL,
payload TEXT NOT NULL,
password TEXT NOT NULL,
metadata_json TEXT NOT NULL DEFAULT '{}',
created_by_kind TEXT NOT NULL DEFAULT 'user',
created_by_subject_id TEXT NOT NULL DEFAULT '',
+1
View File
@@ -4,6 +4,7 @@
"private": true,
"type": "module",
"scripts": {
"type-check": "tsc --noEmit",
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
+33 -9
View File
@@ -686,7 +686,7 @@ export interface SSHAccessProfile {
name: string
description: string
remote_username: string
auth_method: 'managed_ssh_cert' | 'stored_private_key'
auth_method: 'managed_ssh_cert' | 'prompted_password' | 'stored_password' | 'stored_private_key'
owner_scope: 'admin_shared' | 'user'
owner_user_id: string
allow_user_edit: boolean
@@ -715,7 +715,7 @@ export interface SSHSession {
user_id: string
username: string
remote_username: string
auth_method: 'managed_ssh_cert' | 'stored_private_key'
auth_method: 'managed_ssh_cert' | 'prompted_password' | 'stored_password' | 'stored_private_key'
host: string
port: number
status: string
@@ -731,6 +731,13 @@ export interface SSHSession {
error: string
}
export interface SSHSessionListResponse {
items: SSHSession[]
limit: number
offset: number
has_more: boolean
}
export interface SSHSessionConnectResponse {
session_id: string
status: string
@@ -1154,15 +1161,16 @@ export const api = {
request<SSHServerHostKey>(`/api/ssh/servers/${id}/host-keys`, { method: 'POST', body: JSON.stringify({ public_key }) }),
deleteSSHServerHostKeyForSelf: (id: string, hostKeyID: string) =>
request<void>(`/api/ssh/servers/${id}/host-keys/${hostKeyID}`, { method: 'DELETE' }),
listSSHAccessProfilesForSelf: () => request<SSHAccessProfile[]>('/api/ssh/access-profiles'),
listSSHAccessProfilesForSelf: (options?: RequestInit) => request<SSHAccessProfile[]>('/api/ssh/access-profiles', options),
createSSHAccessProfileForSelf: (payload: {
server_id: string
name: string
description: string
remote_username: string
auth_method: 'managed_ssh_cert' | 'stored_private_key'
auth_method: 'managed_ssh_cert' | 'prompted_password' | 'stored_password' | 'stored_private_key'
enabled: boolean
private_key_pem?: string
passowrd_text?: string
ssh_user_ca_id?: string
ssh_principal_mode?: 'explicit' | 'grant'
ssh_principals?: string[]
@@ -1171,15 +1179,16 @@ export const api = {
max_valid_seconds: number
}) =>
request<SSHAccessProfile>('/api/ssh/access-profiles', { method: 'POST', body: JSON.stringify(payload) }),
getSSHAccessProfileForSelf: (id: string) => request<SSHAccessProfile>(`/api/ssh/access-profiles/${id}`),
getSSHAccessProfileForSelf: (id: string, options?: RequestInit) => request<SSHAccessProfile>(`/api/ssh/access-profiles/${id}`),
updateSSHAccessProfileForSelf: (id: string, payload: {
server_id: string
name: string
description: string
remote_username: string
auth_method: 'managed_ssh_cert' | 'stored_private_key'
auth_method: 'managed_ssh_cert' | 'prompted_password' | 'stored_password' | 'stored_private_key'
enabled: boolean
private_key_pem?: string
password_text?: string
ssh_user_ca_id?: string
ssh_principal_mode?: 'explicit' | 'grant'
ssh_principals?: string[]
@@ -1189,10 +1198,25 @@ export const api = {
}) =>
request<SSHAccessProfile>(`/api/ssh/access-profiles/${id}`, { method: 'PATCH', body: JSON.stringify(payload) }),
deleteSSHAccessProfileForSelf: (id: string) => request<void>(`/api/ssh/access-profiles/${id}`, { method: 'DELETE' }),
connectSSHAccessProfileForSelf: (id: string, payload?: { cols?: number; rows?: number; term?: string }) =>
connectSSHAccessProfileForSelf: (id: string, payload?: { cols?: number; rows?: number; term?: string; password?: string }) =>
request<SSHSessionConnectResponse>(`/api/ssh/access-profiles/${id}/connect`, { method: 'POST', body: JSON.stringify(payload || {}) }),
listSSHSessionsForSelf: () => request<SSHSession[]>('/api/ssh/sessions'),
getSSHSessionForSelf: (id: string) => request<SSHSession>(`/api/ssh/sessions/${id}`),
listSSHSessionsForSelf: (params?: { limit?: number; offset?: number; q?: string; status?: string }, options?: RequestInit) => {
const search = new URLSearchParams()
if (params?.limit && params.limit > 0) search.set('limit', String(params.limit))
if (params?.offset && params.offset > 0) search.set('offset', String(params.offset))
if (params?.q) search.set('q', params.q)
if (params?.status) search.set('status', params.status)
return request<SSHSessionListResponse>(`/api/ssh/sessions${search.toString() ? `?${search.toString()}` : ''}`, options)
},
listSSHSessionsAdmin: (params?: { limit?: number; offset?: number; q?: string; status?: string }, options?: RequestInit) => {
const search = new URLSearchParams()
if (params?.limit && params.limit > 0) search.set('limit', String(params.limit))
if (params?.offset && params.offset > 0) search.set('offset', String(params.offset))
if (params?.q) search.set('q', params.q)
if (params?.status) search.set('status', params.status)
return request<SSHSessionListResponse>(`/api/admin/ssh/sessions${search.toString() ? `?${search.toString()}` : ''}`, options)
},
getSSHSessionForSelf: (id: string, options?: RequestInit) => request<SSHSession>(`/api/ssh/sessions/${id}`, options),
disconnectSSHSessionForSelf: (id: string) => request<void>(`/api/ssh/sessions/${id}/disconnect`, { method: 'POST' }),
inspectSSHCertificate: (certificate: string) =>
request<{ dump: string }>('/api/ssh/cert/inspect', { method: 'POST', body: JSON.stringify({ certificate }) }),
+1
View File
@@ -81,6 +81,7 @@ export default function Layout() {
adminItems.push({ label: 'Client Cert Profiles', path: '/admin/pki/client-profiles', icon: <VerifiedUserIcon fontSize="small" /> })
adminItems.push({ label: 'SSH Servers', path: '/admin/ssh-servers', icon: <DnsIcon fontSize="small" /> })
adminItems.push({ label: 'SSH Access Profiles', path: '/admin/ssh-access-profiles', icon: <TerminalIcon fontSize="small" /> })
adminItems.push({ label: 'SSH Sessions', path: '/admin/ssh-sessions', icon: <HistoryIcon fontSize="small" /> })
adminItems.push({ label: 'Admin SSH CA', path: '/admin/ssh-ca', icon: <BadgeOutlinedIcon fontSize="small" /> })
adminItems.push({ label: 'SSH Sign History', path: '/admin/ssh-sign-history', icon: <HistoryIcon fontSize="small" /> })
adminItems.push({ label: 'SSH Principal Grants', path: '/admin/ssh-principal-grants', icon: <ManageAccountsIcon fontSize="small" /> })
+2
View File
@@ -18,6 +18,7 @@ import AdminPKIClientProfilesPage from '../pages/AdminPKIClientProfilesPage'
import AdminSSHUserCAPage from '../pages/AdminSSHUserCAPage'
import AdminSSHServersPage from '../pages/AdminSSHServersPage'
import AdminSSHAccessProfilesPage from '../pages/AdminSSHAccessProfilesPage'
import AdminSSHSessionsPage from '../pages/AdminSSHSessionsPage'
import AdminSSHSignHistoryPage from '../pages/AdminSSHSignHistoryPage'
import AdminSSHPrincipalGrantsPage from '../pages/AdminSSHPrincipalGrantsPage'
import AdminTLSAuthPoliciesPage from '../pages/AdminTLSAuthPoliciesPage'
@@ -99,6 +100,7 @@ export const routes: RouteObject[] = [
{ path: 'admin/pki/client-profiles', element: <AdminPKIClientProfilesPage /> },
{ path: 'admin/ssh-servers', element: <AdminSSHServersPage /> },
{ path: 'admin/ssh-access-profiles', element: <AdminSSHAccessProfilesPage /> },
{ path: 'admin/ssh-sessions', element: <AdminSSHSessionsPage /> },
{ path: 'admin/ssh-ca', element: <AdminSSHUserCAPage /> },
{ path: 'admin/ssh-sign-history', element: <AdminSSHSignHistoryPage /> },
{ path: 'admin/ssh-principal-grants', element: <AdminSSHPrincipalGrantsPage /> },
@@ -5,13 +5,15 @@ export default function FormDialogContent(props: DialogContentProps) {
return (
<DialogContent
{...props}
sx={[
sx={[ // props.sx may exist. this overrides it
{
display: 'grid',
gap: 1.5,
pt: '8px !important'
},
props.sx
// revive the overridden props.sx
// Array.isArray() check in case it's already an array.
...(Array.isArray(props.sx)? props.sx: (props.sx? [props.sx] : []))
]}
/>
)
+196
View File
@@ -0,0 +1,196 @@
import Alert from '@mui/material/Alert'
import Box from '@mui/material/Box'
import Button from '@mui/material/Button'
import Chip from '@mui/material/Chip'
import MenuItem from '@mui/material/MenuItem'
import TextField from '@mui/material/TextField'
import Typography from '@mui/material/Typography'
import { useCallback, useEffect, useState } from 'react'
import SectionCard from '../components/SectionCard'
import { api, SSHSession, SSHSessionListResponse } from '../api'
function fmt(value: number): string {
if (!value || value <= 0) {
return '-'
}
return new Date(value * 1000).toLocaleString()
}
function sessionTitle(item: SSHSession): string {
return `${item.remote_username}@${item.host}:${item.port}`
}
export default function AdminSSHSessionsPage() {
const [items, setItems] = useState<SSHSession[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [query, setQuery] = useState('')
const [status, setStatus] = useState('')
const [limit, setLimit] = useState(25)
const [offset, setOffset] = useState(0)
const [hasMore, setHasMore] = useState(false)
const load = useCallback(async (signal?: AbortSignal) => {
let response: SSHSessionListResponse
let nextQuery: string
setLoading(true)
setError(null)
try {
nextQuery = query.trim()
response = await api.listSSHSessionsAdmin({
limit,
offset,
q: nextQuery || undefined,
status: status || undefined
}, signal ? { signal } : undefined)
setItems(Array.isArray(response.items) ? response.items : [])
setHasMore(Boolean(response.has_more))
} catch (err) {
if (signal?.aborted) { return }
setError(err instanceof Error ? err.message : 'Failed to load SSH session history')
setItems([])
setHasMore(false)
} finally {
if (!signal?.aborted) setLoading(false)
}
}, [limit, offset, query, status])
useEffect(() => {
const ac: AbortController = new AbortController()
void load(ac.signal)
return () => {
ac.abort()
}
}, [load])
return (
<Box sx={{ display: 'grid', gap: 1 }}>
<Typography variant="h5">Admin: SSH Sessions</Typography>
{error ? <Alert severity="error">{error}</Alert> : null}
<SectionCard
title="SSH Session History"
subtitle="All SSH sessions across users."
actions={(
<Button variant="outlined" size="small" onClick={() => void load()} disabled={loading}>
{loading ? 'Refreshing...' : 'Refresh'}
</Button>
)}
>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center', flexWrap: 'wrap' }}>
<TextField
size="small"
label="Search"
value={query}
onChange={(event) => {
setQuery(event.target.value)
setOffset(0)
}}
sx={{ minWidth: 240, maxWidth: 360 }}
/>
<TextField
select
size="small"
label="Status"
value={status}
onChange={(event) => {
setStatus(event.target.value)
setOffset(0)
}}
sx={{ minWidth: 150 }}
>
<MenuItem value="">(all)</MenuItem>
<MenuItem value="pending">pending</MenuItem>
<MenuItem value="connecting">connecting</MenuItem>
<MenuItem value="connected">connected</MenuItem>
<MenuItem value="closed">closed</MenuItem>
<MenuItem value="error">error</MenuItem>
</TextField>
<TextField
select
size="small"
label="Page Size"
value={String(limit)}
onChange={(event) => {
setLimit(Number(event.target.value) || 25)
setOffset(0)
}}
sx={{ minWidth: 130 }}
>
<MenuItem value="10">10</MenuItem>
<MenuItem value="25">25</MenuItem>
<MenuItem value="50">50</MenuItem>
<MenuItem value="100">100</MenuItem>
</TextField>
</Box>
{loading && !items.length ? (
<Typography variant="body2" color="text.secondary">
Loading...
</Typography>
) : null}
{!loading && !items.length ? (
<Typography variant="body2" color="text.secondary">
No SSH session history found.
</Typography>
) : null}
{items.length ? (
<Box sx={{ display: 'grid', gap: 0 }}>
{items.map((item) => (
<Box
key={item.id}
sx={{
display: 'grid',
gap: 0.5,
py: 1,
borderBottom: (theme) => `1px solid ${theme.palette.divider}`
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1, flexWrap: 'wrap' }}>
<Box sx={{ minWidth: 0, display: 'grid', gap: 0.25 }}>
<Typography variant="subtitle2" sx={{ wordBreak: 'break-word' }}>
{sessionTitle(item)}
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ wordBreak: 'break-word' }}>
User: {item.username} · Auth: {item.auth_method} · Started: {fmt(item.started_at)} · Ended: {fmt(item.ended_at)}
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ wordBreak: 'break-word' }}>
Session: {item.id} · Profile: {item.profile_id} · Server: {item.server_id}
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ wordBreak: 'break-word' }}>
Remote: {item.remote_addr || '-'} · Host key: {item.host_key_fingerprint || '-'}
</Typography>
{item.error ? (
<Typography variant="caption" color="error" sx={{ wordBreak: 'break-word' }}>
{item.error}
</Typography>
) : null}
</Box>
<Chip
label={item.status || 'unknown'}
size="small"
color={item.status === 'connected' ? 'success' : item.status === 'error' ? 'error' : 'default'}
variant={item.status === 'connected' ? 'filled' : 'outlined'}
/>
</Box>
</Box>
))}
</Box>
) : null}
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1, flexWrap: 'wrap' }}>
<Typography variant="caption" color="text.secondary">
Showing {items.length ? offset + 1 : 0}-{offset + items.length}
</Typography>
<Box sx={{ display: 'flex', gap: 1 }}>
<Button variant="outlined" size="small" onClick={() => setOffset((prev) => Math.max(0, prev - limit))} disabled={loading || offset <= 0}>
Previous
</Button>
<Button variant="outlined" size="small" onClick={() => setOffset((prev) => prev + limit)} disabled={loading || !hasMore}>
Next
</Button>
</Box>
</Box>
</SectionCard>
</Box>
)
}
+84 -7
View File
@@ -24,9 +24,10 @@ type SSHAccessProfileForm = {
name: string
description: string
remote_username: string
auth_method: 'managed_ssh_cert' | 'stored_private_key'
auth_method: 'managed_ssh_cert' | 'prompted_password' | 'stored_password' | 'stored_private_key'
enabled: boolean
private_key_pem: string
password_text: string
ssh_user_ca_id: string
ssh_principal_mode: 'explicit' | 'grant'
ssh_principals_text: string
@@ -52,6 +53,7 @@ const emptyForm = (): SSHAccessProfileForm => ({
auth_method: 'managed_ssh_cert',
enabled: true,
private_key_pem: '',
password_text: '',
ssh_user_ca_id: '',
ssh_principal_mode: 'explicit',
ssh_principals_text: '',
@@ -79,6 +81,9 @@ export default function SSHServersPage() {
const [search, setSearch] = useState('')
const [viewItem, setViewItem] = useState<SSHAccessProfile | null>(null)
const [connectingID, setConnectingID] = useState<string | null>(null)
const [connectPasswordItem, setConnectPasswordItem] = useState<SSHAccessProfile | null>(null)
const [connectPassword, setConnectPassword] = useState('')
const [connectPasswordError, setConnectPasswordError] = useState<string | null>(null)
const [dialogOpen, setDialogOpen] = useState(false)
const [editingID, setEditingID] = useState<string | null>(null)
const [form, setForm] = useState<SSHAccessProfileForm>(emptyForm())
@@ -194,6 +199,7 @@ export default function SSHServersPage() {
auth_method: item.auth_method,
enabled: item.enabled,
private_key_pem: '',
password_text: '',
ssh_user_ca_id: item.ssh_user_ca_id || '',
ssh_principal_mode: item.ssh_principal_mode || 'explicit',
ssh_principals_text: (item.ssh_principals || []).join(', '),
@@ -255,11 +261,20 @@ export default function SSHServersPage() {
setHostKeySaving(false)
}
const handleConnect = async (item: SSHAccessProfile) => {
const closeConnectPasswordPrompt = () => {
if (connectingID) return
setConnectPasswordItem(null)
setConnectPassword('')
setConnectPasswordError(null)
}
const connectToProfile = async (item: SSHAccessProfile, password?: string) => {
let cols = 120
let rows = 36
let message: string
setError(null)
setConnectPasswordError(null)
setConnectingID(item.id)
try {
if (typeof window !== 'undefined') {
@@ -269,16 +284,33 @@ export default function SSHServersPage() {
const session = await api.connectSSHAccessProfileForSelf(item.id, {
cols,
rows,
term: 'xterm-256color'
term: 'xterm-256color',
password
})
closeConnectPasswordPrompt()
navigate(`/ssh-sessions/${session.session_id}`)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to connect')
message = err instanceof Error ? err.message : 'Failed to connect'
if (password !== undefined) {
setConnectPasswordError(message)
} else {
setError(message)
}
} finally {
setConnectingID(null)
}
}
const handleConnect = async (item: SSHAccessProfile) => {
if (item.auth_method === 'prompted_password') {
setConnectPasswordItem(item)
setConnectPassword('')
setConnectPasswordError(null)
return
}
await connectToProfile(item)
}
const handleSave = async () => {
const payload = {
server_id: form.server_id,
@@ -288,6 +320,7 @@ export default function SSHServersPage() {
auth_method: form.auth_method,
enabled: form.enabled,
private_key_pem: form.private_key_pem.trim() || undefined,
password_text: form.password_text.trim() || undefined,
ssh_user_ca_id: form.auth_method === 'managed_ssh_cert' ? (form.ssh_user_ca_id || undefined) : undefined,
ssh_principal_mode: form.auth_method === 'managed_ssh_cert' ? form.ssh_principal_mode : undefined,
ssh_principals: form.auth_method === 'managed_ssh_cert' && form.ssh_principal_mode === 'explicit' ? parseNames(form.ssh_principals_text) : [],
@@ -552,7 +585,12 @@ export default function SSHServersPage() {
return (
<Box sx={{ display: 'grid', gap: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1, flexWrap: 'wrap' }}>
<Typography variant="h5">SSH Servers</Typography>
<Button variant="outlined" onClick={() => navigate('/ssh-sessions', { state: { historyOpen: true } })}>
Session History
</Button>
</Box>
{error ? <Alert severity="error">{error}</Alert> : null}
<SectionCard
title={
@@ -665,6 +703,8 @@ export default function SSHServersPage() {
onChange={(event) => setForm((prev) => ({ ...prev, auth_method: event.target.value as SSHAccessProfileForm['auth_method'] }))}
>
<MenuItem value="managed_ssh_cert">managed_ssh_cert</MenuItem>
<MenuItem value="prompted_password">prompted_password</MenuItem>
<MenuItem value="stored_password">stored_password</MenuItem>
<MenuItem value="stored_private_key">stored_private_key</MenuItem>
</SelectField>
<FormControlLabel
@@ -700,7 +740,7 @@ export default function SSHServersPage() {
helperText="Comma-separated principal names."
/>
) : (
<Autocomplete
<Autocomplete<SSHPrincipalGrant, true, false, false>
multiple
options={grants}
value={selectedGrantOptions}
@@ -730,7 +770,7 @@ export default function SSHServersPage() {
helperText={editingID ? 'Leave blank to keep the existing managed key.' : 'Leave blank to auto-generate an ed25519 key.'}
/>
</>
) : (
) : form.auth_method == 'stored_private_key'? (
<TextField
label="Private Key PEM"
multiline
@@ -739,7 +779,15 @@ export default function SSHServersPage() {
onChange={(event) => setForm((prev) => ({ ...prev, private_key_pem: event.target.value }))}
helperText={editingID ? 'Leave blank to keep the existing key.' : 'Required for stored_private_key.'}
/>
)}
) : form.auth_method == 'stored_password'? (
<TextField
label="Password"
type="password"
value={form.password_text}
onChange={(event) => setForm((prev) => ({ ...prev, password_text: event.target.value }))}
helperText={editingID ? 'Leave blank to keep the existing password.' : 'Required for stored_password.'}
/>
) : null }
</FormDialogContent>
<DialogActions>
<Button onClick={() => setDialogOpen(false)} disabled={saving}>
@@ -831,6 +879,35 @@ export default function SSHServersPage() {
</DialogActions>
</Dialog>
<Dialog open={Boolean(connectPasswordItem)} onClose={closeConnectPasswordPrompt} maxWidth="xs" fullWidth>
<DialogTitle>Enter SSH Password</DialogTitle>
<FormDialogContent>
{connectPasswordError ? <Alert severity="error">{connectPasswordError}</Alert> : null}
<Typography variant="body2" color="text.secondary">
{connectPasswordItem ? `${connectPasswordItem.remote_username}@${connectPasswordItem.server?.host || connectPasswordItem.server_id}:${connectPasswordItem.server?.port || 22}` : 'Enter the password for this SSH access profile.'}
</Typography>
<TextField
label="Password"
type="password"
value={connectPassword}
onChange={(event) => setConnectPassword(event.target.value)}
autoFocus
/>
</FormDialogContent>
<DialogActions>
<Button onClick={closeConnectPasswordPrompt} disabled={Boolean(connectingID)}>
Cancel
</Button>
<Button
variant="contained"
onClick={() => connectPasswordItem ? void connectToProfile(connectPasswordItem, connectPassword) : undefined}
disabled={Boolean(connectingID) || connectPassword === ''}
>
{connectingID ? 'Connecting...' : 'Connect'}
</Button>
</DialogActions>
</Dialog>
<Dialog open={Boolean(hostKeyServer)} onClose={closeHostKeys} maxWidth="md" fullWidth>
<DialogTitle>
<Box sx={{ display: 'grid', gap: 1 }}>
File diff suppressed because it is too large Load Diff