Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dbe48dec2b | |||
| 9d1e552aeb | |||
| ad144b874e | |||
| b14462d9a3 |
@@ -29,6 +29,7 @@ import "codit/internal/models"
|
|||||||
const sshFileTransferMaxUploadBytes int64 = 5 * 1024 * 1024 * 1024
|
const sshFileTransferMaxUploadBytes int64 = 5 * 1024 * 1024 * 1024
|
||||||
const sshFileTransferMaxDownloadBytes int64 = 10 * 1024 * 1024 * 1024
|
const sshFileTransferMaxDownloadBytes int64 = 10 * 1024 * 1024 * 1024
|
||||||
const sshFileTransferMaxFiles int = 256
|
const sshFileTransferMaxFiles int = 256
|
||||||
|
const sshFileTransferCopyBufferSize int = 1024 * 1024
|
||||||
|
|
||||||
type sshSessionFileDownloadRequest struct {
|
type sshSessionFileDownloadRequest struct {
|
||||||
Paths []string `json:"paths"`
|
Paths []string `json:"paths"`
|
||||||
@@ -36,6 +37,17 @@ type sshSessionFileDownloadRequest struct {
|
|||||||
OTPCode string `json:"otp_code"`
|
OTPCode string `json:"otp_code"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type sshSessionFileCopyRequest struct {
|
||||||
|
TargetSessionID string `json:"target_session_id"`
|
||||||
|
Paths []string `json:"paths"`
|
||||||
|
TargetDir string `json:"target_dir"`
|
||||||
|
Overwrite bool `json:"overwrite"`
|
||||||
|
SourcePassword string `json:"source_password"`
|
||||||
|
SourceOTPCode string `json:"source_otp_code"`
|
||||||
|
TargetPassword string `json:"target_password"`
|
||||||
|
TargetOTPCode string `json:"target_otp_code"`
|
||||||
|
}
|
||||||
|
|
||||||
type sshSessionFileUploadItem struct {
|
type sshSessionFileUploadItem struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Path string `json:"path"`
|
Path string `json:"path"`
|
||||||
@@ -55,6 +67,43 @@ type sshSessionFileDownloadItem struct {
|
|||||||
Size int64
|
Size int64
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type sshSessionFileCopyItem struct {
|
||||||
|
SourcePath string `json:"source_path"`
|
||||||
|
TargetPath string `json:"target_path"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type sshSessionFileCopyResponse struct {
|
||||||
|
Items []sshSessionFileCopyItem `json:"items"`
|
||||||
|
Copied int `json:"copied"`
|
||||||
|
Failed int `json:"failed"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type sshFileTransferContextReader struct {
|
||||||
|
Context context.Context
|
||||||
|
Reader io.Reader
|
||||||
|
}
|
||||||
|
|
||||||
|
type sshFileTransferContextWriter struct {
|
||||||
|
Context context.Context
|
||||||
|
Writer io.Writer
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *sshFileTransferContextReader) Read(p []byte) (int, error) {
|
||||||
|
if r.Context.Err() != nil {
|
||||||
|
return 0, r.Context.Err()
|
||||||
|
}
|
||||||
|
return r.Reader.Read(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *sshFileTransferContextWriter) Write(p []byte) (int, error) {
|
||||||
|
if w.Context.Err() != nil {
|
||||||
|
return 0, w.Context.Err()
|
||||||
|
}
|
||||||
|
return w.Writer.Write(p)
|
||||||
|
}
|
||||||
|
|
||||||
func (api *API) UploadSSHSessionFilesForSelf(w http.ResponseWriter, r *http.Request, params map[string]string) {
|
func (api *API) UploadSSHSessionFilesForSelf(w http.ResponseWriter, r *http.Request, params map[string]string) {
|
||||||
var user models.User
|
var user models.User
|
||||||
var ok bool
|
var ok bool
|
||||||
@@ -74,6 +123,7 @@ func (api *API) UploadSSHSessionFilesForSelf(w http.ResponseWriter, r *http.Requ
|
|||||||
var fieldValue string
|
var fieldValue string
|
||||||
var password string
|
var password string
|
||||||
var otpCode string
|
var otpCode string
|
||||||
|
var seenUploadPaths map[string]bool
|
||||||
var uploadCount int
|
var uploadCount int
|
||||||
var opened bool
|
var opened bool
|
||||||
var copied int64
|
var copied int64
|
||||||
@@ -91,6 +141,7 @@ func (api *API) UploadSSHSessionFilesForSelf(w http.ResponseWriter, r *http.Requ
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
targetDir = "."
|
targetDir = "."
|
||||||
|
seenUploadPaths = make(map[string]bool)
|
||||||
for {
|
for {
|
||||||
if r.Context().Err() != nil {
|
if r.Context().Err() != nil {
|
||||||
return
|
return
|
||||||
@@ -125,6 +176,20 @@ func (api *API) UploadSSHSessionFilesForSelf(w http.ResponseWriter, r *http.Requ
|
|||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
item = sshSessionFileUploadItem{Name: path.Base(part.FileName())}
|
||||||
|
if item.Name == "." || item.Name == "/" || strings.Contains(item.Name, "\x00") {
|
||||||
|
item.Error = "invalid file name"
|
||||||
|
response.Failed += 1
|
||||||
|
response.Items = append(response.Items, item)
|
||||||
|
_ = part.Close()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
remotePath = path.Join(targetDir, item.Name)
|
||||||
|
if seenUploadPaths[remotePath] {
|
||||||
|
_ = part.Close()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seenUploadPaths[remotePath] = true
|
||||||
uploadCount += 1
|
uploadCount += 1
|
||||||
if uploadCount > sshFileTransferMaxFiles {
|
if uploadCount > sshFileTransferMaxFiles {
|
||||||
_ = part.Close()
|
_ = part.Close()
|
||||||
@@ -157,15 +222,6 @@ func (api *API) UploadSSHSessionFilesForSelf(w http.ResponseWriter, r *http.Requ
|
|||||||
defer sftpClient.Close()
|
defer sftpClient.Close()
|
||||||
opened = true
|
opened = true
|
||||||
}
|
}
|
||||||
item = sshSessionFileUploadItem{Name: path.Base(part.FileName())}
|
|
||||||
if item.Name == "." || item.Name == "/" || strings.Contains(item.Name, "\x00") {
|
|
||||||
item.Error = "invalid file name"
|
|
||||||
response.Failed += 1
|
|
||||||
response.Items = append(response.Items, item)
|
|
||||||
_ = part.Close()
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
remotePath = path.Join(targetDir, item.Name)
|
|
||||||
item.Path = remotePath
|
item.Path = remotePath
|
||||||
copied, err = api.sftpUploadFile(r.Context(), sftpClient, targetDir, item.Name, part, overwrite)
|
copied, err = api.sftpUploadFile(r.Context(), sftpClient, targetDir, item.Name, part, overwrite)
|
||||||
_ = part.Close()
|
_ = part.Close()
|
||||||
@@ -200,6 +256,8 @@ func (api *API) DownloadSSHSessionFilesForSelf(w http.ResponseWriter, r *http.Re
|
|||||||
var items []sshSessionFileDownloadItem
|
var items []sshSessionFileDownloadItem
|
||||||
var item sshSessionFileDownloadItem
|
var item sshSessionFileDownloadItem
|
||||||
var remotePath string
|
var remotePath string
|
||||||
|
var seenPaths map[string]bool
|
||||||
|
var pathCount int
|
||||||
var totalSize int64
|
var totalSize int64
|
||||||
var contentDisposition string
|
var contentDisposition string
|
||||||
var archiveName string
|
var archiveName string
|
||||||
@@ -220,10 +278,7 @@ func (api *API) DownloadSSHSessionFilesForSelf(w http.ResponseWriter, r *http.Re
|
|||||||
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "at least one path is required"})
|
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "at least one path is required"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if len(req.Paths) > sshFileTransferMaxFiles {
|
seenPaths = make(map[string]bool)
|
||||||
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": fmt.Sprintf("too many files; max is %d", sshFileTransferMaxFiles)})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
store = api.store(r)
|
store = api.store(r)
|
||||||
sessionItem, err = api.getSSHSessionFileTransferItem(store, user, params["id"])
|
sessionItem, err = api.getSSHSessionFileTransferItem(store, user, params["id"])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -253,6 +308,15 @@ func (api *API) DownloadSSHSessionFilesForSelf(w http.ResponseWriter, r *http.Re
|
|||||||
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid download path"})
|
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid download path"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if seenPaths[remotePath] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seenPaths[remotePath] = true
|
||||||
|
pathCount += 1
|
||||||
|
if pathCount > sshFileTransferMaxFiles {
|
||||||
|
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": fmt.Sprintf("too many files; max is %d", sshFileTransferMaxFiles)})
|
||||||
|
return
|
||||||
|
}
|
||||||
item, err = api.sftpDownloadMetadata(sftpClient, remotePath)
|
item, err = api.sftpDownloadMetadata(sftpClient, remotePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": fmt.Sprintf("%s: %s", remotePath, err.Error())})
|
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": fmt.Sprintf("%s: %s", remotePath, err.Error())})
|
||||||
@@ -286,6 +350,187 @@ func (api *API) DownloadSSHSessionFilesForSelf(w http.ResponseWriter, r *http.Re
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (api *API) CopySSHSessionFilesForSelf(w http.ResponseWriter, r *http.Request, params map[string]string) {
|
||||||
|
var user models.User
|
||||||
|
var ok bool
|
||||||
|
var store *db.Store
|
||||||
|
var req sshSessionFileCopyRequest
|
||||||
|
var sourceSession models.SSHSession
|
||||||
|
var targetSession models.SSHSession
|
||||||
|
var sourceSSHClient *ssh.Client
|
||||||
|
var targetSSHClient *ssh.Client
|
||||||
|
var sourceSFTPClient *sftp.Client
|
||||||
|
var targetSFTPClient *sftp.Client
|
||||||
|
var stopSourceClose func()
|
||||||
|
var stopTargetClose func()
|
||||||
|
var response sshSessionFileCopyResponse
|
||||||
|
var item sshSessionFileCopyItem
|
||||||
|
var metadata sshSessionFileDownloadItem
|
||||||
|
var remotePath string
|
||||||
|
var targetDir string
|
||||||
|
var seenPaths map[string]bool
|
||||||
|
var pathCount int
|
||||||
|
var totalSize int64
|
||||||
|
var startedAt time.Time
|
||||||
|
var duration time.Duration
|
||||||
|
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 strings.TrimSpace(req.TargetSessionID) == "" {
|
||||||
|
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "target_session_id is required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(req.TargetSessionID) == strings.TrimSpace(params["id"]) {
|
||||||
|
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "target session must be different from source session"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(req.Paths) == 0 {
|
||||||
|
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "at least one path is required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
targetDir = strings.TrimSpace(req.TargetDir)
|
||||||
|
if targetDir == "" {
|
||||||
|
targetDir = "."
|
||||||
|
}
|
||||||
|
if strings.Contains(targetDir, "\x00") {
|
||||||
|
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid target directory"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
seenPaths = make(map[string]bool)
|
||||||
|
store = api.store(r)
|
||||||
|
sourceSession, err = api.getSSHSessionFileTransferItem(store, user, params["id"])
|
||||||
|
if err != nil {
|
||||||
|
api.writeSSHSessionFileTransferError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
targetSession, err = api.getSSHSessionFileTransferItem(store, user, req.TargetSessionID)
|
||||||
|
if err != nil {
|
||||||
|
api.writeSSHSessionFileTransferError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sourceSSHClient, err = api.openSSHSessionFileTransferClient(store, user, sourceSession, req.SourcePassword, req.SourceOTPCode)
|
||||||
|
if err != nil {
|
||||||
|
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "source session: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
stopSourceClose = closeSSHClientOnContext(r.Context(), sourceSSHClient)
|
||||||
|
defer stopSourceClose()
|
||||||
|
defer sourceSSHClient.Close()
|
||||||
|
targetSSHClient, err = api.openSSHSessionFileTransferClient(store, user, targetSession, req.TargetPassword, req.TargetOTPCode)
|
||||||
|
if err != nil {
|
||||||
|
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "target session: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
stopTargetClose = closeSSHClientOnContext(r.Context(), targetSSHClient)
|
||||||
|
defer stopTargetClose()
|
||||||
|
defer targetSSHClient.Close()
|
||||||
|
sourceSFTPClient, err = sftp.NewClient(sourceSSHClient)
|
||||||
|
if err != nil {
|
||||||
|
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "source sftp subsystem unavailable: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer sourceSFTPClient.Close()
|
||||||
|
targetSFTPClient, err = sftp.NewClient(targetSSHClient)
|
||||||
|
if err != nil {
|
||||||
|
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "target sftp subsystem unavailable: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer targetSFTPClient.Close()
|
||||||
|
for i = 0; i < len(req.Paths); i++ {
|
||||||
|
if r.Context().Err() != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
remotePath = strings.TrimSpace(req.Paths[i])
|
||||||
|
item = sshSessionFileCopyItem{SourcePath: remotePath}
|
||||||
|
if remotePath == "" || strings.Contains(remotePath, "\x00") {
|
||||||
|
item.Error = "invalid source path"
|
||||||
|
response.Failed += 1
|
||||||
|
response.Items = append(response.Items, item)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if seenPaths[remotePath] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seenPaths[remotePath] = true
|
||||||
|
pathCount += 1
|
||||||
|
if pathCount > sshFileTransferMaxFiles {
|
||||||
|
item.Error = fmt.Sprintf("too many files; max is %d", sshFileTransferMaxFiles)
|
||||||
|
response.Failed += 1
|
||||||
|
response.Items = append(response.Items, item)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
metadata, err = api.sftpDownloadMetadata(sourceSFTPClient, remotePath)
|
||||||
|
if err != nil {
|
||||||
|
item.Error = err.Error()
|
||||||
|
response.Failed += 1
|
||||||
|
response.Items = append(response.Items, item)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
totalSize += metadata.Size
|
||||||
|
if totalSize > sshFileTransferMaxDownloadBytes {
|
||||||
|
item.Error = "copy size exceeds limit"
|
||||||
|
response.Failed += 1
|
||||||
|
response.Items = append(response.Items, item)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
item.TargetPath = path.Join(targetDir, metadata.Name)
|
||||||
|
startedAt = time.Now()
|
||||||
|
api.Logger.Write(logIDSSHBroker, codit_logger.LOG_INFO,
|
||||||
|
"ssh file copy start source_session=%s source_server=%s target_session=%s target_server=%s source_path=%q target_path=%q size=%d",
|
||||||
|
sourceSession.ID,
|
||||||
|
sourceSession.ServerName,
|
||||||
|
targetSession.ID,
|
||||||
|
targetSession.ServerName,
|
||||||
|
metadata.RemotePath,
|
||||||
|
item.TargetPath,
|
||||||
|
metadata.Size)
|
||||||
|
item.Size, err = api.sftpCopyFile(r.Context(), sourceSFTPClient, targetSFTPClient, metadata.RemotePath, targetDir, metadata.Name, req.Overwrite)
|
||||||
|
duration = time.Since(startedAt)
|
||||||
|
if err != nil {
|
||||||
|
if r.Context().Err() != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
api.Logger.Write(logIDSSHBroker, codit_logger.LOG_WARN,
|
||||||
|
"ssh file copy failed source_session=%s source_server=%s target_session=%s target_server=%s source_path=%q target_path=%q size=%d dur_ms=%d err=%s",
|
||||||
|
sourceSession.ID,
|
||||||
|
sourceSession.ServerName,
|
||||||
|
targetSession.ID,
|
||||||
|
targetSession.ServerName,
|
||||||
|
metadata.RemotePath,
|
||||||
|
item.TargetPath,
|
||||||
|
item.Size,
|
||||||
|
duration.Milliseconds(),
|
||||||
|
err.Error())
|
||||||
|
item.Error = err.Error()
|
||||||
|
response.Failed += 1
|
||||||
|
} else {
|
||||||
|
api.Logger.Write(logIDSSHBroker, codit_logger.LOG_INFO,
|
||||||
|
"ssh file copy done source_session=%s source_server=%s target_session=%s target_server=%s source_path=%q target_path=%q size=%d dur_ms=%d",
|
||||||
|
sourceSession.ID,
|
||||||
|
sourceSession.ServerName,
|
||||||
|
targetSession.ID,
|
||||||
|
targetSession.ServerName,
|
||||||
|
metadata.RemotePath,
|
||||||
|
item.TargetPath,
|
||||||
|
item.Size,
|
||||||
|
duration.Milliseconds())
|
||||||
|
response.Copied += 1
|
||||||
|
}
|
||||||
|
response.Items = append(response.Items, item)
|
||||||
|
}
|
||||||
|
WriteJSON(w, http.StatusOK, response)
|
||||||
|
}
|
||||||
|
|
||||||
func (api *API) getSSHSessionFileTransferItem(store *db.Store, user models.User, sessionID string) (models.SSHSession, error) {
|
func (api *API) getSSHSessionFileTransferItem(store *db.Store, user models.User, sessionID string) (models.SSHSession, error) {
|
||||||
var item models.SSHSession
|
var item models.SSHSession
|
||||||
var err error
|
var err error
|
||||||
@@ -514,6 +759,19 @@ func (api *API) sftpDownloadFile(ctx context.Context, client *sftp.Client, remot
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (api *API) sftpCopyFile(ctx context.Context, sourceClient *sftp.Client, targetClient *sftp.Client, sourcePath string, targetDir string, name string, overwrite bool) (int64, error) {
|
||||||
|
var sourceFile *sftp.File
|
||||||
|
var copied int64
|
||||||
|
var err error
|
||||||
|
|
||||||
|
if ctx.Err() != nil { return 0, ctx.Err() }
|
||||||
|
sourceFile, err = sourceClient.Open(sourcePath)
|
||||||
|
if err != nil { return 0, err }
|
||||||
|
defer sourceFile.Close()
|
||||||
|
copied, err = api.sftpUploadFile(ctx, targetClient, targetDir, name, sourceFile, overwrite)
|
||||||
|
return copied, err
|
||||||
|
}
|
||||||
|
|
||||||
func (api *API) writeSSHSessionFileZip(ctx context.Context, client *sftp.Client, items []sshSessionFileDownloadItem, writer io.Writer) error {
|
func (api *API) writeSSHSessionFileZip(ctx context.Context, client *sftp.Client, items []sshSessionFileDownloadItem, writer io.Writer) error {
|
||||||
var zipWriter *zip.Writer
|
var zipWriter *zip.Writer
|
||||||
var zipFile io.Writer
|
var zipFile io.Writer
|
||||||
@@ -540,37 +798,27 @@ func (api *API) writeSSHSessionFileZip(ctx context.Context, client *sftp.Client,
|
|||||||
}
|
}
|
||||||
|
|
||||||
func copyWithContext(ctx context.Context, writer io.Writer, reader io.Reader) (int64, error) {
|
func copyWithContext(ctx context.Context, writer io.Writer, reader io.Reader) (int64, error) {
|
||||||
|
var contextWriter *sshFileTransferContextWriter
|
||||||
|
var contextReader *sshFileTransferContextReader
|
||||||
var buffer []byte
|
var buffer []byte
|
||||||
var written int64
|
var writerTo io.WriterTo
|
||||||
var nr int
|
var readerFrom io.ReaderFrom
|
||||||
var nw int
|
|
||||||
var er error
|
|
||||||
var ew error
|
|
||||||
|
|
||||||
buffer = make([]byte, 64 * 1024)
|
if ctx.Err() != nil {
|
||||||
for {
|
return 0, ctx.Err()
|
||||||
if ctx.Err() != nil { return written, ctx.Err() }
|
|
||||||
nr, er = reader.Read(buffer)
|
|
||||||
if nr > 0 {
|
|
||||||
if ctx.Err() != nil { return written, ctx.Err() }
|
|
||||||
nw, ew = writer.Write(buffer[0:nr])
|
|
||||||
if nw > 0 {
|
|
||||||
written += int64(nw)
|
|
||||||
}
|
}
|
||||||
if ew != nil {
|
contextWriter = &sshFileTransferContextWriter{Context: ctx, Writer: writer}
|
||||||
return written, ew
|
contextReader = &sshFileTransferContextReader{Context: ctx, Reader: reader}
|
||||||
}
|
writerTo, _ = reader.(io.WriterTo)
|
||||||
if nr != nw {
|
if writerTo != nil {
|
||||||
return written, io.ErrShortWrite
|
return writerTo.WriteTo(contextWriter)
|
||||||
}
|
|
||||||
}
|
|
||||||
if er != nil {
|
|
||||||
if er == io.EOF {
|
|
||||||
return written, nil
|
|
||||||
}
|
|
||||||
return written, er
|
|
||||||
}
|
}
|
||||||
|
readerFrom, _ = writer.(io.ReaderFrom)
|
||||||
|
if readerFrom != nil {
|
||||||
|
return readerFrom.ReadFrom(contextReader)
|
||||||
}
|
}
|
||||||
|
buffer = make([]byte, sshFileTransferCopyBufferSize)
|
||||||
|
return io.CopyBuffer(contextWriter, contextReader, buffer)
|
||||||
}
|
}
|
||||||
|
|
||||||
func readMultipartFieldValue(part *multipart.Part, maxBytes int64) (string, error) {
|
func readMultipartFieldValue(part *multipart.Part, maxBytes int64) (string, error) {
|
||||||
|
|||||||
@@ -793,6 +793,7 @@ func (app *Server) initHandlers() (*handlers.API, *http.ServeMux, error) {
|
|||||||
router.Handle("POST", "/api/ssh/sessions/:id/disconnect", api.DisconnectSSHSessionForSelf)
|
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/upload", api.UploadSSHSessionFilesForSelf)
|
||||||
router.Handle("POST", "/api/ssh/sessions/:id/files/download", api.DownloadSSHSessionFilesForSelf)
|
router.Handle("POST", "/api/ssh/sessions/:id/files/download", api.DownloadSSHSessionFilesForSelf)
|
||||||
|
router.Handle("POST", "/api/ssh/sessions/:id/files/copy-to", api.CopySSHSessionFilesForSelf)
|
||||||
router.Handle("POST", "/api/ssh/cert/inspect", api.InspectSSHCertificate)
|
router.Handle("POST", "/api/ssh/cert/inspect", api.InspectSSHCertificate)
|
||||||
router.Handle("POST", "/api/ssh/user-cas/:id/sign", api.SignSSHUserKeyForSelf)
|
router.Handle("POST", "/api/ssh/user-cas/:id/sign", api.SignSSHUserKeyForSelf)
|
||||||
router.Handle("GET", "/api/ssh/issuances", api.ListSSHUserCAIssuancesForSelf)
|
router.Handle("GET", "/api/ssh/issuances", api.ListSSHUserCAIssuancesForSelf)
|
||||||
|
|||||||
@@ -5970,6 +5970,39 @@ paths:
|
|||||||
$ref: '#/components/responses/Unauthorized'
|
$ref: '#/components/responses/Unauthorized'
|
||||||
'404':
|
'404':
|
||||||
$ref: '#/components/responses/NotFound'
|
$ref: '#/components/responses/NotFound'
|
||||||
|
/api/ssh/sessions/{id}/files/copy-to:
|
||||||
|
post:
|
||||||
|
tags:
|
||||||
|
- SSH Self Service
|
||||||
|
summary: Copy Files Between SSH Sessions
|
||||||
|
operationId: CopySSHSessionFilesForSelf
|
||||||
|
x-codit-handler: CopySSHSessionFilesForSelf
|
||||||
|
description: Copies files from one connected SSH session to another connected SSH session over SFTP. File data is relayed by the backend and does not pass through the browser.
|
||||||
|
parameters:
|
||||||
|
- name: id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/SSHSessionFileCopyRequest'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Successful response.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/SSHSessionFileCopyResponse'
|
||||||
|
'400':
|
||||||
|
$ref: '#/components/responses/BadRequest'
|
||||||
|
'401':
|
||||||
|
$ref: '#/components/responses/Unauthorized'
|
||||||
|
'404':
|
||||||
|
$ref: '#/components/responses/NotFound'
|
||||||
/api/ssh/cert/inspect:
|
/api/ssh/cert/inspect:
|
||||||
post:
|
post:
|
||||||
tags:
|
tags:
|
||||||
@@ -10278,6 +10311,55 @@ components:
|
|||||||
otp_code:
|
otp_code:
|
||||||
type: string
|
type: string
|
||||||
additionalProperties: false
|
additionalProperties: false
|
||||||
|
SSHSessionFileCopyRequest:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
target_session_id:
|
||||||
|
type: string
|
||||||
|
paths:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
target_dir:
|
||||||
|
type: string
|
||||||
|
overwrite:
|
||||||
|
type: boolean
|
||||||
|
source_password:
|
||||||
|
type: string
|
||||||
|
format: password
|
||||||
|
source_otp_code:
|
||||||
|
type: string
|
||||||
|
target_password:
|
||||||
|
type: string
|
||||||
|
format: password
|
||||||
|
target_otp_code:
|
||||||
|
type: string
|
||||||
|
additionalProperties: false
|
||||||
|
SSHSessionFileCopyItem:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
source_path:
|
||||||
|
type: string
|
||||||
|
target_path:
|
||||||
|
type: string
|
||||||
|
size:
|
||||||
|
type: integer
|
||||||
|
format: int64
|
||||||
|
error:
|
||||||
|
type: string
|
||||||
|
additionalProperties: true
|
||||||
|
SSHSessionFileCopyResponse:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
items:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/SSHSessionFileCopyItem'
|
||||||
|
copied:
|
||||||
|
type: integer
|
||||||
|
failed:
|
||||||
|
type: integer
|
||||||
|
additionalProperties: true
|
||||||
SSHSessionFileUploadItem:
|
SSHSessionFileUploadItem:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
|
|||||||
@@ -1166,6 +1166,30 @@ export interface SSHSessionFileDownloadPayload {
|
|||||||
otp_code?: string
|
otp_code?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SSHSessionFileCopyPayload {
|
||||||
|
target_session_id: string
|
||||||
|
paths: string[]
|
||||||
|
target_dir: string
|
||||||
|
overwrite: boolean
|
||||||
|
source_password?: string
|
||||||
|
source_otp_code?: string
|
||||||
|
target_password?: string
|
||||||
|
target_otp_code?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SSHSessionFileCopyItem {
|
||||||
|
source_path: string
|
||||||
|
target_path: string
|
||||||
|
size: number
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SSHSessionFileCopyResponse {
|
||||||
|
items: SSHSessionFileCopyItem[]
|
||||||
|
copied: number
|
||||||
|
failed: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface BinaryDownload {
|
export interface BinaryDownload {
|
||||||
data: ArrayBuffer
|
data: ArrayBuffer
|
||||||
filename: string
|
filename: string
|
||||||
@@ -1572,6 +1596,8 @@ export const api = {
|
|||||||
requestBinaryDownload(`/api/ssh/sessions/${id}/files/download`, `ssh-session-${id}-files`, { method: 'POST', body: JSON.stringify(payload), headers: { 'Content-Type': 'application/json' }, ...(options || {}) }),
|
requestBinaryDownload(`/api/ssh/sessions/${id}/files/download`, `ssh-session-${id}-files`, { method: 'POST', body: JSON.stringify(payload), headers: { 'Content-Type': 'application/json' }, ...(options || {}) }),
|
||||||
streamSSHSessionFilesForSelf: (id: string, payload: SSHSessionFileDownloadPayload, options?: RequestInit) =>
|
streamSSHSessionFilesForSelf: (id: string, payload: SSHSessionFileDownloadPayload, options?: RequestInit) =>
|
||||||
requestStreamingDownload(`/api/ssh/sessions/${id}/files/download`, `ssh-session-${id}-files`, { method: 'POST', body: JSON.stringify(payload), headers: { 'Content-Type': 'application/json' }, ...(options || {}) }),
|
requestStreamingDownload(`/api/ssh/sessions/${id}/files/download`, `ssh-session-${id}-files`, { method: 'POST', body: JSON.stringify(payload), headers: { 'Content-Type': 'application/json' }, ...(options || {}) }),
|
||||||
|
copySSHSessionFilesForSelf: (id: string, payload: SSHSessionFileCopyPayload, options?: RequestInit) =>
|
||||||
|
request<SSHSessionFileCopyResponse>(`/api/ssh/sessions/${id}/files/copy-to`, { method: 'POST', body: JSON.stringify(payload), headers: { 'Content-Type': 'application/json' }, ...(options || {}) }),
|
||||||
inspectSSHCertificate: (certificate: string) =>
|
inspectSSHCertificate: (certificate: string) =>
|
||||||
request<{ dump: string }>('/api/ssh/cert/inspect', { method: 'POST', body: JSON.stringify({ certificate }) }),
|
request<{ dump: string }>('/api/ssh/cert/inspect', { method: 'POST', body: JSON.stringify({ certificate }) }),
|
||||||
signSSHUserKeyForSelf: (id: string, payload: SSHUserCASelfSignPayload) =>
|
signSSHUserKeyForSelf: (id: string, payload: SSHUserCASelfSignPayload) =>
|
||||||
|
|||||||
@@ -0,0 +1,286 @@
|
|||||||
|
import Box from '@mui/material/Box'
|
||||||
|
import Button from '@mui/material/Button'
|
||||||
|
import Checkbox from '@mui/material/Checkbox'
|
||||||
|
import Dialog from './ModalDialog'
|
||||||
|
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 MenuItem from '@mui/material/MenuItem'
|
||||||
|
import TextField from '@mui/material/TextField'
|
||||||
|
import Typography from '@mui/material/Typography'
|
||||||
|
import { ChangeEvent as ReactChangeEvent, useEffect, useRef, useState } from 'react'
|
||||||
|
import { api, SSHSession, SSHSessionFileCopyItem, SSHSessionFileCopyResponse } from '../api'
|
||||||
|
import PageAlert from './PageAlert'
|
||||||
|
|
||||||
|
type SSHSessionFileCopyDialogProps = {
|
||||||
|
open: boolean
|
||||||
|
sessionID: string
|
||||||
|
sourceSession: SSHSession | null
|
||||||
|
peerSessionIDs: string[]
|
||||||
|
sourcePasswordRequired: boolean
|
||||||
|
sourceOTPRequired: boolean
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function sessionOptionLabel(item: SSHSession): string {
|
||||||
|
const serverName: string = item.server_name || item.server_id
|
||||||
|
const target: string = `${item.remote_username}@${item.host}:${item.port}`
|
||||||
|
|
||||||
|
return `${serverName} (${target})`
|
||||||
|
}
|
||||||
|
|
||||||
|
function needsPassword(item: SSHSession | null): boolean {
|
||||||
|
return item?.auth_method === 'prompted_password'
|
||||||
|
}
|
||||||
|
|
||||||
|
function needsOTP(item: SSHSession | null): boolean {
|
||||||
|
return item?.second_factor_mode === 'prompted_totp'
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSameSessionTarget(left: SSHSession | null, right: SSHSession): boolean {
|
||||||
|
if (!left) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (left.server_id && right.server_id && left.server_id === right.server_id && left.remote_username === right.remote_username) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return left.host === right.host && left.port === right.port && left.remote_username === right.remote_username
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SSHSessionFileCopyDialog(props: SSHSessionFileCopyDialogProps) {
|
||||||
|
const [pathsText, setPathsText] = useState('')
|
||||||
|
const [targetDir, setTargetDir] = useState('.')
|
||||||
|
const [overwrite, setOverwrite] = useState(true)
|
||||||
|
const [targetSessionID, setTargetSessionID] = useState('')
|
||||||
|
const [targetSessions, setTargetSessions] = useState<SSHSession[]>([])
|
||||||
|
const [sourcePassword, setSourcePassword] = useState('')
|
||||||
|
const [sourceOTPCode, setSourceOTPCode] = useState('')
|
||||||
|
const [targetPassword, setTargetPassword] = useState('')
|
||||||
|
const [targetOTPCode, setTargetOTPCode] = useState('')
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [loadingTargets, setLoadingTargets] = useState(false)
|
||||||
|
const [progressText, setProgressText] = useState('')
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [result, setResult] = useState<SSHSessionFileCopyResponse | null>(null)
|
||||||
|
const abortRef = useRef<AbortController | null>(null)
|
||||||
|
const targetSession: SSHSession | null = targetSessions.find((item: SSHSession) => item.id === targetSessionID) || null
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const controller: AbortController = new AbortController()
|
||||||
|
let mounted: boolean
|
||||||
|
|
||||||
|
mounted = true
|
||||||
|
if (!props.open) return
|
||||||
|
setPathsText('')
|
||||||
|
setTargetDir('.')
|
||||||
|
setOverwrite(true)
|
||||||
|
setTargetSessionID('')
|
||||||
|
setTargetSessions([])
|
||||||
|
setSourcePassword('')
|
||||||
|
setSourceOTPCode('')
|
||||||
|
setTargetPassword('')
|
||||||
|
setTargetOTPCode('')
|
||||||
|
setProgressText('')
|
||||||
|
setError(null)
|
||||||
|
setResult(null)
|
||||||
|
setLoadingTargets(true)
|
||||||
|
void Promise.all(props.peerSessionIDs.filter((id: string) => id !== props.sessionID).map((id: string) => api.getSSHSessionForSelf(id, { signal: controller.signal })))
|
||||||
|
.then((items: SSHSession[]) => {
|
||||||
|
const connectedItems: SSHSession[] = items.filter((item: SSHSession) => item.status === 'connected' && !isSameSessionTarget(props.sourceSession, item))
|
||||||
|
|
||||||
|
if (!mounted) return
|
||||||
|
setTargetSessions(connectedItems)
|
||||||
|
if (connectedItems.length === 1) {
|
||||||
|
setTargetSessionID(connectedItems[0].id)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
if (!mounted || controller.signal.aborted) return
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to load target sessions')
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (mounted && !controller.signal.aborted) setLoadingTargets(false)
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
mounted = false
|
||||||
|
controller.abort()
|
||||||
|
}
|
||||||
|
}, [props.open, props.peerSessionIDs, props.sessionID, props.sourceSession])
|
||||||
|
|
||||||
|
const close = () => {
|
||||||
|
if (busy) {
|
||||||
|
abortRef.current?.abort()
|
||||||
|
setProgressText('Canceling copy...')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
props.onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
const copyFiles = async () => {
|
||||||
|
const rawPaths: string[] = pathsText
|
||||||
|
.split('\n')
|
||||||
|
.map((item: string) => item.trim())
|
||||||
|
.filter((item: string) => item !== '')
|
||||||
|
const paths: string[] = []
|
||||||
|
const seenPaths: Record<string, boolean> = {}
|
||||||
|
const controller: AbortController = new AbortController()
|
||||||
|
let response: SSHSessionFileCopyResponse
|
||||||
|
let i: number
|
||||||
|
|
||||||
|
for (i = 0; i < rawPaths.length; i += 1) {
|
||||||
|
if (seenPaths[rawPaths[i]]) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seenPaths[rawPaths[i]] = true
|
||||||
|
paths.push(rawPaths[i])
|
||||||
|
}
|
||||||
|
if (!targetSessionID) {
|
||||||
|
setError('Select a target SSH session')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (paths.length === 0) {
|
||||||
|
setError('Enter at least one source file path')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setBusy(true)
|
||||||
|
abortRef.current = controller
|
||||||
|
setProgressText(`Copying ${paths.length} file(s)...`)
|
||||||
|
setError(null)
|
||||||
|
setResult(null)
|
||||||
|
try {
|
||||||
|
response = await api.copySSHSessionFilesForSelf(props.sessionID, {
|
||||||
|
target_session_id: targetSessionID,
|
||||||
|
paths,
|
||||||
|
target_dir: targetDir.trim() || '.',
|
||||||
|
overwrite,
|
||||||
|
source_password: props.sourcePasswordRequired ? sourcePassword : undefined,
|
||||||
|
source_otp_code: props.sourceOTPRequired ? sourceOTPCode : undefined,
|
||||||
|
target_password: needsPassword(targetSession) ? targetPassword : undefined,
|
||||||
|
target_otp_code: needsOTP(targetSession) ? targetOTPCode : undefined
|
||||||
|
}, { signal: controller.signal })
|
||||||
|
if (response.failed === 0) {
|
||||||
|
props.onClose()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setResult(response)
|
||||||
|
setProgressText('Copy completed with errors.')
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof Error && err.name === 'AbortError') {
|
||||||
|
setProgressText('Copy canceled.')
|
||||||
|
} else {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to copy files')
|
||||||
|
setProgressText('')
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (abortRef.current === controller) {
|
||||||
|
abortRef.current = null
|
||||||
|
}
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={props.open} onClose={close} maxWidth="sm" fullWidth>
|
||||||
|
<DialogTitle>Copy Files To Session</DialogTitle>
|
||||||
|
<DialogContent sx={{ display: 'grid', gap: 2, pt: 1 }}>
|
||||||
|
<PageAlert message={error} onClose={() => setError(null)} />
|
||||||
|
<TextField
|
||||||
|
label="Target session"
|
||||||
|
select
|
||||||
|
value={targetSessionID}
|
||||||
|
onChange={(event: ReactChangeEvent<HTMLInputElement>) => setTargetSessionID(event.target.value)}
|
||||||
|
helperText={targetSessions.length === 0 ? 'No other connected SSH sessions are available.' : 'Files are copied directly between SSH servers by the backend.'}
|
||||||
|
disabled={loadingTargets || busy}
|
||||||
|
fullWidth
|
||||||
|
sx={{ mt: 2 }}
|
||||||
|
>
|
||||||
|
{targetSessions.map((item: SSHSession) => (
|
||||||
|
<MenuItem key={item.id} value={item.id}>{sessionOptionLabel(item)}</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
<TextField
|
||||||
|
label="Source file paths"
|
||||||
|
value={pathsText}
|
||||||
|
onChange={(event: ReactChangeEvent<HTMLInputElement>) => setPathsText(event.target.value)}
|
||||||
|
helperText="Enter one source file path per line. Directories are not supported."
|
||||||
|
multiline
|
||||||
|
minRows={5}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Target directory"
|
||||||
|
value={targetDir}
|
||||||
|
onChange={(event: ReactChangeEvent<HTMLInputElement>) => setTargetDir(event.target.value)}
|
||||||
|
helperText="Files are copied into this directory on the target SSH server."
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
<FormControlLabel
|
||||||
|
control={<Checkbox checked={overwrite} onChange={(event: ReactChangeEvent<HTMLInputElement>) => setOverwrite(event.target.checked)} />}
|
||||||
|
label="Overwrite existing target files"
|
||||||
|
/>
|
||||||
|
{props.sourcePasswordRequired ? (
|
||||||
|
<TextField
|
||||||
|
label="Source password"
|
||||||
|
type="password"
|
||||||
|
value={sourcePassword}
|
||||||
|
onChange={(event: ReactChangeEvent<HTMLInputElement>) => setSourcePassword(event.target.value)}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{props.sourceOTPRequired ? (
|
||||||
|
<TextField
|
||||||
|
label="Source OTP code"
|
||||||
|
value={sourceOTPCode}
|
||||||
|
onChange={(event: ReactChangeEvent<HTMLInputElement>) => setSourceOTPCode(event.target.value)}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{needsPassword(targetSession) ? (
|
||||||
|
<TextField
|
||||||
|
label="Target password"
|
||||||
|
type="password"
|
||||||
|
value={targetPassword}
|
||||||
|
onChange={(event: ReactChangeEvent<HTMLInputElement>) => setTargetPassword(event.target.value)}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{needsOTP(targetSession) ? (
|
||||||
|
<TextField
|
||||||
|
label="Target OTP code"
|
||||||
|
value={targetOTPCode}
|
||||||
|
onChange={(event: ReactChangeEvent<HTMLInputElement>) => setTargetOTPCode(event.target.value)}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<PageAlert
|
||||||
|
severity={result && result.failed > 0 ? 'warning' : 'success'}
|
||||||
|
message={result ? `Copied ${result.copied}; failed ${result.failed}.` : null}
|
||||||
|
onClose={() => setResult(null)}
|
||||||
|
/>
|
||||||
|
{result && result.items.length > 0 ? (
|
||||||
|
<Box sx={{ display: 'grid', maxHeight: 140, overflow: 'auto' }}>
|
||||||
|
{result.items.map((item: SSHSessionFileCopyItem, index: number) => (
|
||||||
|
<Typography key={`${item.source_path}-${index}`} variant="body2" color={item.error ? 'error' : 'text.secondary'}>
|
||||||
|
{item.error ? 'Failed' : 'Copied'}: {item.source_path} → {item.target_path || '-'}{item.error ? ` - ${item.error}` : ''}
|
||||||
|
</Typography>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
) : null}
|
||||||
|
{busy || loadingTargets ? (
|
||||||
|
<Box sx={{ display: 'grid', gap: 0.75 }}>
|
||||||
|
<LinearProgress />
|
||||||
|
<Typography variant="caption" color="text.secondary">{progressText || 'Loading target sessions...'}</Typography>
|
||||||
|
</Box>
|
||||||
|
) : progressText ? (
|
||||||
|
<Typography variant="caption" color="text.secondary">{progressText}</Typography>
|
||||||
|
) : null}
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => void copyFiles()} disabled={busy || loadingTargets || !targetSessionID} variant="contained">Copy</Button>
|
||||||
|
<Button onClick={close}>{busy ? 'Cancel' : 'Close'}</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -54,10 +54,12 @@ export default function SSHSessionFileDownloadDialog(props: SSHSessionFileDownlo
|
|||||||
}
|
}
|
||||||
|
|
||||||
const download = async () => {
|
const download = async () => {
|
||||||
const paths: string[] = pathsText
|
const rawPaths: string[] = pathsText
|
||||||
.split('\n')
|
.split('\n')
|
||||||
.map((item: string) => item.trim())
|
.map((item: string) => item.trim())
|
||||||
.filter((item: string) => item !== '')
|
.filter((item: string) => item !== '')
|
||||||
|
const paths: string[] = []
|
||||||
|
const seenPaths: Record<string, boolean> = {}
|
||||||
const controller: AbortController = new AbortController()
|
const controller: AbortController = new AbortController()
|
||||||
let downloadResult: StreamingDownload
|
let downloadResult: StreamingDownload
|
||||||
let blob: Blob
|
let blob: Blob
|
||||||
@@ -68,7 +70,15 @@ export default function SSHSessionFileDownloadDialog(props: SSHSessionFileDownlo
|
|||||||
let writable: WritableStream<Uint8Array>
|
let writable: WritableStream<Uint8Array>
|
||||||
let data: ArrayBuffer
|
let data: ArrayBuffer
|
||||||
let canUseSavePicker: boolean
|
let canUseSavePicker: boolean
|
||||||
|
let i: number
|
||||||
|
|
||||||
|
for (i = 0; i < rawPaths.length; i += 1) {
|
||||||
|
if (seenPaths[rawPaths[i]]) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seenPaths[rawPaths[i]] = true
|
||||||
|
paths.push(rawPaths[i])
|
||||||
|
}
|
||||||
if (paths.length === 0) {
|
if (paths.length === 0) {
|
||||||
setError('Enter at least one remote file path')
|
setError('Enter at least one remote file path')
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -75,6 +75,9 @@ export default function SSHSessionFileUploadDialog(props: SSHSessionFileUploadDi
|
|||||||
const upload = async () => {
|
const upload = async () => {
|
||||||
const form: FormData = new FormData()
|
const form: FormData = new FormData()
|
||||||
const controller: AbortController = new AbortController()
|
const controller: AbortController = new AbortController()
|
||||||
|
const uniqueFiles: File[] = []
|
||||||
|
const seenNames: Record<string, boolean> = {}
|
||||||
|
const nextTargetDir: string = targetDir.trim() || '.'
|
||||||
let response: SSHSessionFileUploadResponse
|
let response: SSHSessionFileUploadResponse
|
||||||
let i: number
|
let i: number
|
||||||
|
|
||||||
@@ -82,16 +85,23 @@ export default function SSHSessionFileUploadDialog(props: SSHSessionFileUploadDi
|
|||||||
setError('Select at least one file')
|
setError('Select at least one file')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
form.append('target_dir', targetDir.trim() || '.')
|
for (i = 0; i < files.length; i += 1) {
|
||||||
|
if (seenNames[files[i].name]) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seenNames[files[i].name] = true
|
||||||
|
uniqueFiles.push(files[i])
|
||||||
|
}
|
||||||
|
form.append('target_dir', nextTargetDir)
|
||||||
form.append('overwrite', overwrite ? 'true' : 'false')
|
form.append('overwrite', overwrite ? 'true' : 'false')
|
||||||
if (props.passwordRequired) form.append('password', password)
|
if (props.passwordRequired) form.append('password', password)
|
||||||
if (props.otpRequired) form.append('otp_code', otpCode)
|
if (props.otpRequired) form.append('otp_code', otpCode)
|
||||||
for (i = 0; i < files.length; i += 1) {
|
for (i = 0; i < uniqueFiles.length; i += 1) {
|
||||||
form.append('files', files[i])
|
form.append('files', uniqueFiles[i])
|
||||||
}
|
}
|
||||||
setBusy(true)
|
setBusy(true)
|
||||||
abortRef.current = controller
|
abortRef.current = controller
|
||||||
setProgressText(`Uploading ${files.length} file(s) to ${targetDir.trim() || '.'}...`)
|
setProgressText(`Uploading ${uniqueFiles.length} file(s) to ${nextTargetDir}...`)
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
response = await api.uploadSSHSessionFilesForSelf(props.sessionID, form, { signal: controller.signal })
|
response = await api.uploadSSHSessionFilesForSelf(props.sessionID, form, { signal: controller.signal })
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { Unicode11Addon } from '@xterm/addon-unicode11'
|
|||||||
import { Terminal } from '@xterm/xterm'
|
import { Terminal } from '@xterm/xterm'
|
||||||
import SSHCredentialsPromptDialog from '../components/SSHCredentialsPromptDialog'
|
import SSHCredentialsPromptDialog from '../components/SSHCredentialsPromptDialog'
|
||||||
import SSHSessionCreateDialog from '../components/SSHSessionCreateDialog'
|
import SSHSessionCreateDialog from '../components/SSHSessionCreateDialog'
|
||||||
|
import SSHSessionFileCopyDialog from '../components/SSHSessionFileCopyDialog'
|
||||||
import SSHSessionFileDownloadDialog from '../components/SSHSessionFileDownloadDialog'
|
import SSHSessionFileDownloadDialog from '../components/SSHSessionFileDownloadDialog'
|
||||||
import SSHSessionFileUploadDialog from '../components/SSHSessionFileUploadDialog'
|
import SSHSessionFileUploadDialog from '../components/SSHSessionFileUploadDialog'
|
||||||
import SSHTranscriptDialog from '../components/SSHTranscriptDialog'
|
import SSHTranscriptDialog from '../components/SSHTranscriptDialog'
|
||||||
@@ -56,6 +57,7 @@ type RowDragState = {
|
|||||||
|
|
||||||
type SessionPanelProps = {
|
type SessionPanelProps = {
|
||||||
sessionID: string
|
sessionID: string
|
||||||
|
openSessionIDs: string[]
|
||||||
layoutToken: string
|
layoutToken: string
|
||||||
registerStream: (sessionID: string, handlers: SessionStreamHandlers) => void
|
registerStream: (sessionID: string, handlers: SessionStreamHandlers) => void
|
||||||
unregisterStream: (sessionID: string) => void
|
unregisterStream: (sessionID: string) => void
|
||||||
@@ -151,7 +153,7 @@ function isSSHSessionStreamActive(status: string): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function SessionTerminalPanel(props: SessionPanelProps) {
|
function SessionTerminalPanel(props: SessionPanelProps) {
|
||||||
const { sessionID, layoutToken, registerStream, unregisterStream, sendStreamInput, sendStreamResize, onDuplicate, onClose, onReplace, onSummaryChange } = props
|
const { sessionID, openSessionIDs, layoutToken, registerStream, unregisterStream, sendStreamInput, sendStreamResize, onDuplicate, onClose, onReplace, onSummaryChange } = props
|
||||||
const [session, setSession] = useState<SSHSession | null>(null)
|
const [session, setSession] = useState<SSHSession | null>(null)
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
@@ -165,6 +167,7 @@ function SessionTerminalPanel(props: SessionPanelProps) {
|
|||||||
const [actionMenuAnchor, setActionMenuAnchor] = useState<HTMLElement | null>(null)
|
const [actionMenuAnchor, setActionMenuAnchor] = useState<HTMLElement | null>(null)
|
||||||
const [uploadDialogOpen, setUploadDialogOpen] = useState(false)
|
const [uploadDialogOpen, setUploadDialogOpen] = useState(false)
|
||||||
const [downloadDialogOpen, setDownloadDialogOpen] = useState(false)
|
const [downloadDialogOpen, setDownloadDialogOpen] = useState(false)
|
||||||
|
const [copyDialogOpen, setCopyDialogOpen] = useState(false)
|
||||||
const terminalHostRef = useRef<HTMLDivElement | null>(null)
|
const terminalHostRef = useRef<HTMLDivElement | null>(null)
|
||||||
const terminalRef = useRef<Terminal | null>(null)
|
const terminalRef = useRef<Terminal | null>(null)
|
||||||
const fitAddonRef = useRef<FitAddon | null>(null)
|
const fitAddonRef = useRef<FitAddon | null>(null)
|
||||||
@@ -179,6 +182,7 @@ function SessionTerminalPanel(props: SessionPanelProps) {
|
|||||||
const canSessionAction = Boolean(session) && !actionPending
|
const canSessionAction = Boolean(session) && !actionPending
|
||||||
const canDownloadBuffer = Boolean(terminalRef.current)
|
const canDownloadBuffer = Boolean(terminalRef.current)
|
||||||
const canTransferFiles = status === 'connected' && Boolean(session) && !actionPending
|
const canTransferFiles = status === 'connected' && Boolean(session) && !actionPending
|
||||||
|
const canCopyFiles = canTransferFiles && openSessionIDs.some((id: string) => id !== sessionID)
|
||||||
const needsPasswordPrompt = Boolean(session && requiresPromptedPassword(session))
|
const needsPasswordPrompt = Boolean(session && requiresPromptedPassword(session))
|
||||||
const needsOTPPrompt = Boolean(session && requiresPromptedTOTP(session))
|
const needsOTPPrompt = Boolean(session && requiresPromptedTOTP(session))
|
||||||
|
|
||||||
@@ -569,6 +573,15 @@ function SessionTerminalPanel(props: SessionPanelProps) {
|
|||||||
setDownloadDialogOpen(false)
|
setDownloadDialogOpen(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const openCopyDialog = () => {
|
||||||
|
handleCloseActionMenu()
|
||||||
|
setCopyDialogOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeCopyDialog = () => {
|
||||||
|
setCopyDialogOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
const handleClosePanel = async () => {
|
const handleClosePanel = async () => {
|
||||||
if (!canReconnect) {
|
if (!canReconnect) {
|
||||||
await handleDisconnect()
|
await handleDisconnect()
|
||||||
@@ -635,6 +648,9 @@ function SessionTerminalPanel(props: SessionPanelProps) {
|
|||||||
<MenuItem onClick={openDownloadDialog} disabled={!canTransferFiles}>
|
<MenuItem onClick={openDownloadDialog} disabled={!canTransferFiles}>
|
||||||
Download Files
|
Download Files
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
|
<MenuItem onClick={openCopyDialog} disabled={!canCopyFiles}>
|
||||||
|
Copy To Session
|
||||||
|
</MenuItem>
|
||||||
<MenuItem
|
<MenuItem
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
handleCloseActionMenu()
|
handleCloseActionMenu()
|
||||||
@@ -698,6 +714,15 @@ function SessionTerminalPanel(props: SessionPanelProps) {
|
|||||||
otpRequired={needsOTPPrompt}
|
otpRequired={needsOTPPrompt}
|
||||||
onClose={closeDownloadDialog}
|
onClose={closeDownloadDialog}
|
||||||
/>
|
/>
|
||||||
|
<SSHSessionFileCopyDialog
|
||||||
|
open={copyDialogOpen}
|
||||||
|
sessionID={sessionID}
|
||||||
|
sourceSession={session}
|
||||||
|
peerSessionIDs={openSessionIDs}
|
||||||
|
sourcePasswordRequired={needsPasswordPrompt}
|
||||||
|
sourceOTPRequired={needsOTPPrompt}
|
||||||
|
onClose={closeCopyDialog}
|
||||||
|
/>
|
||||||
|
|
||||||
</TintedPanel>
|
</TintedPanel>
|
||||||
)
|
)
|
||||||
@@ -1664,6 +1689,7 @@ export default function SSHSessionPage() {
|
|||||||
>
|
>
|
||||||
<SessionTerminalPanel
|
<SessionTerminalPanel
|
||||||
sessionID={id}
|
sessionID={id}
|
||||||
|
openSessionIDs={openSessionIDs}
|
||||||
layoutToken={layoutToken}
|
layoutToken={layoutToken}
|
||||||
registerStream={registerStream}
|
registerStream={registerStream}
|
||||||
unregisterStream={unregisterStream}
|
unregisterStream={unregisterStream}
|
||||||
@@ -1752,6 +1778,7 @@ export default function SSHSessionPage() {
|
|||||||
>
|
>
|
||||||
<SessionTerminalPanel
|
<SessionTerminalPanel
|
||||||
sessionID={id}
|
sessionID={id}
|
||||||
|
openSessionIDs={openSessionIDs}
|
||||||
layoutToken={layoutToken}
|
layoutToken={layoutToken}
|
||||||
registerStream={registerStream}
|
registerStream={registerStream}
|
||||||
unregisterStream={unregisterStream}
|
unregisterStream={unregisterStream}
|
||||||
|
|||||||
Reference in New Issue
Block a user