Compare commits

...

4 Commits

9 changed files with 845 additions and 6 deletions
+2
View File
@@ -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
+4
View File
@@ -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"
}
+2
View File
@@ -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)
+56
View File
@@ -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>
)
}
+44 -2
View File
@@ -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}
/>