Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 52b9199a15 | |||
| d2959a84b0 | |||
| 37595757c8 | |||
| 463f421846 |
@@ -7,6 +7,7 @@ require (
|
||||
github.com/go-git/go-git/v5 v5.12.0
|
||||
github.com/go-ldap/ldap/v3 v3.4.6
|
||||
github.com/goccy/go-yaml v1.19.0
|
||||
github.com/pkg/sftp v1.13.10
|
||||
github.com/rivo/tview v0.42.0
|
||||
github.com/sosedoff/gitkit v0.4.0
|
||||
golang.org/x/crypto v0.47.0
|
||||
@@ -36,6 +37,7 @@ require (
|
||||
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
|
||||
github.com/kevinburke/ssh_config v1.2.0 // indirect
|
||||
github.com/klauspost/compress v1.18.3 // indirect
|
||||
github.com/kr/fs v0.1.0 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||
|
||||
@@ -67,6 +67,8 @@ github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4
|
||||
github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM=
|
||||
github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw=
|
||||
github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||
github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8=
|
||||
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
@@ -86,6 +88,8 @@ github.com/pjbgf/sha1cd v0.3.0 h1:4D5XXmUUBUl/xQ6IjCkEAbqXskkq/4O7LmGn0AqMDs4=
|
||||
github.com/pjbgf/sha1cd v0.3.0/go.mod h1:nZ1rrWOcGJ5uZgEEVL1VUM9iRQiZvWdbZjkKyFzPPsI=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pkg/sftp v1.13.10 h1:+5FbKNTe5Z9aspU88DPIKJ9z2KZoaGCu6Sr6kKR/5mU=
|
||||
github.com/pkg/sftp v1.13.10/go.mod h1:bJ1a7uDhrX/4OII+agvy28lzRvQrmIQuaHrcI1HbeGA=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
|
||||
@@ -0,0 +1,417 @@
|
||||
package handlers
|
||||
|
||||
import codit_logger "codit/logger"
|
||||
|
||||
import "archive/zip"
|
||||
import "database/sql"
|
||||
import "errors"
|
||||
import "fmt"
|
||||
import "io"
|
||||
import "mime"
|
||||
import "mime/multipart"
|
||||
import "net"
|
||||
import "net/http"
|
||||
import "path"
|
||||
import "strings"
|
||||
import "os"
|
||||
import "time"
|
||||
|
||||
import "github.com/pkg/sftp"
|
||||
|
||||
import "golang.org/x/crypto/ssh"
|
||||
|
||||
import "codit/internal/db"
|
||||
import "codit/internal/middleware"
|
||||
import "codit/internal/models"
|
||||
|
||||
// TODO: make these configurable??
|
||||
const sshFileTransferMaxUploadBytes int64 = 5 * 1024 * 1024 * 1024
|
||||
const sshFileTransferMaxDownloadBytes int64 = 10 * 1024 * 1024 * 1024
|
||||
const sshFileTransferMaxFiles int = 256
|
||||
|
||||
type sshSessionFileDownloadRequest struct {
|
||||
Paths []string `json:"paths"`
|
||||
Password string `json:"password"`
|
||||
OTPCode string `json:"otp_code"`
|
||||
}
|
||||
|
||||
type sshSessionFileUploadItem struct {
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
Size int64 `json:"size"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type sshSessionFileUploadResponse struct {
|
||||
Items []sshSessionFileUploadItem `json:"items"`
|
||||
Uploaded int `json:"uploaded"`
|
||||
Failed int `json:"failed"`
|
||||
}
|
||||
|
||||
type sshSessionFileDownloadItem struct {
|
||||
RemotePath string
|
||||
Name string
|
||||
Size int64
|
||||
}
|
||||
|
||||
func (api *API) UploadSSHSessionFilesForSelf(w http.ResponseWriter, r *http.Request, params map[string]string) {
|
||||
var user models.User
|
||||
var ok bool
|
||||
var store *db.Store
|
||||
var sessionItem models.SSHSession
|
||||
var sshClient *ssh.Client
|
||||
var sftpClient *sftp.Client
|
||||
var targetDir string
|
||||
var overwrite bool
|
||||
var response sshSessionFileUploadResponse
|
||||
var files []*multipart.FileHeader
|
||||
var file multipart.File
|
||||
var item sshSessionFileUploadItem
|
||||
var remotePath string
|
||||
var err error
|
||||
var i int
|
||||
|
||||
user, ok = middleware.UserFromContext(r.Context())
|
||||
if !ok || user.Disabled {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
r.Body = http.MaxBytesReader(w, r.Body, sshFileTransferMaxUploadBytes)
|
||||
err = r.ParseMultipartForm(16 * 1024 * 1024)
|
||||
if err != nil {
|
||||
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid multipart upload"})
|
||||
return
|
||||
}
|
||||
targetDir = strings.TrimSpace(r.FormValue("target_dir"))
|
||||
if targetDir == "" {
|
||||
targetDir = "."
|
||||
}
|
||||
overwrite = parseSSHFileTransferBool(r.FormValue("overwrite"))
|
||||
if r.MultipartForm != nil {
|
||||
files = r.MultipartForm.File["files"]
|
||||
}
|
||||
if len(files) == 0 {
|
||||
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "at least one file is required"})
|
||||
return
|
||||
}
|
||||
if len(files) > sshFileTransferMaxFiles {
|
||||
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": fmt.Sprintf("too many files; max is %d", sshFileTransferMaxFiles)})
|
||||
return
|
||||
}
|
||||
store = api.store(r)
|
||||
sessionItem, err = api.getSSHSessionFileTransferItem(store, user, params["id"])
|
||||
if err != nil {
|
||||
api.writeSSHSessionFileTransferError(w, err)
|
||||
return
|
||||
}
|
||||
sshClient, err = api.openSSHSessionFileTransferClient(store, user, sessionItem, r.FormValue("password"), r.FormValue("otp_code"))
|
||||
if err != nil {
|
||||
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
defer sshClient.Close()
|
||||
sftpClient, err = sftp.NewClient(sshClient)
|
||||
if err != nil {
|
||||
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "sftp subsystem unavailable: " + err.Error()})
|
||||
return
|
||||
}
|
||||
defer sftpClient.Close()
|
||||
for i = 0; i < len(files); i++ {
|
||||
item = sshSessionFileUploadItem{Name: path.Base(files[i].Filename), Size: files[i].Size}
|
||||
if item.Name == "." || item.Name == "/" || strings.Contains(item.Name, "\x00") {
|
||||
item.Error = "invalid file name"
|
||||
response.Failed += 1
|
||||
response.Items = append(response.Items, item)
|
||||
continue
|
||||
}
|
||||
remotePath = path.Join(targetDir, item.Name)
|
||||
item.Path = remotePath
|
||||
file, err = files[i].Open()
|
||||
if err == nil {
|
||||
err = api.sftpUploadFile(sftpClient, targetDir, item.Name, file, overwrite)
|
||||
_ = file.Close()
|
||||
}
|
||||
if err != nil {
|
||||
item.Error = err.Error()
|
||||
response.Failed += 1
|
||||
} else {
|
||||
response.Uploaded += 1
|
||||
}
|
||||
response.Items = append(response.Items, item)
|
||||
}
|
||||
WriteJSON(w, http.StatusOK, response)
|
||||
}
|
||||
|
||||
func (api *API) DownloadSSHSessionFilesForSelf(w http.ResponseWriter, r *http.Request, params map[string]string) {
|
||||
var user models.User
|
||||
var ok bool
|
||||
var store *db.Store
|
||||
var req sshSessionFileDownloadRequest
|
||||
var sessionItem models.SSHSession
|
||||
var sshClient *ssh.Client
|
||||
var sftpClient *sftp.Client
|
||||
var items []sshSessionFileDownloadItem
|
||||
var item sshSessionFileDownloadItem
|
||||
var remotePath string
|
||||
var totalSize int64
|
||||
var contentDisposition string
|
||||
var err error
|
||||
var i int
|
||||
|
||||
user, ok = middleware.UserFromContext(r.Context())
|
||||
if !ok || user.Disabled {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
err = DecodeJSON(r, &req)
|
||||
if err != nil {
|
||||
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
||||
return
|
||||
}
|
||||
if len(req.Paths) == 0 {
|
||||
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "at least one path is required"})
|
||||
return
|
||||
}
|
||||
if len(req.Paths) > sshFileTransferMaxFiles {
|
||||
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": fmt.Sprintf("too many files; max is %d", sshFileTransferMaxFiles)})
|
||||
return
|
||||
}
|
||||
store = api.store(r)
|
||||
sessionItem, err = api.getSSHSessionFileTransferItem(store, user, params["id"])
|
||||
if err != nil {
|
||||
api.writeSSHSessionFileTransferError(w, err)
|
||||
return
|
||||
}
|
||||
sshClient, err = api.openSSHSessionFileTransferClient(store, user, sessionItem, req.Password, req.OTPCode)
|
||||
if err != nil {
|
||||
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
defer sshClient.Close()
|
||||
sftpClient, err = sftp.NewClient(sshClient)
|
||||
if err != nil {
|
||||
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "sftp subsystem unavailable: " + err.Error()})
|
||||
return
|
||||
}
|
||||
defer sftpClient.Close()
|
||||
for i = 0; i < len(req.Paths); i++ {
|
||||
remotePath = strings.TrimSpace(req.Paths[i])
|
||||
if remotePath == "" || strings.Contains(remotePath, "\x00") {
|
||||
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid download path"})
|
||||
return
|
||||
}
|
||||
item, err = api.sftpDownloadMetadata(sftpClient, remotePath)
|
||||
if err != nil {
|
||||
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": fmt.Sprintf("%s: %s", remotePath, err.Error())})
|
||||
return
|
||||
}
|
||||
totalSize += item.Size
|
||||
if totalSize > sshFileTransferMaxDownloadBytes {
|
||||
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "download size exceeds limit"})
|
||||
return
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
if len(items) == 1 {
|
||||
contentDisposition = mime.FormatMediaType("attachment", map[string]string{"filename": items[0].Name})
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
w.Header().Set("Content-Disposition", contentDisposition)
|
||||
err = api.sftpDownloadFile(sftpClient, items[0].RemotePath, w)
|
||||
if err != nil {
|
||||
api.Logger.Write(logIDSSHBroker, codit_logger.LOG_WARN, "ssh file download failed session=%s path=%q err=%s", sessionItem.ID, items[0].RemotePath, err.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
contentDisposition = mime.FormatMediaType("attachment", map[string]string{"filename": fmt.Sprintf("ssh-session-%s-files.zip", sessionItem.ID)})
|
||||
w.Header().Set("Content-Type", "application/zip")
|
||||
w.Header().Set("Content-Disposition", contentDisposition)
|
||||
err = api.writeSSHSessionFileZip(sftpClient, items, w)
|
||||
if err != nil {
|
||||
api.Logger.Write(logIDSSHBroker, codit_logger.LOG_WARN, "ssh file zip download failed session=%s err=%s", sessionItem.ID, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func (api *API) getSSHSessionFileTransferItem(store *db.Store, user models.User, sessionID string) (models.SSHSession, error) {
|
||||
var item models.SSHSession
|
||||
var err error
|
||||
|
||||
item, err = store.GetSSHSession(sessionID)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return item, errors.New("ssh session not found")
|
||||
}
|
||||
return item, err
|
||||
}
|
||||
if item.UserID != user.ID {
|
||||
return item, errors.New("ssh session not found")
|
||||
}
|
||||
if item.Status != "connected" {
|
||||
return item, errors.New("ssh session is not connected")
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func (api *API) writeSSHSessionFileTransferError(w http.ResponseWriter, err error) {
|
||||
if err.Error() == "ssh session not found" {
|
||||
WriteJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
func (api *API) openSSHSessionFileTransferClient(store *db.Store, user models.User, sessionItem models.SSHSession, promptedPassword string, otpCode string) (*ssh.Client, error) {
|
||||
var prepared sshSessionPrepared
|
||||
var prepareStage string
|
||||
var sshConfig *ssh.ClientConfig
|
||||
var sshClient *ssh.Client
|
||||
var connectedFingerprint string
|
||||
var connectedHostKey ssh.PublicKey
|
||||
var err error
|
||||
|
||||
prepared, prepareStage, err = api.prepareSSHSession(store, user, sessionItem, promptedPassword, otpCode)
|
||||
if err != nil {
|
||||
if prepareStage == "load_profile" {
|
||||
return nil, errors.New("ssh access profile not found")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
sshConfig = &ssh.ClientConfig{
|
||||
User: prepared.Profile.RemoteUsername,
|
||||
Auth: prepared.AuthMethods,
|
||||
HostKeyCallback: sshHostKeyCallback(prepared.HostKeys, &connectedFingerprint, &connectedHostKey, prepared.PinHostKeyOnFirstUse),
|
||||
Timeout: 15 * time.Second,
|
||||
}
|
||||
sshClient, err = ssh.Dial("tcp", net.JoinHostPort(prepared.Profile.Server.Host, fmt.Sprintf("%d", prepared.Profile.Server.Port)), sshConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if prepared.PinHostKeyOnFirstUse {
|
||||
err = api.pinSSHSessionFileTransferHostKey(store, sessionItem, prepared.Profile, connectedFingerprint, connectedHostKey)
|
||||
if err != nil {
|
||||
_ = sshClient.Close()
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return sshClient, nil
|
||||
}
|
||||
|
||||
func (api *API) pinSSHSessionFileTransferHostKey(store *db.Store, sessionItem models.SSHSession, profile models.SSHAccessProfile, fingerprint string, hostKey ssh.PublicKey) error {
|
||||
var item models.SSHServerHostKey
|
||||
var reloaded []models.SSHServerHostKey
|
||||
var err error
|
||||
var i int
|
||||
|
||||
if hostKey == nil {
|
||||
return errors.New("failed to capture ssh host key")
|
||||
}
|
||||
item = models.SSHServerHostKey{
|
||||
ServerID: sessionItem.ServerID,
|
||||
Algorithm: hostKey.Type(),
|
||||
PublicKey: strings.TrimSpace(string(ssh.MarshalAuthorizedKey(hostKey))),
|
||||
Fingerprint: fingerprint,
|
||||
}
|
||||
item, err = store.CreateSSHServerHostKey(item)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
reloaded, err = store.ListSSHServerHostKeys(sessionItem.ServerID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for i = 0; i < len(reloaded); i++ {
|
||||
if strings.TrimSpace(reloaded[i].Fingerprint) == fingerprint {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("failed to pin host key for %s: %s", profile.Server.Name, err.Error())
|
||||
}
|
||||
|
||||
func (api *API) sftpUploadFile(client *sftp.Client, targetDir string, name string, reader io.Reader, overwrite bool) error {
|
||||
var remotePath string
|
||||
var flags int
|
||||
var remoteFile *sftp.File
|
||||
var err error
|
||||
|
||||
err = client.MkdirAll(targetDir)
|
||||
if err != nil { return err }
|
||||
remotePath = path.Join(targetDir, name)
|
||||
flags = os.O_WRONLY | os.O_CREATE
|
||||
if overwrite {
|
||||
flags = flags | os.O_TRUNC
|
||||
} else {
|
||||
flags = flags | os.O_EXCL
|
||||
}
|
||||
remoteFile, err = client.OpenFile(remotePath, flags)
|
||||
if err != nil { return err }
|
||||
defer remoteFile.Close()
|
||||
_, err = io.Copy(remoteFile, reader)
|
||||
return err
|
||||
}
|
||||
|
||||
func (api *API) sftpDownloadMetadata(client *sftp.Client, remotePath string) (sshSessionFileDownloadItem, error) {
|
||||
var info os.FileInfo
|
||||
var item sshSessionFileDownloadItem
|
||||
var err error
|
||||
|
||||
info, err = client.Stat(remotePath)
|
||||
if err != nil { return item, err }
|
||||
if info.IsDir() {
|
||||
return item, errors.New("directory downloads are not supported")
|
||||
}
|
||||
item = sshSessionFileDownloadItem{
|
||||
RemotePath: remotePath,
|
||||
Name: path.Base(remotePath),
|
||||
Size: info.Size(),
|
||||
}
|
||||
if item.Name == "." || item.Name == "/" || strings.TrimSpace(item.Name) == "" {
|
||||
item.Name = info.Name()
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func (api *API) sftpDownloadFile(client *sftp.Client, remotePath string, writer io.Writer) error {
|
||||
var remoteFile *sftp.File
|
||||
var err error
|
||||
|
||||
remoteFile, err = client.Open(remotePath)
|
||||
if err != nil { return err }
|
||||
defer remoteFile.Close()
|
||||
_, err = io.Copy(writer, remoteFile)
|
||||
return err
|
||||
}
|
||||
|
||||
func (api *API) writeSSHSessionFileZip(client *sftp.Client, items []sshSessionFileDownloadItem, writer io.Writer) error {
|
||||
var zipWriter *zip.Writer
|
||||
var zipFile io.Writer
|
||||
var closeErr error
|
||||
var err error
|
||||
var i int
|
||||
|
||||
zipWriter = zip.NewWriter(writer)
|
||||
for i = 0; i < len(items); i++ {
|
||||
zipFile, err = zipWriter.Create(sshDownloadEntryName(items[i], i))
|
||||
if err != nil { break }
|
||||
err = api.sftpDownloadFile(client, items[i].RemotePath, zipFile)
|
||||
if err != nil { break }
|
||||
}
|
||||
closeErr = zipWriter.Close()
|
||||
if err == nil && closeErr != nil {
|
||||
err = closeErr
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func sshDownloadEntryName(item sshSessionFileDownloadItem, index int) string {
|
||||
var name string
|
||||
|
||||
name = strings.TrimSpace(item.Name)
|
||||
if name == "" || name == "." || name == "/" {
|
||||
name = fmt.Sprintf("file-%d", index + 1)
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
func parseSSHFileTransferBool(value string) bool {
|
||||
value = strings.ToLower(strings.TrimSpace(value))
|
||||
return value == "1" || value == "true" || value == "yes" || value == "on"
|
||||
}
|
||||
@@ -791,6 +791,8 @@ func (app *Server) initHandlers() (*handlers.API, *http.ServeMux, error) {
|
||||
router.Handle("GET", "/api/ssh/sessions/:id", api.GetSSHSessionForSelf)
|
||||
router.Handle("GET", "/api/ssh/sessions/:id/transcript", api.GetSSHSessionTranscriptForSelf)
|
||||
router.Handle("POST", "/api/ssh/sessions/:id/disconnect", api.DisconnectSSHSessionForSelf)
|
||||
router.Handle("POST", "/api/ssh/sessions/:id/files/upload", api.UploadSSHSessionFilesForSelf)
|
||||
router.Handle("POST", "/api/ssh/sessions/:id/files/download", api.DownloadSSHSessionFilesForSelf)
|
||||
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)
|
||||
|
||||
@@ -1147,6 +1147,31 @@ export interface SSHSessionConnectResponse {
|
||||
host_key_fingerprint: string
|
||||
}
|
||||
|
||||
export interface SSHSessionFileUploadItem {
|
||||
name: string
|
||||
path: string
|
||||
size: number
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface SSHSessionFileUploadResponse {
|
||||
items: SSHSessionFileUploadItem[]
|
||||
uploaded: number
|
||||
failed: number
|
||||
}
|
||||
|
||||
export interface SSHSessionFileDownloadPayload {
|
||||
paths: string[]
|
||||
password?: string
|
||||
otp_code?: string
|
||||
}
|
||||
|
||||
export interface BinaryDownload {
|
||||
data: ArrayBuffer
|
||||
filename: string
|
||||
contentType: string
|
||||
}
|
||||
|
||||
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
const res = await fetch(path, {
|
||||
credentials: 'include',
|
||||
@@ -1189,6 +1214,33 @@ async function requestBinary(path: string, options: RequestInit = {}): Promise<A
|
||||
return res.arrayBuffer()
|
||||
}
|
||||
|
||||
function filenameFromContentDisposition(value: string | null, fallback: string): string {
|
||||
const match: RegExpMatchArray | null = (value || '').match(/filename="?([^";]+)"?/i)
|
||||
|
||||
if (!match) return fallback
|
||||
return decodeURIComponent(match[1])
|
||||
}
|
||||
|
||||
async function requestBinaryDownload(path: string, fallbackFilename: string, options: RequestInit = {}): Promise<BinaryDownload> {
|
||||
const res: Response = await fetch(path, {
|
||||
credentials: 'include',
|
||||
...options
|
||||
})
|
||||
let error: { error?: string }
|
||||
let data: ArrayBuffer
|
||||
let filename: string
|
||||
let contentType: string
|
||||
|
||||
if (!res.ok) {
|
||||
error = await res.json().catch(() => ({ error: res.statusText }))
|
||||
throw new Error(error.error || 'Request failed')
|
||||
}
|
||||
data = await res.arrayBuffer()
|
||||
filename = filenameFromContentDisposition(res.headers.get('Content-Disposition'), fallbackFilename)
|
||||
contentType = res.headers.get('Content-Type') || 'application/octet-stream'
|
||||
return { data, filename, contentType }
|
||||
}
|
||||
|
||||
async function requestText(path: string, options: RequestInit = {}): Promise<string> {
|
||||
const res = await fetch(path, {
|
||||
credentials: 'include',
|
||||
@@ -1487,6 +1539,10 @@ export const api = {
|
||||
getSSHSessionTranscriptForSelf: (id: string, options?: RequestInit) => requestText(`/api/ssh/sessions/${id}/transcript`, options),
|
||||
getSSHSessionTranscriptAdmin: (id: string, options?: RequestInit) => requestText(`/api/admin/ssh/sessions/${id}/transcript`, options),
|
||||
disconnectSSHSessionForSelf: (id: string) => request<void>(`/api/ssh/sessions/${id}/disconnect`, { method: 'POST' }),
|
||||
uploadSSHSessionFilesForSelf: (id: string, form: FormData) =>
|
||||
requestForm<SSHSessionFileUploadResponse>(`/api/ssh/sessions/${id}/files/upload`, { method: 'POST', body: form }),
|
||||
downloadSSHSessionFilesForSelf: (id: string, payload: SSHSessionFileDownloadPayload) =>
|
||||
requestBinaryDownload(`/api/ssh/sessions/${id}/files/download`, `ssh-session-${id}-files`, { method: 'POST', body: JSON.stringify(payload), headers: { 'Content-Type': 'application/json' } }),
|
||||
inspectSSHCertificate: (certificate: string) =>
|
||||
request<{ dump: string }>('/api/ssh/cert/inspect', { method: 'POST', body: JSON.stringify({ certificate }) }),
|
||||
signSSHUserKeyForSelf: (id: string, payload: SSHUserCASelfSignPayload) =>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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'
|
||||
@@ -89,16 +90,16 @@ export default function SSHServerGroupMembersDialog(props: SSHServerGroupMembers
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{ display: 'grid', gap: 1 }}>
|
||||
<Box sx={{ display: 'grid' }}>
|
||||
<Typography variant="subtitle2">Current Members ({props.members.length})</Typography>
|
||||
{props.loading ? <Typography variant="body2" color="text.secondary">Loading...</Typography> : null}
|
||||
{!props.loading && props.members.length === 0 ? <Typography variant="body2" color="text.secondary">No member servers.</Typography> : null}
|
||||
<List dense sx={{ maxHeight: 320, overflow: 'auto' }}>
|
||||
<List sx={{ maxHeight: 320, overflow: 'auto' }}>
|
||||
{props.members.map((item) => (
|
||||
<ListItem key={item.id} divider sx={{ alignItems: 'flex-start' }}>
|
||||
<ListItemText
|
||||
primary={`${item.name}${item.enabled ? '' : ' (disabled)'}`}
|
||||
secondary={`${item.host}:${item.port} · ${item.id}`}
|
||||
primary={<Typography>{item.name} {item.enabled ? '': <Chip size="small" label="Disabled" color="error" />}</Typography>}
|
||||
secondary={<Typography variant="caption" color="text.secondary">{item.host}:{item.port} · {item.id}</Typography>}
|
||||
/>
|
||||
<ListRowActions>
|
||||
<ListRowActionButton color="error" disabled={props.busy} onClick={() => props.onRemove(item.id)}>
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import Alert from '@mui/material/Alert'
|
||||
import Box from '@mui/material/Box'
|
||||
import Button from '@mui/material/Button'
|
||||
import Dialog from '@mui/material/Dialog'
|
||||
import DialogActions from '@mui/material/DialogActions'
|
||||
import DialogContent from '@mui/material/DialogContent'
|
||||
import DialogTitle from '@mui/material/DialogTitle'
|
||||
import LinearProgress from '@mui/material/LinearProgress'
|
||||
import TextField from '@mui/material/TextField'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import { ChangeEvent as ReactChangeEvent, useEffect, useState } from 'react'
|
||||
import { api, BinaryDownload } from '../api'
|
||||
|
||||
type SSHSessionFileDownloadDialogProps = {
|
||||
open: boolean
|
||||
sessionID: string
|
||||
passwordRequired: boolean
|
||||
otpRequired: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export default function SSHSessionFileDownloadDialog(props: SSHSessionFileDownloadDialogProps) {
|
||||
const [pathsText, setPathsText] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [otpCode, setOTPCode] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [progressText, setProgressText] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!props.open) return
|
||||
setPathsText('')
|
||||
setPassword('')
|
||||
setOTPCode('')
|
||||
setProgressText('')
|
||||
setError(null)
|
||||
}, [props.open])
|
||||
|
||||
const close = () => {
|
||||
if (busy) return
|
||||
props.onClose()
|
||||
}
|
||||
|
||||
const download = async () => {
|
||||
const paths: string[] = pathsText
|
||||
.split('\n')
|
||||
.map((item: string) => item.trim())
|
||||
.filter((item: string) => item !== '')
|
||||
let downloadResult: BinaryDownload
|
||||
let blob: Blob
|
||||
let url: string
|
||||
let link: HTMLAnchorElement
|
||||
|
||||
if (paths.length === 0) {
|
||||
setError('Enter at least one remote file path')
|
||||
return
|
||||
}
|
||||
setBusy(true)
|
||||
setProgressText(`Preparing ${paths.length} remote file(s)...`)
|
||||
setError(null)
|
||||
try {
|
||||
downloadResult = await api.downloadSSHSessionFilesForSelf(props.sessionID, {
|
||||
paths,
|
||||
password: props.passwordRequired ? password : undefined,
|
||||
otp_code: props.otpRequired ? otpCode : undefined
|
||||
})
|
||||
blob = new Blob([downloadResult.data], { type: downloadResult.contentType })
|
||||
url = window.URL.createObjectURL(blob)
|
||||
link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = downloadResult.filename
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
window.URL.revokeObjectURL(url)
|
||||
setProgressText('Download started.')
|
||||
props.onClose()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to download files')
|
||||
setProgressText('')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={props.open} onClose={close} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>Download Files</DialogTitle>
|
||||
<DialogContent sx={{ display: 'grid', gap: 2, pt: 1 }}>
|
||||
{error ? <Alert severity="error">{error}</Alert> : null}
|
||||
<TextField
|
||||
label="Remote file paths"
|
||||
value={pathsText}
|
||||
onChange={(event: ReactChangeEvent<HTMLInputElement>) => setPathsText(event.target.value)}
|
||||
helperText="Enter one remote file path per line. Multiple files are downloaded as a zip."
|
||||
multiline
|
||||
minRows={5}
|
||||
fullWidth
|
||||
sx={{ mt: 2 }}
|
||||
/>
|
||||
{props.passwordRequired ? (
|
||||
<TextField
|
||||
label="Password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(event: ReactChangeEvent<HTMLInputElement>) => setPassword(event.target.value)}
|
||||
fullWidth
|
||||
/>
|
||||
) : null}
|
||||
{props.otpRequired ? (
|
||||
<TextField
|
||||
label="OTP code"
|
||||
value={otpCode}
|
||||
onChange={(event: ReactChangeEvent<HTMLInputElement>) => setOTPCode(event.target.value)}
|
||||
fullWidth
|
||||
/>
|
||||
) : null}
|
||||
{busy ? (
|
||||
<Box sx={{ display: 'grid', gap: 0.75 }}>
|
||||
<LinearProgress />
|
||||
<Typography variant="caption" color="text.secondary">{progressText || 'Preparing download...'}</Typography>
|
||||
</Box>
|
||||
) : progressText ? (
|
||||
<Typography variant="caption" color="text.secondary">{progressText}</Typography>
|
||||
) : null}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => void download()} disabled={busy} variant="contained">Download</Button>
|
||||
<Button onClick={close} disabled={busy}>Close</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
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 DialogContent from '@mui/material/DialogContent'
|
||||
import DialogTitle from '@mui/material/DialogTitle'
|
||||
import FormControlLabel from '@mui/material/FormControlLabel'
|
||||
import LinearProgress from '@mui/material/LinearProgress'
|
||||
import TextField from '@mui/material/TextField'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import { ChangeEvent as ReactChangeEvent, useEffect, useState } from 'react'
|
||||
import { api, SSHSessionFileUploadItem, SSHSessionFileUploadResponse } from '../api'
|
||||
|
||||
type SSHSessionFileUploadDialogProps = {
|
||||
open: boolean
|
||||
sessionID: string
|
||||
passwordRequired: boolean
|
||||
otpRequired: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
function formatFileSize(size: number): string {
|
||||
if (size >= 1024 * 1024) {
|
||||
return `${(size / (1024 * 1024)).toFixed(1)} MiB`
|
||||
}
|
||||
if (size >= 1024) {
|
||||
return `${(size / 1024).toFixed(1)} KiB`
|
||||
}
|
||||
return `${size} B`
|
||||
}
|
||||
|
||||
export default function SSHSessionFileUploadDialog(props: SSHSessionFileUploadDialogProps) {
|
||||
const [targetDir, setTargetDir] = useState('.')
|
||||
const [overwrite, setOverwrite] = useState(true)
|
||||
const [files, setFiles] = useState<File[]>([])
|
||||
const [password, setPassword] = useState('')
|
||||
const [otpCode, setOTPCode] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [progressText, setProgressText] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [result, setResult] = useState<SSHSessionFileUploadResponse | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!props.open) return
|
||||
setTargetDir('.')
|
||||
setOverwrite(true)
|
||||
setFiles([])
|
||||
setPassword('')
|
||||
setOTPCode('')
|
||||
setProgressText('')
|
||||
setError(null)
|
||||
setResult(null)
|
||||
}, [props.open])
|
||||
|
||||
const close = () => {
|
||||
if (busy) return
|
||||
props.onClose()
|
||||
}
|
||||
|
||||
const handleFileChange = (event: ReactChangeEvent<HTMLInputElement>) => {
|
||||
const nextFiles: File[] = Array.from(event.target.files || [])
|
||||
|
||||
setFiles(nextFiles)
|
||||
setProgressText('')
|
||||
setResult(null)
|
||||
}
|
||||
|
||||
const upload = async () => {
|
||||
const form: FormData = new FormData()
|
||||
let response: SSHSessionFileUploadResponse
|
||||
let i: number
|
||||
|
||||
if (files.length === 0) {
|
||||
setError('Select at least one file')
|
||||
return
|
||||
}
|
||||
form.append('target_dir', targetDir.trim() || '.')
|
||||
form.append('overwrite', overwrite ? 'true' : 'false')
|
||||
if (props.passwordRequired) form.append('password', password)
|
||||
if (props.otpRequired) form.append('otp_code', otpCode)
|
||||
for (i = 0; i < files.length; i += 1) {
|
||||
form.append('files', files[i])
|
||||
}
|
||||
setBusy(true)
|
||||
setProgressText(`Uploading ${files.length} file(s) to ${targetDir.trim() || '.'}...`)
|
||||
setError(null)
|
||||
try {
|
||||
response = await api.uploadSSHSessionFilesForSelf(props.sessionID, form)
|
||||
setResult(response)
|
||||
setProgressText(response.failed > 0 ? 'Upload completed with errors.' : 'Upload completed.')
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to upload files')
|
||||
setProgressText('')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={props.open} onClose={close} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>Upload Files</DialogTitle>
|
||||
<DialogContent sx={{ display: 'grid', gap: 2, pt: 1 }}>
|
||||
{error ? <Alert severity="error">{error}</Alert> : null}
|
||||
<TextField
|
||||
label="Target directory"
|
||||
value={targetDir}
|
||||
onChange={(event: ReactChangeEvent<HTMLInputElement>) => setTargetDir(event.target.value)}
|
||||
helperText="Files are copied into this directory on the remote SSH server."
|
||||
fullWidth
|
||||
sx={{ mt: 2 }}
|
||||
/>
|
||||
<Button variant="outlined" component="label">
|
||||
Select Files
|
||||
<input type="file" multiple hidden onChange={handleFileChange} />
|
||||
</Button>
|
||||
<Box sx={{ display: 'grid', gap: 0.5 }}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{files.length > 0 ? `${files.length} file(s) selected` : 'No files selected'}
|
||||
</Typography>
|
||||
{files.length > 0 ? (
|
||||
<Box sx={{ display: 'grid', maxHeight: 140, overflow: 'auto', borderRadius: 1, bgcolor: 'action.hover', p: 1 }}>
|
||||
{files.map((file: File, index: number) => (
|
||||
<Typography key={`${file.name}-${file.size}-${index}`} variant="body2">
|
||||
{file.name} · {formatFileSize(file.size)}
|
||||
</Typography>
|
||||
))}
|
||||
</Box>
|
||||
) : null}
|
||||
</Box>
|
||||
<FormControlLabel
|
||||
control={<Checkbox checked={overwrite} onChange={(event: ReactChangeEvent<HTMLInputElement>) => setOverwrite(event.target.checked)} />}
|
||||
label="Overwrite existing remote files"
|
||||
/>
|
||||
{props.passwordRequired ? (
|
||||
<TextField
|
||||
label="Password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(event: ReactChangeEvent<HTMLInputElement>) => setPassword(event.target.value)}
|
||||
fullWidth
|
||||
/>
|
||||
) : null}
|
||||
{props.otpRequired ? (
|
||||
<TextField
|
||||
label="OTP code"
|
||||
value={otpCode}
|
||||
onChange={(event: ReactChangeEvent<HTMLInputElement>) => setOTPCode(event.target.value)}
|
||||
fullWidth
|
||||
/>
|
||||
) : null}
|
||||
{result ? (
|
||||
<Alert severity={result.failed > 0 ? 'warning' : 'success'}>
|
||||
Uploaded {result.uploaded}; failed {result.failed}.
|
||||
</Alert>
|
||||
) : null}
|
||||
{result && result.items.length > 0 ? (
|
||||
<Box sx={{ display: 'grid', maxHeight: 140, overflow: 'auto' }}>
|
||||
{result.items.map((item: SSHSessionFileUploadItem, index: number) => (
|
||||
<Typography key={`${item.path}-${index}`} variant="body2" color={item.error ? 'error' : 'text.secondary'}>
|
||||
{item.error ? 'Failed' : 'Uploaded'}: {item.path}{item.error ? ` - ${item.error}` : ''}
|
||||
</Typography>
|
||||
))}
|
||||
</Box>
|
||||
) : null}
|
||||
{busy ? (
|
||||
<Box sx={{ display: 'grid', gap: 0.75 }}>
|
||||
<LinearProgress />
|
||||
<Typography variant="caption" color="text.secondary">{progressText || 'Uploading files...'}</Typography>
|
||||
</Box>
|
||||
) : progressText ? (
|
||||
<Typography variant="caption" color="text.secondary">{progressText}</Typography>
|
||||
) : null}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => void upload()} disabled={busy || files.length === 0} variant="contained">Upload</Button>
|
||||
<Button onClick={close} disabled={busy}>Close</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -5,7 +5,6 @@ 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 Drawer from '@mui/material/Drawer'
|
||||
import IconButton from '@mui/material/IconButton'
|
||||
import Menu from '@mui/material/Menu'
|
||||
@@ -22,6 +21,8 @@ import { Unicode11Addon } from '@xterm/addon-unicode11'
|
||||
import { Terminal } from '@xterm/xterm'
|
||||
import SSHCredentialsPromptDialog from '../components/SSHCredentialsPromptDialog'
|
||||
import SSHSessionCreateDialog from '../components/SSHSessionCreateDialog'
|
||||
import SSHSessionFileDownloadDialog from '../components/SSHSessionFileDownloadDialog'
|
||||
import SSHSessionFileUploadDialog from '../components/SSHSessionFileUploadDialog'
|
||||
import SSHTranscriptDialog from '../components/SSHTranscriptDialog'
|
||||
import TintedPanel from '../components/TintedPanel'
|
||||
import { api, SSHAccessProfile, SSHServer, SSHSession, SSHSessionConnectResponse, SSHSessionListResponse } from '../api'
|
||||
@@ -156,6 +157,8 @@ function SessionTerminalPanel(props: SessionPanelProps) {
|
||||
const [passwordPromptError, setPasswordPromptError] = useState<string | null>(null)
|
||||
const [passwordPromptMode, setPasswordPromptMode] = useState<'reconnect' | 'duplicate' | null>(null)
|
||||
const [actionMenuAnchor, setActionMenuAnchor] = useState<HTMLElement | null>(null)
|
||||
const [uploadDialogOpen, setUploadDialogOpen] = useState(false)
|
||||
const [downloadDialogOpen, setDownloadDialogOpen] = useState(false)
|
||||
const terminalHostRef = useRef<HTMLDivElement | null>(null)
|
||||
const terminalRef = useRef<Terminal | null>(null)
|
||||
const fitAddonRef = useRef<FitAddon | null>(null)
|
||||
@@ -168,6 +171,7 @@ function SessionTerminalPanel(props: SessionPanelProps) {
|
||||
const canDuplicate = Boolean(session?.profile_id) && !actionPending
|
||||
const canSessionAction = Boolean(session) && !actionPending
|
||||
const canDownloadBuffer = Boolean(terminalRef.current)
|
||||
const canTransferFiles = status === 'connected' && Boolean(session) && !actionPending
|
||||
const needsPasswordPrompt = Boolean(session && requiresPromptedPassword(session))
|
||||
const needsOTPPrompt = Boolean(session && requiresPromptedTOTP(session))
|
||||
|
||||
@@ -525,6 +529,24 @@ function SessionTerminalPanel(props: SessionPanelProps) {
|
||||
window.URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
const openUploadDialog = () => {
|
||||
handleCloseActionMenu()
|
||||
setUploadDialogOpen(true)
|
||||
}
|
||||
|
||||
const closeUploadDialog = () => {
|
||||
setUploadDialogOpen(false)
|
||||
}
|
||||
|
||||
const openDownloadDialog = () => {
|
||||
handleCloseActionMenu()
|
||||
setDownloadDialogOpen(true)
|
||||
}
|
||||
|
||||
const closeDownloadDialog = () => {
|
||||
setDownloadDialogOpen(false)
|
||||
}
|
||||
|
||||
const handleClosePanel = async () => {
|
||||
if (!canReconnect) {
|
||||
await handleDisconnect()
|
||||
@@ -585,6 +607,12 @@ function SessionTerminalPanel(props: SessionPanelProps) {
|
||||
<MenuItem onClick={handleDownloadTerminalBuffer} disabled={!canDownloadBuffer}>
|
||||
Download Buffer
|
||||
</MenuItem>
|
||||
<MenuItem onClick={openUploadDialog} disabled={!canTransferFiles}>
|
||||
Upload Files
|
||||
</MenuItem>
|
||||
<MenuItem onClick={openDownloadDialog} disabled={!canTransferFiles}>
|
||||
Download Files
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
handleCloseActionMenu()
|
||||
@@ -634,6 +662,21 @@ function SessionTerminalPanel(props: SessionPanelProps) {
|
||||
onClose={closePasswordPrompt}
|
||||
onConfirm={() => void handleConfirmPasswordPrompt()}
|
||||
/>
|
||||
<SSHSessionFileUploadDialog
|
||||
open={uploadDialogOpen}
|
||||
sessionID={sessionID}
|
||||
passwordRequired={needsPasswordPrompt}
|
||||
otpRequired={needsOTPPrompt}
|
||||
onClose={closeUploadDialog}
|
||||
/>
|
||||
<SSHSessionFileDownloadDialog
|
||||
open={downloadDialogOpen}
|
||||
sessionID={sessionID}
|
||||
passwordRequired={needsPasswordPrompt}
|
||||
otpRequired={needsOTPPrompt}
|
||||
onClose={closeDownloadDialog}
|
||||
/>
|
||||
|
||||
</TintedPanel>
|
||||
)
|
||||
}
|
||||
@@ -1749,7 +1792,6 @@ export default function SSHSessionPage() {
|
||||
error={transcriptError}
|
||||
loading={loadingTranscript}
|
||||
downloadDisabled={!transcriptSession}
|
||||
closeText="Cancel"
|
||||
onClose={handleCloseTranscript}
|
||||
onDownload={handleDownloadTranscript}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user