Compare commits

...

6 Commits

26 changed files with 7294 additions and 86 deletions
+42 -9
View File
@@ -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
+1
View File
@@ -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
}
+57
View File
@@ -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
+100
View File
@@ -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
}
+128
View File
@@ -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);
+24
View File
@@ -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",
+6 -3
View File
@@ -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",
+227
View File
@@ -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 }) =>
+6 -1
View File
@@ -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
View File
@@ -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 /> },
+35
View File
@@ -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>
)
}
+48
View File
@@ -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>
)
}
+490
View File
@@ -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>
)
}
+3 -3
View File
@@ -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>
)}
+14 -51
View File
@@ -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>
)
) : (
+2 -2
View File
@@ -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>
+925
View File
@@ -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>
)
}
+927
View File
@@ -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
View File
@@ -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
}
}
}
})