waveterm/wavesrv/pkg/scws/scws.go

335 lines
8.8 KiB
Go
Raw Permalink Normal View History

2023-10-17 06:31:13 +02:00
// Copyright 2023, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
package scws
import (
"context"
"fmt"
2022-10-31 20:40:45 +01:00
"log"
"runtime/debug"
"sync"
"time"
"github.com/google/uuid"
2023-10-16 22:30:10 +02:00
"github.com/wavetermdev/waveterm/waveshell/pkg/packet"
Simplified terminal theming (#570) * save work * reusable StyleBlock component * StyleBlock in elements dir * root level * ability to inherit root styles * change prop from classname to selector * selector should always be :root * remove selector prop from StyleBlock * working * cleanup * loadThemeStyles doesn't have to be async * revert changes in tabs2.less * remove old implementation * cleanup * remove file from another branch * fix issue where line in history view doesn't reflect the terminal theme * add key and value validation * add label to tab settings terminal theme dropdown * save work * save work * save work * working * trigger componentDidUpdate when switching tabs and sessions * cleanup * save work * save work * use UpdatePacket for theme changes as well * make methods cohesive * use themes coming from backend * reload terminal when styel block is unmounted and mounted * fix validation * re-render terminal when theme is updated * remove test styles * cleanup * more cleanup * revert unneeded change * more cleanup * fix type * more cleanup * render style blocks in the header instead of body using portal * add ability to reuse and dispose TermThemes instance and file watcher * remove comment * minor change * separate filewatcher as singleton * do not render app when term theme style blocks aren't rendered first * only render main when termstyles have been rendered already * add comment * use DoUpdate to send themes to front-end * support to watch subdirectories * added support for watch subdirectories * make watcher more flexible so it can be closed anywhere * cleanup * undo the app/main split * use TermThemesType in creating initial value for Themes field * simplify code * fix issue where dropdown label doesn't float when the theme selected is Inherit * remove unsed var * start watcher in main, merge themes (don't overwrite) on event. * ensure terminal-themes directory is created on startup * ah, wait for termThemes to be set (the connect packet needs to have been processed to proceed with rendering)
2024-04-24 08:22:35 +02:00
"github.com/wavetermdev/waveterm/wavesrv/pkg/configstore"
2023-10-16 22:30:10 +02:00
"github.com/wavetermdev/waveterm/wavesrv/pkg/mapqueue"
"github.com/wavetermdev/waveterm/wavesrv/pkg/remote"
"github.com/wavetermdev/waveterm/wavesrv/pkg/scbus"
2023-10-16 22:30:10 +02:00
"github.com/wavetermdev/waveterm/wavesrv/pkg/scpacket"
"github.com/wavetermdev/waveterm/wavesrv/pkg/sstore"
"github.com/wavetermdev/waveterm/wavesrv/pkg/telemetry"
"github.com/wavetermdev/waveterm/wavesrv/pkg/userinput"
2023-10-16 22:30:10 +02:00
"github.com/wavetermdev/waveterm/wavesrv/pkg/wsshell"
)
const WSStatePacketChSize = 20
const RemoteInputQueueSize = 100
var RemoteInputMapQueue *mapqueue.MapQueue
func init() {
RemoteInputMapQueue = mapqueue.MakeMapQueue(RemoteInputQueueSize)
}
type WSState struct {
Lock *sync.Mutex
ClientId string
ConnectTime time.Time
Shell *wsshell.WSShell
UpdateCh chan scbus.UpdatePacket
UpdateQueue []any
Authenticated bool
AuthKey string
SessionId string
ScreenId string
}
func MakeWSState(clientId string, authKey string) *WSState {
rtn := &WSState{}
rtn.Lock = &sync.Mutex{}
rtn.ClientId = clientId
rtn.ConnectTime = time.Now()
rtn.AuthKey = authKey
return rtn
}
func (ws *WSState) SetAuthenticated(authVal bool) {
ws.Lock.Lock()
defer ws.Lock.Unlock()
ws.Authenticated = authVal
}
func (ws *WSState) IsAuthenticated() bool {
ws.Lock.Lock()
defer ws.Lock.Unlock()
return ws.Authenticated
}
func (ws *WSState) GetShell() *wsshell.WSShell {
ws.Lock.Lock()
defer ws.Lock.Unlock()
return ws.Shell
}
func (ws *WSState) WriteUpdate(update any) error {
shell := ws.GetShell()
if shell == nil {
return fmt.Errorf("cannot write update, empty shell")
}
err := shell.WriteJson(update)
if err != nil {
return err
}
return nil
}
func (ws *WSState) UpdateConnectTime() {
ws.Lock.Lock()
defer ws.Lock.Unlock()
ws.ConnectTime = time.Now()
}
func (ws *WSState) GetConnectTime() time.Time {
ws.Lock.Lock()
defer ws.Lock.Unlock()
return ws.ConnectTime
}
func (ws *WSState) WatchScreen(sessionId string, screenId string) {
ws.Lock.Lock()
defer ws.Lock.Unlock()
if ws.SessionId == sessionId && ws.ScreenId == screenId {
return
}
ws.SessionId = sessionId
ws.ScreenId = screenId
ws.UpdateCh = scbus.MainUpdateBus.RegisterChannel(ws.ClientId, &scbus.UpdateChannel{ScreenId: ws.ScreenId})
log.Printf("[ws] watch screen clientid=%s sessionid=%s screenid=%s, updateCh=%v\n", ws.ClientId, sessionId, screenId, ws.UpdateCh)
go ws.RunUpdates(ws.UpdateCh)
}
func (ws *WSState) UnWatchScreen() {
ws.Lock.Lock()
defer ws.Lock.Unlock()
scbus.MainUpdateBus.UnregisterChannel(ws.ClientId)
ws.SessionId = ""
ws.ScreenId = ""
2022-10-31 20:40:45 +01:00
log.Printf("[ws] unwatch screen clientid=%s\n", ws.ClientId)
}
func (ws *WSState) RunUpdates(updateCh chan scbus.UpdatePacket) {
if updateCh == nil {
panic("invalid nil updateCh passed to RunUpdates")
}
for update := range updateCh {
shell := ws.GetShell()
if shell != nil {
2023-11-10 22:36:37 +01:00
writeJsonProtected(shell, update)
}
}
}
2023-11-10 22:36:37 +01:00
func writeJsonProtected(shell *wsshell.WSShell, update any) {
defer func() {
r := recover()
if r == nil {
return
}
log.Printf("[error] in scws RunUpdates WriteJson: %v\n", r)
}()
shell.WriteJson(update)
}
func (ws *WSState) ReplaceShell(shell *wsshell.WSShell) {
ws.Lock.Lock()
defer ws.Lock.Unlock()
if ws.Shell == nil {
ws.Shell = shell
return
}
ws.Shell.Conn.Close()
ws.Shell = shell
}
// returns all state required to display current UI
func (ws *WSState) handleConnection() error {
ctx, cancelFn := context.WithTimeout(context.Background(), 5*time.Second)
defer cancelFn()
connectUpdate, err := sstore.GetConnectUpdate(ctx)
if err != nil {
return fmt.Errorf("getting sessions: %w", err)
}
remotes := remote.GetAllRemoteRuntimeState()
connectUpdate.Remotes = remotes
// restore status indicators
connectUpdate.ScreenStatusIndicators, connectUpdate.ScreenNumRunningCommands = sstore.GetCurrentIndicatorState()
Simplified terminal theming (#570) * save work * reusable StyleBlock component * StyleBlock in elements dir * root level * ability to inherit root styles * change prop from classname to selector * selector should always be :root * remove selector prop from StyleBlock * working * cleanup * loadThemeStyles doesn't have to be async * revert changes in tabs2.less * remove old implementation * cleanup * remove file from another branch * fix issue where line in history view doesn't reflect the terminal theme * add key and value validation * add label to tab settings terminal theme dropdown * save work * save work * save work * working * trigger componentDidUpdate when switching tabs and sessions * cleanup * save work * save work * use UpdatePacket for theme changes as well * make methods cohesive * use themes coming from backend * reload terminal when styel block is unmounted and mounted * fix validation * re-render terminal when theme is updated * remove test styles * cleanup * more cleanup * revert unneeded change * more cleanup * fix type * more cleanup * render style blocks in the header instead of body using portal * add ability to reuse and dispose TermThemes instance and file watcher * remove comment * minor change * separate filewatcher as singleton * do not render app when term theme style blocks aren't rendered first * only render main when termstyles have been rendered already * add comment * use DoUpdate to send themes to front-end * support to watch subdirectories * added support for watch subdirectories * make watcher more flexible so it can be closed anywhere * cleanup * undo the app/main split * use TermThemesType in creating initial value for Themes field * simplify code * fix issue where dropdown label doesn't float when the theme selected is Inherit * remove unsed var * start watcher in main, merge themes (don't overwrite) on event. * ensure terminal-themes directory is created on startup * ah, wait for termThemes to be set (the connect packet needs to have been processed to proceed with rendering)
2024-04-24 08:22:35 +02:00
configs, err := configstore.ScanConfigs()
if err != nil {
return fmt.Errorf("getting configs: %w", err)
}
connectUpdate.TermThemes = &configs
mu := scbus.MakeUpdatePacket()
mu.AddUpdate(*connectUpdate)
err = ws.Shell.WriteJson(mu)
if err != nil {
return err
}
return nil
}
func (ws *WSState) handleWatchScreen(wsPk *scpacket.WatchScreenPacketType) error {
if wsPk.SessionId != "" {
if _, err := uuid.Parse(wsPk.SessionId); err != nil {
return fmt.Errorf("invalid watchscreen sessionid: %w", err)
}
}
if wsPk.ScreenId != "" {
if _, err := uuid.Parse(wsPk.ScreenId); err != nil {
return fmt.Errorf("invalid watchscreen screenid: %w", err)
}
}
if wsPk.AuthKey == "" {
ws.SetAuthenticated(false)
return fmt.Errorf("invalid watchscreen, no authkey")
}
if wsPk.AuthKey != ws.AuthKey {
ws.SetAuthenticated(false)
return fmt.Errorf("invalid watchscreen, invalid authkey")
}
ws.SetAuthenticated(true)
if wsPk.SessionId == "" || wsPk.ScreenId == "" {
ws.UnWatchScreen()
} else {
ws.WatchScreen(wsPk.SessionId, wsPk.ScreenId)
2022-10-31 20:40:45 +01:00
log.Printf("[ws %s] watchscreen %s/%s\n", ws.ClientId, wsPk.SessionId, wsPk.ScreenId)
}
if wsPk.Connect {
2023-04-05 08:44:47 +02:00
// log.Printf("[ws %s] watchscreen connect\n", ws.ClientId)
err := ws.handleConnection()
if err != nil {
return fmt.Errorf("connect: %w", err)
}
}
return nil
}
func (ws *WSState) processMessage(msgBytes []byte) error {
defer func() {
r := recover()
if r == nil {
return
}
log.Printf("[scws] panic in processMessage: %v\n", r)
debug.PrintStack()
}()
pk, err := packet.ParseJsonPacket(msgBytes)
if err != nil {
return fmt.Errorf("error unmarshalling ws message: %w", err)
}
if pk.GetType() == scpacket.WatchScreenPacketStr {
wsPk := pk.(*scpacket.WatchScreenPacketType)
err := ws.handleWatchScreen(wsPk)
if err != nil {
return fmt.Errorf("client:%s error %w", ws.ClientId, err)
}
return nil
}
isAuth := ws.IsAuthenticated()
if !isAuth {
return fmt.Errorf("cannot process ws-packet[%s], not authenticated", pk.GetType())
}
if pk.GetType() == scpacket.FeInputPacketStr {
feInputPk := pk.(*scpacket.FeInputPacketType)
if feInputPk.Remote.OwnerId != "" {
return fmt.Errorf("error cannot send input to remote with ownerid")
}
if feInputPk.Remote.RemoteId == "" {
return fmt.Errorf("error invalid input packet, remoteid is not set")
}
err := RemoteInputMapQueue.Enqueue(feInputPk.Remote.RemoteId, func() {
sendErr := sendCmdInput(feInputPk)
if sendErr != nil {
log.Printf("[scws] sending command input: %v\n", sendErr)
2022-08-24 22:21:54 +02:00
}
})
if err != nil {
return fmt.Errorf("[error] could not queue sendCmdInput: %w", err)
}
return nil
}
if pk.GetType() == scpacket.RemoteInputPacketStr {
inputPk := pk.(*scpacket.RemoteInputPacketType)
if inputPk.RemoteId == "" {
return fmt.Errorf("error invalid remoteinput packet, remoteid is not set")
}
go func() {
sendErr := remote.SendRemoteInput(inputPk)
if sendErr != nil {
log.Printf("[scws] error processing remote input: %v\n", sendErr)
}
}()
return nil
}
if pk.GetType() == scpacket.CmdInputTextPacketStr {
cmdInputPk := pk.(*scpacket.CmdInputTextPacketType)
if cmdInputPk.ScreenId == "" {
return fmt.Errorf("error invalid cmdinput packet, screenid is not set")
}
// no need for goroutine for memory ops
sstore.ScreenMemSetCmdInputText(cmdInputPk.ScreenId, cmdInputPk.Text, cmdInputPk.SeqNum)
return nil
}
if pk.GetType() == userinput.UserInputResponsePacketStr {
userInputRespPk := pk.(*userinput.UserInputResponsePacketType)
uich, ok := scbus.MainRpcBus.GetRpcChannel(userInputRespPk.RequestId)
Use ssh library: add user input (#281) * feat: create backend for user input requests This is the first part of a change that allows the backend to request user input from the frontend. Essentially, the backend will send a request for the user to answer some query, and the frontend will send that answer back. It is blocking, so it needs to be used within a goroutine. There is some placeholder code in the frontend that will be updated in future commits. Similarly, there is some debug code in the backend remote.go file. * feat: create frontend for user input requests This is part of a change to allow the backend to request user input from the frontend. This adds a component specifically for handling this logic. It is only a starting point, and does not work perfectly yet. * refactor: update user input backend/interface This updates the user input backend to fix a few potential bugs. It also refactors the user input request and response types to better handle markdown and errors while making it more convenient to work with. A couple frontend changes were made to keep everything compatible. * fix: add props to user input request modal There was a second place that the modals were created that I previously missed. This fixes that second casel * feat: complete user input modal This rounds out the most immediate concerns for the new user input modal. The frontend now includes a timer to show how much time is left and will close itself once it reaches zero. Css formatting has been cleaned up to be more reasonable. There is still some test code present on the back end. This will be removed once actuall examples of the new modal are in place. * feat: create first pass known_hosts detection Manually integrating with golang's ssh library means that the code must authenticate known_hosts on its own. This is a first pass at creating a system that parses the known hosts files and denys a connection if there is a mismatch. This needs to be updated with a means to add keys to the known-hosts file if the user requests it. * feat: allow writing to known_hosts first pass As a follow-up to the previous change, we now allow the user to respond to interactive queries in order to determine if an unknown known hosts key can be added to a known_hosts file if it is missing. This needs to be refined further, but it gets the basic functionality there. * feat: add user input for kbd-interactive auth This adds a modal so the user can respond to prompts provided using the keyboard interactive authentication method. * feat: add interactive password authentication This makes the ssh password authentication interactive with its own user input modal. Unfortunately, this method does not allow trying a default first. This will need to be expanded in the future to accomodate that. * fix: allow automatic and interactive auth together Previously, it was impossible to use to separate methods of the same type to try ssh authentication. This made it impossible to make an auto attempt before a manual one. This change restricts that by combining them into one method where the auto attempt is tried once first and cannot be tried again. Following that, interactive authentication can be tried separately. It also lowers the time limit on kbd interactive authentication to 15 seconds due to limitations on the library we are using. * fix: set number of retries to one in ssh Number of retries means number of attempts after the fact, not number of total attempts. It has been adjusted from 2 to 1 to reflect this. * refactor: change argument order in GetUserInput This is a simple change to move the context to the first argument of GetUserInput to match the convention used elsewhere in the code. * fix: set number of retries to two again I was wrong in my previous analysis. The number given is the total number of tries. This is confusing when keyboard authentication and password authentication are both available which usually doesn't happen. * feat: create naive ui for ssh key passphrases This isn't quite as reactive as the other methods, but it does attempt to use publickey without a passphrase, then attempt to use the password as the passphrase, and finally prompting the user for a passphrase. The problem with this approach is that if multiple keys are used and they all have passphrases, they need to all be checked up front. In practice, this will not happen often, but it is something to be aware of. * fix: add the userinput.tsx changes These were missed in the previous commit. Adding them now.
2024-02-09 04:16:56 +01:00
if !ok {
return fmt.Errorf("received User Input Response with invalid Id (%s): %v", userInputRespPk.RequestId, err)
Use ssh library: add user input (#281) * feat: create backend for user input requests This is the first part of a change that allows the backend to request user input from the frontend. Essentially, the backend will send a request for the user to answer some query, and the frontend will send that answer back. It is blocking, so it needs to be used within a goroutine. There is some placeholder code in the frontend that will be updated in future commits. Similarly, there is some debug code in the backend remote.go file. * feat: create frontend for user input requests This is part of a change to allow the backend to request user input from the frontend. This adds a component specifically for handling this logic. It is only a starting point, and does not work perfectly yet. * refactor: update user input backend/interface This updates the user input backend to fix a few potential bugs. It also refactors the user input request and response types to better handle markdown and errors while making it more convenient to work with. A couple frontend changes were made to keep everything compatible. * fix: add props to user input request modal There was a second place that the modals were created that I previously missed. This fixes that second casel * feat: complete user input modal This rounds out the most immediate concerns for the new user input modal. The frontend now includes a timer to show how much time is left and will close itself once it reaches zero. Css formatting has been cleaned up to be more reasonable. There is still some test code present on the back end. This will be removed once actuall examples of the new modal are in place. * feat: create first pass known_hosts detection Manually integrating with golang's ssh library means that the code must authenticate known_hosts on its own. This is a first pass at creating a system that parses the known hosts files and denys a connection if there is a mismatch. This needs to be updated with a means to add keys to the known-hosts file if the user requests it. * feat: allow writing to known_hosts first pass As a follow-up to the previous change, we now allow the user to respond to interactive queries in order to determine if an unknown known hosts key can be added to a known_hosts file if it is missing. This needs to be refined further, but it gets the basic functionality there. * feat: add user input for kbd-interactive auth This adds a modal so the user can respond to prompts provided using the keyboard interactive authentication method. * feat: add interactive password authentication This makes the ssh password authentication interactive with its own user input modal. Unfortunately, this method does not allow trying a default first. This will need to be expanded in the future to accomodate that. * fix: allow automatic and interactive auth together Previously, it was impossible to use to separate methods of the same type to try ssh authentication. This made it impossible to make an auto attempt before a manual one. This change restricts that by combining them into one method where the auto attempt is tried once first and cannot be tried again. Following that, interactive authentication can be tried separately. It also lowers the time limit on kbd interactive authentication to 15 seconds due to limitations on the library we are using. * fix: set number of retries to one in ssh Number of retries means number of attempts after the fact, not number of total attempts. It has been adjusted from 2 to 1 to reflect this. * refactor: change argument order in GetUserInput This is a simple change to move the context to the first argument of GetUserInput to match the convention used elsewhere in the code. * fix: set number of retries to two again I was wrong in my previous analysis. The number given is the total number of tries. This is confusing when keyboard authentication and password authentication are both available which usually doesn't happen. * feat: create naive ui for ssh key passphrases This isn't quite as reactive as the other methods, but it does attempt to use publickey without a passphrase, then attempt to use the password as the passphrase, and finally prompting the user for a passphrase. The problem with this approach is that if multiple keys are used and they all have passphrases, they need to all be checked up front. In practice, this will not happen often, but it is something to be aware of. * fix: add the userinput.tsx changes These were missed in the previous commit. Adding them now.
2024-02-09 04:16:56 +01:00
}
select {
case uich <- userInputRespPk:
default:
}
return nil
}
if pk.GetType() == scpacket.FeActivityPacketStr {
feActivityPk := pk.(*scpacket.FeActivityPacketType)
telemetry.UpdateFeActivityWrap(feActivityPk)
return nil
}
return fmt.Errorf("got ws bad message: %v", pk.GetType())
}
func (ws *WSState) RunWSRead() {
shell := ws.GetShell()
if shell == nil {
return
}
shell.WriteJson(map[string]any{"type": "hello"}) // let client know we accepted this connection, ignore error
for msgBytes := range shell.ReadChan {
err := ws.processMessage(msgBytes)
if err != nil {
// TODO send errors back to client? likely unrecoverable
log.Printf("[scws] %v\n", err)
}
}
}
func sendCmdInput(pk *scpacket.FeInputPacketType) error {
err := pk.CK.Validate("input packet")
if err != nil {
return err
}
if pk.Remote.RemoteId == "" {
return fmt.Errorf("input must set remoteid")
}
wsh := remote.GetRemoteById(pk.Remote.RemoteId)
if wsh == nil {
2022-12-28 22:56:19 +01:00
return fmt.Errorf("remote %s not found", pk.Remote.RemoteId)
}
return wsh.HandleFeInput(pk)
}