Compare commits

..

4 Commits

8 changed files with 737 additions and 47 deletions
+289 -41
View File
@@ -29,6 +29,7 @@ import "codit/internal/models"
const sshFileTransferMaxUploadBytes int64 = 5 * 1024 * 1024 * 1024
const sshFileTransferMaxDownloadBytes int64 = 10 * 1024 * 1024 * 1024
const sshFileTransferMaxFiles int = 256
const sshFileTransferCopyBufferSize int = 1024 * 1024
type sshSessionFileDownloadRequest struct {
Paths []string `json:"paths"`
@@ -36,6 +37,17 @@ type sshSessionFileDownloadRequest struct {
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 {
Name string `json:"name"`
Path string `json:"path"`
@@ -55,6 +67,43 @@ type sshSessionFileDownloadItem struct {
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) {
var user models.User
var ok bool
@@ -74,6 +123,7 @@ func (api *API) UploadSSHSessionFilesForSelf(w http.ResponseWriter, r *http.Requ
var fieldValue string
var password string
var otpCode string
var seenUploadPaths map[string]bool
var uploadCount int
var opened bool
var copied int64
@@ -91,6 +141,7 @@ func (api *API) UploadSSHSessionFilesForSelf(w http.ResponseWriter, r *http.Requ
return
}
targetDir = "."
seenUploadPaths = make(map[string]bool)
for {
if r.Context().Err() != nil {
return
@@ -125,6 +176,20 @@ func (api *API) UploadSSHSessionFilesForSelf(w http.ResponseWriter, r *http.Requ
}
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
if uploadCount > sshFileTransferMaxFiles {
_ = part.Close()
@@ -157,15 +222,6 @@ func (api *API) UploadSSHSessionFilesForSelf(w http.ResponseWriter, r *http.Requ
defer sftpClient.Close()
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
copied, err = api.sftpUploadFile(r.Context(), sftpClient, targetDir, item.Name, part, overwrite)
_ = part.Close()
@@ -200,6 +256,8 @@ func (api *API) DownloadSSHSessionFilesForSelf(w http.ResponseWriter, r *http.Re
var items []sshSessionFileDownloadItem
var item sshSessionFileDownloadItem
var remotePath string
var seenPaths map[string]bool
var pathCount int
var totalSize int64
var contentDisposition 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"})
return
}
if len(req.Paths) > sshFileTransferMaxFiles {
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": fmt.Sprintf("too many files; max is %d", sshFileTransferMaxFiles)})
return
}
seenPaths = make(map[string]bool)
store = api.store(r)
sessionItem, err = api.getSSHSessionFileTransferItem(store, user, params["id"])
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"})
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)
if err != nil {
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) {
var item models.SSHSession
var err error
@@ -514,6 +759,19 @@ func (api *API) sftpDownloadFile(ctx context.Context, client *sftp.Client, remot
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 {
var zipWriter *zip.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) {
var contextWriter *sshFileTransferContextWriter
var contextReader *sshFileTransferContextReader
var buffer []byte
var written int64
var nr int
var nw int
var er error
var ew error
var writerTo io.WriterTo
var readerFrom io.ReaderFrom
buffer = make([]byte, 64 * 1024)
for {
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 {
return written, ew
}
if nr != nw {
return written, io.ErrShortWrite
}
}
if er != nil {
if er == io.EOF {
return written, nil
}
return written, er
}
if ctx.Err() != nil {
return 0, ctx.Err()
}
contextWriter = &sshFileTransferContextWriter{Context: ctx, Writer: writer}
contextReader = &sshFileTransferContextReader{Context: ctx, Reader: reader}
writerTo, _ = reader.(io.WriterTo)
if writerTo != nil {
return writerTo.WriteTo(contextWriter)
}
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) {
+1
View File
@@ -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/files/upload", api.UploadSSHSessionFilesForSelf)
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/user-cas/:id/sign", api.SignSSHUserKeyForSelf)
router.Handle("GET", "/api/ssh/issuances", api.ListSSHUserCAIssuancesForSelf)
+82
View File
@@ -5970,6 +5970,39 @@ paths:
$ref: '#/components/responses/Unauthorized'
'404':
$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:
post:
tags:
@@ -10278,6 +10311,55 @@ components:
otp_code:
type: string
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:
type: object
properties:
+26
View File
@@ -1166,6 +1166,30 @@ export interface SSHSessionFileDownloadPayload {
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 {
data: ArrayBuffer
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 || {}) }),
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 || {}) }),
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) =>
request<{ dump: string }>('/api/ssh/cert/inspect', { method: 'POST', body: JSON.stringify({ certificate }) }),
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 paths: string[] = pathsText
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 downloadResult: StreamingDownload
let blob: Blob
@@ -68,7 +70,15 @@ export default function SSHSessionFileDownloadDialog(props: SSHSessionFileDownlo
let writable: WritableStream<Uint8Array>
let data: ArrayBuffer
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) {
setError('Enter at least one remote file path')
return
@@ -75,6 +75,9 @@ export default function SSHSessionFileUploadDialog(props: SSHSessionFileUploadDi
const upload = async () => {
const form: FormData = new FormData()
const controller: AbortController = new AbortController()
const uniqueFiles: File[] = []
const seenNames: Record<string, boolean> = {}
const nextTargetDir: string = targetDir.trim() || '.'
let response: SSHSessionFileUploadResponse
let i: number
@@ -82,16 +85,23 @@ export default function SSHSessionFileUploadDialog(props: SSHSessionFileUploadDi
setError('Select at least one file')
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')
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])
for (i = 0; i < uniqueFiles.length; i += 1) {
form.append('files', uniqueFiles[i])
}
setBusy(true)
abortRef.current = controller
setProgressText(`Uploading ${files.length} file(s) to ${targetDir.trim() || '.'}...`)
setProgressText(`Uploading ${uniqueFiles.length} file(s) to ${nextTargetDir}...`)
setError(null)
try {
response = await api.uploadSSHSessionFilesForSelf(props.sessionID, form, { signal: controller.signal })
+28 -1
View File
@@ -20,6 +20,7 @@ import { Unicode11Addon } from '@xterm/addon-unicode11'
import { Terminal } from '@xterm/xterm'
import SSHCredentialsPromptDialog from '../components/SSHCredentialsPromptDialog'
import SSHSessionCreateDialog from '../components/SSHSessionCreateDialog'
import SSHSessionFileCopyDialog from '../components/SSHSessionFileCopyDialog'
import SSHSessionFileDownloadDialog from '../components/SSHSessionFileDownloadDialog'
import SSHSessionFileUploadDialog from '../components/SSHSessionFileUploadDialog'
import SSHTranscriptDialog from '../components/SSHTranscriptDialog'
@@ -56,6 +57,7 @@ type RowDragState = {
type SessionPanelProps = {
sessionID: string
openSessionIDs: string[]
layoutToken: string
registerStream: (sessionID: string, handlers: SessionStreamHandlers) => void
unregisterStream: (sessionID: string) => void
@@ -151,7 +153,7 @@ function isSSHSessionStreamActive(status: string): boolean {
}
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 [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
@@ -165,6 +167,7 @@ function SessionTerminalPanel(props: SessionPanelProps) {
const [actionMenuAnchor, setActionMenuAnchor] = useState<HTMLElement | null>(null)
const [uploadDialogOpen, setUploadDialogOpen] = useState(false)
const [downloadDialogOpen, setDownloadDialogOpen] = useState(false)
const [copyDialogOpen, setCopyDialogOpen] = useState(false)
const terminalHostRef = useRef<HTMLDivElement | null>(null)
const terminalRef = useRef<Terminal | null>(null)
const fitAddonRef = useRef<FitAddon | null>(null)
@@ -179,6 +182,7 @@ function SessionTerminalPanel(props: SessionPanelProps) {
const canSessionAction = Boolean(session) && !actionPending
const canDownloadBuffer = Boolean(terminalRef.current)
const canTransferFiles = status === 'connected' && Boolean(session) && !actionPending
const canCopyFiles = canTransferFiles && openSessionIDs.some((id: string) => id !== sessionID)
const needsPasswordPrompt = Boolean(session && requiresPromptedPassword(session))
const needsOTPPrompt = Boolean(session && requiresPromptedTOTP(session))
@@ -569,6 +573,15 @@ function SessionTerminalPanel(props: SessionPanelProps) {
setDownloadDialogOpen(false)
}
const openCopyDialog = () => {
handleCloseActionMenu()
setCopyDialogOpen(true)
}
const closeCopyDialog = () => {
setCopyDialogOpen(false)
}
const handleClosePanel = async () => {
if (!canReconnect) {
await handleDisconnect()
@@ -635,6 +648,9 @@ function SessionTerminalPanel(props: SessionPanelProps) {
<MenuItem onClick={openDownloadDialog} disabled={!canTransferFiles}>
Download Files
</MenuItem>
<MenuItem onClick={openCopyDialog} disabled={!canCopyFiles}>
Copy To Session
</MenuItem>
<MenuItem
onClick={() => {
handleCloseActionMenu()
@@ -698,6 +714,15 @@ function SessionTerminalPanel(props: SessionPanelProps) {
otpRequired={needsOTPPrompt}
onClose={closeDownloadDialog}
/>
<SSHSessionFileCopyDialog
open={copyDialogOpen}
sessionID={sessionID}
sourceSession={session}
peerSessionIDs={openSessionIDs}
sourcePasswordRequired={needsPasswordPrompt}
sourceOTPRequired={needsOTPPrompt}
onClose={closeCopyDialog}
/>
</TintedPanel>
)
@@ -1664,6 +1689,7 @@ export default function SSHSessionPage() {
>
<SessionTerminalPanel
sessionID={id}
openSessionIDs={openSessionIDs}
layoutToken={layoutToken}
registerStream={registerStream}
unregisterStream={unregisterStream}
@@ -1752,6 +1778,7 @@ export default function SSHSessionPage() {
>
<SessionTerminalPanel
sessionID={id}
openSessionIDs={openSessionIDs}
layoutToken={layoutToken}
registerStream={registerStream}
unregisterStream={unregisterStream}