Compare commits
6 Commits
a895592477
...
fc4686720d
| Author | SHA1 | Date | |
|---|---|---|---|
| fc4686720d | |||
| a4f46cc598 | |||
| 8b4fd15652 | |||
| 8233b91942 | |||
| 71ba9fd107 | |||
| a291ffb0e4 |
@@ -402,15 +402,16 @@ func main() {
|
||||
|
||||
var api *handlers.API
|
||||
api = &handlers.API{
|
||||
Cfg: cfg,
|
||||
Store: store,
|
||||
Repos: repoManager,
|
||||
RpmBase: rpmBase,
|
||||
RpmMeta: rpmMeta,
|
||||
RpmMirror: rpmMirror,
|
||||
DockerBase: dockerBase,
|
||||
Uploads: uploadStore,
|
||||
Logger: logger,
|
||||
Cfg: cfg,
|
||||
Store: store,
|
||||
Repos: repoManager,
|
||||
RpmBase: rpmBase,
|
||||
RpmMeta: rpmMeta,
|
||||
RpmMirror: rpmMirror,
|
||||
DockerBase: dockerBase,
|
||||
Uploads: uploadStore,
|
||||
Logger: logger,
|
||||
SSHSessionRegistry: handlers.NewSSHSessionRegistry(),
|
||||
}
|
||||
rpmMirror.Start()
|
||||
|
||||
@@ -586,10 +587,42 @@ func main() {
|
||||
router.Handle("POST", "/api/admin/ssh/principal-grants", api.CreateSSHPrincipalGrant)
|
||||
router.Handle("PATCH", "/api/admin/ssh/principal-grants/:id", api.UpdateSSHPrincipalGrant)
|
||||
router.Handle("DELETE", "/api/admin/ssh/principal-grants/:id", api.DeleteSSHPrincipalGrant)
|
||||
router.Handle("GET", "/api/admin/ssh/servers", api.ListSSHServersAdmin)
|
||||
router.Handle("POST", "/api/admin/ssh/servers", api.CreateSSHServerAdmin)
|
||||
router.Handle("GET", "/api/admin/ssh/servers/:id", api.GetSSHServerAdmin)
|
||||
router.Handle("PATCH", "/api/admin/ssh/servers/:id", api.UpdateSSHServerAdmin)
|
||||
router.Handle("DELETE", "/api/admin/ssh/servers/:id", api.DeleteSSHServerAdmin)
|
||||
router.Handle("GET", "/api/admin/ssh/servers/:id/host-keys", api.ListSSHServerHostKeysAdmin)
|
||||
router.Handle("POST", "/api/admin/ssh/servers/:id/host-keys", api.CreateSSHServerHostKeyAdmin)
|
||||
router.Handle("POST", "/api/admin/ssh/servers/:id/host-keys/discover", api.DiscoverSSHServerHostKeyAdmin)
|
||||
router.Handle("DELETE", "/api/admin/ssh/servers/:id/host-keys/:hostKeyId", api.DeleteSSHServerHostKeyAdmin)
|
||||
router.Handle("GET", "/api/admin/ssh/access-profiles", api.ListSSHAccessProfilesAdmin)
|
||||
router.Handle("POST", "/api/admin/ssh/access-profiles", api.CreateSSHAccessProfileAdmin)
|
||||
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/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)
|
||||
router.Handle("GET", "/api/ssh/principal-grants", api.ListSSHPrincipalGrantsForSelf)
|
||||
router.Handle("GET", "/api/ssh/servers", api.ListSSHServersForSelf)
|
||||
router.Handle("POST", "/api/ssh/servers", api.CreateSSHServerForSelf)
|
||||
router.Handle("PATCH", "/api/ssh/servers/:id", api.UpdateSSHServerForSelf)
|
||||
router.Handle("DELETE", "/api/ssh/servers/:id", api.DeleteSSHServerForSelf)
|
||||
router.Handle("GET", "/api/ssh/servers/:id/host-keys", api.ListSSHServerHostKeysForSelf)
|
||||
router.Handle("POST", "/api/ssh/servers/:id/host-keys", api.CreateSSHServerHostKeyForSelf)
|
||||
router.Handle("POST", "/api/ssh/servers/:id/host-keys/discover", api.DiscoverSSHServerHostKeyForSelf)
|
||||
router.Handle("DELETE", "/api/ssh/servers/:id/host-keys/:hostKeyId", api.DeleteSSHServerHostKeyForSelf)
|
||||
router.Handle("GET", "/api/ssh/access-profiles", api.ListSSHAccessProfilesForSelf)
|
||||
router.Handle("POST", "/api/ssh/access-profiles", api.CreateSSHAccessProfileForSelf)
|
||||
router.Handle("GET", "/api/ssh/access-profiles/:id", api.GetSSHAccessProfileForSelf)
|
||||
router.Handle("PATCH", "/api/ssh/access-profiles/:id", api.UpdateSSHAccessProfileForSelf)
|
||||
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/sessions/:id", api.GetSSHSessionForSelf)
|
||||
router.Handle("POST", "/api/ssh/sessions/:id/disconnect", api.DisconnectSSHSessionForSelf)
|
||||
router.Handle("GET", "/api/ssh/sessions/:id/stream", api.StreamSSHSessionForSelf)
|
||||
router.Handle("POST", "/api/ssh/cert/inspect", api.InspectSSHCertificate)
|
||||
router.Handle("POST", "/api/ssh/user-cas/:id/sign", api.SignSSHUserKeyForSelf)
|
||||
router.Handle("GET", "/api/ssh/issuances", api.ListSSHUserCAIssuancesForSelf)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -39,6 +39,7 @@ type API struct {
|
||||
DockerBase string
|
||||
Uploads storage.FileStore
|
||||
Logger *util.Logger
|
||||
SSHSessionRegistry *SSHSessionRegistry
|
||||
OnTLSListenersChanged func()
|
||||
OnTLSListenerRuntimeStatus func() map[string]int
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,141 @@
|
||||
package handlers
|
||||
|
||||
import "errors"
|
||||
import "io"
|
||||
import "sync"
|
||||
|
||||
import "golang.org/x/crypto/ssh"
|
||||
import "golang.org/x/net/websocket"
|
||||
|
||||
type sshActiveSession struct {
|
||||
mu sync.Mutex
|
||||
ws *websocket.Conn
|
||||
client *ssh.Client
|
||||
session *ssh.Session
|
||||
stdin io.WriteCloser
|
||||
closeReason string
|
||||
closed bool
|
||||
}
|
||||
|
||||
type SSHSessionRegistry struct {
|
||||
mu sync.Mutex
|
||||
items map[string]*sshActiveSession
|
||||
}
|
||||
|
||||
func NewSSHSessionRegistry() *SSHSessionRegistry {
|
||||
var registry *SSHSessionRegistry
|
||||
|
||||
registry = &SSHSessionRegistry{
|
||||
items: map[string]*sshActiveSession{},
|
||||
}
|
||||
return registry
|
||||
}
|
||||
|
||||
func (r *SSHSessionRegistry) Register(id string, item *sshActiveSession) error {
|
||||
if r == nil || item == nil {
|
||||
return nil
|
||||
}
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.items[id] != nil {
|
||||
return errors.New("ssh session already active")
|
||||
}
|
||||
r.items[id] = item
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *SSHSessionRegistry) Unregister(id string, item *sshActiveSession) {
|
||||
if r == nil || item == nil {
|
||||
return
|
||||
}
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.items[id] == item {
|
||||
delete(r.items, id)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *SSHSessionRegistry) RequestClose(id string, reason string) bool {
|
||||
var item *sshActiveSession
|
||||
|
||||
if r == nil {
|
||||
return false
|
||||
}
|
||||
r.mu.Lock()
|
||||
item = r.items[id]
|
||||
r.mu.Unlock()
|
||||
if item == nil {
|
||||
return false
|
||||
}
|
||||
item.RequestClose(reason)
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *sshActiveSession) SetResources(ws *websocket.Conn, client *ssh.Client, session *ssh.Session, stdin io.WriteCloser) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
if ws != nil {
|
||||
s.ws = ws
|
||||
}
|
||||
if client != nil {
|
||||
s.client = client
|
||||
}
|
||||
if session != nil {
|
||||
s.session = session
|
||||
}
|
||||
if stdin != nil {
|
||||
s.stdin = stdin
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *sshActiveSession) CloseReason() string {
|
||||
var reason string
|
||||
|
||||
if s == nil {
|
||||
return ""
|
||||
}
|
||||
s.mu.Lock()
|
||||
reason = s.closeReason
|
||||
s.mu.Unlock()
|
||||
return reason
|
||||
}
|
||||
|
||||
func (s *sshActiveSession) RequestClose(reason string) {
|
||||
var ws *websocket.Conn
|
||||
var client *ssh.Client
|
||||
var session *ssh.Session
|
||||
var stdin io.WriteCloser
|
||||
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
if s.closeReason == "" {
|
||||
s.closeReason = reason
|
||||
}
|
||||
if s.closed {
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
s.closed = true
|
||||
ws = s.ws
|
||||
client = s.client
|
||||
session = s.session
|
||||
stdin = s.stdin
|
||||
s.mu.Unlock()
|
||||
if stdin != nil {
|
||||
_ = stdin.Close()
|
||||
}
|
||||
if session != nil {
|
||||
_ = session.Close()
|
||||
}
|
||||
if client != nil {
|
||||
_ = client.Close()
|
||||
}
|
||||
if ws != nil {
|
||||
_ = ws.Close()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package handlers
|
||||
|
||||
import "crypto/aes"
|
||||
import "crypto/cipher"
|
||||
import "crypto/rand"
|
||||
import "encoding/base64"
|
||||
import "errors"
|
||||
import "os"
|
||||
import "path/filepath"
|
||||
import "strings"
|
||||
|
||||
const sshSecretPrefix string = "enc:v1:"
|
||||
|
||||
func (api *API) encryptSSHSecretPayload(payload string) (string, error) {
|
||||
var key []byte
|
||||
var block cipher.Block
|
||||
var gcm cipher.AEAD
|
||||
var nonce []byte
|
||||
var ciphertext []byte
|
||||
var raw []byte
|
||||
var err error
|
||||
|
||||
if strings.TrimSpace(payload) == "" {
|
||||
return "", nil
|
||||
}
|
||||
key, err = api.sshBrokerSecretKey()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
block, err = aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
gcm, err = cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
nonce = make([]byte, gcm.NonceSize())
|
||||
_, err = rand.Read(nonce)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
ciphertext = gcm.Seal(nil, nonce, []byte(payload), nil)
|
||||
raw = append(nonce, ciphertext...)
|
||||
return sshSecretPrefix + base64.StdEncoding.EncodeToString(raw), nil
|
||||
}
|
||||
|
||||
func (api *API) decryptSSHSecretPayload(payload string) (string, error) {
|
||||
var key []byte
|
||||
var encoded string
|
||||
var raw []byte
|
||||
var block cipher.Block
|
||||
var gcm cipher.AEAD
|
||||
var nonce []byte
|
||||
var ciphertext []byte
|
||||
var plaintext []byte
|
||||
var err error
|
||||
|
||||
if !strings.HasPrefix(payload, sshSecretPrefix) {
|
||||
return payload, nil
|
||||
}
|
||||
key, err = api.sshBrokerSecretKey()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
encoded = strings.TrimSpace(strings.TrimPrefix(payload, sshSecretPrefix))
|
||||
raw, err = base64.StdEncoding.DecodeString(encoded)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
block, err = aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
gcm, err = cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(raw) < gcm.NonceSize() {
|
||||
return "", errors.New("invalid encrypted ssh secret")
|
||||
}
|
||||
nonce = raw[:gcm.NonceSize()]
|
||||
ciphertext = raw[gcm.NonceSize():]
|
||||
plaintext, err = gcm.Open(nil, nonce, ciphertext, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(plaintext), nil
|
||||
}
|
||||
|
||||
func (api *API) sshBrokerSecretKey() ([]byte, error) {
|
||||
var path string
|
||||
var data []byte
|
||||
var err error
|
||||
|
||||
path = filepath.Join(api.Cfg.DataDir, "ssh-broker.secret")
|
||||
data, err = os.ReadFile(path)
|
||||
if err == nil {
|
||||
if len(data) != 32 {
|
||||
return nil, errors.New("invalid ssh broker secret key length")
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
if !errors.Is(err, os.ErrNotExist) {
|
||||
return nil, err
|
||||
}
|
||||
data = make([]byte, 32)
|
||||
_, err = rand.Read(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = os.MkdirAll(api.Cfg.DataDir, 0o755)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = os.WriteFile(path, data, 0o600)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
@@ -1,5 +1,9 @@
|
||||
package middleware
|
||||
|
||||
import "bufio"
|
||||
import "fmt"
|
||||
import "io"
|
||||
import "net"
|
||||
import "net/http"
|
||||
import "time"
|
||||
|
||||
@@ -18,6 +22,59 @@ func (r *statusRecorder) WriteHeader(code int) {
|
||||
r.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
func (r *statusRecorder) Write(data []byte) (int, error) {
|
||||
if r.status == 0 {
|
||||
r.status = http.StatusOK
|
||||
}
|
||||
return r.ResponseWriter.Write(data)
|
||||
}
|
||||
|
||||
func (r *statusRecorder) Flush() {
|
||||
var flusher http.Flusher
|
||||
var ok bool
|
||||
|
||||
flusher, ok = r.ResponseWriter.(http.Flusher)
|
||||
if ok {
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
func (r *statusRecorder) Hijack() (net.Conn, *bufio.ReadWriter, error) {
|
||||
var hijacker http.Hijacker
|
||||
var ok bool
|
||||
|
||||
hijacker, ok = r.ResponseWriter.(http.Hijacker)
|
||||
if !ok {
|
||||
return nil, nil, fmt.Errorf("response writer does not support hijacking")
|
||||
}
|
||||
return hijacker.Hijack()
|
||||
}
|
||||
|
||||
func (r *statusRecorder) Push(target string, opts *http.PushOptions) error {
|
||||
var pusher http.Pusher
|
||||
var ok bool
|
||||
|
||||
pusher, ok = r.ResponseWriter.(http.Pusher)
|
||||
if !ok {
|
||||
return http.ErrNotSupported
|
||||
}
|
||||
return pusher.Push(target, opts)
|
||||
}
|
||||
|
||||
func (r *statusRecorder) ReadFrom(src io.Reader) (int64, error) {
|
||||
var readerFrom io.ReaderFrom
|
||||
var ok bool
|
||||
|
||||
if r.status == 0 {
|
||||
r.status = http.StatusOK
|
||||
}
|
||||
readerFrom, ok = r.ResponseWriter.(io.ReaderFrom)
|
||||
if ok {
|
||||
return readerFrom.ReadFrom(src)
|
||||
}
|
||||
return io.Copy(r.ResponseWriter, src)
|
||||
}
|
||||
|
||||
func AccessLog(logger *util.Logger, next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var recorder *statusRecorder
|
||||
|
||||
@@ -115,6 +115,10 @@ func withStoreTransaction(store *db.Store, next http.Handler, bufferWrites bool)
|
||||
var statusWriter *passThroughStatusWriter
|
||||
var err error
|
||||
|
||||
if isWebSocketUpgrade(r) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
txStore, err = store.BeginStore(r.Context())
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
@@ -185,6 +189,14 @@ func withStoreTransaction(store *db.Store, next http.Handler, bufferWrites bool)
|
||||
})
|
||||
}
|
||||
|
||||
func isWebSocketUpgrade(r *http.Request) bool {
|
||||
if r == nil {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(strings.ToLower(strings.TrimSpace(r.Header.Get("Connection"))), "upgrade") &&
|
||||
strings.EqualFold(strings.TrimSpace(r.Header.Get("Upgrade")), "websocket")
|
||||
}
|
||||
|
||||
func RegisterAfterCommit(ctx context.Context, fn func()) {
|
||||
var holder *afterCommitHolder
|
||||
var ok bool
|
||||
|
||||
@@ -510,6 +510,106 @@ type SSHPrincipalGrantTarget struct {
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
}
|
||||
|
||||
type SSHServer struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
Description string `json:"description"`
|
||||
Tags []string `json:"tags"`
|
||||
Enabled bool `json:"enabled"`
|
||||
CreatedByKind string `json:"created_by_kind"`
|
||||
CreatedBySubjectID string `json:"created_by_subject_id"`
|
||||
CreatedBySubjectName string `json:"created_by_subject_name"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
Editable bool `json:"editable"`
|
||||
}
|
||||
|
||||
type SSHServerHostKey struct {
|
||||
ID string `json:"id"`
|
||||
ServerID string `json:"server_id"`
|
||||
Algorithm string `json:"algorithm"`
|
||||
PublicKey string `json:"public_key"`
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
}
|
||||
|
||||
type SSHSecret struct {
|
||||
ID string `json:"id"`
|
||||
Kind string `json:"kind"`
|
||||
Payload string `json:"-"`
|
||||
MetadataJSON string `json:"-"`
|
||||
CreatedByKind string `json:"created_by_kind"`
|
||||
CreatedBySubjectID string `json:"created_by_subject_id"`
|
||||
CreatedBySubjectName string `json:"created_by_subject_name"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
}
|
||||
|
||||
type SSHAccessProfile struct {
|
||||
ID string `json:"id"`
|
||||
ServerID string `json:"server_id"`
|
||||
Server SSHServer `json:"server"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
RemoteUsername string `json:"remote_username"`
|
||||
AuthMethod string `json:"auth_method"`
|
||||
OwnerScope string `json:"owner_scope"`
|
||||
OwnerUserID string `json:"owner_user_id"`
|
||||
AllowUserEdit bool `json:"allow_user_edit"`
|
||||
Enabled bool `json:"enabled"`
|
||||
SecretID string `json:"-"`
|
||||
SecretPayload string `json:"-"`
|
||||
AuthPublicKey string `json:"auth_public_key"`
|
||||
AuthPublicKeyFingerprint string `json:"auth_public_key_fingerprint"`
|
||||
SSHUserCAID string `json:"ssh_user_ca_id"`
|
||||
SSHPrincipalMode string `json:"ssh_principal_mode"`
|
||||
SSHPrincipals []string `json:"ssh_principals"`
|
||||
SSHPrincipalGrantIDs []string `json:"ssh_principal_grant_ids"`
|
||||
DefaultValidSeconds int64 `json:"default_valid_seconds"`
|
||||
MaxValidSeconds int64 `json:"max_valid_seconds"`
|
||||
CreatedByKind string `json:"created_by_kind"`
|
||||
CreatedBySubjectID string `json:"created_by_subject_id"`
|
||||
CreatedBySubjectName string `json:"created_by_subject_name"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
Targets []SSHAccessProfileTarget `json:"targets"`
|
||||
Editable bool `json:"editable"`
|
||||
}
|
||||
|
||||
type SSHAccessProfileTarget struct {
|
||||
ProfileID string `json:"profile_id"`
|
||||
TargetType string `json:"target_type"`
|
||||
TargetID string `json:"target_id"`
|
||||
TargetName string `json:"target_name"`
|
||||
TargetActive bool `json:"target_active"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
}
|
||||
|
||||
type SSHSession struct {
|
||||
ID string `json:"id"`
|
||||
ProfileID string `json:"profile_id"`
|
||||
ServerID string `json:"server_id"`
|
||||
UserID string `json:"user_id"`
|
||||
Username string `json:"username"`
|
||||
RemoteUsername string `json:"remote_username"`
|
||||
AuthMethod string `json:"auth_method"`
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
Status string `json:"status"`
|
||||
HostKeyFingerprint string `json:"host_key_fingerprint"`
|
||||
RequestedTerm string `json:"requested_term"`
|
||||
RequestedCols int `json:"requested_cols"`
|
||||
RequestedRows int `json:"requested_rows"`
|
||||
StartedAt int64 `json:"started_at"`
|
||||
ConnectedAt int64 `json:"connected_at"`
|
||||
EndedAt int64 `json:"ended_at"`
|
||||
RemoteAddr string `json:"remote_addr"`
|
||||
UserAgent string `json:"user_agent"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
type ServicePrincipal struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
package pkiutil
|
||||
|
||||
import "crypto/x509"
|
||||
import "encoding/asn1"
|
||||
|
||||
var OIDCoditMTLSAuthorization = asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 57264, 1, 1}
|
||||
|
||||
type clientAuthorizationValue struct {
|
||||
Version int
|
||||
UserID string
|
||||
Username string
|
||||
ProfileID string
|
||||
Permissions []string
|
||||
Scope string
|
||||
}
|
||||
|
||||
type ClientAuthorizationInfo struct {
|
||||
Version int
|
||||
UserID string
|
||||
Username string
|
||||
ProfileID string
|
||||
Permissions []string
|
||||
Scope string
|
||||
}
|
||||
|
||||
func ParseClientAuthorizationExtension(raw []byte) (ClientAuthorizationInfo, error) {
|
||||
var value clientAuthorizationValue
|
||||
var info ClientAuthorizationInfo
|
||||
var err error
|
||||
|
||||
_, err = asn1.Unmarshal(raw, &value)
|
||||
if err != nil {
|
||||
return info, err
|
||||
}
|
||||
info = ClientAuthorizationInfo{
|
||||
Version: value.Version,
|
||||
UserID: value.UserID,
|
||||
Username: value.Username,
|
||||
ProfileID: value.ProfileID,
|
||||
Permissions: append([]string{}, value.Permissions...),
|
||||
Scope: value.Scope,
|
||||
}
|
||||
return info, nil
|
||||
}
|
||||
|
||||
func ParseClientAuthorizationFromCertificate(cert *x509.Certificate) (ClientAuthorizationInfo, bool, error) {
|
||||
var i int
|
||||
var info ClientAuthorizationInfo
|
||||
var err error
|
||||
|
||||
if cert == nil {
|
||||
return info, false, nil
|
||||
}
|
||||
for i = 0; i < len(cert.Extensions); i++ {
|
||||
if !cert.Extensions[i].Id.Equal(OIDCoditMTLSAuthorization) {
|
||||
continue
|
||||
}
|
||||
info, err = ParseClientAuthorizationExtension(cert.Extensions[i].Value)
|
||||
if err != nil {
|
||||
return info, true, err
|
||||
}
|
||||
return info, true, nil
|
||||
}
|
||||
return info, false, nil
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
CREATE TABLE IF NOT EXISTS ssh_servers (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
public_id TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
host TEXT NOT NULL,
|
||||
port INTEGER NOT NULL DEFAULT 22,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
tags_json TEXT NOT NULL DEFAULT '[]',
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
created_by_kind TEXT NOT NULL DEFAULT 'user',
|
||||
created_by_subject_id TEXT NOT NULL DEFAULT '',
|
||||
created_by_subject_name TEXT NOT NULL DEFAULT '',
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_ssh_servers_name ON ssh_servers(name);
|
||||
CREATE INDEX IF NOT EXISTS idx_ssh_servers_host_port ON ssh_servers(host, port);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ssh_server_host_keys (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
public_id TEXT NOT NULL UNIQUE,
|
||||
server_public_id TEXT NOT NULL,
|
||||
algorithm TEXT NOT NULL,
|
||||
public_key TEXT NOT NULL,
|
||||
fingerprint TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
UNIQUE(server_public_id, fingerprint),
|
||||
FOREIGN KEY (server_public_id) REFERENCES ssh_servers(public_id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_ssh_server_host_keys_server ON ssh_server_host_keys(server_public_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ssh_secrets (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
public_id TEXT NOT NULL UNIQUE,
|
||||
kind TEXT NOT NULL,
|
||||
payload 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 '',
|
||||
created_by_subject_name TEXT NOT NULL DEFAULT '',
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_ssh_secrets_kind ON ssh_secrets(kind);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ssh_access_profiles (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
public_id TEXT NOT NULL UNIQUE,
|
||||
server_public_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
remote_username TEXT NOT NULL,
|
||||
auth_method TEXT NOT NULL,
|
||||
owner_scope TEXT NOT NULL DEFAULT 'admin_shared',
|
||||
owner_user_public_id TEXT NOT NULL DEFAULT '',
|
||||
allow_user_edit INTEGER NOT NULL DEFAULT 0,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
secret_public_id TEXT DEFAULT '',
|
||||
auth_public_key TEXT NOT NULL DEFAULT '',
|
||||
auth_public_key_fingerprint TEXT NOT NULL DEFAULT '',
|
||||
ssh_user_ca_public_id TEXT DEFAULT '',
|
||||
ssh_principal_mode TEXT NOT NULL DEFAULT 'explicit',
|
||||
ssh_principals_json TEXT NOT NULL DEFAULT '[]',
|
||||
ssh_principal_grant_ids_json TEXT NOT NULL DEFAULT '[]',
|
||||
default_valid_seconds INTEGER NOT NULL DEFAULT 3600,
|
||||
max_valid_seconds INTEGER NOT NULL DEFAULT 3600,
|
||||
created_by_kind TEXT NOT NULL DEFAULT 'user',
|
||||
created_by_subject_id TEXT NOT NULL DEFAULT '',
|
||||
created_by_subject_name TEXT NOT NULL DEFAULT '',
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
FOREIGN KEY (server_public_id) REFERENCES ssh_servers(public_id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (secret_public_id) REFERENCES ssh_secrets(public_id) ON DELETE SET NULL,
|
||||
FOREIGN KEY (ssh_user_ca_public_id) REFERENCES ssh_user_cas(public_id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_ssh_access_profiles_server ON ssh_access_profiles(server_public_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_ssh_access_profiles_owner_scope ON ssh_access_profiles(owner_scope);
|
||||
CREATE INDEX IF NOT EXISTS idx_ssh_access_profiles_owner_user ON ssh_access_profiles(owner_user_public_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_ssh_access_profiles_enabled ON ssh_access_profiles(enabled);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ssh_access_profile_targets (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
public_id TEXT NOT NULL UNIQUE,
|
||||
profile_public_id TEXT NOT NULL,
|
||||
target_type TEXT NOT NULL,
|
||||
target_public_id TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
UNIQUE(profile_public_id, target_type, target_public_id),
|
||||
FOREIGN KEY (profile_public_id) REFERENCES ssh_access_profiles(public_id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_ssh_access_profile_targets_profile ON ssh_access_profile_targets(profile_public_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_ssh_access_profile_targets_target ON ssh_access_profile_targets(target_type, target_public_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ssh_sessions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
public_id TEXT NOT NULL UNIQUE,
|
||||
profile_public_id TEXT NOT NULL,
|
||||
server_public_id TEXT NOT NULL,
|
||||
user_public_id TEXT NOT NULL,
|
||||
username TEXT NOT NULL,
|
||||
remote_username TEXT NOT NULL,
|
||||
auth_method TEXT NOT NULL,
|
||||
host TEXT NOT NULL,
|
||||
port INTEGER NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
host_key_fingerprint TEXT NOT NULL DEFAULT '',
|
||||
requested_term TEXT NOT NULL DEFAULT 'xterm-256color',
|
||||
requested_cols INTEGER NOT NULL DEFAULT 80,
|
||||
requested_rows INTEGER NOT NULL DEFAULT 24,
|
||||
started_at INTEGER NOT NULL,
|
||||
connected_at INTEGER NOT NULL DEFAULT 0,
|
||||
ended_at INTEGER NOT NULL DEFAULT 0,
|
||||
remote_addr TEXT NOT NULL DEFAULT '',
|
||||
user_agent TEXT NOT NULL DEFAULT '',
|
||||
error TEXT NOT NULL DEFAULT '',
|
||||
FOREIGN KEY (profile_public_id) REFERENCES ssh_access_profiles(public_id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (server_public_id) REFERENCES ssh_servers(public_id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_public_id) REFERENCES users(public_id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_ssh_sessions_user ON ssh_sessions(user_public_id, started_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_ssh_sessions_profile ON ssh_sessions(profile_public_id, started_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_ssh_sessions_status ON ssh_sessions(status);
|
||||
Generated
+24
@@ -12,6 +12,9 @@
|
||||
"@emotion/styled": "^11.11.0",
|
||||
"@mui/icons-material": "^5.15.14",
|
||||
"@mui/material": "^5.15.14",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/addon-unicode11": "^0.9.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"prismjs": "^1.29.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
@@ -1698,6 +1701,27 @@
|
||||
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@xterm/addon-fit": {
|
||||
"version": "0.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz",
|
||||
"integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@xterm/addon-unicode11": {
|
||||
"version": "0.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.9.0.tgz",
|
||||
"integrity": "sha512-FxDnYcyuXhNl+XSqGZL/t0U9eiNb/q3EWT5rYkQT/zuig8Gz/VagnQANKHdDWFM2lTMk9ly0EFQxxxtZUoRetw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@xterm/xterm": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz",
|
||||
"integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"addons/*"
|
||||
]
|
||||
},
|
||||
"node_modules/babel-plugin-macros": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz",
|
||||
|
||||
@@ -13,13 +13,16 @@
|
||||
"@emotion/styled": "^11.11.0",
|
||||
"@mui/icons-material": "^5.15.14",
|
||||
"@mui/material": "^5.15.14",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/addon-unicode11": "^0.9.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"prismjs": "^1.29.0",
|
||||
"react": "^18.2.0",
|
||||
"react-markdown": "^9.0.1",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-markdown": "^9.0.1",
|
||||
"react-router-dom": "^6.23.1",
|
||||
"remark-gfm": "^4.0.0",
|
||||
"rehype-slug": "^6.0.0"
|
||||
"rehype-slug": "^6.0.0",
|
||||
"remark-gfm": "^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.55",
|
||||
|
||||
@@ -645,6 +645,103 @@ export interface SSHPrincipalGrantTarget {
|
||||
created_at: number
|
||||
}
|
||||
|
||||
export interface SSHServer {
|
||||
id: string
|
||||
name: string
|
||||
host: string
|
||||
port: number
|
||||
description: string
|
||||
tags: string[]
|
||||
enabled: boolean
|
||||
created_by_kind: string
|
||||
created_by_subject_id: string
|
||||
created_by_subject_name: string
|
||||
created_at: number
|
||||
updated_at: number
|
||||
editable: boolean
|
||||
}
|
||||
|
||||
export interface SSHServerHostKey {
|
||||
id: string
|
||||
server_id: string
|
||||
algorithm: string
|
||||
public_key: string
|
||||
fingerprint: string
|
||||
created_at: number
|
||||
}
|
||||
|
||||
export interface SSHAccessProfileTarget {
|
||||
profile_id: string
|
||||
target_type: 'user' | 'group'
|
||||
target_id: string
|
||||
target_name: string
|
||||
target_active: boolean
|
||||
created_at: number
|
||||
}
|
||||
|
||||
export interface SSHAccessProfile {
|
||||
id: string
|
||||
server_id: string
|
||||
server: SSHServer
|
||||
name: string
|
||||
description: string
|
||||
remote_username: string
|
||||
auth_method: 'managed_ssh_cert' | 'stored_private_key'
|
||||
owner_scope: 'admin_shared' | 'user'
|
||||
owner_user_id: string
|
||||
allow_user_edit: boolean
|
||||
enabled: boolean
|
||||
auth_public_key: string
|
||||
auth_public_key_fingerprint: string
|
||||
ssh_user_ca_id: string
|
||||
ssh_principal_mode: 'explicit' | 'grant'
|
||||
ssh_principals: string[]
|
||||
ssh_principal_grant_ids: string[]
|
||||
default_valid_seconds: number
|
||||
max_valid_seconds: number
|
||||
created_by_kind: string
|
||||
created_by_subject_id: string
|
||||
created_by_subject_name: string
|
||||
created_at: number
|
||||
updated_at: number
|
||||
targets: SSHAccessProfileTarget[]
|
||||
editable: boolean
|
||||
}
|
||||
|
||||
export interface SSHSession {
|
||||
id: string
|
||||
profile_id: string
|
||||
server_id: string
|
||||
user_id: string
|
||||
username: string
|
||||
remote_username: string
|
||||
auth_method: 'managed_ssh_cert' | 'stored_private_key'
|
||||
host: string
|
||||
port: number
|
||||
status: string
|
||||
host_key_fingerprint: string
|
||||
requested_term: string
|
||||
requested_cols: number
|
||||
requested_rows: number
|
||||
started_at: number
|
||||
connected_at: number
|
||||
ended_at: number
|
||||
remote_addr: string
|
||||
user_agent: string
|
||||
error: string
|
||||
}
|
||||
|
||||
export interface SSHSessionConnectResponse {
|
||||
session_id: string
|
||||
status: string
|
||||
websocket_path: string
|
||||
server_name: string
|
||||
host: string
|
||||
port: number
|
||||
remote_username: string
|
||||
host_key_fingerprint: string
|
||||
}
|
||||
|
||||
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
const res = await fetch(path, {
|
||||
credentials: 'include',
|
||||
@@ -963,10 +1060,140 @@ export const api = {
|
||||
}) =>
|
||||
request<SSHPrincipalGrant>(`/api/admin/ssh/principal-grants/${id}`, { method: 'PATCH', body: JSON.stringify(payload) }),
|
||||
deleteSSHPrincipalGrant: (id: string) => request<void>(`/api/admin/ssh/principal-grants/${id}`, { method: 'DELETE' }),
|
||||
listSSHServersAdmin: () => request<SSHServer[]>('/api/admin/ssh/servers'),
|
||||
getSSHServerAdmin: (id: string) => request<SSHServer>(`/api/admin/ssh/servers/${id}`),
|
||||
createSSHServerAdmin: (payload: {
|
||||
name: string
|
||||
host: string
|
||||
port: number
|
||||
description: string
|
||||
tags: string[]
|
||||
enabled: boolean
|
||||
}) =>
|
||||
request<SSHServer>('/api/admin/ssh/servers', { method: 'POST', body: JSON.stringify(payload) }),
|
||||
updateSSHServerAdmin: (id: string, payload: {
|
||||
name: string
|
||||
host: string
|
||||
port: number
|
||||
description: string
|
||||
tags: string[]
|
||||
enabled: boolean
|
||||
}) =>
|
||||
request<SSHServer>(`/api/admin/ssh/servers/${id}`, { method: 'PATCH', body: JSON.stringify(payload) }),
|
||||
deleteSSHServerAdmin: (id: string) => request<void>(`/api/admin/ssh/servers/${id}`, { method: 'DELETE' }),
|
||||
listSSHServerHostKeysAdmin: (id: string) => request<SSHServerHostKey[]>(`/api/admin/ssh/servers/${id}/host-keys`),
|
||||
discoverSSHServerHostKeyAdmin: (id: string) => request<SSHServerHostKey>(`/api/admin/ssh/servers/${id}/host-keys/discover`, { method: 'POST' }),
|
||||
createSSHServerHostKeyAdmin: (id: string, public_key: string) =>
|
||||
request<SSHServerHostKey>(`/api/admin/ssh/servers/${id}/host-keys`, { method: 'POST', body: JSON.stringify({ public_key }) }),
|
||||
deleteSSHServerHostKeyAdmin: (id: string, hostKeyID: string) =>
|
||||
request<void>(`/api/admin/ssh/servers/${id}/host-keys/${hostKeyID}`, { method: 'DELETE' }),
|
||||
listSSHAccessProfilesAdmin: () => request<SSHAccessProfile[]>('/api/admin/ssh/access-profiles'),
|
||||
getSSHAccessProfileAdmin: (id: string) => request<SSHAccessProfile>(`/api/admin/ssh/access-profiles/${id}`),
|
||||
createSSHAccessProfileAdmin: (payload: {
|
||||
server_id: string
|
||||
name: string
|
||||
description: string
|
||||
remote_username: string
|
||||
auth_method: 'managed_ssh_cert' | 'stored_private_key'
|
||||
enabled: boolean
|
||||
private_key_pem?: string
|
||||
ssh_user_ca_id?: string
|
||||
ssh_principal_mode?: 'explicit' | 'grant'
|
||||
ssh_principals?: string[]
|
||||
ssh_principal_grant_ids?: string[]
|
||||
default_valid_seconds: number
|
||||
max_valid_seconds: number
|
||||
targets: Array<{ target_type: 'user' | 'group'; target_id: string }>
|
||||
}) =>
|
||||
request<SSHAccessProfile>('/api/admin/ssh/access-profiles', { method: 'POST', body: JSON.stringify(payload) }),
|
||||
updateSSHAccessProfileAdmin: (id: string, payload: {
|
||||
server_id: string
|
||||
name: string
|
||||
description: string
|
||||
remote_username: string
|
||||
auth_method: 'managed_ssh_cert' | 'stored_private_key'
|
||||
enabled: boolean
|
||||
private_key_pem?: string
|
||||
ssh_user_ca_id?: string
|
||||
ssh_principal_mode?: 'explicit' | 'grant'
|
||||
ssh_principals?: string[]
|
||||
ssh_principal_grant_ids?: string[]
|
||||
default_valid_seconds: number
|
||||
max_valid_seconds: number
|
||||
targets: Array<{ target_type: 'user' | 'group'; target_id: string }>
|
||||
}) =>
|
||||
request<SSHAccessProfile>(`/api/admin/ssh/access-profiles/${id}`, { method: 'PATCH', body: JSON.stringify(payload) }),
|
||||
deleteSSHAccessProfileAdmin: (id: string) => request<void>(`/api/admin/ssh/access-profiles/${id}`, { method: 'DELETE' }),
|
||||
listSSHUserCAsForSelf: () => request<SSHUserCA[]>('/api/ssh/user-cas'),
|
||||
getSSHUserCAForSelf: (id: string) => request<SSHUserCA>(`/api/ssh/user-cas/${id}`),
|
||||
downloadSSHUserCAPublicKeyForSelf: (id: string) => requestText(`/api/ssh/user-cas/${id}/public-key`),
|
||||
listSSHPrincipalGrantsForSelf: () => request<SSHPrincipalGrant[]>('/api/ssh/principal-grants'),
|
||||
listSSHServersForSelf: () => request<SSHServer[]>('/api/ssh/servers'),
|
||||
createSSHServerForSelf: (payload: {
|
||||
name: string
|
||||
host: string
|
||||
port: number
|
||||
description: string
|
||||
tags: string[]
|
||||
enabled: boolean
|
||||
}) =>
|
||||
request<SSHServer>('/api/ssh/servers', { method: 'POST', body: JSON.stringify(payload) }),
|
||||
updateSSHServerForSelf: (id: string, payload: {
|
||||
name: string
|
||||
host: string
|
||||
port: number
|
||||
description: string
|
||||
tags: string[]
|
||||
enabled: boolean
|
||||
}) =>
|
||||
request<SSHServer>(`/api/ssh/servers/${id}`, { method: 'PATCH', body: JSON.stringify(payload) }),
|
||||
deleteSSHServerForSelf: (id: string) => request<void>(`/api/ssh/servers/${id}`, { method: 'DELETE' }),
|
||||
listSSHServerHostKeysForSelf: (id: string) => request<SSHServerHostKey[]>(`/api/ssh/servers/${id}/host-keys`),
|
||||
discoverSSHServerHostKeyForSelf: (id: string) => request<SSHServerHostKey>(`/api/ssh/servers/${id}/host-keys/discover`, { method: 'POST' }),
|
||||
createSSHServerHostKeyForSelf: (id: string, public_key: string) =>
|
||||
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'),
|
||||
createSSHAccessProfileForSelf: (payload: {
|
||||
server_id: string
|
||||
name: string
|
||||
description: string
|
||||
remote_username: string
|
||||
auth_method: 'managed_ssh_cert' | 'stored_private_key'
|
||||
enabled: boolean
|
||||
private_key_pem?: string
|
||||
ssh_user_ca_id?: string
|
||||
ssh_principal_mode?: 'explicit' | 'grant'
|
||||
ssh_principals?: string[]
|
||||
ssh_principal_grant_ids?: string[]
|
||||
default_valid_seconds: number
|
||||
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}`),
|
||||
updateSSHAccessProfileForSelf: (id: string, payload: {
|
||||
server_id: string
|
||||
name: string
|
||||
description: string
|
||||
remote_username: string
|
||||
auth_method: 'managed_ssh_cert' | 'stored_private_key'
|
||||
enabled: boolean
|
||||
private_key_pem?: string
|
||||
ssh_user_ca_id?: string
|
||||
ssh_principal_mode?: 'explicit' | 'grant'
|
||||
ssh_principals?: string[]
|
||||
ssh_principal_grant_ids?: string[]
|
||||
default_valid_seconds: number
|
||||
max_valid_seconds: number
|
||||
}) =>
|
||||
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 }) =>
|
||||
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}`),
|
||||
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 }) }),
|
||||
signSSHUserKeyForSelf: (id: string, payload: { public_key: string; key_id?: string; grant_ids?: string[]; principals?: string[]; valid_seconds?: number }) =>
|
||||
|
||||
@@ -36,6 +36,8 @@ import GroupWorkIcon from '@mui/icons-material/GroupWork'
|
||||
import VerifiedUserIcon from '@mui/icons-material/VerifiedUser'
|
||||
import PaletteIcon from '@mui/icons-material/Palette'
|
||||
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'
|
||||
import DnsIcon from '@mui/icons-material/Dns'
|
||||
import TerminalIcon from '@mui/icons-material/Terminal'
|
||||
import { ThemeModeContext } from './ThemeModeContext'
|
||||
import { themeSchemeOptions } from './theme'
|
||||
|
||||
@@ -67,7 +69,8 @@ export default function Layout() {
|
||||
{ label: 'Repositories', path: '/repos', icon: <StorageIcon fontSize="small" /> },
|
||||
{ label: 'API Keys', path: '/api-keys', icon: <KeyIcon fontSize="small" /> },
|
||||
{ label: 'Client Certs', path: '/client-certs', icon: <VerifiedUserIcon fontSize="small" /> },
|
||||
{ label: 'SSH Certs', path: '/ssh-certs', icon: <BadgeIcon fontSize="small" /> }
|
||||
{ label: 'SSH Certs', path: '/ssh-certs', icon: <BadgeIcon fontSize="small" /> },
|
||||
{ label: 'SSH Servers', path: '/ssh-servers', icon: <DnsIcon fontSize="small" /> }
|
||||
]
|
||||
const adminItems = []
|
||||
if (user?.is_admin) {
|
||||
@@ -76,6 +79,8 @@ export default function Layout() {
|
||||
adminItems.push({ label: 'Admin API Keys', path: '/admin/api-keys', icon: <AdminPanelSettingsIcon fontSize="small" /> })
|
||||
adminItems.push({ label: 'Admin PKI', path: '/admin/pki', icon: <SecurityIcon fontSize="small" /> })
|
||||
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: '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" /> })
|
||||
|
||||
+55
-16
@@ -1,3 +1,6 @@
|
||||
import Box from '@mui/material/Box'
|
||||
import CircularProgress from '@mui/material/CircularProgress'
|
||||
import { lazy, Suspense, type ReactNode } from 'react'
|
||||
import { RouteObject } from 'react-router-dom'
|
||||
import Layout from './Layout'
|
||||
import DashboardPage from '../pages/DashboardPage'
|
||||
@@ -7,20 +10,14 @@ import GlobalReposPage from '../pages/GlobalReposPage'
|
||||
import ProjectEntryPage from '../pages/ProjectEntryPage'
|
||||
import ProjectHomePage from '../pages/ProjectHomePage'
|
||||
import ReposPage from '../pages/ReposPage'
|
||||
import RepoDetailPage from '../pages/RepoDetailPage'
|
||||
import BranchesPage from '../pages/BranchesPage'
|
||||
import CommitDetailPage from '../pages/CommitDetailPage'
|
||||
import CommitsPage from '../pages/CommitsPage'
|
||||
import IssuesPage from '../pages/IssuesPage'
|
||||
import WikiPage from '../pages/WikiPage'
|
||||
import FilesPage from '../pages/FilesPage'
|
||||
import AdminUsersPage from '../pages/AdminUsersPage'
|
||||
import AdminUserGroupsPage from '../pages/AdminUserGroupsPage'
|
||||
import AdminApiKeysPage from '../pages/AdminApiKeysPage'
|
||||
import AdminAuthLdapPage from '../pages/AdminAuthLdapPage'
|
||||
import AdminPKIPage from '../pages/AdminPKIPage'
|
||||
import AdminPKIClientProfilesPage from '../pages/AdminPKIClientProfilesPage'
|
||||
import AdminSSHUserCAPage from '../pages/AdminSSHUserCAPage'
|
||||
import AdminSSHServersPage from '../pages/AdminSSHServersPage'
|
||||
import AdminSSHAccessProfilesPage from '../pages/AdminSSHAccessProfilesPage'
|
||||
import AdminSSHSignHistoryPage from '../pages/AdminSSHSignHistoryPage'
|
||||
import AdminSSHPrincipalGrantsPage from '../pages/AdminSSHPrincipalGrantsPage'
|
||||
import AdminTLSAuthPoliciesPage from '../pages/AdminTLSAuthPoliciesPage'
|
||||
@@ -30,8 +27,31 @@ import ApiKeysPage from '../pages/ApiKeysPage'
|
||||
import AccountPage from '../pages/AccountPage'
|
||||
import ClientCertificatesPage from '../pages/ClientCertificatesPage'
|
||||
import SSHCertificatesPage from '../pages/SSHCertificatesPage'
|
||||
import SSHServersPage from '../pages/SSHServersPage'
|
||||
import NotFoundPage from '../pages/NotFoundPage'
|
||||
|
||||
const SSHSessionPage = lazy(() => import('../pages/SSHSessionPage'))
|
||||
const RepoDetailPage = lazy(() => import('../pages/RepoDetailPage'))
|
||||
const BranchesPage = lazy(() => import('../pages/BranchesPage'))
|
||||
const CommitsPage = lazy(() => import('../pages/CommitsPage'))
|
||||
const CommitDetailPage = lazy(() => import('../pages/CommitDetailPage'))
|
||||
const IssuesPage = lazy(() => import('../pages/IssuesPage'))
|
||||
const WikiPage = lazy(() => import('../pages/WikiPage'))
|
||||
const FilesPage = lazy(() => import('../pages/FilesPage'))
|
||||
const AdminPKIPage = lazy(() => import('../pages/AdminPKIPage'))
|
||||
|
||||
function RouteLoadingFallback() {
|
||||
return (
|
||||
<Box sx={{ py: 6, display: 'grid', placeItems: 'center' }}>
|
||||
<CircularProgress size={28} />
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
function withRouteSuspense(element: ReactNode) {
|
||||
return <Suspense fallback={<RouteLoadingFallback />}>{element}</Suspense>
|
||||
}
|
||||
|
||||
export const routes: RouteObject[] = [
|
||||
{ path: '/login', element: <LoginPage /> },
|
||||
{
|
||||
@@ -45,21 +65,40 @@ export const routes: RouteObject[] = [
|
||||
{ path: 'api-keys', element: <ApiKeysPage /> },
|
||||
{ path: 'client-certs', element: <ClientCertificatesPage /> },
|
||||
{ path: 'ssh-certs', element: <SSHCertificatesPage /> },
|
||||
{ path: 'ssh-servers', element: <SSHServersPage /> },
|
||||
{
|
||||
path: 'ssh-sessions',
|
||||
element: (
|
||||
<Suspense fallback={<RouteLoadingFallback />}>
|
||||
<SSHSessionPage />
|
||||
</Suspense>
|
||||
)
|
||||
},
|
||||
{
|
||||
path: 'ssh-sessions/:sessionId',
|
||||
element: (
|
||||
<Suspense fallback={<RouteLoadingFallback />}>
|
||||
<SSHSessionPage />
|
||||
</Suspense>
|
||||
)
|
||||
},
|
||||
{ path: 'projects/:projectId', element: <ProjectEntryPage /> },
|
||||
{ path: 'projects/:projectId/info', element: <ProjectHomePage /> },
|
||||
{ path: 'projects/:projectId/repos', element: <ReposPage /> },
|
||||
{ path: 'projects/:projectId/repos/:repoId', element: <RepoDetailPage /> },
|
||||
{ path: 'projects/:projectId/repos/:repoId/branches', element: <BranchesPage /> },
|
||||
{ path: 'projects/:projectId/repos/:repoId/commits', element: <CommitsPage /> },
|
||||
{ path: 'projects/:projectId/repos/:repoId/commits/:hash', element: <CommitDetailPage /> },
|
||||
{ path: 'projects/:projectId/issues', element: <IssuesPage /> },
|
||||
{ path: 'projects/:projectId/wiki', element: <WikiPage /> },
|
||||
{ path: 'projects/:projectId/files', element: <FilesPage /> },
|
||||
{ path: 'projects/:projectId/repos/:repoId', element: withRouteSuspense(<RepoDetailPage />) },
|
||||
{ path: 'projects/:projectId/repos/:repoId/branches', element: withRouteSuspense(<BranchesPage />) },
|
||||
{ path: 'projects/:projectId/repos/:repoId/commits', element: withRouteSuspense(<CommitsPage />) },
|
||||
{ path: 'projects/:projectId/repos/:repoId/commits/:hash', element: withRouteSuspense(<CommitDetailPage />) },
|
||||
{ path: 'projects/:projectId/issues', element: withRouteSuspense(<IssuesPage />) },
|
||||
{ path: 'projects/:projectId/wiki', element: withRouteSuspense(<WikiPage />) },
|
||||
{ path: 'projects/:projectId/files', element: withRouteSuspense(<FilesPage />) },
|
||||
{ path: 'admin/users', element: <AdminUsersPage /> },
|
||||
{ path: 'admin/user-groups', element: <AdminUserGroupsPage /> },
|
||||
{ path: 'admin/api-keys', element: <AdminApiKeysPage /> },
|
||||
{ path: 'admin/pki', element: <AdminPKIPage /> },
|
||||
{ path: 'admin/pki', element: withRouteSuspense(<AdminPKIPage />) },
|
||||
{ path: 'admin/pki/client-profiles', element: <AdminPKIClientProfilesPage /> },
|
||||
{ path: 'admin/ssh-servers', element: <AdminSSHServersPage /> },
|
||||
{ path: 'admin/ssh-access-profiles', element: <AdminSSHAccessProfilesPage /> },
|
||||
{ path: 'admin/ssh-ca', element: <AdminSSHUserCAPage /> },
|
||||
{ path: 'admin/ssh-sign-history', element: <AdminSSHSignHistoryPage /> },
|
||||
{ path: 'admin/ssh-principal-grants', element: <AdminSSHPrincipalGrantsPage /> },
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import Box from '@mui/material/Box'
|
||||
import { lazy, Suspense } from 'react'
|
||||
|
||||
type LazyCodeBlockProps = {
|
||||
code?: string
|
||||
language?: string
|
||||
showLineNumbers?: boolean
|
||||
}
|
||||
|
||||
const CodeBlock = lazy(() => import('./CodeBlock'))
|
||||
|
||||
export default function LazyCodeBlock(props: LazyCodeBlockProps) {
|
||||
const { code = '' } = props
|
||||
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<Box
|
||||
component="pre"
|
||||
sx={{
|
||||
m: 0,
|
||||
p: 0,
|
||||
whiteSpace: 'pre',
|
||||
overflow: 'auto',
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, monospace'
|
||||
}}
|
||||
>
|
||||
<code>{code}</code>
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
<CodeBlock {...props} />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import Box from '@mui/material/Box'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import rehypeSlug from 'rehype-slug'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import LazyCodeBlock from './LazyCodeBlock'
|
||||
|
||||
type RepoMarkdownProps = {
|
||||
content: string
|
||||
resolveAsset: (src: string) => string
|
||||
extractLanguage: (className?: string) => string
|
||||
}
|
||||
|
||||
export default function RepoMarkdown(props: RepoMarkdownProps) {
|
||||
const { content, resolveAsset, extractLanguage } = props
|
||||
|
||||
return (
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[rehypeSlug]}
|
||||
components={{
|
||||
h1: ({ node, ...rest }) => <Typography component="h1" variant="h5" sx={{ m: 0 }} {...rest} />,
|
||||
h2: ({ node, ...rest }) => <Typography component="h2" variant="h6" sx={{ m: 0 }} {...rest} />,
|
||||
h3: ({ node, ...rest }) => <Typography component="h3" variant="subtitle1" sx={{ m: 0 }} {...rest} />,
|
||||
h4: ({ node, ...rest }) => <Typography component="h4" variant="subtitle2" sx={{ m: 0 }} {...rest} />,
|
||||
h5: ({ node, ...rest }) => <Typography component="h5" variant="body2" sx={{ m: 0, fontWeight: 600 }} {...rest} />,
|
||||
h6: ({ node, ...rest }) => <Typography component="h6" variant="body2" sx={{ m: 0, fontWeight: 600 }} {...rest} />,
|
||||
pre: ({ children }) => <Box sx={{ m: 0, p: 0, mt: 2, mb: 2 }}>{children}</Box>,
|
||||
img: ({ src, alt }) => <img src={resolveAsset(src || '')} alt={alt || ''} style={{ maxWidth: '100%' }} />,
|
||||
code: ({ inline, className, children }) => {
|
||||
const text = String(children)
|
||||
const looksInline = inline || (!className && !text.includes('\n'))
|
||||
|
||||
if (looksInline) {
|
||||
return <code style={{ whiteSpace: 'pre-wrap' }}>{children}</code>
|
||||
}
|
||||
return (
|
||||
<Box sx={{ p: 1, border: '1px solid', borderColor: 'divider', borderRadius: 1 }}>
|
||||
<LazyCodeBlock code={text.replace(/\n$/, '')} language={extractLanguage(className)} />
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,488 @@
|
||||
import Alert from '@mui/material/Alert'
|
||||
import Box from '@mui/material/Box'
|
||||
import Button from '@mui/material/Button'
|
||||
import Checkbox from '@mui/material/Checkbox'
|
||||
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 Paper from '@mui/material/Paper'
|
||||
import TextField from '@mui/material/TextField'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import Autocomplete from '../components/Autocomplete'
|
||||
import FormDialogContent from '../components/FormDialogContent'
|
||||
import { ListRowActionButton, ListRowActions } from '../components/ListRowActions'
|
||||
import SectionCard from '../components/SectionCard'
|
||||
import SelectField from '../components/SelectField'
|
||||
import { api, SSHAccessProfile, SSHPrincipalGrant, SSHServer, SSHUserCA, User, UserGroup } from '../api'
|
||||
|
||||
type SSHAccessProfileForm = {
|
||||
server_id: string
|
||||
name: string
|
||||
description: string
|
||||
remote_username: string
|
||||
auth_method: 'managed_ssh_cert' | 'stored_private_key'
|
||||
enabled: boolean
|
||||
private_key_pem: string
|
||||
ssh_user_ca_id: string
|
||||
ssh_principal_mode: 'explicit' | 'grant'
|
||||
ssh_principals_text: string
|
||||
ssh_principal_grant_ids: string[]
|
||||
default_valid_seconds: number
|
||||
max_valid_seconds: number
|
||||
user_ids: string[]
|
||||
group_ids: string[]
|
||||
}
|
||||
|
||||
const emptyForm = (): SSHAccessProfileForm => ({
|
||||
server_id: '',
|
||||
name: '',
|
||||
description: '',
|
||||
remote_username: '',
|
||||
auth_method: 'managed_ssh_cert',
|
||||
enabled: true,
|
||||
private_key_pem: '',
|
||||
ssh_user_ca_id: '',
|
||||
ssh_principal_mode: 'explicit',
|
||||
ssh_principals_text: '',
|
||||
ssh_principal_grant_ids: [],
|
||||
default_valid_seconds: 3600,
|
||||
max_valid_seconds: 3600,
|
||||
user_ids: [],
|
||||
group_ids: []
|
||||
})
|
||||
|
||||
export default function AdminSSHAccessProfilesPage() {
|
||||
const [items, setItems] = useState<SSHAccessProfile[]>([])
|
||||
const [servers, setServers] = useState<SSHServer[]>([])
|
||||
const [users, setUsers] = useState<User[]>([])
|
||||
const [groups, setGroups] = useState<UserGroup[]>([])
|
||||
const [cas, setCAs] = useState<SSHUserCA[]>([])
|
||||
const [grants, setGrants] = useState<SSHPrincipalGrant[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [search, setSearch] = useState('')
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [editingID, setEditingID] = useState<string | null>(null)
|
||||
const [form, setForm] = useState<SSHAccessProfileForm>(emptyForm())
|
||||
const [dialogError, setDialogError] = useState<string | null>(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [deleteItem, setDeleteItem] = useState<SSHAccessProfile | null>(null)
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [profileList, serverList, userList, groupList, caList, grantList] = await Promise.all([
|
||||
api.listSSHAccessProfilesAdmin(),
|
||||
api.listSSHServersAdmin(),
|
||||
api.listUsers(),
|
||||
api.listUserGroups(),
|
||||
api.listSSHUserCAs(),
|
||||
api.listSSHPrincipalGrants()
|
||||
])
|
||||
setItems(Array.isArray(profileList) ? profileList : [])
|
||||
setServers(Array.isArray(serverList) ? serverList : [])
|
||||
setUsers(Array.isArray(userList) ? userList : [])
|
||||
setGroups(Array.isArray(groupList) ? groupList : [])
|
||||
setCAs(Array.isArray(caList) ? caList : [])
|
||||
setGrants(Array.isArray(grantList) ? grantList : [])
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load SSH access profiles')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [])
|
||||
|
||||
const filteredItems = useMemo(() => {
|
||||
const needle = search.trim().toLowerCase()
|
||||
if (!needle) {
|
||||
return items
|
||||
}
|
||||
return items.filter((item) =>
|
||||
[
|
||||
item.name,
|
||||
item.server?.name,
|
||||
item.server?.host,
|
||||
item.remote_username,
|
||||
item.auth_method,
|
||||
item.auth_public_key_fingerprint,
|
||||
item.ssh_user_ca_id,
|
||||
(item.ssh_principals || []).join(' '),
|
||||
(item.targets || []).map((target) => `${target.target_type}:${target.target_name}`).join(' ')
|
||||
]
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
.includes(needle)
|
||||
)
|
||||
}, [items, search])
|
||||
|
||||
const userOptions = useMemo(() => users.filter((item) => !item.disabled), [users])
|
||||
const groupOptions = useMemo(() => groups.filter((item) => !item.disabled), [groups])
|
||||
const caOptions = useMemo(() => cas.filter((item) => item.enabled), [cas])
|
||||
|
||||
const openCreate = () => {
|
||||
setEditingID(null)
|
||||
setForm({
|
||||
...emptyForm(),
|
||||
server_id: servers[0]?.id || '',
|
||||
ssh_user_ca_id: caOptions[0]?.id || ''
|
||||
})
|
||||
setDialogError(null)
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
const openEdit = (item: SSHAccessProfile) => {
|
||||
setEditingID(item.id)
|
||||
setForm({
|
||||
server_id: item.server_id,
|
||||
name: item.name,
|
||||
description: item.description || '',
|
||||
remote_username: item.remote_username,
|
||||
auth_method: item.auth_method,
|
||||
enabled: item.enabled,
|
||||
private_key_pem: '',
|
||||
ssh_user_ca_id: item.ssh_user_ca_id || '',
|
||||
ssh_principal_mode: item.ssh_principal_mode || 'explicit',
|
||||
ssh_principals_text: (item.ssh_principals || []).join(', '),
|
||||
ssh_principal_grant_ids: item.ssh_principal_grant_ids || [],
|
||||
default_valid_seconds: item.default_valid_seconds || 3600,
|
||||
max_valid_seconds: item.max_valid_seconds || item.default_valid_seconds || 3600,
|
||||
user_ids: (item.targets || []).filter((target) => target.target_type === 'user').map((target) => target.target_id),
|
||||
group_ids: (item.targets || []).filter((target) => target.target_type === 'group').map((target) => target.target_id)
|
||||
})
|
||||
setDialogError(null)
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
const parseNames = (value: string) =>
|
||||
Array.from(
|
||||
new Set(
|
||||
value
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => item !== '')
|
||||
)
|
||||
)
|
||||
|
||||
const handleSave = async () => {
|
||||
const payload = {
|
||||
server_id: form.server_id,
|
||||
name: form.name.trim(),
|
||||
description: form.description.trim(),
|
||||
remote_username: form.remote_username.trim(),
|
||||
auth_method: form.auth_method,
|
||||
enabled: form.enabled,
|
||||
private_key_pem: form.private_key_pem.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) : [],
|
||||
ssh_principal_grant_ids: form.auth_method === 'managed_ssh_cert' && form.ssh_principal_mode === 'grant' ? form.ssh_principal_grant_ids : [],
|
||||
default_valid_seconds: Number(form.default_valid_seconds) || 0,
|
||||
max_valid_seconds: Number(form.max_valid_seconds) || 0,
|
||||
targets: [
|
||||
...form.user_ids.map((id) => ({ target_type: 'user' as const, target_id: id })),
|
||||
...form.group_ids.map((id) => ({ target_type: 'group' as const, target_id: id }))
|
||||
]
|
||||
}
|
||||
if (!payload.server_id) {
|
||||
setDialogError('Server is required')
|
||||
return
|
||||
}
|
||||
if (!payload.name) {
|
||||
setDialogError('Name is required')
|
||||
return
|
||||
}
|
||||
if (!payload.remote_username) {
|
||||
setDialogError('Remote username is required')
|
||||
return
|
||||
}
|
||||
if (!payload.targets.length) {
|
||||
setDialogError('Select at least one user or group target')
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
setDialogError(null)
|
||||
try {
|
||||
if (editingID) {
|
||||
await api.updateSSHAccessProfileAdmin(editingID, payload)
|
||||
} else {
|
||||
await api.createSSHAccessProfileAdmin(payload)
|
||||
}
|
||||
setDialogOpen(false)
|
||||
await load()
|
||||
} catch (err) {
|
||||
setDialogError(err instanceof Error ? err.message : 'Failed to save SSH access profile')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteItem) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await api.deleteSSHAccessProfileAdmin(deleteItem.id)
|
||||
setDeleteItem(null)
|
||||
await load()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to delete SSH access profile')
|
||||
setDeleteItem(null)
|
||||
}
|
||||
}
|
||||
|
||||
const selectedUserOptions = userOptions.filter((item) => form.user_ids.includes(item.id))
|
||||
const selectedGroupOptions = groupOptions.filter((item) => form.group_ids.includes(item.id))
|
||||
const selectedGrantOptions = grants.filter((item) => form.ssh_principal_grant_ids.includes(item.id))
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h5" sx={{ mb: 2 }}>
|
||||
Admin: SSH Access Profiles
|
||||
</Typography>
|
||||
<SectionCard
|
||||
title={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, minWidth: 0, flexWrap: 'wrap' }}>
|
||||
<Typography variant="h6">SSH Access Profiles</Typography>
|
||||
<TextField
|
||||
size="small"
|
||||
label="Search"
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
sx={{ minWidth: 240, maxWidth: 320 }}
|
||||
/>
|
||||
</Box>
|
||||
}
|
||||
actions={
|
||||
<Button variant="outlined" onClick={openCreate} disabled={!servers.length}>
|
||||
New Profile
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{error ? <Alert severity="error">{error}</Alert> : null}
|
||||
{!servers.length ? <Alert severity="info">Create an SSH server first.</Alert> : null}
|
||||
{loading ? (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Loading...
|
||||
</Typography>
|
||||
) : null}
|
||||
<Box sx={{ display: 'grid', gap: 0 }}>
|
||||
{filteredItems.length === 0 && !loading ? (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
No SSH access profiles found.
|
||||
</Typography>
|
||||
) : null}
|
||||
{filteredItems.map((item) => (
|
||||
<Paper
|
||||
key={item.id}
|
||||
variant="outlined"
|
||||
sx={{
|
||||
p: 1,
|
||||
display: 'grid',
|
||||
gap: 0.5,
|
||||
backgroundColor: 'transparent',
|
||||
backgroundImage: 'none',
|
||||
borderLeft: 'none',
|
||||
borderRight: 'none',
|
||||
borderTop: 'none',
|
||||
borderBottom: (theme) => `1px solid ${theme.palette.divider}`,
|
||||
borderRadius: 0,
|
||||
boxShadow: 'none'
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1, flexWrap: 'wrap' }}>
|
||||
<Typography variant="subtitle2" sx={{ wordBreak: 'break-word' }}>
|
||||
{item.name}
|
||||
</Typography>
|
||||
<ListRowActions>
|
||||
<ListRowActionButton onClick={() => openEdit(item)}>Edit</ListRowActionButton>
|
||||
<ListRowActionButton color="error" onClick={() => setDeleteItem(item)}>
|
||||
Delete
|
||||
</ListRowActionButton>
|
||||
</ListRowActions>
|
||||
</Box>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ wordBreak: 'break-word' }}>
|
||||
{item.server?.name} · {item.server?.host}:{item.server?.port} · {item.remote_username} · {item.auth_method} · {item.enabled ? 'enabled' : 'disabled'}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ wordBreak: 'break-word' }}>
|
||||
Targets: {(item.targets || []).map((target) => `${target.target_type}:${target.target_name}`).join(', ') || '-'}
|
||||
</Typography>
|
||||
{item.auth_method === 'managed_ssh_cert' ? (
|
||||
<Typography variant="caption" color="text.secondary" sx={{ wordBreak: 'break-word' }}>
|
||||
CA: {item.ssh_user_ca_id || '-'} · Principal mode: {item.ssh_principal_mode} · Principals: {(item.ssh_principals || []).join(', ') || '-'} · Grants: {(item.ssh_principal_grant_ids || []).join(', ') || '-'}
|
||||
</Typography>
|
||||
) : (
|
||||
<Typography variant="caption" color="text.secondary" sx={{ wordBreak: 'break-word' }}>
|
||||
Key fingerprint: {item.auth_public_key_fingerprint || '-'}
|
||||
</Typography>
|
||||
)}
|
||||
</Paper>
|
||||
))}
|
||||
</Box>
|
||||
</SectionCard>
|
||||
|
||||
<Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} maxWidth="md" fullWidth>
|
||||
<DialogTitle>
|
||||
<Box sx={{ display: 'grid', gap: 1 }}>
|
||||
<Typography variant="h6">{editingID ? 'Edit SSH Access Profile' : 'New SSH Access Profile'}</Typography>
|
||||
{dialogError ? <Alert severity="error">{dialogError}</Alert> : null}
|
||||
</Box>
|
||||
</DialogTitle>
|
||||
<FormDialogContent>
|
||||
<SelectField
|
||||
label="SSH Server"
|
||||
value={form.server_id}
|
||||
onChange={(event) => setForm((prev) => ({ ...prev, server_id: event.target.value }))}
|
||||
>
|
||||
{servers.map((item) => (
|
||||
<MenuItem key={item.id} value={item.id}>
|
||||
{item.name} ({item.host}:{item.port})
|
||||
</MenuItem>
|
||||
))}
|
||||
</SelectField>
|
||||
<TextField
|
||||
label="Name"
|
||||
value={form.name}
|
||||
onChange={(event) => setForm((prev) => ({ ...prev, name: event.target.value }))}
|
||||
/>
|
||||
<TextField
|
||||
label="Description"
|
||||
value={form.description}
|
||||
onChange={(event) => setForm((prev) => ({ ...prev, description: event.target.value }))}
|
||||
/>
|
||||
<TextField
|
||||
label="Remote Username"
|
||||
value={form.remote_username}
|
||||
onChange={(event) => setForm((prev) => ({ ...prev, remote_username: event.target.value }))}
|
||||
/>
|
||||
<SelectField
|
||||
label="Auth Method"
|
||||
value={form.auth_method}
|
||||
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="stored_private_key">stored_private_key</MenuItem>
|
||||
</SelectField>
|
||||
<FormControlLabel
|
||||
control={<Checkbox checked={form.enabled} onChange={(event) => setForm((prev) => ({ ...prev, enabled: event.target.checked }))} />}
|
||||
label="Enabled"
|
||||
/>
|
||||
{form.auth_method === 'managed_ssh_cert' ? (
|
||||
<>
|
||||
<SelectField
|
||||
label="SSH User CA"
|
||||
value={form.ssh_user_ca_id}
|
||||
onChange={(event) => setForm((prev) => ({ ...prev, ssh_user_ca_id: event.target.value }))}
|
||||
>
|
||||
{caOptions.map((item) => (
|
||||
<MenuItem key={item.id} value={item.id}>
|
||||
{item.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</SelectField>
|
||||
<SelectField
|
||||
label="Principal Mode"
|
||||
value={form.ssh_principal_mode}
|
||||
onChange={(event) => setForm((prev) => ({ ...prev, ssh_principal_mode: event.target.value as SSHAccessProfileForm['ssh_principal_mode'] }))}
|
||||
>
|
||||
<MenuItem value="explicit">explicit</MenuItem>
|
||||
<MenuItem value="grant">grant</MenuItem>
|
||||
</SelectField>
|
||||
{form.ssh_principal_mode === 'explicit' ? (
|
||||
<TextField
|
||||
label="SSH Principals"
|
||||
value={form.ssh_principals_text}
|
||||
onChange={(event) => setForm((prev) => ({ ...prev, ssh_principals_text: event.target.value }))}
|
||||
helperText="Comma-separated principal names."
|
||||
/>
|
||||
) : (
|
||||
<Autocomplete
|
||||
multiple
|
||||
options={grants}
|
||||
value={selectedGrantOptions}
|
||||
onChange={(_, values) => setForm((prev) => ({ ...prev, ssh_principal_grant_ids: values.map((item) => item.id) }))}
|
||||
getOptionLabel={(item) => `${item.name || item.principal} · ${(item.principals || []).join(', ')}`}
|
||||
renderInput={(params) => <TextField {...params} label="Principal Grants" />}
|
||||
/>
|
||||
)}
|
||||
<TextField
|
||||
label="Default Validity Seconds"
|
||||
type="number"
|
||||
value={form.default_valid_seconds}
|
||||
onChange={(event) => setForm((prev) => ({ ...prev, default_valid_seconds: Number(event.target.value) || 0 }))}
|
||||
/>
|
||||
<TextField
|
||||
label="Max Validity Seconds"
|
||||
type="number"
|
||||
value={form.max_valid_seconds}
|
||||
onChange={(event) => setForm((prev) => ({ ...prev, max_valid_seconds: Number(event.target.value) || 0 }))}
|
||||
/>
|
||||
<TextField
|
||||
label="Managed Private Key PEM"
|
||||
multiline
|
||||
minRows={4}
|
||||
value={form.private_key_pem}
|
||||
onChange={(event) => setForm((prev) => ({ ...prev, private_key_pem: event.target.value }))}
|
||||
helperText={editingID ? 'Leave blank to keep the existing managed key.' : 'Leave blank to auto-generate an ed25519 key.'}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<TextField
|
||||
label="Private Key PEM"
|
||||
multiline
|
||||
minRows={4}
|
||||
value={form.private_key_pem}
|
||||
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.'}
|
||||
/>
|
||||
)}
|
||||
<Autocomplete
|
||||
multiple
|
||||
options={userOptions}
|
||||
value={selectedUserOptions}
|
||||
onChange={(_, values) => setForm((prev) => ({ ...prev, user_ids: values.map((item) => item.id) }))}
|
||||
getOptionLabel={(item) => item.display_name ? `${item.display_name} (${item.username})` : item.username}
|
||||
renderInput={(params) => <TextField {...params} label="Users" />}
|
||||
/>
|
||||
<Autocomplete
|
||||
multiple
|
||||
options={groupOptions}
|
||||
value={selectedGroupOptions}
|
||||
onChange={(_, values) => setForm((prev) => ({ ...prev, group_ids: values.map((item) => item.id) }))}
|
||||
getOptionLabel={(item) => item.name}
|
||||
renderInput={(params) => <TextField {...params} label="Groups" />}
|
||||
/>
|
||||
</FormDialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setDialogOpen(false)} disabled={saving}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="contained" onClick={handleSave} disabled={saving}>
|
||||
Save
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={Boolean(deleteItem)} onClose={() => setDeleteItem(null)} maxWidth="xs" fullWidth>
|
||||
<DialogTitle>Delete SSH Access Profile</DialogTitle>
|
||||
<FormDialogContent>
|
||||
<Typography variant="body2">
|
||||
Delete <strong>{deleteItem?.name}</strong>?
|
||||
</Typography>
|
||||
</FormDialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setDeleteItem(null)}>Cancel</Button>
|
||||
<Button variant="contained" color="error" onClick={handleDelete}>
|
||||
Delete
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,490 @@
|
||||
import Alert from '@mui/material/Alert'
|
||||
import Box from '@mui/material/Box'
|
||||
import Button from '@mui/material/Button'
|
||||
import Checkbox from '@mui/material/Checkbox'
|
||||
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 Paper from '@mui/material/Paper'
|
||||
import TextField from '@mui/material/TextField'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import FormDialogContent from '../components/FormDialogContent'
|
||||
import { ListRowActionButton, ListRowActions } from '../components/ListRowActions'
|
||||
import SectionCard from '../components/SectionCard'
|
||||
import { api, SSHServer, SSHServerHostKey } from '../api'
|
||||
|
||||
type SSHServerForm = {
|
||||
name: string
|
||||
host: string
|
||||
port: number
|
||||
description: string
|
||||
tagsText: string
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
const emptyForm = (): SSHServerForm => ({
|
||||
name: '',
|
||||
host: '',
|
||||
port: 22,
|
||||
description: '',
|
||||
tagsText: '',
|
||||
enabled: true
|
||||
})
|
||||
|
||||
export default function AdminSSHServersPage() {
|
||||
const [items, setItems] = useState<SSHServer[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [search, setSearch] = useState('')
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [editingID, setEditingID] = useState<string | null>(null)
|
||||
const [form, setForm] = useState<SSHServerForm>(emptyForm())
|
||||
const [dialogError, setDialogError] = useState<string | null>(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [deleteItem, setDeleteItem] = useState<SSHServer | null>(null)
|
||||
const [hostKeyServer, setHostKeyServer] = useState<SSHServer | null>(null)
|
||||
const [hostKeys, setHostKeys] = useState<SSHServerHostKey[]>([])
|
||||
const [hostKeysLoading, setHostKeysLoading] = useState(false)
|
||||
const [hostKeysError, setHostKeysError] = useState<string | null>(null)
|
||||
const [hostKeyInput, setHostKeyInput] = useState('')
|
||||
const [hostKeySaving, setHostKeySaving] = useState(false)
|
||||
const [discoveredHostKey, setDiscoveredHostKey] = useState<SSHServerHostKey | null>(null)
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const list = await api.listSSHServersAdmin()
|
||||
setItems(Array.isArray(list) ? list : [])
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load SSH servers')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [])
|
||||
|
||||
const filteredItems = useMemo(() => {
|
||||
const needle = search.trim().toLowerCase()
|
||||
if (!needle) {
|
||||
return items
|
||||
}
|
||||
return items.filter((item) =>
|
||||
[
|
||||
item.name,
|
||||
item.host,
|
||||
String(item.port),
|
||||
item.description,
|
||||
(item.tags || []).join(' ')
|
||||
]
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
.includes(needle)
|
||||
)
|
||||
}, [items, search])
|
||||
|
||||
const openCreate = () => {
|
||||
setEditingID(null)
|
||||
setForm(emptyForm())
|
||||
setDialogError(null)
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
const openEdit = (item: SSHServer) => {
|
||||
setEditingID(item.id)
|
||||
setForm({
|
||||
name: item.name,
|
||||
host: item.host,
|
||||
port: item.port,
|
||||
description: item.description || '',
|
||||
tagsText: (item.tags || []).join(', '),
|
||||
enabled: item.enabled
|
||||
})
|
||||
setDialogError(null)
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
const parseTags = (value: string) =>
|
||||
Array.from(
|
||||
new Set(
|
||||
value
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => item !== '')
|
||||
)
|
||||
)
|
||||
|
||||
const handleSave = async () => {
|
||||
const payload = {
|
||||
name: form.name.trim(),
|
||||
host: form.host.trim(),
|
||||
port: Number(form.port) || 0,
|
||||
description: form.description.trim(),
|
||||
tags: parseTags(form.tagsText),
|
||||
enabled: form.enabled
|
||||
}
|
||||
if (!payload.name) {
|
||||
setDialogError('Name is required')
|
||||
return
|
||||
}
|
||||
if (!payload.host) {
|
||||
setDialogError('Host is required')
|
||||
return
|
||||
}
|
||||
if (!payload.port || payload.port < 1 || payload.port > 65535) {
|
||||
setDialogError('Port must be between 1 and 65535')
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
setDialogError(null)
|
||||
try {
|
||||
if (editingID) {
|
||||
await api.updateSSHServerAdmin(editingID, payload)
|
||||
} else {
|
||||
await api.createSSHServerAdmin(payload)
|
||||
}
|
||||
setDialogOpen(false)
|
||||
await load()
|
||||
} catch (err) {
|
||||
setDialogError(err instanceof Error ? err.message : 'Failed to save SSH server')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteItem) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await api.deleteSSHServerAdmin(deleteItem.id)
|
||||
setDeleteItem(null)
|
||||
await load()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to delete SSH server')
|
||||
setDeleteItem(null)
|
||||
}
|
||||
}
|
||||
|
||||
const loadHostKeys = async (serverID: string) => {
|
||||
setHostKeysLoading(true)
|
||||
setHostKeysError(null)
|
||||
try {
|
||||
const list = await api.listSSHServerHostKeysAdmin(serverID)
|
||||
setHostKeys(Array.isArray(list) ? list : [])
|
||||
} catch (err) {
|
||||
setHostKeysError(err instanceof Error ? err.message : 'Failed to load pinned host keys')
|
||||
} finally {
|
||||
setHostKeysLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const openHostKeys = async (item: SSHServer) => {
|
||||
setHostKeyServer(item)
|
||||
setHostKeyInput('')
|
||||
setDiscoveredHostKey(null)
|
||||
await loadHostKeys(item.id)
|
||||
}
|
||||
|
||||
const closeHostKeys = () => {
|
||||
setHostKeyServer(null)
|
||||
setHostKeys([])
|
||||
setHostKeysError(null)
|
||||
setHostKeyInput('')
|
||||
setDiscoveredHostKey(null)
|
||||
setHostKeySaving(false)
|
||||
}
|
||||
|
||||
const handleDiscoverHostKey = async () => {
|
||||
if (!hostKeyServer) {
|
||||
return
|
||||
}
|
||||
setHostKeySaving(true)
|
||||
setHostKeysError(null)
|
||||
try {
|
||||
const item = await api.discoverSSHServerHostKeyAdmin(hostKeyServer.id)
|
||||
setDiscoveredHostKey(item)
|
||||
} catch (err) {
|
||||
setHostKeysError(err instanceof Error ? err.message : 'Failed to discover host key')
|
||||
} finally {
|
||||
setHostKeySaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddHostKey = async (publicKey: string) => {
|
||||
if (!hostKeyServer) {
|
||||
return
|
||||
}
|
||||
setHostKeySaving(true)
|
||||
setHostKeysError(null)
|
||||
try {
|
||||
await api.createSSHServerHostKeyAdmin(hostKeyServer.id, publicKey)
|
||||
setHostKeyInput('')
|
||||
setDiscoveredHostKey(null)
|
||||
await loadHostKeys(hostKeyServer.id)
|
||||
} catch (err) {
|
||||
setHostKeysError(err instanceof Error ? err.message : 'Failed to pin host key')
|
||||
} finally {
|
||||
setHostKeySaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteHostKey = async (hostKeyID: string) => {
|
||||
if (!hostKeyServer) {
|
||||
return
|
||||
}
|
||||
setHostKeySaving(true)
|
||||
setHostKeysError(null)
|
||||
try {
|
||||
await api.deleteSSHServerHostKeyAdmin(hostKeyServer.id, hostKeyID)
|
||||
await loadHostKeys(hostKeyServer.id)
|
||||
} catch (err) {
|
||||
setHostKeysError(err instanceof Error ? err.message : 'Failed to delete pinned host key')
|
||||
} finally {
|
||||
setHostKeySaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h5" sx={{ mb: 2 }}>
|
||||
Admin: SSH Servers
|
||||
</Typography>
|
||||
<SectionCard
|
||||
title={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, minWidth: 0, flexWrap: 'wrap' }}>
|
||||
<Typography variant="h6">SSH Servers</Typography>
|
||||
<TextField
|
||||
size="small"
|
||||
label="Search"
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
sx={{ minWidth: 240, maxWidth: 320 }}
|
||||
/>
|
||||
</Box>
|
||||
}
|
||||
actions={
|
||||
<Button variant="outlined" onClick={openCreate}>
|
||||
New Server
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{error ? <Alert severity="error">{error}</Alert> : null}
|
||||
{loading ? (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Loading...
|
||||
</Typography>
|
||||
) : null}
|
||||
<Box sx={{ display: 'grid', gap: 0 }}>
|
||||
{filteredItems.length === 0 && !loading ? (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
No SSH servers found.
|
||||
</Typography>
|
||||
) : null}
|
||||
{filteredItems.map((item) => (
|
||||
<Paper
|
||||
key={item.id}
|
||||
variant="outlined"
|
||||
sx={{
|
||||
p: 1,
|
||||
display: 'grid',
|
||||
gap: 0.5,
|
||||
backgroundColor: 'transparent',
|
||||
backgroundImage: 'none',
|
||||
borderLeft: 'none',
|
||||
borderRight: 'none',
|
||||
borderTop: 'none',
|
||||
borderBottom: (theme) => `1px solid ${theme.palette.divider}`,
|
||||
borderRadius: 0,
|
||||
boxShadow: 'none'
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1, flexWrap: 'wrap' }}>
|
||||
<Typography variant="subtitle2" sx={{ wordBreak: 'break-word' }}>
|
||||
{item.name}
|
||||
</Typography>
|
||||
<ListRowActions>
|
||||
<ListRowActionButton onClick={() => void openHostKeys(item)}>Host Keys</ListRowActionButton>
|
||||
<ListRowActionButton onClick={() => openEdit(item)}>Edit</ListRowActionButton>
|
||||
<ListRowActionButton color="error" onClick={() => setDeleteItem(item)}>
|
||||
Delete
|
||||
</ListRowActionButton>
|
||||
</ListRowActions>
|
||||
</Box>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ wordBreak: 'break-word' }}>
|
||||
{item.host}:{item.port} · {item.enabled ? 'enabled' : 'disabled'}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ wordBreak: 'break-word' }}>
|
||||
Tags: {(item.tags || []).join(', ') || '-'} · {item.id}
|
||||
</Typography>
|
||||
{item.description ? (
|
||||
<Typography variant="caption" color="text.secondary" sx={{ wordBreak: 'break-word' }}>
|
||||
{item.description}
|
||||
</Typography>
|
||||
) : null}
|
||||
</Paper>
|
||||
))}
|
||||
</Box>
|
||||
</SectionCard>
|
||||
|
||||
<Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>
|
||||
<Box sx={{ display: 'grid', gap: 1 }}>
|
||||
<Typography variant="h6">{editingID ? 'Edit SSH Server' : 'New SSH Server'}</Typography>
|
||||
{dialogError ? <Alert severity="error">{dialogError}</Alert> : null}
|
||||
</Box>
|
||||
</DialogTitle>
|
||||
<FormDialogContent>
|
||||
<TextField
|
||||
label="Name"
|
||||
value={form.name}
|
||||
onChange={(event) => setForm((prev) => ({ ...prev, name: event.target.value }))}
|
||||
/>
|
||||
<TextField
|
||||
label="Host"
|
||||
value={form.host}
|
||||
onChange={(event) => setForm((prev) => ({ ...prev, host: event.target.value }))}
|
||||
/>
|
||||
<TextField
|
||||
label="Port"
|
||||
type="number"
|
||||
value={form.port}
|
||||
onChange={(event) => setForm((prev) => ({ ...prev, port: Number(event.target.value) || 0 }))}
|
||||
/>
|
||||
<TextField
|
||||
label="Description"
|
||||
value={form.description}
|
||||
onChange={(event) => setForm((prev) => ({ ...prev, description: event.target.value }))}
|
||||
/>
|
||||
<TextField
|
||||
label="Tags"
|
||||
value={form.tagsText}
|
||||
onChange={(event) => setForm((prev) => ({ ...prev, tagsText: event.target.value }))}
|
||||
helperText="Comma-separated tags."
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={<Checkbox checked={form.enabled} onChange={(event) => setForm((prev) => ({ ...prev, enabled: event.target.checked }))} />}
|
||||
label="Enabled"
|
||||
/>
|
||||
</FormDialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setDialogOpen(false)} disabled={saving}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="contained" onClick={handleSave} disabled={saving}>
|
||||
Save
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={Boolean(deleteItem)} onClose={() => setDeleteItem(null)} maxWidth="xs" fullWidth>
|
||||
<DialogTitle>Delete SSH Server</DialogTitle>
|
||||
<FormDialogContent>
|
||||
<Typography variant="body2">
|
||||
Delete <strong>{deleteItem?.name}</strong>?
|
||||
</Typography>
|
||||
</FormDialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setDeleteItem(null)}>Cancel</Button>
|
||||
<Button variant="contained" color="error" onClick={handleDelete}>
|
||||
Delete
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={Boolean(hostKeyServer)} onClose={closeHostKeys} maxWidth="md" fullWidth>
|
||||
<DialogTitle>
|
||||
<Box sx={{ display: 'grid', gap: 1 }}>
|
||||
<Typography variant="h6">Pinned Host Keys</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{hostKeyServer ? `${hostKeyServer.name} (${hostKeyServer.host}:${hostKeyServer.port})` : ''}
|
||||
</Typography>
|
||||
{hostKeysError ? <Alert severity="error">{hostKeysError}</Alert> : null}
|
||||
</Box>
|
||||
</DialogTitle>
|
||||
<FormDialogContent>
|
||||
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
|
||||
<Button variant="outlined" onClick={handleDiscoverHostKey} disabled={hostKeySaving || hostKeysLoading}>
|
||||
Discover
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={() => void handleAddHostKey(hostKeyInput)}
|
||||
disabled={hostKeySaving || !hostKeyInput.trim()}
|
||||
>
|
||||
Add Manual Key
|
||||
</Button>
|
||||
</Box>
|
||||
{discoveredHostKey ? (
|
||||
<Alert
|
||||
severity="info"
|
||||
action={
|
||||
<Button color="inherit" size="small" onClick={() => void handleAddHostKey(discoveredHostKey.public_key)} disabled={hostKeySaving}>
|
||||
Pin
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
Discovered {discoveredHostKey.algorithm} · {discoveredHostKey.fingerprint}
|
||||
</Alert>
|
||||
) : null}
|
||||
<TextField
|
||||
label="Manual Host Public Key"
|
||||
multiline
|
||||
minRows={3}
|
||||
value={hostKeyInput}
|
||||
onChange={(event) => setHostKeyInput(event.target.value)}
|
||||
helperText="Paste an authorized_keys style SSH host public key."
|
||||
/>
|
||||
<Box sx={{ display: 'grid', gap: 1 }}>
|
||||
<Typography variant="subtitle2">Pinned Keys</Typography>
|
||||
{hostKeysLoading ? (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Loading...
|
||||
</Typography>
|
||||
) : null}
|
||||
{!hostKeysLoading && hostKeys.length === 0 ? (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
No pinned host keys.
|
||||
</Typography>
|
||||
) : null}
|
||||
{hostKeys.map((item) => (
|
||||
<Paper
|
||||
key={item.id}
|
||||
variant="outlined"
|
||||
sx={{
|
||||
p: 1,
|
||||
display: 'grid',
|
||||
gap: 0.5,
|
||||
backgroundColor: 'transparent',
|
||||
backgroundImage: 'none'
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1, flexWrap: 'wrap' }}>
|
||||
<Typography variant="subtitle2">{item.fingerprint}</Typography>
|
||||
<ListRowActions>
|
||||
<ListRowActionButton color="error" onClick={() => void handleDeleteHostKey(item.id)} disabled={hostKeySaving}>
|
||||
Delete
|
||||
</ListRowActionButton>
|
||||
</ListRowActions>
|
||||
</Box>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ wordBreak: 'break-word' }}>
|
||||
{item.algorithm}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ wordBreak: 'break-word' }}>
|
||||
{item.public_key}
|
||||
</Typography>
|
||||
</Paper>
|
||||
))}
|
||||
</Box>
|
||||
</FormDialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={closeHostKeys}>Close</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { Box, Button, Divider, IconButton, List, ListItem, ListItemButton, ListI
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link, useParams, useSearchParams } from 'react-router-dom'
|
||||
import { api, Project, Repo, RepoCommitDetail } from '../api'
|
||||
import CodeBlock from '../components/CodeBlock'
|
||||
import LazyCodeBlock from '../components/LazyCodeBlock'
|
||||
import ProjectNavBar from '../components/ProjectNavBar'
|
||||
import RepoSubNav from '../components/RepoSubNav'
|
||||
import AddCircleOutlineIcon from '@mui/icons-material/AddCircleOutline'
|
||||
@@ -405,7 +405,7 @@ export default function CommitDetailPage() {
|
||||
{selectedFile ? (
|
||||
fileDiff ? (
|
||||
diffView === 'unified' ? (
|
||||
<CodeBlock code={fileDiff} language="diff" showLineNumbers />
|
||||
<LazyCodeBlock code={fileDiff} language="diff" showLineNumbers />
|
||||
) : (
|
||||
renderSplitDiff(fileDiff)
|
||||
)
|
||||
@@ -413,7 +413,7 @@ export default function CommitDetailPage() {
|
||||
<Typography>No diff available.</Typography>
|
||||
)
|
||||
) : diff ? (
|
||||
diffView === 'unified' ? <CodeBlock code={diff} language="diff" showLineNumbers /> : renderSplitDiff(diff)
|
||||
diffView === 'unified' ? <LazyCodeBlock code={diff} language="diff" showLineNumbers /> : renderSplitDiff(diff)
|
||||
) : (
|
||||
<Typography>No diff available.</Typography>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Alert, Box, Button, Divider, IconButton, List, ListItem, ListItemButton, ListItemText, Paper, Popover, Tab, Tabs, TextField, Tooltip, Typography } from '@mui/material'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { lazy, Suspense, useEffect, useRef, useState } from 'react'
|
||||
import { Link, useParams, useSearchParams } from 'react-router-dom'
|
||||
import { api, Project, Repo, RepoCommit, RepoTreeEntry } from '../api'
|
||||
import Autocomplete from '../components/Autocomplete'
|
||||
@@ -11,13 +11,12 @@ import ChevronLeftIcon from '@mui/icons-material/ChevronLeft'
|
||||
import ChevronRightIcon from '@mui/icons-material/ChevronRight'
|
||||
import FileDownloadIcon from '@mui/icons-material/FileDownload'
|
||||
import OpenInNewIcon from '@mui/icons-material/OpenInNew'
|
||||
import CodeBlock from '../components/CodeBlock'
|
||||
import LazyCodeBlock from '../components/LazyCodeBlock'
|
||||
import ProjectNavBar from '../components/ProjectNavBar'
|
||||
import RepoSubNav from '../components/RepoSubNav'
|
||||
import TintedPanel from '../components/TintedPanel'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import rehypeSlug from 'rehype-slug'
|
||||
|
||||
const RepoMarkdown = lazy(() => import('../components/RepoMarkdown'))
|
||||
|
||||
type RepoGitDetailPageProps = {
|
||||
initialRepo?: Repo
|
||||
@@ -890,7 +889,7 @@ export default function RepoGitDetailPage(props: RepoGitDetailPageProps) {
|
||||
</Paper>
|
||||
) : (
|
||||
<Paper variant="outlined" sx={{ p: 1, pr: 0.5, mt: 1, backgroundColor: 'transparent' }}>
|
||||
<CodeBlock code={content} language={detectLanguage(selectedFile)} showLineNumbers />
|
||||
<LazyCodeBlock code={content} language={detectLanguage(selectedFile)} showLineNumbers />
|
||||
</Paper>
|
||||
)
|
||||
) : null}
|
||||
@@ -930,7 +929,7 @@ export default function RepoGitDetailPage(props: RepoGitDetailPageProps) {
|
||||
{previewTab === 'diff' ? (
|
||||
diff ? (
|
||||
<Paper variant="outlined" sx={{ p: 1, pr: 0.5, mt: 1, backgroundColor: 'transparent' }}>
|
||||
<CodeBlock code={diff} language="diff" />
|
||||
<LazyCodeBlock code={diff} language="diff" />
|
||||
</Paper>
|
||||
) : (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
|
||||
@@ -943,54 +942,18 @@ export default function RepoGitDetailPage(props: RepoGitDetailPageProps) {
|
||||
readmeKind === 'markdown' ? (
|
||||
<Paper variant="outlined" sx={{ p: 1, mt: 1, backgroundColor: 'transparent' }}>
|
||||
<Box>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[rehypeSlug]}
|
||||
components={{
|
||||
h1: ({ node, ...props }) => (
|
||||
<Typography component="h1" variant="h5" sx={{ m: 0 }} {...props} />
|
||||
),
|
||||
h2: ({ node, ...props }) => (
|
||||
<Typography component="h2" variant="h6" sx={{ m: 0 }} {...props} />
|
||||
),
|
||||
h3: ({ node, ...props }) => (
|
||||
<Typography component="h3" variant="subtitle1" sx={{ m: 0 }} {...props} />
|
||||
),
|
||||
h4: ({ node, ...props }) => (
|
||||
<Typography component="h4" variant="subtitle2" sx={{ m: 0 }} {...props} />
|
||||
),
|
||||
h5: ({ node, ...props }) => (
|
||||
<Typography component="h5" variant="body2" sx={{ m: 0, fontWeight: 600 }} {...props} />
|
||||
),
|
||||
h6: ({ node, ...props }) => (
|
||||
<Typography component="h6" variant="body2" sx={{ m: 0, fontWeight: 600 }} {...props} />
|
||||
),
|
||||
pre: ({ children }) => <Box sx={{ m: 0, p: 0, mt: 2, mb: 2 }}>{children}</Box>,
|
||||
img: ({ src, alt }) => (
|
||||
<img src={resolveReadmeAsset(src || '')} alt={alt || ''} style={{ maxWidth: '100%' }} />
|
||||
),
|
||||
code: ({ inline, className, children }) => {
|
||||
const text = String(children)
|
||||
const looksInline = inline || (!className && !text.includes('\n'))
|
||||
if (looksInline) {
|
||||
return <code style={{ whiteSpace: 'pre-wrap' }}>{children}</code>
|
||||
}
|
||||
const language = extractMarkdownLanguage(className)
|
||||
return (
|
||||
<Box sx={{ p: 1, border: '1px solid', borderColor: 'divider', borderRadius: 1 }}>
|
||||
<CodeBlock code={text.replace(/\n$/, '')} language={language} />
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{readmeContent}
|
||||
</ReactMarkdown>
|
||||
<Suspense fallback={<Typography variant="body2" color="text.secondary">Loading README...</Typography>}>
|
||||
<RepoMarkdown
|
||||
content={readmeContent}
|
||||
resolveAsset={resolveReadmeAsset}
|
||||
extractLanguage={extractMarkdownLanguage}
|
||||
/>
|
||||
</Suspense>
|
||||
</Box>
|
||||
</Paper>
|
||||
) : (
|
||||
<Paper variant="outlined" sx={{ p: 1, mt: 1, backgroundColor: 'transparent' }}>
|
||||
<CodeBlock code={readmeContent} language="text" showLineNumbers />
|
||||
<LazyCodeBlock code={readmeContent} language="text" showLineNumbers />
|
||||
</Paper>
|
||||
)
|
||||
) : (
|
||||
|
||||
@@ -36,7 +36,7 @@ import HomeOutlinedIcon from '@mui/icons-material/HomeOutlined'
|
||||
import MonitorHeartOutlinedIcon from '@mui/icons-material/MonitorHeartOutlined'
|
||||
import ProjectNavBar from '../components/ProjectNavBar'
|
||||
import RepoSubNav from '../components/RepoSubNav'
|
||||
import CodeBlock from '../components/CodeBlock'
|
||||
import LazyCodeBlock from '../components/LazyCodeBlock'
|
||||
import SelectField from '../components/SelectField'
|
||||
import TintedPanel from '../components/TintedPanel'
|
||||
|
||||
@@ -1174,7 +1174,7 @@ export default function RepoRpmDetailPage(props: RepoRpmDetailPageProps) {
|
||||
{rpmMetaError}
|
||||
</Alert>
|
||||
) : null}
|
||||
{!rpmMetaLoading && !rpmMetaError ? <CodeBlock code={rpmMetaContent} language="xml" showLineNumbers /> : null}
|
||||
{!rpmMetaLoading && !rpmMetaError ? <LazyCodeBlock code={rpmMetaContent} language="xml" showLineNumbers /> : null}
|
||||
</Box>
|
||||
) : rpmSelectedEntry && rpmSelectedEntry.type !== 'dir' ? (
|
||||
<Box>
|
||||
|
||||
@@ -0,0 +1,925 @@
|
||||
import Alert from '@mui/material/Alert'
|
||||
import Box from '@mui/material/Box'
|
||||
import Button from '@mui/material/Button'
|
||||
import Checkbox from '@mui/material/Checkbox'
|
||||
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 Paper from '@mui/material/Paper'
|
||||
import TextField from '@mui/material/TextField'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import Autocomplete from '../components/Autocomplete'
|
||||
import FormDialogContent from '../components/FormDialogContent'
|
||||
import { ListRowActionButton, ListRowActions } from '../components/ListRowActions'
|
||||
import SectionCard from '../components/SectionCard'
|
||||
import SelectField from '../components/SelectField'
|
||||
import { api, SSHAccessProfile, SSHPrincipalGrant, SSHServer, SSHServerHostKey, SSHUserCA } from '../api'
|
||||
|
||||
type SSHAccessProfileForm = {
|
||||
server_id: string
|
||||
name: string
|
||||
description: string
|
||||
remote_username: string
|
||||
auth_method: 'managed_ssh_cert' | 'stored_private_key'
|
||||
enabled: boolean
|
||||
private_key_pem: string
|
||||
ssh_user_ca_id: string
|
||||
ssh_principal_mode: 'explicit' | 'grant'
|
||||
ssh_principals_text: string
|
||||
ssh_principal_grant_ids: string[]
|
||||
default_valid_seconds: number
|
||||
max_valid_seconds: number
|
||||
}
|
||||
|
||||
type SSHServerForm = {
|
||||
name: string
|
||||
host: string
|
||||
port: number
|
||||
description: string
|
||||
tagsText: string
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
const emptyForm = (): SSHAccessProfileForm => ({
|
||||
server_id: '',
|
||||
name: '',
|
||||
description: '',
|
||||
remote_username: '',
|
||||
auth_method: 'managed_ssh_cert',
|
||||
enabled: true,
|
||||
private_key_pem: '',
|
||||
ssh_user_ca_id: '',
|
||||
ssh_principal_mode: 'explicit',
|
||||
ssh_principals_text: '',
|
||||
ssh_principal_grant_ids: [],
|
||||
default_valid_seconds: 3600,
|
||||
max_valid_seconds: 3600
|
||||
})
|
||||
|
||||
const emptyServerForm = (): SSHServerForm => ({
|
||||
name: '',
|
||||
host: '',
|
||||
port: 22,
|
||||
description: '',
|
||||
tagsText: '',
|
||||
enabled: true
|
||||
})
|
||||
|
||||
export default function SSHServersPage() {
|
||||
const [items, setItems] = useState<SSHAccessProfile[]>([])
|
||||
const [servers, setServers] = useState<SSHServer[]>([])
|
||||
const [cas, setCAs] = useState<SSHUserCA[]>([])
|
||||
const [grants, setGrants] = useState<SSHPrincipalGrant[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [search, setSearch] = useState('')
|
||||
const [viewItem, setViewItem] = useState<SSHAccessProfile | null>(null)
|
||||
const [connectingID, setConnectingID] = useState<string | null>(null)
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [editingID, setEditingID] = useState<string | null>(null)
|
||||
const [form, setForm] = useState<SSHAccessProfileForm>(emptyForm())
|
||||
const [dialogError, setDialogError] = useState<string | null>(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [deleteItem, setDeleteItem] = useState<SSHAccessProfile | null>(null)
|
||||
const [serverDialogOpen, setServerDialogOpen] = useState(false)
|
||||
const [editingServerID, setEditingServerID] = useState<string | null>(null)
|
||||
const [serverForm, setServerForm] = useState<SSHServerForm>(emptyServerForm())
|
||||
const [serverDialogError, setServerDialogError] = useState<string | null>(null)
|
||||
const [serverSaving, setServerSaving] = useState(false)
|
||||
const [deleteServerItem, setDeleteServerItem] = useState<SSHServer | null>(null)
|
||||
const [hostKeyServer, setHostKeyServer] = useState<SSHServer | null>(null)
|
||||
const [hostKeys, setHostKeys] = useState<SSHServerHostKey[]>([])
|
||||
const [hostKeysLoading, setHostKeysLoading] = useState(false)
|
||||
const [hostKeysError, setHostKeysError] = useState<string | null>(null)
|
||||
const [hostKeyInput, setHostKeyInput] = useState('')
|
||||
const [hostKeySaving, setHostKeySaving] = useState(false)
|
||||
const [discoveredHostKey, setDiscoveredHostKey] = useState<SSHServerHostKey | null>(null)
|
||||
const navigate = useNavigate()
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [profileList, serverList, caList, grantList] = await Promise.all([
|
||||
api.listSSHAccessProfilesForSelf(),
|
||||
api.listSSHServersForSelf(),
|
||||
api.listSSHUserCAsForSelf(),
|
||||
api.listSSHPrincipalGrantsForSelf()
|
||||
])
|
||||
setItems(Array.isArray(profileList) ? profileList : [])
|
||||
setServers(Array.isArray(serverList) ? serverList : [])
|
||||
setCAs(Array.isArray(caList) ? caList : [])
|
||||
setGrants(Array.isArray(grantList) ? grantList : [])
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load SSH servers')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [])
|
||||
|
||||
const filteredItems = useMemo(() => {
|
||||
const needle = search.trim().toLowerCase()
|
||||
if (!needle) {
|
||||
return items
|
||||
}
|
||||
return items.filter((item) =>
|
||||
[
|
||||
item.name,
|
||||
item.server?.name,
|
||||
item.server?.host,
|
||||
item.remote_username,
|
||||
item.auth_method,
|
||||
item.auth_public_key_fingerprint,
|
||||
item.ssh_user_ca_id,
|
||||
(item.ssh_principals || []).join(' '),
|
||||
(item.ssh_principal_grant_ids || []).join(' ')
|
||||
]
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
.includes(needle)
|
||||
)
|
||||
}, [items, search])
|
||||
|
||||
const sharedItems = filteredItems.filter((item) => item.owner_scope !== 'user')
|
||||
const ownItems = filteredItems.filter((item) => item.owner_scope === 'user')
|
||||
const ownServers = useMemo(() => servers.filter((item) => item.editable), [servers])
|
||||
const caOptions = useMemo(() => cas.filter((item) => item.enabled), [cas])
|
||||
const selectedGrantOptions = grants.filter((item) => form.ssh_principal_grant_ids.includes(item.id))
|
||||
|
||||
const parseNames = (value: string) =>
|
||||
Array.from(
|
||||
new Set(
|
||||
value
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => item !== '')
|
||||
)
|
||||
)
|
||||
|
||||
const parseTags = (value: string) =>
|
||||
Array.from(
|
||||
new Set(
|
||||
value
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => item !== '')
|
||||
)
|
||||
)
|
||||
|
||||
const openCreate = () => {
|
||||
setEditingID(null)
|
||||
setForm({
|
||||
...emptyForm(),
|
||||
server_id: servers[0]?.id || '',
|
||||
ssh_user_ca_id: caOptions[0]?.id || ''
|
||||
})
|
||||
setDialogError(null)
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
const openEdit = (item: SSHAccessProfile) => {
|
||||
setEditingID(item.id)
|
||||
setForm({
|
||||
server_id: item.server_id,
|
||||
name: item.name,
|
||||
description: item.description || '',
|
||||
remote_username: item.remote_username,
|
||||
auth_method: item.auth_method,
|
||||
enabled: item.enabled,
|
||||
private_key_pem: '',
|
||||
ssh_user_ca_id: item.ssh_user_ca_id || '',
|
||||
ssh_principal_mode: item.ssh_principal_mode || 'explicit',
|
||||
ssh_principals_text: (item.ssh_principals || []).join(', '),
|
||||
ssh_principal_grant_ids: item.ssh_principal_grant_ids || [],
|
||||
default_valid_seconds: item.default_valid_seconds || 3600,
|
||||
max_valid_seconds: item.max_valid_seconds || item.default_valid_seconds || 3600
|
||||
})
|
||||
setDialogError(null)
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
const openServerCreate = () => {
|
||||
setEditingServerID(null)
|
||||
setServerForm(emptyServerForm())
|
||||
setServerDialogError(null)
|
||||
setServerDialogOpen(true)
|
||||
}
|
||||
|
||||
const openServerEdit = (item: SSHServer) => {
|
||||
setEditingServerID(item.id)
|
||||
setServerForm({
|
||||
name: item.name,
|
||||
host: item.host,
|
||||
port: item.port,
|
||||
description: item.description || '',
|
||||
tagsText: (item.tags || []).join(', '),
|
||||
enabled: item.enabled
|
||||
})
|
||||
setServerDialogError(null)
|
||||
setServerDialogOpen(true)
|
||||
}
|
||||
|
||||
const loadHostKeys = async (serverID: string) => {
|
||||
setHostKeysLoading(true)
|
||||
setHostKeysError(null)
|
||||
try {
|
||||
const list = await api.listSSHServerHostKeysForSelf(serverID)
|
||||
setHostKeys(Array.isArray(list) ? list : [])
|
||||
} catch (err) {
|
||||
setHostKeysError(err instanceof Error ? err.message : 'Failed to load pinned host keys')
|
||||
} finally {
|
||||
setHostKeysLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const openHostKeys = async (item: SSHServer) => {
|
||||
setHostKeyServer(item)
|
||||
setHostKeyInput('')
|
||||
setDiscoveredHostKey(null)
|
||||
await loadHostKeys(item.id)
|
||||
}
|
||||
|
||||
const closeHostKeys = () => {
|
||||
setHostKeyServer(null)
|
||||
setHostKeys([])
|
||||
setHostKeysError(null)
|
||||
setHostKeyInput('')
|
||||
setDiscoveredHostKey(null)
|
||||
setHostKeySaving(false)
|
||||
}
|
||||
|
||||
const handleConnect = async (item: SSHAccessProfile) => {
|
||||
let cols = 120
|
||||
let rows = 36
|
||||
|
||||
setError(null)
|
||||
setConnectingID(item.id)
|
||||
try {
|
||||
if (typeof window !== 'undefined') {
|
||||
cols = Math.max(80, Math.min(200, Math.floor(window.innerWidth / 9)))
|
||||
rows = Math.max(24, Math.min(80, Math.floor(window.innerHeight / 22)))
|
||||
}
|
||||
const session = await api.connectSSHAccessProfileForSelf(item.id, {
|
||||
cols,
|
||||
rows,
|
||||
term: 'xterm-256color'
|
||||
})
|
||||
navigate(`/ssh-sessions/${session.session_id}`)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to connect')
|
||||
} finally {
|
||||
setConnectingID(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
const payload = {
|
||||
server_id: form.server_id,
|
||||
name: form.name.trim(),
|
||||
description: form.description.trim(),
|
||||
remote_username: form.remote_username.trim(),
|
||||
auth_method: form.auth_method,
|
||||
enabled: form.enabled,
|
||||
private_key_pem: form.private_key_pem.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) : [],
|
||||
ssh_principal_grant_ids: form.auth_method === 'managed_ssh_cert' && form.ssh_principal_mode === 'grant' ? form.ssh_principal_grant_ids : [],
|
||||
default_valid_seconds: Number(form.default_valid_seconds) || 0,
|
||||
max_valid_seconds: Number(form.max_valid_seconds) || 0
|
||||
}
|
||||
|
||||
if (!payload.server_id) {
|
||||
setDialogError('Server is required')
|
||||
return
|
||||
}
|
||||
if (!payload.name) {
|
||||
setDialogError('Name is required')
|
||||
return
|
||||
}
|
||||
if (!payload.remote_username) {
|
||||
setDialogError('Remote username is required')
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
setDialogError(null)
|
||||
try {
|
||||
if (editingID) {
|
||||
await api.updateSSHAccessProfileForSelf(editingID, payload)
|
||||
} else {
|
||||
await api.createSSHAccessProfileForSelf(payload)
|
||||
}
|
||||
setDialogOpen(false)
|
||||
await load()
|
||||
} catch (err) {
|
||||
setDialogError(err instanceof Error ? err.message : 'Failed to save SSH access profile')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteItem) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await api.deleteSSHAccessProfileForSelf(deleteItem.id)
|
||||
setDeleteItem(null)
|
||||
await load()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to delete SSH access profile')
|
||||
setDeleteItem(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleServerSave = async () => {
|
||||
const payload = {
|
||||
name: serverForm.name.trim(),
|
||||
host: serverForm.host.trim(),
|
||||
port: Number(serverForm.port) || 0,
|
||||
description: serverForm.description.trim(),
|
||||
tags: parseTags(serverForm.tagsText),
|
||||
enabled: serverForm.enabled
|
||||
}
|
||||
|
||||
if (!payload.name) {
|
||||
setServerDialogError('Name is required')
|
||||
return
|
||||
}
|
||||
if (!payload.host) {
|
||||
setServerDialogError('Host is required')
|
||||
return
|
||||
}
|
||||
if (!payload.port || payload.port < 1 || payload.port > 65535) {
|
||||
setServerDialogError('Port must be between 1 and 65535')
|
||||
return
|
||||
}
|
||||
setServerSaving(true)
|
||||
setServerDialogError(null)
|
||||
try {
|
||||
if (editingServerID) {
|
||||
await api.updateSSHServerForSelf(editingServerID, payload)
|
||||
} else {
|
||||
await api.createSSHServerForSelf(payload)
|
||||
}
|
||||
setServerDialogOpen(false)
|
||||
await load()
|
||||
} catch (err) {
|
||||
setServerDialogError(err instanceof Error ? err.message : 'Failed to save SSH server')
|
||||
} finally {
|
||||
setServerSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleServerDelete = async () => {
|
||||
if (!deleteServerItem) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await api.deleteSSHServerForSelf(deleteServerItem.id)
|
||||
setDeleteServerItem(null)
|
||||
await load()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to delete SSH server')
|
||||
setDeleteServerItem(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDiscoverHostKey = async () => {
|
||||
if (!hostKeyServer) {
|
||||
return
|
||||
}
|
||||
setHostKeySaving(true)
|
||||
setHostKeysError(null)
|
||||
try {
|
||||
const item = await api.discoverSSHServerHostKeyForSelf(hostKeyServer.id)
|
||||
setDiscoveredHostKey(item)
|
||||
} catch (err) {
|
||||
setHostKeysError(err instanceof Error ? err.message : 'Failed to discover host key')
|
||||
} finally {
|
||||
setHostKeySaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddHostKey = async (publicKey: string) => {
|
||||
if (!hostKeyServer) {
|
||||
return
|
||||
}
|
||||
setHostKeySaving(true)
|
||||
setHostKeysError(null)
|
||||
try {
|
||||
await api.createSSHServerHostKeyForSelf(hostKeyServer.id, publicKey)
|
||||
setHostKeyInput('')
|
||||
setDiscoveredHostKey(null)
|
||||
await loadHostKeys(hostKeyServer.id)
|
||||
} catch (err) {
|
||||
setHostKeysError(err instanceof Error ? err.message : 'Failed to pin host key')
|
||||
} finally {
|
||||
setHostKeySaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteHostKey = async (hostKeyID: string) => {
|
||||
if (!hostKeyServer) {
|
||||
return
|
||||
}
|
||||
setHostKeySaving(true)
|
||||
setHostKeysError(null)
|
||||
try {
|
||||
await api.deleteSSHServerHostKeyForSelf(hostKeyServer.id, hostKeyID)
|
||||
await loadHostKeys(hostKeyServer.id)
|
||||
} catch (err) {
|
||||
setHostKeysError(err instanceof Error ? err.message : 'Failed to delete pinned host key')
|
||||
} finally {
|
||||
setHostKeySaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const renderList = (list: SSHAccessProfile[], emptyText: string) => (
|
||||
<Box sx={{ display: 'grid', gap: 0 }}>
|
||||
{!list.length ? (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{emptyText}
|
||||
</Typography>
|
||||
) : null}
|
||||
{list.map((item) => (
|
||||
<Paper
|
||||
key={item.id}
|
||||
variant="outlined"
|
||||
sx={{
|
||||
p: 1,
|
||||
display: 'grid',
|
||||
gap: 0.5,
|
||||
backgroundColor: 'transparent',
|
||||
backgroundImage: 'none',
|
||||
borderLeft: 'none',
|
||||
borderRight: 'none',
|
||||
borderTop: 'none',
|
||||
borderBottom: (theme) => `1px solid ${theme.palette.divider}`,
|
||||
borderRadius: 0,
|
||||
boxShadow: 'none'
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1, flexWrap: 'wrap' }}>
|
||||
<Typography variant="subtitle2" sx={{ wordBreak: 'break-word' }}>
|
||||
{item.name}
|
||||
</Typography>
|
||||
<ListRowActions>
|
||||
<ListRowActionButton onClick={() => void handleConnect(item)} disabled={connectingID === item.id}>
|
||||
{connectingID === item.id ? 'Connecting...' : 'Connect'}
|
||||
</ListRowActionButton>
|
||||
<ListRowActionButton onClick={() => setViewItem(item)}>View</ListRowActionButton>
|
||||
{item.owner_scope === 'user' ? (
|
||||
<ListRowActionButton onClick={() => openEdit(item)}>Edit</ListRowActionButton>
|
||||
) : null}
|
||||
{item.owner_scope === 'user' ? (
|
||||
<ListRowActionButton color="error" onClick={() => setDeleteItem(item)}>
|
||||
Delete
|
||||
</ListRowActionButton>
|
||||
) : null}
|
||||
</ListRowActions>
|
||||
</Box>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ wordBreak: 'break-word' }}>
|
||||
{item.server?.name} · {item.server?.host}:{item.server?.port} · {item.remote_username} · {item.auth_method}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ wordBreak: 'break-word' }}>
|
||||
Key fingerprint: {item.auth_public_key_fingerprint || '-'}
|
||||
</Typography>
|
||||
</Paper>
|
||||
))}
|
||||
</Box>
|
||||
)
|
||||
|
||||
const renderServerList = (list: SSHServer[], emptyText: string) => (
|
||||
<Box sx={{ display: 'grid', gap: 0 }}>
|
||||
{!list.length ? (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{emptyText}
|
||||
</Typography>
|
||||
) : null}
|
||||
{list.map((item) => (
|
||||
<Paper
|
||||
key={item.id}
|
||||
variant="outlined"
|
||||
sx={{
|
||||
p: 1,
|
||||
display: 'grid',
|
||||
gap: 0.5,
|
||||
backgroundColor: 'transparent',
|
||||
backgroundImage: 'none',
|
||||
borderLeft: 'none',
|
||||
borderRight: 'none',
|
||||
borderTop: 'none',
|
||||
borderBottom: (theme) => `1px solid ${theme.palette.divider}`,
|
||||
borderRadius: 0,
|
||||
boxShadow: 'none'
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1, flexWrap: 'wrap' }}>
|
||||
<Typography variant="subtitle2" sx={{ wordBreak: 'break-word' }}>
|
||||
{item.name}
|
||||
</Typography>
|
||||
<ListRowActions>
|
||||
<ListRowActionButton onClick={() => void openHostKeys(item)}>Host Keys</ListRowActionButton>
|
||||
<ListRowActionButton onClick={() => openServerEdit(item)}>Edit</ListRowActionButton>
|
||||
<ListRowActionButton color="error" onClick={() => setDeleteServerItem(item)}>
|
||||
Delete
|
||||
</ListRowActionButton>
|
||||
</ListRowActions>
|
||||
</Box>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ wordBreak: 'break-word' }}>
|
||||
{item.host}:{item.port} · {item.enabled ? 'enabled' : 'disabled'}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ wordBreak: 'break-word' }}>
|
||||
Tags: {(item.tags || []).join(', ') || '-'} · {item.id}
|
||||
</Typography>
|
||||
{item.description ? (
|
||||
<Typography variant="caption" color="text.secondary" sx={{ wordBreak: 'break-word' }}>
|
||||
{item.description}
|
||||
</Typography>
|
||||
) : null}
|
||||
</Paper>
|
||||
))}
|
||||
</Box>
|
||||
)
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'grid', gap: 1 }}>
|
||||
<Typography variant="h5">SSH Servers</Typography>
|
||||
{error ? <Alert severity="error">{error}</Alert> : null}
|
||||
<SectionCard
|
||||
title={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, minWidth: 0, flexWrap: 'wrap' }}>
|
||||
<Typography variant="h6">Shared With Me</Typography>
|
||||
<TextField
|
||||
size="small"
|
||||
label="Search"
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
sx={{ minWidth: 220, maxWidth: 320 }}
|
||||
/>
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
{loading ? (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Loading...
|
||||
</Typography>
|
||||
) : renderList(sharedItems, 'No shared SSH access profiles found.')}
|
||||
</SectionCard>
|
||||
<SectionCard
|
||||
title="My Profiles"
|
||||
actions={
|
||||
<Button variant="outlined" onClick={openCreate} disabled={!servers.length}>
|
||||
New Profile
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{loading ? (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Loading...
|
||||
</Typography>
|
||||
) : renderList(ownItems, 'No personal SSH access profiles found.')}
|
||||
</SectionCard>
|
||||
<SectionCard
|
||||
title="My Servers"
|
||||
actions={
|
||||
<Button variant="outlined" onClick={openServerCreate}>
|
||||
New Server
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{loading ? (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Loading...
|
||||
</Typography>
|
||||
) : renderServerList(ownServers, 'No personal SSH servers found.')}
|
||||
</SectionCard>
|
||||
|
||||
<Dialog open={Boolean(viewItem)} onClose={() => setViewItem(null)} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>SSH Access Profile</DialogTitle>
|
||||
<FormDialogContent>
|
||||
<TextField label="Name" value={viewItem?.name || ''} InputProps={{ readOnly: true }} />
|
||||
<TextField label="Server" value={viewItem ? `${viewItem.server?.name} (${viewItem.server?.host}:${viewItem.server?.port})` : ''} InputProps={{ readOnly: true }} />
|
||||
<TextField label="Remote Username" value={viewItem?.remote_username || ''} InputProps={{ readOnly: true }} />
|
||||
<TextField label="Auth Method" value={viewItem?.auth_method || ''} InputProps={{ readOnly: true }} />
|
||||
<TextField label="Key Fingerprint" value={viewItem?.auth_public_key_fingerprint || ''} InputProps={{ readOnly: true }} />
|
||||
{viewItem?.auth_method === 'managed_ssh_cert' ? (
|
||||
<>
|
||||
<TextField label="SSH User CA" value={viewItem?.ssh_user_ca_id || ''} InputProps={{ readOnly: true }} />
|
||||
<TextField label="Principals" value={(viewItem?.ssh_principals || []).join(', ')} InputProps={{ readOnly: true }} />
|
||||
<TextField label="Principal Grants" value={(viewItem?.ssh_principal_grant_ids || []).join(', ')} InputProps={{ readOnly: true }} />
|
||||
</>
|
||||
) : null}
|
||||
<TextField label="Description" value={viewItem?.description || ''} InputProps={{ readOnly: true }} multiline minRows={2} />
|
||||
</FormDialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setViewItem(null)}>Close</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} maxWidth="md" fullWidth>
|
||||
<DialogTitle>
|
||||
<Box sx={{ display: 'grid', gap: 1 }}>
|
||||
<Typography variant="h6">{editingID ? 'Edit SSH Access Profile' : 'New SSH Access Profile'}</Typography>
|
||||
{dialogError ? <Alert severity="error">{dialogError}</Alert> : null}
|
||||
</Box>
|
||||
</DialogTitle>
|
||||
<FormDialogContent>
|
||||
<SelectField
|
||||
label="SSH Server"
|
||||
value={form.server_id}
|
||||
onChange={(event) => setForm((prev) => ({ ...prev, server_id: event.target.value }))}
|
||||
>
|
||||
{servers.map((item) => (
|
||||
<MenuItem key={item.id} value={item.id}>
|
||||
{item.name} ({item.host}:{item.port})
|
||||
</MenuItem>
|
||||
))}
|
||||
</SelectField>
|
||||
<TextField
|
||||
label="Name"
|
||||
value={form.name}
|
||||
onChange={(event) => setForm((prev) => ({ ...prev, name: event.target.value }))}
|
||||
/>
|
||||
<TextField
|
||||
label="Description"
|
||||
value={form.description}
|
||||
onChange={(event) => setForm((prev) => ({ ...prev, description: event.target.value }))}
|
||||
/>
|
||||
<TextField
|
||||
label="Remote Username"
|
||||
value={form.remote_username}
|
||||
onChange={(event) => setForm((prev) => ({ ...prev, remote_username: event.target.value }))}
|
||||
/>
|
||||
<SelectField
|
||||
label="Auth Method"
|
||||
value={form.auth_method}
|
||||
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="stored_private_key">stored_private_key</MenuItem>
|
||||
</SelectField>
|
||||
<FormControlLabel
|
||||
control={<Checkbox checked={form.enabled} onChange={(event) => setForm((prev) => ({ ...prev, enabled: event.target.checked }))} />}
|
||||
label="Enabled"
|
||||
/>
|
||||
{form.auth_method === 'managed_ssh_cert' ? (
|
||||
<>
|
||||
<SelectField
|
||||
label="SSH User CA"
|
||||
value={form.ssh_user_ca_id}
|
||||
onChange={(event) => setForm((prev) => ({ ...prev, ssh_user_ca_id: event.target.value }))}
|
||||
>
|
||||
{caOptions.map((item) => (
|
||||
<MenuItem key={item.id} value={item.id}>
|
||||
{item.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</SelectField>
|
||||
<SelectField
|
||||
label="Principal Mode"
|
||||
value={form.ssh_principal_mode}
|
||||
onChange={(event) => setForm((prev) => ({ ...prev, ssh_principal_mode: event.target.value as SSHAccessProfileForm['ssh_principal_mode'] }))}
|
||||
>
|
||||
<MenuItem value="explicit">explicit</MenuItem>
|
||||
<MenuItem value="grant">grant</MenuItem>
|
||||
</SelectField>
|
||||
{form.ssh_principal_mode === 'explicit' ? (
|
||||
<TextField
|
||||
label="SSH Principals"
|
||||
value={form.ssh_principals_text}
|
||||
onChange={(event) => setForm((prev) => ({ ...prev, ssh_principals_text: event.target.value }))}
|
||||
helperText="Comma-separated principal names."
|
||||
/>
|
||||
) : (
|
||||
<Autocomplete
|
||||
multiple
|
||||
options={grants}
|
||||
value={selectedGrantOptions}
|
||||
onChange={(_, values) => setForm((prev) => ({ ...prev, ssh_principal_grant_ids: values.map((item) => item.id) }))}
|
||||
getOptionLabel={(item) => `${item.name || item.principal} · ${(item.principals || []).join(', ')}`}
|
||||
renderInput={(params) => <TextField {...params} label="Principal Grants" />}
|
||||
/>
|
||||
)}
|
||||
<TextField
|
||||
label="Default Validity Seconds"
|
||||
type="number"
|
||||
value={form.default_valid_seconds}
|
||||
onChange={(event) => setForm((prev) => ({ ...prev, default_valid_seconds: Number(event.target.value) || 0 }))}
|
||||
/>
|
||||
<TextField
|
||||
label="Max Validity Seconds"
|
||||
type="number"
|
||||
value={form.max_valid_seconds}
|
||||
onChange={(event) => setForm((prev) => ({ ...prev, max_valid_seconds: Number(event.target.value) || 0 }))}
|
||||
/>
|
||||
<TextField
|
||||
label="Managed Private Key PEM"
|
||||
multiline
|
||||
minRows={4}
|
||||
value={form.private_key_pem}
|
||||
onChange={(event) => setForm((prev) => ({ ...prev, private_key_pem: event.target.value }))}
|
||||
helperText={editingID ? 'Leave blank to keep the existing managed key.' : 'Leave blank to auto-generate an ed25519 key.'}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<TextField
|
||||
label="Private Key PEM"
|
||||
multiline
|
||||
minRows={4}
|
||||
value={form.private_key_pem}
|
||||
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.'}
|
||||
/>
|
||||
)}
|
||||
</FormDialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setDialogOpen(false)} disabled={saving}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="contained" onClick={handleSave} disabled={saving}>
|
||||
Save
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={Boolean(deleteItem)} onClose={() => setDeleteItem(null)} maxWidth="xs" fullWidth>
|
||||
<DialogTitle>Delete SSH Access Profile</DialogTitle>
|
||||
<FormDialogContent>
|
||||
<Typography variant="body2">
|
||||
Delete <strong>{deleteItem?.name}</strong>?
|
||||
</Typography>
|
||||
</FormDialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setDeleteItem(null)}>Cancel</Button>
|
||||
<Button variant="contained" color="error" onClick={handleDelete}>
|
||||
Delete
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={serverDialogOpen} onClose={() => setServerDialogOpen(false)} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>
|
||||
<Box sx={{ display: 'grid', gap: 1 }}>
|
||||
<Typography variant="h6">{editingServerID ? 'Edit SSH Server' : 'New SSH Server'}</Typography>
|
||||
{serverDialogError ? <Alert severity="error">{serverDialogError}</Alert> : null}
|
||||
</Box>
|
||||
</DialogTitle>
|
||||
<FormDialogContent>
|
||||
<TextField
|
||||
label="Name"
|
||||
value={serverForm.name}
|
||||
onChange={(event) => setServerForm((prev) => ({ ...prev, name: event.target.value }))}
|
||||
/>
|
||||
<TextField
|
||||
label="Host"
|
||||
value={serverForm.host}
|
||||
onChange={(event) => setServerForm((prev) => ({ ...prev, host: event.target.value }))}
|
||||
/>
|
||||
<TextField
|
||||
label="Port"
|
||||
type="number"
|
||||
value={serverForm.port}
|
||||
onChange={(event) => setServerForm((prev) => ({ ...prev, port: Number(event.target.value) || 0 }))}
|
||||
/>
|
||||
<TextField
|
||||
label="Description"
|
||||
value={serverForm.description}
|
||||
onChange={(event) => setServerForm((prev) => ({ ...prev, description: event.target.value }))}
|
||||
/>
|
||||
<TextField
|
||||
label="Tags"
|
||||
value={serverForm.tagsText}
|
||||
onChange={(event) => setServerForm((prev) => ({ ...prev, tagsText: event.target.value }))}
|
||||
helperText="Comma-separated tags."
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={<Checkbox checked={serverForm.enabled} onChange={(event) => setServerForm((prev) => ({ ...prev, enabled: event.target.checked }))} />}
|
||||
label="Enabled"
|
||||
/>
|
||||
</FormDialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setServerDialogOpen(false)} disabled={serverSaving}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="contained" onClick={handleServerSave} disabled={serverSaving}>
|
||||
Save
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={Boolean(deleteServerItem)} onClose={() => setDeleteServerItem(null)} maxWidth="xs" fullWidth>
|
||||
<DialogTitle>Delete SSH Server</DialogTitle>
|
||||
<FormDialogContent>
|
||||
<Typography variant="body2">
|
||||
Delete <strong>{deleteServerItem?.name}</strong>?
|
||||
</Typography>
|
||||
</FormDialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setDeleteServerItem(null)}>Cancel</Button>
|
||||
<Button variant="contained" color="error" onClick={handleServerDelete}>
|
||||
Delete
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={Boolean(hostKeyServer)} onClose={closeHostKeys} maxWidth="md" fullWidth>
|
||||
<DialogTitle>
|
||||
<Box sx={{ display: 'grid', gap: 1 }}>
|
||||
<Typography variant="h6">Pinned Host Keys</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{hostKeyServer ? `${hostKeyServer.name} (${hostKeyServer.host}:${hostKeyServer.port})` : ''}
|
||||
</Typography>
|
||||
{hostKeysError ? <Alert severity="error">{hostKeysError}</Alert> : null}
|
||||
</Box>
|
||||
</DialogTitle>
|
||||
<FormDialogContent>
|
||||
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
|
||||
<Button variant="outlined" onClick={handleDiscoverHostKey} disabled={hostKeySaving || hostKeysLoading}>
|
||||
Discover
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={() => void handleAddHostKey(hostKeyInput)}
|
||||
disabled={hostKeySaving || !hostKeyInput.trim()}
|
||||
>
|
||||
Add Manual Key
|
||||
</Button>
|
||||
</Box>
|
||||
{discoveredHostKey ? (
|
||||
<Alert
|
||||
severity="info"
|
||||
action={
|
||||
<Button color="inherit" size="small" onClick={() => void handleAddHostKey(discoveredHostKey.public_key)} disabled={hostKeySaving}>
|
||||
Pin
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
Discovered {discoveredHostKey.algorithm} · {discoveredHostKey.fingerprint}
|
||||
</Alert>
|
||||
) : null}
|
||||
<TextField
|
||||
label="Manual Host Public Key"
|
||||
multiline
|
||||
minRows={3}
|
||||
value={hostKeyInput}
|
||||
onChange={(event) => setHostKeyInput(event.target.value)}
|
||||
helperText="Paste an authorized_keys style SSH host public key."
|
||||
/>
|
||||
<Box sx={{ display: 'grid', gap: 1 }}>
|
||||
<Typography variant="subtitle2">Pinned Keys</Typography>
|
||||
{hostKeysLoading ? (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Loading...
|
||||
</Typography>
|
||||
) : null}
|
||||
{!hostKeysLoading && hostKeys.length === 0 ? (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
No pinned host keys.
|
||||
</Typography>
|
||||
) : null}
|
||||
{hostKeys.map((item) => (
|
||||
<Paper
|
||||
key={item.id}
|
||||
variant="outlined"
|
||||
sx={{
|
||||
p: 1,
|
||||
display: 'grid',
|
||||
gap: 0.5,
|
||||
backgroundColor: 'transparent',
|
||||
backgroundImage: 'none'
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1, flexWrap: 'wrap' }}>
|
||||
<Typography variant="subtitle2">{item.fingerprint}</Typography>
|
||||
<ListRowActions>
|
||||
<ListRowActionButton color="error" onClick={() => void handleDeleteHostKey(item.id)} disabled={hostKeySaving}>
|
||||
Delete
|
||||
</ListRowActionButton>
|
||||
</ListRowActions>
|
||||
</Box>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ wordBreak: 'break-word' }}>
|
||||
{item.algorithm}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ wordBreak: 'break-word' }}>
|
||||
{item.public_key}
|
||||
</Typography>
|
||||
</Paper>
|
||||
))}
|
||||
</Box>
|
||||
</FormDialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={closeHostKeys}>Close</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,927 @@
|
||||
import '@xterm/xterm/css/xterm.css'
|
||||
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 Dialog from '@mui/material/Dialog'
|
||||
import DialogActions from '@mui/material/DialogActions'
|
||||
import DialogTitle from '@mui/material/DialogTitle'
|
||||
import TextField from '@mui/material/TextField'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import useMediaQuery from '@mui/material/useMediaQuery'
|
||||
import { useTheme } from '@mui/material/styles'
|
||||
import { PointerEvent as ReactPointerEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import { FitAddon } from '@xterm/addon-fit'
|
||||
import { Unicode11Addon } from '@xterm/addon-unicode11'
|
||||
import { Terminal } from '@xterm/xterm'
|
||||
import Autocomplete from '../components/Autocomplete'
|
||||
import FormDialogContent from '../components/FormDialogContent'
|
||||
import TintedPanel from '../components/TintedPanel'
|
||||
import { api, SSHAccessProfile, SSHSession } from '../api'
|
||||
|
||||
type SSHSessionStreamMessage = {
|
||||
type?: string
|
||||
data?: string
|
||||
cols?: number
|
||||
rows?: number
|
||||
status?: string
|
||||
message?: string
|
||||
}
|
||||
|
||||
type SessionSummary = {
|
||||
title: string
|
||||
status: string
|
||||
}
|
||||
|
||||
type RowDragState = {
|
||||
index: number
|
||||
startY: number
|
||||
weights: number[]
|
||||
}
|
||||
|
||||
type SessionPanelProps = {
|
||||
sessionID: string
|
||||
layoutToken: string
|
||||
onDuplicate: (sessionID: string) => void
|
||||
onClose: (sessionID: string) => void
|
||||
onReplace: (sessionID: string, nextSessionID: string) => void
|
||||
onSummaryChange: (sessionID: string, summary: SessionSummary) => void
|
||||
}
|
||||
|
||||
function buildWebSocketURL(path: string): string {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
return `${protocol}//${window.location.host}${path}`
|
||||
}
|
||||
|
||||
function buildSessionTitle(session: SSHSession | null): string {
|
||||
if (!session) {
|
||||
return 'SSH Session'
|
||||
}
|
||||
return `${session.remote_username}@${session.host}:${session.port}`
|
||||
}
|
||||
|
||||
function buildInitialTerminalSize() {
|
||||
let cols
|
||||
let rows
|
||||
|
||||
cols = 120
|
||||
rows = 36
|
||||
if (typeof window !== 'undefined') {
|
||||
cols = Math.max(80, Math.min(200, Math.floor(window.innerWidth / 9)))
|
||||
rows = Math.max(24, Math.min(80, Math.floor(window.innerHeight / 22)))
|
||||
}
|
||||
return { cols, rows, term: 'xterm-256color' }
|
||||
}
|
||||
|
||||
function SessionTerminalPanel(props: SessionPanelProps) {
|
||||
const { sessionID, layoutToken, onDuplicate, onClose, onReplace, onSummaryChange } = props
|
||||
const [session, setSession] = useState<SSHSession | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [status, setStatus] = useState('pending')
|
||||
const [actionPending, setActionPending] = useState(false)
|
||||
const terminalHostRef = useRef<HTMLDivElement | null>(null)
|
||||
const terminalRef = useRef<Terminal | null>(null)
|
||||
const fitAddonRef = useRef<FitAddon | null>(null)
|
||||
const unicodeAddonRef = useRef<Unicode11Addon | null>(null)
|
||||
const wsRef = useRef<WebSocket | null>(null)
|
||||
const sessionReady = Boolean(session)
|
||||
const sessionTitle = useMemo(() => buildSessionTitle(session), [session])
|
||||
const canReconnect = status === 'closed' || status === 'error'
|
||||
const sessionActionLabel = canReconnect ? 'Connect' : 'Disconnect'
|
||||
|
||||
const sendResize = () => {
|
||||
const fitAddon = fitAddonRef.current
|
||||
const ws = wsRef.current
|
||||
let dimensions
|
||||
|
||||
if (!fitAddon) {
|
||||
return
|
||||
}
|
||||
fitAddon.fit()
|
||||
dimensions = fitAddon.proposeDimensions()
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN || !dimensions) {
|
||||
return
|
||||
}
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: 'resize',
|
||||
cols: dimensions.cols,
|
||||
rows: dimensions.rows
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled
|
||||
|
||||
cancelled = false
|
||||
setSession(null)
|
||||
setStatus('pending')
|
||||
setError(null)
|
||||
|
||||
const load = async () => {
|
||||
let item
|
||||
|
||||
if (!sessionID) {
|
||||
setError('Missing SSH session id')
|
||||
return
|
||||
}
|
||||
setLoading(true)
|
||||
try {
|
||||
item = await api.getSSHSessionForSelf(sessionID)
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
setSession(item)
|
||||
setStatus(item.status || 'pending')
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load SSH session')
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void load()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [sessionID])
|
||||
|
||||
useEffect(() => {
|
||||
onSummaryChange(sessionID, { title: sessionTitle, status })
|
||||
}, [onSummaryChange, sessionID, sessionTitle, status])
|
||||
|
||||
useEffect(() => {
|
||||
let animationFrameID
|
||||
let settleTimer
|
||||
|
||||
animationFrameID = 0
|
||||
settleTimer = 0
|
||||
if (!sessionReady) {
|
||||
return
|
||||
}
|
||||
animationFrameID = window.requestAnimationFrame(() => {
|
||||
sendResize()
|
||||
})
|
||||
settleTimer = window.setTimeout(() => {
|
||||
sendResize()
|
||||
}, 60)
|
||||
return () => {
|
||||
if (animationFrameID) {
|
||||
window.cancelAnimationFrame(animationFrameID)
|
||||
}
|
||||
if (settleTimer) {
|
||||
window.clearTimeout(settleTimer)
|
||||
}
|
||||
}
|
||||
}, [layoutToken, sessionReady])
|
||||
|
||||
useEffect(() => {
|
||||
let ws: WebSocket | null
|
||||
let term: Terminal | null
|
||||
let fitAddon: FitAddon | null
|
||||
let unicodeAddon: Unicode11Addon | null
|
||||
let resizeObserver: ResizeObserver | null
|
||||
let resizeTimer: number | null
|
||||
let settleTimer: number | null
|
||||
let disposed
|
||||
|
||||
disposed = false
|
||||
ws = null
|
||||
term = null
|
||||
fitAddon = null
|
||||
unicodeAddon = null
|
||||
resizeObserver = null
|
||||
resizeTimer = null
|
||||
settleTimer = null
|
||||
|
||||
const scheduleResize = () => {
|
||||
if (resizeTimer !== null) {
|
||||
window.cancelAnimationFrame(resizeTimer)
|
||||
}
|
||||
resizeTimer = window.requestAnimationFrame(() => {
|
||||
resizeTimer = null
|
||||
sendResize()
|
||||
})
|
||||
}
|
||||
|
||||
if (!sessionID || !session || error || !terminalHostRef.current) {
|
||||
return
|
||||
}
|
||||
if (session.status === 'closed' || session.status === 'error') {
|
||||
return
|
||||
}
|
||||
|
||||
term = new Terminal({
|
||||
allowProposedApi: true,
|
||||
cursorBlink: true,
|
||||
convertEol: true,
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, monospace',
|
||||
fontSize: 14,
|
||||
lineHeight: 1.3,
|
||||
theme: {
|
||||
background: 'transparent'
|
||||
}
|
||||
})
|
||||
fitAddon = new FitAddon()
|
||||
unicodeAddon = new Unicode11Addon()
|
||||
term.loadAddon(fitAddon)
|
||||
term.loadAddon(unicodeAddon)
|
||||
term.unicode.activeVersion = '11'
|
||||
term.open(terminalHostRef.current)
|
||||
fitAddon.fit()
|
||||
term.focus()
|
||||
term.writeln('Connecting...')
|
||||
|
||||
ws = new WebSocket(buildWebSocketURL(`/api/ssh/sessions/${sessionID}/stream`))
|
||||
ws.binaryType = 'arraybuffer'
|
||||
terminalRef.current = term
|
||||
fitAddonRef.current = fitAddon
|
||||
unicodeAddonRef.current = unicodeAddon
|
||||
wsRef.current = ws
|
||||
|
||||
term.onData((data) => {
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
||||
return
|
||||
}
|
||||
ws.send(JSON.stringify({ type: 'input', data }))
|
||||
})
|
||||
|
||||
ws.onopen = () => {
|
||||
if (disposed) {
|
||||
return
|
||||
}
|
||||
scheduleResize()
|
||||
settleTimer = window.setTimeout(() => {
|
||||
if (!disposed) {
|
||||
scheduleResize()
|
||||
}
|
||||
}, 60)
|
||||
if (typeof document !== 'undefined' && 'fonts' in document && document.fonts?.ready) {
|
||||
void document.fonts.ready.then(() => {
|
||||
if (!disposed) {
|
||||
scheduleResize()
|
||||
}
|
||||
})
|
||||
}
|
||||
term?.focus()
|
||||
}
|
||||
ws.onmessage = (event) => {
|
||||
let message: SSHSessionStreamMessage
|
||||
|
||||
if (disposed || !term) {
|
||||
return
|
||||
}
|
||||
if (event.data instanceof ArrayBuffer) {
|
||||
term.write(new Uint8Array(event.data))
|
||||
return
|
||||
}
|
||||
if (event.data instanceof Blob) {
|
||||
void event.data.arrayBuffer().then((buffer) => {
|
||||
if (!disposed && term) {
|
||||
term.write(new Uint8Array(buffer))
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
try {
|
||||
message = JSON.parse(String(event.data)) as SSHSessionStreamMessage
|
||||
} catch {
|
||||
term.write(String(event.data))
|
||||
return
|
||||
}
|
||||
if (message.type === 'status') {
|
||||
setStatus(message.status || 'unknown')
|
||||
if (message.status === 'connected') {
|
||||
setSession((prev) => (prev ? { ...prev, host_key_fingerprint: message.message || prev.host_key_fingerprint, status: 'connected' } : prev))
|
||||
return
|
||||
}
|
||||
if (message.status === 'error' && message.message) {
|
||||
setError(message.message)
|
||||
term.writeln(`\r\nERROR: ${message.message}`)
|
||||
return
|
||||
}
|
||||
if (message.status === 'closed') {
|
||||
if (message.message) {
|
||||
term.writeln(`\r\n${message.message}`)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
ws.onerror = () => {
|
||||
if (!disposed) {
|
||||
setError((prev) => prev || 'SSH websocket error')
|
||||
}
|
||||
}
|
||||
ws.onclose = () => {
|
||||
if (!disposed) {
|
||||
setStatus((prev) => (prev === 'closed' || prev === 'error' ? prev : 'closed'))
|
||||
}
|
||||
wsRef.current = null
|
||||
}
|
||||
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
scheduleResize()
|
||||
})
|
||||
resizeObserver.observe(terminalHostRef.current)
|
||||
window.addEventListener('resize', scheduleResize)
|
||||
|
||||
return () => {
|
||||
disposed = true
|
||||
if (resizeTimer !== null) {
|
||||
window.cancelAnimationFrame(resizeTimer)
|
||||
}
|
||||
if (settleTimer !== null) {
|
||||
window.clearTimeout(settleTimer)
|
||||
}
|
||||
resizeObserver?.disconnect()
|
||||
window.removeEventListener('resize', scheduleResize)
|
||||
wsRef.current = null
|
||||
fitAddonRef.current = null
|
||||
unicodeAddonRef.current = null
|
||||
terminalRef.current = null
|
||||
ws?.close()
|
||||
term?.dispose()
|
||||
}
|
||||
}, [error, sessionID, sessionReady])
|
||||
|
||||
const handleDisconnect = async () => {
|
||||
if (!sessionID) {
|
||||
return
|
||||
}
|
||||
setActionPending(true)
|
||||
try {
|
||||
await api.disconnectSSHSessionForSelf(sessionID)
|
||||
if (wsRef.current) {
|
||||
wsRef.current.close()
|
||||
}
|
||||
setStatus('closed')
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to disconnect SSH session')
|
||||
} finally {
|
||||
setActionPending(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleReconnect = async () => {
|
||||
let cols
|
||||
let rows
|
||||
let term
|
||||
let nextSession
|
||||
|
||||
if (!session || !session.profile_id) {
|
||||
return
|
||||
}
|
||||
setActionPending(true)
|
||||
setError(null)
|
||||
try {
|
||||
cols = session.requested_cols > 0 ? session.requested_cols : buildInitialTerminalSize().cols
|
||||
rows = session.requested_rows > 0 ? session.requested_rows : buildInitialTerminalSize().rows
|
||||
term = session.requested_term || buildInitialTerminalSize().term
|
||||
nextSession = await api.connectSSHAccessProfileForSelf(session.profile_id, {
|
||||
cols,
|
||||
rows,
|
||||
term
|
||||
})
|
||||
onReplace(sessionID, nextSession.session_id)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to connect SSH session')
|
||||
} finally {
|
||||
setActionPending(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDuplicate = async () => {
|
||||
let cols
|
||||
let rows
|
||||
let term
|
||||
let dimensions
|
||||
let fitAddon
|
||||
let nextSession
|
||||
|
||||
if (!session || !session.profile_id) {
|
||||
return
|
||||
}
|
||||
setActionPending(true)
|
||||
setError(null)
|
||||
try {
|
||||
fitAddon = fitAddonRef.current
|
||||
dimensions = fitAddon ? fitAddon.proposeDimensions() : null
|
||||
cols = dimensions?.cols || (session.requested_cols > 0 ? session.requested_cols : buildInitialTerminalSize().cols)
|
||||
rows = dimensions?.rows || (session.requested_rows > 0 ? session.requested_rows : buildInitialTerminalSize().rows)
|
||||
term = session.requested_term || buildInitialTerminalSize().term
|
||||
nextSession = await api.connectSSHAccessProfileForSelf(session.profile_id, {
|
||||
cols,
|
||||
rows,
|
||||
term
|
||||
})
|
||||
onDuplicate(nextSession.session_id)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to duplicate SSH session')
|
||||
} finally {
|
||||
setActionPending(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSessionAction = async () => {
|
||||
if (canReconnect) {
|
||||
await handleReconnect()
|
||||
return
|
||||
}
|
||||
await handleDisconnect()
|
||||
}
|
||||
|
||||
const handleClosePanel = () => {
|
||||
if (wsRef.current) {
|
||||
wsRef.current.close()
|
||||
}
|
||||
onClose(sessionID)
|
||||
}
|
||||
|
||||
return (
|
||||
<TintedPanel
|
||||
sx={{
|
||||
p: 0.75,
|
||||
height: '100%',
|
||||
minHeight: 420,
|
||||
display: 'grid',
|
||||
gap: 0.75,
|
||||
overflow: 'hidden',
|
||||
minWidth: 0
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1, flexWrap: 'wrap', px: 0.25 }}>
|
||||
<Typography variant="subtitle2" sx={{ minWidth: 0, wordBreak: 'break-word' }}>
|
||||
{sessionTitle}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={() => void handleDuplicate()}
|
||||
disabled={actionPending || !session || !session.profile_id || loading}
|
||||
>
|
||||
Duplicate
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color={canReconnect ? 'primary' : 'error'}
|
||||
onClick={() => void handleSessionAction()}
|
||||
disabled={actionPending || !session || loading}
|
||||
>
|
||||
{actionPending ? (canReconnect ? 'Connecting...' : 'Disconnecting...') : sessionActionLabel}
|
||||
</Button>
|
||||
<Button variant="outlined" onClick={handleClosePanel}>
|
||||
Close
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
{error ? <Alert severity="error">{error}</Alert> : null}
|
||||
{session ? (
|
||||
<Typography variant="caption" color="text.secondary" sx={{ px: 0.25, wordBreak: 'break-word' }}>
|
||||
Host key: {session.host_key_fingerprint || '-'} · Auth: {session.auth_method} · User: {session.username}
|
||||
</Typography>
|
||||
) : null}
|
||||
<Box
|
||||
ref={terminalHostRef}
|
||||
sx={{
|
||||
minHeight: 340,
|
||||
height: '100%',
|
||||
minWidth: 0,
|
||||
overflow: 'hidden',
|
||||
'& .xterm': {
|
||||
height: '100%'
|
||||
},
|
||||
'& .xterm-viewport': {
|
||||
overflowY: 'auto !important'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</TintedPanel>
|
||||
)
|
||||
}
|
||||
|
||||
export default function SSHSessionPage() {
|
||||
const { sessionId } = useParams<{ sessionId: string }>()
|
||||
const minPanelHeight = 220
|
||||
const dividerThickness = 12
|
||||
const navigate = useNavigate()
|
||||
const theme = useTheme()
|
||||
const [openSessionIDs, setOpenSessionIDs] = useState<string[]>([])
|
||||
const [sessionSummaries, setSessionSummaries] = useState<Record<string, SessionSummary>>({})
|
||||
const [profiles, setProfiles] = useState<SSHAccessProfile[]>([])
|
||||
const [loadingProfiles, setLoadingProfiles] = useState(false)
|
||||
const [pageError, setPageError] = useState<string | null>(null)
|
||||
const [newSessionOpen, setNewSessionOpen] = useState(false)
|
||||
const [selectedProfile, setSelectedProfile] = useState<SSHAccessProfile | null>(null)
|
||||
const [creatingSession, setCreatingSession] = useState(false)
|
||||
const [splitRatio, setSplitRatio] = useState(0.5)
|
||||
const [splitDragging, setSplitDragging] = useState(false)
|
||||
const [rowWeights, setRowWeights] = useState<number[]>([1])
|
||||
const [rowDragState, setRowDragState] = useState<RowDragState | null>(null)
|
||||
const workspaceRef = useRef<HTMLDivElement | null>(null)
|
||||
const stackedLayout = useMediaQuery(theme.breakpoints.down('lg'))
|
||||
const rowCount = useMemo(() => Math.max(1, Math.ceil(openSessionIDs.length / 2)), [openSessionIDs.length])
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionId) {
|
||||
return
|
||||
}
|
||||
setOpenSessionIDs((prev) => (prev.includes(sessionId) ? prev : [...prev, sessionId]))
|
||||
}, [sessionId])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled
|
||||
|
||||
cancelled = false
|
||||
const loadProfiles = async () => {
|
||||
let items
|
||||
|
||||
setLoadingProfiles(true)
|
||||
try {
|
||||
items = await api.listSSHAccessProfilesForSelf()
|
||||
if (!cancelled) {
|
||||
setProfiles(Array.isArray(items) ? items : [])
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setPageError(err instanceof Error ? err.message : 'Failed to load SSH access profiles')
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setLoadingProfiles(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void loadProfiles()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
const clampSplitRatio = useCallback((value: number) => {
|
||||
return Math.max(0.25, Math.min(0.75, value))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
setRowWeights((prev) => {
|
||||
let changed
|
||||
let next
|
||||
|
||||
changed = prev.length !== rowCount
|
||||
next = prev.slice(0, rowCount)
|
||||
while (next.length < rowCount) {
|
||||
next.push(1)
|
||||
}
|
||||
if (!changed) {
|
||||
changed = next.some((value, index) => value !== prev[index])
|
||||
}
|
||||
return changed ? next : prev
|
||||
})
|
||||
}, [rowCount])
|
||||
|
||||
const layoutToken = useMemo(() => {
|
||||
if (stackedLayout) {
|
||||
return `stacked:${openSessionIDs.length}`
|
||||
}
|
||||
return `split:${openSessionIDs.length}:${Math.round(splitRatio * 1000)}:${rowWeights.map((value) => Math.round(value * 1000)).join(',')}`
|
||||
}, [openSessionIDs.length, rowWeights, splitRatio, stackedLayout])
|
||||
|
||||
const handleSummaryChange = useCallback((id: string, summary: SessionSummary) => {
|
||||
setSessionSummaries((prev) => {
|
||||
const current = prev[id]
|
||||
|
||||
if (current && current.title === summary.title && current.status === summary.status) {
|
||||
return prev
|
||||
}
|
||||
return { ...prev, [id]: summary }
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handleCloseSession = useCallback((id: string) => {
|
||||
setOpenSessionIDs((prev) => prev.filter((item) => item !== id))
|
||||
setSessionSummaries((prev) => {
|
||||
const next = { ...prev }
|
||||
|
||||
delete next[id]
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handleReplaceSession = useCallback((currentID: string, nextID: string) => {
|
||||
setOpenSessionIDs((prev) => prev.map((item) => (item === currentID ? nextID : item)))
|
||||
setSessionSummaries((prev) => {
|
||||
const next = { ...prev }
|
||||
|
||||
delete next[currentID]
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handleDuplicateSession = useCallback((id: string) => {
|
||||
setOpenSessionIDs((prev) => (prev.includes(id) ? prev : [...prev, id]))
|
||||
}, [])
|
||||
|
||||
const handleCreateSession = async () => {
|
||||
let nextSession
|
||||
let initial
|
||||
|
||||
if (!selectedProfile) {
|
||||
setPageError('SSH access profile is required')
|
||||
return
|
||||
}
|
||||
setCreatingSession(true)
|
||||
setPageError(null)
|
||||
try {
|
||||
initial = buildInitialTerminalSize()
|
||||
nextSession = await api.connectSSHAccessProfileForSelf(selectedProfile.id, initial)
|
||||
setOpenSessionIDs((prev) => (prev.includes(nextSession.session_id) ? prev : [...prev, nextSession.session_id]))
|
||||
setNewSessionOpen(false)
|
||||
setSelectedProfile(null)
|
||||
} catch (err) {
|
||||
setPageError(err instanceof Error ? err.message : 'Failed to create SSH session')
|
||||
} finally {
|
||||
setCreatingSession(false)
|
||||
}
|
||||
}
|
||||
|
||||
const openNewSessionDialog = () => {
|
||||
setSelectedProfile(null)
|
||||
setPageError(null)
|
||||
setNewSessionOpen(true)
|
||||
}
|
||||
|
||||
const handleLeave = () => {
|
||||
navigate('/ssh-servers')
|
||||
}
|
||||
|
||||
const handleSplitPointerDown = () => {
|
||||
setSplitDragging(true)
|
||||
}
|
||||
|
||||
const handleRowPointerDown = (index: number) => {
|
||||
return (event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
setRowDragState({
|
||||
index,
|
||||
startY: event.clientY,
|
||||
weights: rowWeights.slice()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
let handlePointerMove
|
||||
let handlePointerUp
|
||||
|
||||
if (!splitDragging) {
|
||||
return
|
||||
}
|
||||
handlePointerMove = (event: PointerEvent) => {
|
||||
const element = workspaceRef.current
|
||||
let rect
|
||||
let nextRatio
|
||||
|
||||
if (!element) {
|
||||
return
|
||||
}
|
||||
rect = element.getBoundingClientRect()
|
||||
if (rect.width <= 0) {
|
||||
return
|
||||
}
|
||||
nextRatio = (event.clientX - rect.left) / rect.width
|
||||
setSplitRatio(clampSplitRatio(nextRatio))
|
||||
}
|
||||
handlePointerUp = () => {
|
||||
setSplitDragging(false)
|
||||
}
|
||||
window.addEventListener('pointermove', handlePointerMove)
|
||||
window.addEventListener('pointerup', handlePointerUp)
|
||||
return () => {
|
||||
window.removeEventListener('pointermove', handlePointerMove)
|
||||
window.removeEventListener('pointerup', handlePointerUp)
|
||||
}
|
||||
}, [clampSplitRatio, splitDragging])
|
||||
|
||||
useEffect(() => {
|
||||
let handlePointerMove
|
||||
let handlePointerUp
|
||||
|
||||
if (!rowDragState) {
|
||||
return
|
||||
}
|
||||
handlePointerMove = (event: PointerEvent) => {
|
||||
const element = workspaceRef.current
|
||||
const totalWeight = rowDragState.weights.reduce((sum, value) => sum + value, 0)
|
||||
let contentHeight
|
||||
let nextHeights
|
||||
let nextWeights
|
||||
let pairHeight
|
||||
let rect
|
||||
let topHeight
|
||||
|
||||
if (!element || rowCount <= 1 || totalWeight <= 0) {
|
||||
return
|
||||
}
|
||||
rect = element.getBoundingClientRect()
|
||||
contentHeight = rect.height - (rowCount - 1) * dividerThickness
|
||||
if (contentHeight <= 0) {
|
||||
return
|
||||
}
|
||||
nextHeights = rowDragState.weights.map((value) => (value / totalWeight) * contentHeight)
|
||||
pairHeight = nextHeights[rowDragState.index] + nextHeights[rowDragState.index + 1]
|
||||
topHeight = nextHeights[rowDragState.index] + (event.clientY - rowDragState.startY)
|
||||
topHeight = Math.max(minPanelHeight, Math.min(pairHeight - minPanelHeight, topHeight))
|
||||
nextHeights[rowDragState.index] = topHeight
|
||||
nextHeights[rowDragState.index + 1] = pairHeight - topHeight
|
||||
nextWeights = nextHeights.map((value) => Math.max(0.001, (value / contentHeight) * totalWeight))
|
||||
setRowWeights(nextWeights)
|
||||
}
|
||||
handlePointerUp = () => {
|
||||
setRowDragState(null)
|
||||
}
|
||||
window.addEventListener('pointermove', handlePointerMove)
|
||||
window.addEventListener('pointerup', handlePointerUp)
|
||||
return () => {
|
||||
window.removeEventListener('pointermove', handlePointerMove)
|
||||
window.removeEventListener('pointerup', handlePointerUp)
|
||||
}
|
||||
}, [dividerThickness, minPanelHeight, rowCount, rowDragState])
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'grid', gap: 1 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1, flexWrap: 'wrap' }}>
|
||||
<Typography variant="h5">SSH Sessions</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
|
||||
<Button variant="contained" onClick={openNewSessionDialog} disabled={loadingProfiles}>
|
||||
New Session
|
||||
</Button>
|
||||
<Button variant="outlined" onClick={handleLeave}>
|
||||
Close
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
{pageError ? <Alert severity="error">{pageError}</Alert> : null}
|
||||
{openSessionIDs.length ? (
|
||||
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
|
||||
{openSessionIDs.map((id) => (
|
||||
<Chip
|
||||
key={id}
|
||||
label={`${sessionSummaries[id]?.title || id} · ${sessionSummaries[id]?.status || 'pending'}`}
|
||||
variant="outlined"
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
) : null}
|
||||
{openSessionIDs.length ? (
|
||||
stackedLayout ? (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'minmax(0, 1fr)',
|
||||
gap: 1,
|
||||
alignItems: 'start'
|
||||
}}
|
||||
>
|
||||
{openSessionIDs.map((id) => (
|
||||
<SessionTerminalPanel
|
||||
key={id}
|
||||
sessionID={id}
|
||||
layoutToken={layoutToken}
|
||||
onDuplicate={handleDuplicateSession}
|
||||
onClose={handleCloseSession}
|
||||
onReplace={handleReplaceSession}
|
||||
onSummaryChange={handleSummaryChange}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
) : (
|
||||
<Box
|
||||
ref={workspaceRef}
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: openSessionIDs.length > 1 ? `${splitRatio}fr 12px ${1 - splitRatio}fr` : 'minmax(0, 1fr)',
|
||||
gridTemplateRows:
|
||||
rowCount > 1
|
||||
? rowWeights.flatMap((value, index) => {
|
||||
return index < rowCount - 1 ? [`minmax(${minPanelHeight}px, ${value}fr)`, `${dividerThickness}px`] : [`minmax(${minPanelHeight}px, ${value}fr)`]
|
||||
}).join(' ')
|
||||
: `minmax(${minPanelHeight}px, 1fr)`,
|
||||
alignItems: 'stretch',
|
||||
height: 'calc(100vh - 220px)',
|
||||
minHeight: minPanelHeight,
|
||||
minWidth: 0
|
||||
}}
|
||||
>
|
||||
{openSessionIDs.length > 1 ? (
|
||||
<Box
|
||||
onPointerDown={handleSplitPointerDown}
|
||||
sx={{
|
||||
gridColumn: 2,
|
||||
gridRow: `1 / span ${Math.ceil(openSessionIDs.length / 2)}`,
|
||||
cursor: 'col-resize',
|
||||
display: 'grid',
|
||||
placeItems: 'stretch',
|
||||
userSelect: 'none',
|
||||
touchAction: 'none',
|
||||
'&::before': {
|
||||
content: '""',
|
||||
display: 'block',
|
||||
width: '2px',
|
||||
justifySelf: 'center',
|
||||
backgroundColor: splitDragging ? 'primary.main' : 'divider',
|
||||
borderRadius: 999
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{rowCount > 1
|
||||
? Array.from({ length: rowCount - 1 }, (_, index) => (
|
||||
<Box
|
||||
key={`row-divider-${index}`}
|
||||
onPointerDown={handleRowPointerDown(index)}
|
||||
sx={{
|
||||
gridColumn: '1 / -1',
|
||||
gridRow: index * 2 + 2,
|
||||
cursor: 'row-resize',
|
||||
display: 'grid',
|
||||
placeItems: 'stretch',
|
||||
userSelect: 'none',
|
||||
touchAction: 'none',
|
||||
'&::before': {
|
||||
content: '""',
|
||||
display: 'block',
|
||||
height: '2px',
|
||||
alignSelf: 'center',
|
||||
backgroundColor: rowDragState?.index === index ? 'primary.main' : 'divider',
|
||||
borderRadius: 999
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))
|
||||
: null}
|
||||
{openSessionIDs.map((id, index) => (
|
||||
<Box
|
||||
key={id}
|
||||
sx={{
|
||||
gridColumn: openSessionIDs.length > 1 ? (index % 2 === 0 ? 1 : 3) : 1,
|
||||
gridRow: openSessionIDs.length > 1 ? Math.floor(index / 2) * 2 + 1 : 1,
|
||||
minWidth: 0,
|
||||
minHeight: 0
|
||||
}}
|
||||
>
|
||||
<SessionTerminalPanel
|
||||
sessionID={id}
|
||||
layoutToken={layoutToken}
|
||||
onDuplicate={handleDuplicateSession}
|
||||
onClose={handleCloseSession}
|
||||
onReplace={handleReplaceSession}
|
||||
onSummaryChange={handleSummaryChange}
|
||||
/>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)
|
||||
) : (
|
||||
<TintedPanel sx={{ display: 'grid', placeItems: 'center', minHeight: 320 }}>
|
||||
<Box sx={{ display: 'grid', gap: 1, justifyItems: 'center', textAlign: 'center' }}>
|
||||
<Typography variant="subtitle1">No open SSH sessions.</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Start a new SSH session to open one or more terminal panels.
|
||||
</Typography>
|
||||
<Button variant="contained" onClick={openNewSessionDialog} disabled={loadingProfiles}>
|
||||
New Session
|
||||
</Button>
|
||||
</Box>
|
||||
</TintedPanel>
|
||||
)}
|
||||
|
||||
<Dialog open={newSessionOpen} onClose={() => setNewSessionOpen(false)} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>New SSH Session</DialogTitle>
|
||||
<FormDialogContent>
|
||||
<Autocomplete
|
||||
options={profiles}
|
||||
value={selectedProfile}
|
||||
onChange={(_, value) => setSelectedProfile(value)}
|
||||
getOptionLabel={(item) => `${item.name} · ${item.server?.name || item.server_id} · ${item.remote_username}`}
|
||||
renderInput={(params) => <TextField {...params} label="SSH Access Profile" />}
|
||||
/>
|
||||
</FormDialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setNewSessionOpen(false)} disabled={creatingSession}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="contained" onClick={() => void handleCreateSession()} disabled={creatingSession || !selectedProfile}>
|
||||
{creatingSession ? 'Connecting...' : 'Connect'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
+30
-1
@@ -3,9 +3,38 @@ import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
build: {
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks(id) {
|
||||
if (!id.includes('node_modules')) {
|
||||
return
|
||||
}
|
||||
if (id.includes('@xterm/')) {
|
||||
return 'xterm'
|
||||
}
|
||||
if (id.includes('@mui/icons-material')) {
|
||||
return 'mui-icons'
|
||||
}
|
||||
if (id.includes('@mui/material') || id.includes('@emotion/')) {
|
||||
return 'mui'
|
||||
}
|
||||
if (id.includes('react-markdown') || id.includes('remark-gfm') || id.includes('rehype-slug') || id.includes('prismjs')) {
|
||||
return 'content'
|
||||
}
|
||||
if (id.includes('react-router-dom') || id.includes('react-dom') || id.includes('/node_modules/react/')) {
|
||||
return 'react-vendor'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'^/api(?:/|$)': 'http://localhost:1080'
|
||||
'^/api(?:/|$)': {
|
||||
target: 'http://localhost:1080',
|
||||
ws: true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user