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