diff --git a/go.work.sum b/go.work.sum index 8b654192..e3ff478f 100644 --- a/go.work.sum +++ b/go.work.sum @@ -1,4 +1,6 @@ github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= +github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= +github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/sync v0.2.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= diff --git a/src/app/common/modals/modals.less b/src/app/common/modals/modals.less index 7624aa1b..3bdd24c4 100644 --- a/src/app/common/modals/modals.less +++ b/src/app/common/modals/modals.less @@ -601,6 +601,11 @@ align-items: flex-start; gap: 12px; + .name-wrapper { + display: flex; + flex-direction: row; + } + .rconndetail-name { color: @term-bright-white; font-size: 15px; diff --git a/src/app/common/modals/modals.tsx b/src/app/common/modals/modals.tsx index 911c6e42..19f27376 100644 --- a/src/app/common/modals/modals.tsx +++ b/src/app/common/modals/modals.tsx @@ -453,7 +453,9 @@ class AboutModal extends React.Component<{}, {}> { @@ -968,7 +970,7 @@ class ViewRemoteConnDetailModal extends React.Component<{}, {}> { Install Now ); - const archiveButton = ( + let archiveButton = ( @@ -983,6 +985,36 @@ class ViewRemoteConnDetailModal extends React.Component<{}, {}> { updateAuthButton = <>; cancelInstallButton = <>; } + if (remote.sshconfigsrc == "sshconfig-import") { + updateAuthButton = ( + + ); + archiveButton = ( + + ); + } if (remote.status == "connected" || remote.status == "connecting") { buttons.push(disconnectButton); } else if (remote.status == "disconnected") { @@ -1057,7 +1089,9 @@ class ViewRemoteConnDetailModal extends React.Component<{}, {}> {
-
{getName(remote)}
+
+ {getName(remote)}  {getImportTooltip(remote)} +
{this.renderHeaderBtns(remote)}
@@ -1433,6 +1467,21 @@ const getName = (remote: T.RemoteType): string => { return remotealias ? `${remotealias} [${remotecanonicalname}]` : remotecanonicalname; }; +const getImportTooltip = (remote: T.RemoteType): React.ReactElement => { + if (remote.sshconfigsrc == "sshconfig-import") { + return ( + } + > + + + ); + } else { + return <>; + } +}; + export { LoadingSpinner, ClientStopModal, diff --git a/src/app/connections/connections.less b/src/app/connections/connections.less index d604fb19..47e80281 100644 --- a/src/app/connections/connections.less +++ b/src/app/connections/connections.less @@ -87,6 +87,12 @@ display: flex; visibility: hidden; } + + &.col-name { + display: flex; + flex-direction: row; + align-items: center; + } } &.hovered { @@ -99,6 +105,9 @@ footer { margin-left: 10px; + display: flex; + flex-direction: row; + gap: 8px; } .help-entry { diff --git a/src/app/connections/connections.tsx b/src/app/connections/connections.tsx index 7529a8f2..e9b1e03e 100644 --- a/src/app/connections/connections.tsx +++ b/src/app/connections/connections.tsx @@ -58,11 +58,26 @@ class ConnectionsView extends React.Component<{ model: RemotesModel }, { hovered return remotealias ? `${remotealias} [${remotecanonicalname}]` : remotecanonicalname; } + @boundMethod + getImportSymbol(item: T.RemoteType): React.ReactElement { + const { sshconfigsrc } = item; + if (sshconfigsrc == "sshconfig-import") { + return ; + } else { + return <>; + } + } + @boundMethod handleAddConnection(): void { GlobalModel.remotesModel.openAddModal({ remoteedit: true }); } + @boundMethod + handleImportSshConfig(): void { + GlobalCommandRunner.importSshConfig(); + } + @boundMethod handleRead(remoteId: string): void { GlobalModel.remotesModel.openReadModal(remoteId); @@ -148,7 +163,8 @@ class ConnectionsView extends React.Component<{ model: RemotesModel }, { hovered onClick={() => this.handleRead(item.remoteid)} // Moved onClick here > -
{this.getName(item)}
+ + {this.getName(item)} {this.getImportSymbol(item)}
{item.remotetype}
@@ -170,6 +186,13 @@ class ConnectionsView extends React.Component<{ model: RemotesModel }, { hovered > New Connection +
diff --git a/src/model/model.ts b/src/model/model.ts index 8653b986..aeebc424 100644 --- a/src/model/model.ts +++ b/src/model/model.ts @@ -4406,6 +4406,10 @@ class CommandRunner { GlobalModel.submitCommand("remote", "archive", null, { remote: remoteid, nohist: "1" }, true); } + importSshConfig() { + GlobalModel.submitCommand("remote", "parse", null, null, false); + } + screenSelectLine(lineArg: string, focusVal?: string) { let kwargs: Record = { nohist: "1", diff --git a/src/types/types.ts b/src/types/types.ts index 72cebcb6..3fc3cb96 100644 --- a/src/types/types.ts +++ b/src/types/types.ts @@ -110,6 +110,7 @@ type RemoteType = { connectmode: string; autoinstall: boolean; remoteidx: number; + sshconfigsrc: string; archived: boolean; uname: string; mshellversion: string; diff --git a/wavesrv/db/migrations/000028_sshkeysource.down.sql b/wavesrv/db/migrations/000028_sshkeysource.down.sql new file mode 100644 index 00000000..e4f7a57a --- /dev/null +++ b/wavesrv/db/migrations/000028_sshkeysource.down.sql @@ -0,0 +1,3 @@ +DELETE FROM remote WHERE sshconfigsrc != 'waveterm-manual'; + +ALTER TABLE remote DROP COLUMN sshconfigsrc; \ No newline at end of file diff --git a/wavesrv/db/migrations/000028_sshkeysource.up.sql b/wavesrv/db/migrations/000028_sshkeysource.up.sql new file mode 100644 index 00000000..d083fcd9 --- /dev/null +++ b/wavesrv/db/migrations/000028_sshkeysource.up.sql @@ -0,0 +1 @@ +ALTER TABLE remote ADD COLUMN sshconfigsrc varchar(36) NOT NULL DEFAULT 'waveterm-manual'; \ No newline at end of file diff --git a/wavesrv/db/schema.sql b/wavesrv/db/schema.sql index d8b941b3..dcdfea60 100644 --- a/wavesrv/db/schema.sql +++ b/wavesrv/db/schema.sql @@ -55,8 +55,10 @@ CREATE TABLE remote ( lastconnectts bigint NOT NULL, local boolean NOT NULL, archived boolean NOT NULL, - remoteidx int NOT NULL -, statevars json NOT NULL DEFAULT '{}', openaiopts json NOT NULL DEFAULT '{}', sshconfigsrc varchar(36) NOT NULL DEFAULT 'waveterm-manual'); + remoteidx int NOT NULL, + statevars json NOT NULL DEFAULT '{}', + sshconfigsrc varchar(36) NOT NULL DEFAULT 'waveterm-manual', + openaiopts json NOT NULL DEFAULT '{}'); CREATE TABLE history ( historyid varchar(36) PRIMARY KEY, ts bigint NOT NULL, diff --git a/wavesrv/go.mod b/wavesrv/go.mod index 6eca9869..2606d735 100644 --- a/wavesrv/go.mod +++ b/wavesrv/go.mod @@ -6,6 +6,7 @@ require ( github.com/alessio/shellescape v1.4.1 github.com/armon/circbuf v0.0.0-20190214190532-5111143e8da2 github.com/creack/pty v1.1.18 + github.com/kevinburke/ssh_config v1.2.0 github.com/golang-migrate/migrate/v4 v4.16.2 github.com/google/uuid v1.3.0 github.com/gorilla/mux v1.8.0 diff --git a/wavesrv/pkg/cmdrunner/cmdrunner.go b/wavesrv/pkg/cmdrunner/cmdrunner.go index 72795f2a..b7d94d20 100644 --- a/wavesrv/pkg/cmdrunner/cmdrunner.go +++ b/wavesrv/pkg/cmdrunner/cmdrunner.go @@ -7,6 +7,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "io/fs" "log" @@ -23,6 +24,7 @@ import ( "github.com/google/uuid" "github.com/gorilla/websocket" + "github.com/kevinburke/ssh_config" "github.com/wavetermdev/waveterm/waveshell/pkg/base" "github.com/wavetermdev/waveterm/waveshell/pkg/packet" "github.com/wavetermdev/waveterm/waveshell/pkg/shexec" @@ -191,6 +193,7 @@ func init() { registerCmdFn("remote:install", RemoteInstallCommand) registerCmdFn("remote:installcancel", RemoteInstallCancelCommand) registerCmdFn("remote:reset", RemoteResetCommand) + registerCmdFn("remote:parse", RemoteConfigParseCommand) registerCmdFn("screen:resize", ScreenResizeCommand) @@ -1362,6 +1365,7 @@ func RemoteNewCommand(ctx context.Context, pk *scpacket.FeCommandPacketType) (ss ConnectMode: editArgs.ConnectMode, AutoInstall: editArgs.AutoInstall, SSHOpts: editArgs.SSHOpts, + SSHConfigSrc: sstore.SSHConfigSrcTypeManual, } if editArgs.Color != "" { r.RemoteOpts = &sstore.RemoteOptsType{Color: editArgs.Color} @@ -1383,6 +1387,9 @@ func RemoteSetCommand(ctx context.Context, pk *scpacket.FeCommandPacketType) (ss if err != nil { return nil, err } + if ids.Remote.RState.SSHConfigSrc == sstore.SSHConfigSrcTypeImport { + return nil, fmt.Errorf("/remote:new cannot update imported remote") + } visualEdit := resolveBool(pk.Kwargs["visual"], false) isSubmitted := resolveBool(pk.Kwargs["submit"], false) editArgs, err := parseRemoteEditArgs(false, pk, ids.Remote.MShell.IsLocal()) @@ -1447,6 +1454,244 @@ func RemoteShowAllCommand(ctx context.Context, pk *scpacket.FeCommandPacketType) }, nil } +func resolveSshConfigPatterns(configFiles []string) ([]string, error) { + // using two separate containers to track order and have O(1) lookups + // since go does not have an ordered map primitive + var discoveredPatterns []string + alreadyUsed := make(map[string]bool) + alreadyUsed[""] = true // this excludes the empty string from potential alias + var openedFiles []fs.File + + defer func() { + for _, openedFile := range openedFiles { + openedFile.Close() + } + }() + + var errs []error + for _, configFile := range configFiles { + fd, openErr := os.Open(configFile) + openedFiles = append(openedFiles, fd) + if fd == nil { + errs = append(errs, openErr) + continue + } + + cfg, _ := ssh_config.Decode(fd) + for _, host := range cfg.Hosts { + // for each host, find the first good alias + for _, hostPattern := range host.Patterns { + hostPatternStr := hostPattern.String() + if strings.Index(hostPatternStr, "*") == -1 || alreadyUsed[hostPatternStr] == true { + discoveredPatterns = append(discoveredPatterns, hostPatternStr) + alreadyUsed[hostPatternStr] = true + break + } + } + } + } + if len(errs) == len(configFiles) { + errs = append([]error{fmt.Errorf("no ssh config files could be opened:\n")}, errs...) + return nil, errors.Join(errs...) + } + if len(discoveredPatterns) == 0 { + return nil, fmt.Errorf("no compatible hostnames found in ssh config files") + } + + return discoveredPatterns, nil +} + +type HostInfoType struct { + Host string + User string + CanonicalName string + Port int + SshKeyFile string + ConnectMode string + Ignore bool +} + +func NewHostInfo(hostName string) (*HostInfoType, error) { + userName, _ := ssh_config.GetStrict(hostName, "User") + if userName == "" { + // we cannot store a remote with a missing user + // in the current setup + return nil, fmt.Errorf("could not parse \"%s\" - no User in config\n", hostName) + } + canonicalName := userName + "@" + hostName + + // check if user and host are okay + m := userHostRe.FindStringSubmatch(canonicalName) + if m == nil || m[2] == "" || m[3] == "" { + return nil, fmt.Errorf("could not parse \"%s\" - %s did not fit user@host requirement\n", hostName, canonicalName) + } + + portStr, _ := ssh_config.GetStrict(hostName, "Port") + var portVal int + if portStr != "" { + var err error + portVal, err = strconv.Atoi(portStr) + if err != nil { + // do not make assumptions about port if incorrectly configured + return nil, fmt.Errorf("could not parse \"%s\" (%s) - %s could not be converted to a valid port\n", hostName, canonicalName, portStr) + } + if int(int16(portVal)) != portVal { + return nil, fmt.Errorf("could not parse port \"%d\": number is not valid for a port\n", portVal) + } + } + + identityFile, _ := ssh_config.GetStrict(hostName, "IdentityFile") + passwordAuth, _ := ssh_config.GetStrict(hostName, "PasswordAuthentication") + + cfgWaveOptionsStr, _ := ssh_config.GetStrict(hostName, "WaveOptions") + cfgWaveOptionsStr = strings.ToLower(cfgWaveOptionsStr) + cfgWaveOptions := make(map[string]string) + setBracketArgs(cfgWaveOptions, cfgWaveOptionsStr) + + shouldIgnore := false + if result, _ := strconv.ParseBool(cfgWaveOptions["ignore"]); result { + shouldIgnore = true + } + + var sshKeyFile string + connectMode := sstore.ConnectModeAuto + if cfgWaveOptions["connectmode"] == "manual" { + connectMode = sstore.ConnectModeManual + } else if _, err := os.Stat(base.ExpandHomeDir(identityFile)); err == nil { + sshKeyFile = identityFile + } else if passwordAuth == "yes" { + connectMode = sstore.ConnectModeManual + } + + outHostInfo := new(HostInfoType) + outHostInfo.Host = hostName + outHostInfo.User = userName + outHostInfo.CanonicalName = canonicalName + outHostInfo.Port = portVal + outHostInfo.SshKeyFile = sshKeyFile + outHostInfo.ConnectMode = connectMode + outHostInfo.Ignore = shouldIgnore + return outHostInfo, nil +} + +func RemoteConfigParseCommand(ctx context.Context, pk *scpacket.FeCommandPacketType) (sstore.UpdatePacket, error) { + home := base.GetHomeDir() + localConfig := filepath.Join(home, ".ssh", "config") + systemConfig := filepath.Join("/", "ssh", "config") + sshConfigFiles := []string{localConfig, systemConfig} + hostPatterns, hostPatternsErr := resolveSshConfigPatterns(sshConfigFiles) + if hostPatternsErr != nil { + return nil, hostPatternsErr + } + previouslyImportedRemotes, dbQueryErr := sstore.GetAllImportedRemotes(ctx) + if dbQueryErr != nil { + return nil, dbQueryErr + } + + var parsedHostData []*HostInfoType + hostInfoInConfig := make(map[string]*HostInfoType) + for _, hostPattern := range hostPatterns { + hostInfo, hostInfoErr := NewHostInfo(hostPattern) + if hostInfoErr != nil { + log.Printf("sshconfig-import: %s", hostInfoErr) + continue + } + parsedHostData = append(parsedHostData, hostInfo) + hostInfoInConfig[hostInfo.CanonicalName] = hostInfo + } + + // remove all previously imported remotes that + // no longer have a canonical pattern in the config files + for importedRemoteCanonicalName, importedRemote := range previouslyImportedRemotes { + var err error + hostInfo := hostInfoInConfig[importedRemoteCanonicalName] + if !importedRemote.Archived && (hostInfo == nil || hostInfo.Ignore) { + err = remote.ArchiveRemote(ctx, importedRemote.RemoteId) + if err != nil { + log.Printf("sshconfig-import: failed to remove remote \"%s\" (%s)\n", importedRemote.RemoteAlias, importedRemote.RemoteCanonicalName) + } else { + log.Printf("sshconfig-import: archived remote \"%s\" (%s)\n", importedRemote.RemoteAlias, importedRemote.RemoteCanonicalName) + } + } + } + + var updatedRemotes []string + for _, hostInfo := range parsedHostData { + previouslyImportedRemote := previouslyImportedRemotes[hostInfo.CanonicalName] + updatedRemotes = append(updatedRemotes, hostInfo.CanonicalName) + if hostInfo.Ignore { + log.Printf("sshconfig-import: ignore remote[%s] as specified in config file\n", hostInfo.CanonicalName) + continue + } + if previouslyImportedRemote != nil && !previouslyImportedRemote.Archived { + // this already existed and was created via import + // it needs to be updated instead of created + + editMap := make(map[string]interface{}) + editMap[sstore.RemoteField_Alias] = hostInfo.Host + editMap[sstore.RemoteField_ConnectMode] = hostInfo.ConnectMode + // changing port is unique to imports because it lets us avoid conflicts + // if the port is changed in the ssh config + editMap[sstore.RemoteField_SSHPort] = hostInfo.Port + if hostInfo.SshKeyFile != "" { + editMap[sstore.RemoteField_SSHKey] = hostInfo.SshKeyFile + } + msh := remote.GetRemoteById(previouslyImportedRemote.RemoteId) + if msh == nil { + log.Printf("strange, msh for remote %s [%s] not found\n", hostInfo.CanonicalName, previouslyImportedRemote.RemoteId) + continue + } else { + err := msh.UpdateRemote(ctx, editMap) + if err != nil { + log.Printf("error updating remote[%s]: %v\n", hostInfo.CanonicalName, err) + continue + } + } + log.Printf("sshconfig-import: found previously imported remote with canonical name \"%s\": it has been updated\n", hostInfo.CanonicalName) + } else { + sshOpts := &sstore.SSHOpts{ + Local: false, + SSHHost: hostInfo.Host, + SSHUser: hostInfo.User, + IsSudo: false, + SSHPort: hostInfo.Port, + } + if hostInfo.SshKeyFile != "" { + sshOpts.SSHIdentity = hostInfo.SshKeyFile + } + + // this is new and must be created for the first time + r := &sstore.RemoteType{ + RemoteId: scbase.GenWaveUUID(), + RemoteType: sstore.RemoteTypeSsh, + RemoteAlias: hostInfo.Host, + RemoteCanonicalName: hostInfo.CanonicalName, + RemoteUser: hostInfo.User, + RemoteHost: hostInfo.Host, + ConnectMode: hostInfo.ConnectMode, + AutoInstall: true, + SSHOpts: sshOpts, + SSHConfigSrc: sstore.SSHConfigSrcTypeImport, + } + err := remote.AddRemote(ctx, r, false) + if err != nil { + log.Printf("sshconfig-import: failed to add remote \"%s\" (%s): it is being skipped\n", hostInfo.Host, hostInfo.CanonicalName) + continue + } + log.Printf("sshconfig-import: created remote \"%s\" (%s)\n", hostInfo.Host, hostInfo.CanonicalName) + } + } + + update := &sstore.ModelUpdate{Remotes: remote.GetAllRemoteRuntimeState()} + update.Info = &sstore.InfoMsgType{} + if len(updatedRemotes) == 0 { + update.Info.InfoMsg = "no connections imported from ssh config." + } else { + update.Info.InfoMsg = fmt.Sprintf("imported %d connection(s) from ssh config file: %s\n", len(updatedRemotes), strings.Join(updatedRemotes, ", ")) + } + return update, nil +} + func ScreenShowAllCommand(ctx context.Context, pk *scpacket.FeCommandPacketType) (sstore.UpdatePacket, error) { ids, err := resolveUiIds(ctx, pk, R_Session) screenArr, err := sstore.GetSessionScreens(ctx, ids.SessionId) diff --git a/wavesrv/pkg/remote/remote.go b/wavesrv/pkg/remote/remote.go index 0e91d1ca..7b6b5cbf 100644 --- a/wavesrv/pkg/remote/remote.go +++ b/wavesrv/pkg/remote/remote.go @@ -79,10 +79,10 @@ func MakeServerCommandStr() string { } const ( - StatusConnected = "connected" - StatusConnecting = "connecting" - StatusDisconnected = "disconnected" - StatusError = "error" + StatusConnected = sstore.RemoteStatus_Connected + StatusConnecting = sstore.RemoteStatus_Connecting + StatusDisconnected = sstore.RemoteStatus_Disconnected + StatusError = sstore.RemoteStatus_Error ) func init() { @@ -141,36 +141,7 @@ type RunCmdType struct { RunPacket *packet.RunPacketType } -type RemoteRuntimeState struct { - RemoteType string `json:"remotetype"` - RemoteId string `json:"remoteid"` - RemoteAlias string `json:"remotealias,omitempty"` - RemoteCanonicalName string `json:"remotecanonicalname"` - RemoteVars map[string]string `json:"remotevars"` - DefaultFeState map[string]string `json:"defaultfestate"` - Status string `json:"status"` - ConnectTimeout int `json:"connecttimeout,omitempty"` - ErrorStr string `json:"errorstr,omitempty"` - InstallStatus string `json:"installstatus"` - InstallErrorStr string `json:"installerrorstr,omitempty"` - NeedsMShellUpgrade bool `json:"needsmshellupgrade,omitempty"` - NoInitPk bool `json:"noinitpk,omitempty"` - AuthType string `json:"authtype,omitempty"` - ConnectMode string `json:"connectmode"` - AutoInstall bool `json:"autoinstall"` - Archived bool `json:"archived,omitempty"` - RemoteIdx int64 `json:"remoteidx"` - UName string `json:"uname"` - MShellVersion string `json:"mshellversion"` - WaitingForPassword bool `json:"waitingforpassword,omitempty"` - Local bool `json:"local,omitempty"` - RemoteOpts *sstore.RemoteOptsType `json:"remoteopts,omitempty"` - CanComplete bool `json:"cancomplete,omitempty"` -} - -func (state RemoteRuntimeState) IsConnected() bool { - return state.Status == StatusConnected -} +type RemoteRuntimeState = sstore.RemoteRuntimeState func CanComplete(remoteType string) bool { switch remoteType { @@ -225,21 +196,6 @@ func (msh *MShellProc) GetInstallStatus() string { return msh.InstallStatus } -func (state RemoteRuntimeState) GetBaseDisplayName() string { - if state.RemoteAlias != "" { - return state.RemoteAlias - } - return state.RemoteCanonicalName -} - -func (state RemoteRuntimeState) GetDisplayName(rptr *sstore.RemotePtrType) string { - baseDisplayName := state.GetBaseDisplayName() - if rptr == nil { - return baseDisplayName - } - return rptr.GetDisplayName(baseDisplayName) -} - func LoadRemotes(ctx context.Context) error { GlobalStore = &Store{ Lock: &sync.Mutex{}, @@ -363,6 +319,7 @@ func ArchiveRemote(ctx context.Context, remoteId string) error { RemoteCanonicalName: rcopy.RemoteCanonicalName, ConnectMode: sstore.ConnectModeManual, Archived: true, + SSHConfigSrc: rcopy.SSHConfigSrc, } err := sstore.UpsertRemote(ctx, archivedRemote) if err != nil { @@ -549,6 +506,7 @@ func (msh *MShellProc) GetRemoteRuntimeState() RemoteRuntimeState { AutoInstall: msh.Remote.AutoInstall, Archived: msh.Remote.Archived, RemoteIdx: msh.Remote.RemoteIdx, + SSHConfigSrc: msh.Remote.SSHConfigSrc, UName: msh.UName, InstallStatus: msh.InstallStatus, NeedsMShellUpgrade: msh.NeedsMShellUpgrade, @@ -647,7 +605,7 @@ func (msh *MShellProc) GetRemoteRuntimeState() RemoteRuntimeState { func (msh *MShellProc) NotifyRemoteUpdate() { rstate := msh.GetRemoteRuntimeState() - update := &sstore.ModelUpdate{Remotes: []interface{}{rstate}} + update := &sstore.ModelUpdate{Remotes: []RemoteRuntimeState{rstate}} sstore.MainBus.SendUpdate(update) } @@ -1368,20 +1326,6 @@ func replaceHomePath(pathStr string, homeDir string) string { return pathStr } -func (state RemoteRuntimeState) ExpandHomeDir(pathStr string) (string, error) { - if pathStr != "~" && !strings.HasPrefix(pathStr, "~/") { - return pathStr, nil - } - homeDir := state.RemoteVars["home"] - if homeDir == "" { - return "", fmt.Errorf("remote does not have HOME set, cannot do ~ expansion") - } - if pathStr == "~" { - return homeDir, nil - } - return path.Join(homeDir, pathStr[2:]), nil -} - func (msh *MShellProc) IsCmdRunning(ck base.CommandKey) bool { msh.Lock.Lock() defer msh.Lock.Unlock() diff --git a/wavesrv/pkg/scws/scws.go b/wavesrv/pkg/scws/scws.go index d396ec12..1ffb2331 100644 --- a/wavesrv/pkg/scws/scws.go +++ b/wavesrv/pkg/scws/scws.go @@ -166,11 +166,7 @@ func (ws *WSState) handleConnection() error { return fmt.Errorf("getting sessions: %w", err) } remotes := remote.GetAllRemoteRuntimeState() - ifarr := make([]interface{}, len(remotes)) - for idx, r := range remotes { - ifarr[idx] = r - } - update.Remotes = ifarr + update.Remotes = remotes update.Connect = true err = ws.Shell.WriteJson(update) if err != nil { diff --git a/wavesrv/pkg/sstore/dbops.go b/wavesrv/pkg/sstore/dbops.go index 58cc3b6f..2ccb0d66 100644 --- a/wavesrv/pkg/sstore/dbops.go +++ b/wavesrv/pkg/sstore/dbops.go @@ -113,6 +113,25 @@ func GetAllRemotes(ctx context.Context) ([]*RemoteType, error) { return rtn, nil } +func GetAllImportedRemotes(ctx context.Context) (map[string]*RemoteType, error) { + rtn := make(map[string]*RemoteType) + err := WithTx(ctx, func(tx *TxWrap) error { + query := `SELECT * FROM remote + WHERE sshconfigsrc = "sshconfig-import" + ORDER BY remoteidx` + marr := tx.SelectMaps(query) + for _, m := range marr { + remote := dbutil.FromMap[*RemoteType](m) + rtn[remote.RemoteCanonicalName] = remote + } + return nil + }) + if err != nil { + return nil, err + } + return rtn, nil +} + func GetRemoteByAlias(ctx context.Context, alias string) (*RemoteType, error) { var remote *RemoteType err := WithTx(ctx, func(tx *TxWrap) error { @@ -198,8 +217,8 @@ func UpsertRemote(ctx context.Context, r *RemoteType) error { maxRemoteIdx := tx.GetInt(query) r.RemoteIdx = int64(maxRemoteIdx + 1) query = `INSERT INTO remote - ( remoteid, remotetype, remotealias, remotecanonicalname, remoteuser, remotehost, connectmode, autoinstall, sshopts, remoteopts, lastconnectts, archived, remoteidx, local, statevars, openaiopts) VALUES - (:remoteid,:remotetype,:remotealias,:remotecanonicalname,:remoteuser,:remotehost,:connectmode,:autoinstall,:sshopts,:remoteopts,:lastconnectts,:archived,:remoteidx,:local,:statevars,:openaiopts)` + ( remoteid, remotetype, remotealias, remotecanonicalname, remoteuser, remotehost, connectmode, autoinstall, sshopts, remoteopts, lastconnectts, archived, remoteidx, local, statevars, sshconfigsrc, openaiopts) VALUES + (:remoteid,:remotetype,:remotealias,:remotecanonicalname,:remoteuser,:remotehost,:connectmode,:autoinstall,:sshopts,:remoteopts,:lastconnectts,:archived,:remoteidx,:local,:statevars,:sshconfigsrc,:openaiopts)` tx.NamedExec(query, r.ToMap()) return nil }) @@ -1685,6 +1704,7 @@ const ( RemoteField_ConnectMode = "connectmode" // string RemoteField_SSHKey = "sshkey" // string RemoteField_SSHPassword = "sshpassword" // string + RemoteField_SSHPort = "sshport" // string RemoteField_Color = "color" // string ) @@ -1716,6 +1736,10 @@ func UpdateRemote(ctx context.Context, remoteId string, editMap map[string]inter query = `UPDATE remote SET sshopts = json_set(sshopts, '$.sshpassword', ?) WHERE remoteid = ?` tx.Exec(query, sshPassword, remoteId) } + if sshPort, found := editMap[RemoteField_SSHPort]; found { + query = `UPDATE remote SET sshopts = json_set(sshopts, '$.sshport', ?) WHERE remoteid = ?` + tx.Exec(query, sshPort, remoteId) + } if color, found := editMap[RemoteField_Color]; found { query = `UPDATE remote SET remoteopts = json_set(remoteopts, '$.color', ?) WHERE remoteid = ?` tx.Exec(query, color, remoteId) diff --git a/wavesrv/pkg/sstore/migrate.go b/wavesrv/pkg/sstore/migrate.go index df613525..91253a01 100644 --- a/wavesrv/pkg/sstore/migrate.go +++ b/wavesrv/pkg/sstore/migrate.go @@ -22,7 +22,7 @@ import ( "github.com/golang-migrate/migrate/v4" ) -const MaxMigration = 27 +const MaxMigration = 28 const MigratePrimaryScreenVersion = 9 const CmdScreenSpecialMigration = 13 const CmdLineSpecialMigration = 20 diff --git a/wavesrv/pkg/sstore/sstore.go b/wavesrv/pkg/sstore/sstore.go index 27195b26..4f25dbdc 100644 --- a/wavesrv/pkg/sstore/sstore.go +++ b/wavesrv/pkg/sstore/sstore.go @@ -93,6 +93,11 @@ const ( RemoteAuthTypeKeyPassword = "key+password" ) +const ( + SSHConfigSrcTypeManual = "waveterm-manual" + SSHConfigSrcTypeImport = "sshconfig-import" +) + const ( ShareModeLocal = "local" ShareModeWeb = "web" @@ -975,6 +980,74 @@ type OpenAIOptsType struct { MaxChoices int `json:"maxchoices,omitempty"` } +const ( + RemoteStatus_Connected = "connected" + RemoteStatus_Connecting = "connecting" + RemoteStatus_Disconnected = "disconnected" + RemoteStatus_Error = "error" +) + +type RemoteRuntimeState struct { + RemoteType string `json:"remotetype"` + RemoteId string `json:"remoteid"` + RemoteAlias string `json:"remotealias,omitempty"` + RemoteCanonicalName string `json:"remotecanonicalname"` + RemoteVars map[string]string `json:"remotevars"` + DefaultFeState map[string]string `json:"defaultfestate"` + Status string `json:"status"` + ConnectTimeout int `json:"connecttimeout,omitempty"` + ErrorStr string `json:"errorstr,omitempty"` + InstallStatus string `json:"installstatus"` + InstallErrorStr string `json:"installerrorstr,omitempty"` + NeedsMShellUpgrade bool `json:"needsmshellupgrade,omitempty"` + NoInitPk bool `json:"noinitpk,omitempty"` + AuthType string `json:"authtype,omitempty"` + ConnectMode string `json:"connectmode"` + AutoInstall bool `json:"autoinstall"` + Archived bool `json:"archived,omitempty"` + RemoteIdx int64 `json:"remoteidx"` + SSHConfigSrc string `json:"sshconfigsrc"` + UName string `json:"uname"` + MShellVersion string `json:"mshellversion"` + WaitingForPassword bool `json:"waitingforpassword,omitempty"` + Local bool `json:"local,omitempty"` + RemoteOpts *RemoteOptsType `json:"remoteopts,omitempty"` + CanComplete bool `json:"cancomplete,omitempty"` +} + +func (state RemoteRuntimeState) IsConnected() bool { + return state.Status == RemoteStatus_Connected +} + +func (state RemoteRuntimeState) GetBaseDisplayName() string { + if state.RemoteAlias != "" { + return state.RemoteAlias + } + return state.RemoteCanonicalName +} + +func (state RemoteRuntimeState) GetDisplayName(rptr *RemotePtrType) string { + baseDisplayName := state.GetBaseDisplayName() + if rptr == nil { + return baseDisplayName + } + return rptr.GetDisplayName(baseDisplayName) +} + +func (state RemoteRuntimeState) ExpandHomeDir(pathStr string) (string, error) { + if pathStr != "~" && !strings.HasPrefix(pathStr, "~/") { + return pathStr, nil + } + homeDir := state.RemoteVars["home"] + if homeDir == "" { + return "", fmt.Errorf("remote does not have HOME set, cannot do ~ expansion") + } + if pathStr == "~" { + return homeDir, nil + } + return path.Join(homeDir, pathStr[2:]), nil +} + type RemoteType struct { RemoteId string `json:"remoteid"` RemoteType string `json:"remotetype"` @@ -986,13 +1059,14 @@ type RemoteType struct { Archived bool `json:"archived"` // SSH fields - Local bool `json:"local"` - RemoteUser string `json:"remoteuser"` - RemoteHost string `json:"remotehost"` - ConnectMode string `json:"connectmode"` - AutoInstall bool `json:"autoinstall"` - SSHOpts *SSHOpts `json:"sshopts"` - StateVars map[string]string `json:"statevars"` + Local bool `json:"local"` + RemoteUser string `json:"remoteuser"` + RemoteHost string `json:"remotehost"` + ConnectMode string `json:"connectmode"` + AutoInstall bool `json:"autoinstall"` + SSHOpts *SSHOpts `json:"sshopts"` + StateVars map[string]string `json:"statevars"` + SSHConfigSrc string `json:"sshconfigsrc"` // OpenAI fields OpenAIOpts *OpenAIOptsType `json:"openaiopts,omitempty"` @@ -1048,6 +1122,7 @@ func (r *RemoteType) ToMap() map[string]interface{} { rtn["remoteidx"] = r.RemoteIdx rtn["local"] = r.Local rtn["statevars"] = quickJson(r.StateVars) + rtn["sshconfigsrc"] = r.SSHConfigSrc rtn["openaiopts"] = quickJson(r.OpenAIOpts) return rtn } @@ -1068,6 +1143,7 @@ func (r *RemoteType) FromMap(m map[string]interface{}) bool { quickSetInt64(&r.RemoteIdx, m, "remoteidx") quickSetBool(&r.Local, m, "local") quickSetJson(&r.StateVars, m, "statevars") + quickSetStr(&r.SSHConfigSrc, m, "sshconfigsrc") quickSetJson(&r.OpenAIOpts, m, "openaiopts") return true } @@ -1230,6 +1306,7 @@ func EnsureLocalRemote(ctx context.Context) error { AutoInstall: true, SSHOpts: &SSHOpts{Local: true}, Local: true, + SSHConfigSrc: SSHConfigSrcTypeManual, } err = UpsertRemote(ctx, localRemote) if err != nil { @@ -1248,6 +1325,7 @@ func EnsureLocalRemote(ctx context.Context) error { SSHOpts: &SSHOpts{Local: true, IsSudo: true}, RemoteOpts: &RemoteOptsType{Color: "red"}, Local: true, + SSHConfigSrc: SSHConfigSrcTypeManual, } err = UpsertRemote(ctx, sudoRemote) if err != nil { diff --git a/wavesrv/pkg/sstore/updatebus.go b/wavesrv/pkg/sstore/updatebus.go index fe94c882..ee87e009 100644 --- a/wavesrv/pkg/sstore/updatebus.go +++ b/wavesrv/pkg/sstore/updatebus.go @@ -48,7 +48,7 @@ type ModelUpdate struct { CmdLine *utilfn.StrWithPos `json:"cmdline,omitempty"` Info *InfoMsgType `json:"info,omitempty"` ClearInfo bool `json:"clearinfo,omitempty"` - Remotes []interface{} `json:"remotes,omitempty"` // []*remote.RemoteState + Remotes []RemoteRuntimeState `json:"remotes,omitempty"` History *HistoryInfoType `json:"history,omitempty"` Interactive bool `json:"interactive"` Connect bool `json:"connect,omitempty"`