mirror of
https://github.com/wavetermdev/waveterm.git
synced 2024-12-22 16:48:23 +01:00
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.
This commit is contained in:
parent
e692c7c180
commit
903b26bfca
@ -8,6 +8,7 @@ export const SESSION_SETTINGS = "sessionSettings";
|
||||
export const LINE_SETTINGS = "lineSettings";
|
||||
export const CLIENT_SETTINGS = "clientSettings";
|
||||
export const TAB_SWITCHER = "tabSwitcher";
|
||||
export const USER_INPUT = "userInput";
|
||||
|
||||
export const LineContainer_Main = "main";
|
||||
export const LineContainer_History = "history";
|
||||
|
@ -5,6 +5,7 @@
|
||||
margin-bottom: 10px;
|
||||
font-family: @markdown-font;
|
||||
font-size: 14px;
|
||||
overflow-wrap: break-word;
|
||||
|
||||
code {
|
||||
background-color: @markdown-highlight;
|
||||
|
@ -60,7 +60,13 @@ class PasswordField extends TextField {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn(`wave-textfield wave-password ${className || ""}`, { focused: focused, error: error })}>
|
||||
<div
|
||||
className={cn(`wave-textfield wave-password ${className || ""}`, {
|
||||
focused: focused,
|
||||
error: error,
|
||||
"no-label": !label,
|
||||
})}
|
||||
>
|
||||
{decoration?.startDecoration && <>{decoration.startDecoration}</>}
|
||||
<div className="wave-textfield-inner">
|
||||
<label
|
||||
|
@ -9,3 +9,4 @@ export { TabSwitcherModal } from "./tabswitcher";
|
||||
export { SessionSettingsModal } from "./sessionsettings";
|
||||
export { ScreenSettingsModal } from "./screensettings";
|
||||
export { LineSettingsModal } from "./linesettings";
|
||||
export { UserInputModal } from "./userinput";
|
||||
|
@ -17,7 +17,7 @@ class ModalsProvider extends React.Component {
|
||||
for (let i = 0; i < store.length; i++) {
|
||||
let entry = store[i];
|
||||
let Comp = entry.component;
|
||||
rtn.push(<Comp key={entry.uniqueKey} />);
|
||||
rtn.push(<Comp key={entry.uniqueKey} {...entry.props} />);
|
||||
}
|
||||
return <>{rtn}</>;
|
||||
}
|
||||
|
@ -12,19 +12,21 @@ import {
|
||||
SessionSettingsModal,
|
||||
ScreenSettingsModal,
|
||||
LineSettingsModal,
|
||||
UserInputModal,
|
||||
} from "../modals";
|
||||
import * as constants from "../../appconst";
|
||||
|
||||
const modalsRegistry: { [key: string]: () => React.ReactElement } = {
|
||||
[constants.ABOUT]: () => <AboutModal />,
|
||||
[constants.CREATE_REMOTE]: () => <CreateRemoteConnModal />,
|
||||
[constants.VIEW_REMOTE]: () => <ViewRemoteConnDetailModal />,
|
||||
[constants.EDIT_REMOTE]: () => <EditRemoteConnModal />,
|
||||
[constants.ALERT]: () => <AlertModal />,
|
||||
[constants.SCREEN_SETTINGS]: () => <ScreenSettingsModal />,
|
||||
[constants.SESSION_SETTINGS]: () => <SessionSettingsModal />,
|
||||
[constants.LINE_SETTINGS]: () => <LineSettingsModal />,
|
||||
[constants.TAB_SWITCHER]: () => <TabSwitcherModal />,
|
||||
const modalsRegistry: { [key: string]: React.ComponentType } = {
|
||||
[constants.ABOUT]: AboutModal,
|
||||
[constants.CREATE_REMOTE]: CreateRemoteConnModal,
|
||||
[constants.VIEW_REMOTE]: ViewRemoteConnDetailModal,
|
||||
[constants.EDIT_REMOTE]: EditRemoteConnModal,
|
||||
[constants.ALERT]: AlertModal,
|
||||
[constants.SCREEN_SETTINGS]: ScreenSettingsModal,
|
||||
[constants.SESSION_SETTINGS]: SessionSettingsModal,
|
||||
[constants.LINE_SETTINGS]: LineSettingsModal,
|
||||
[constants.TAB_SWITCHER]: TabSwitcherModal,
|
||||
[constants.USER_INPUT]: UserInputModal,
|
||||
};
|
||||
|
||||
export { modalsRegistry };
|
||||
|
15
src/app/common/modals/userinput.less
Normal file
15
src/app/common/modals/userinput.less
Normal file
@ -0,0 +1,15 @@
|
||||
@import "../../../app/common/themes/themes.less";
|
||||
|
||||
.userinput-modal {
|
||||
width: 500px;
|
||||
|
||||
.wave-modal-content {
|
||||
.wave-modal-body {
|
||||
padding: 20px 20px;
|
||||
|
||||
.userinput-query {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
88
src/app/common/modals/userinput.tsx
Normal file
88
src/app/common/modals/userinput.tsx
Normal file
@ -0,0 +1,88 @@
|
||||
import * as React from "react";
|
||||
import { GlobalModel } from "../../../models";
|
||||
import { Choose, When, If } from "tsx-control-statements";
|
||||
import { Modal, PasswordField, Markdown } from "../elements";
|
||||
import { UserInputRequest } from "../../../types/types";
|
||||
|
||||
import "./userinput.less";
|
||||
|
||||
export const UserInputModal = (userInputRequest: UserInputRequest) => {
|
||||
const [responseText, setResponseText] = React.useState(null);
|
||||
const [countdown, setCountdown] = React.useState(Math.floor(userInputRequest.timeoutms / 1000));
|
||||
|
||||
const closeModal = React.useCallback(() => {
|
||||
GlobalModel.sendUserInput({
|
||||
type: "userinputresp",
|
||||
requestid: userInputRequest.requestid,
|
||||
errormsg: "Canceled by the user",
|
||||
});
|
||||
GlobalModel.remotesModel.closeModal();
|
||||
}, [responseText, userInputRequest]);
|
||||
|
||||
const handleSendText = React.useCallback(() => {
|
||||
GlobalModel.sendUserInput({
|
||||
type: "userinputresp",
|
||||
requestid: userInputRequest.requestid,
|
||||
text: responseText,
|
||||
});
|
||||
GlobalModel.remotesModel.closeModal();
|
||||
}, [responseText, userInputRequest]);
|
||||
|
||||
const handleSendConfirm = React.useCallback(
|
||||
(response: boolean) => {
|
||||
GlobalModel.sendUserInput({
|
||||
type: "userinputresp",
|
||||
requestid: userInputRequest.requestid,
|
||||
confirm: response,
|
||||
});
|
||||
GlobalModel.remotesModel.closeModal();
|
||||
},
|
||||
[userInputRequest]
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
let timeout: ReturnType<typeof setTimeout>;
|
||||
if (countdown == 0) {
|
||||
timeout = setTimeout(() => {
|
||||
GlobalModel.remotesModel.closeModal();
|
||||
}, 300);
|
||||
} else {
|
||||
timeout = setTimeout(() => {
|
||||
setCountdown(countdown - 1);
|
||||
}, 1000);
|
||||
}
|
||||
return () => clearTimeout(timeout);
|
||||
}, [countdown]);
|
||||
|
||||
return (
|
||||
<Modal className="userinput-modal">
|
||||
<Modal.Header onClose={closeModal} title={userInputRequest.title + ` (${countdown})`} />
|
||||
<div className="wave-modal-body">
|
||||
<div className="userinput-query">
|
||||
<If condition={userInputRequest.markdown}>
|
||||
<Markdown text={userInputRequest.querytext} />
|
||||
</If>
|
||||
<If condition={!userInputRequest.markdown}>{userInputRequest.querytext}</If>
|
||||
</div>
|
||||
<Choose>
|
||||
<When condition={userInputRequest.responsetype == "text"}>
|
||||
<PasswordField onChange={setResponseText} value={responseText} maxLength={400} />
|
||||
</When>
|
||||
</Choose>
|
||||
</div>
|
||||
<Choose>
|
||||
<When condition={userInputRequest.responsetype == "text"}>
|
||||
<Modal.Footer onCancel={closeModal} onOk={handleSendText} okLabel="Continue" />
|
||||
</When>
|
||||
<When condition={userInputRequest.responsetype == "confirm"}>
|
||||
<Modal.Footer
|
||||
onCancel={() => handleSendConfirm(false)}
|
||||
onOk={() => handleSendConfirm(true)}
|
||||
okLabel="Yes"
|
||||
cancelLabel="No"
|
||||
/>
|
||||
</When>
|
||||
</Choose>
|
||||
</Modal>
|
||||
);
|
||||
};
|
@ -8,14 +8,14 @@ import { modalsRegistry } from "../app/common/modals/registry";
|
||||
import { OArr } from "../types/types";
|
||||
|
||||
class ModalsModel {
|
||||
store: OArr<ModalStoreEntry> = mobx.observable.array([], { name: "ModalsModel-store" });
|
||||
store: OArr<ModalStoreEntry> = mobx.observable.array([], { name: "ModalsModel-store", deep: false });
|
||||
|
||||
pushModal(modalId: string) {
|
||||
pushModal(modalId: string, props?: any) {
|
||||
const modalFactory = modalsRegistry[modalId];
|
||||
|
||||
if (modalFactory && !this.store.some((modal) => modal.id === modalId)) {
|
||||
mobx.action(() => {
|
||||
this.store.push({ id: modalId, component: modalFactory, uniqueKey: uuidv4() });
|
||||
this.store.push({ id: modalId, component: modalFactory, uniqueKey: uuidv4(), props });
|
||||
})();
|
||||
}
|
||||
}
|
||||
|
@ -31,6 +31,8 @@ import {
|
||||
RendererContext,
|
||||
ClientDataType,
|
||||
AlertMessageType,
|
||||
UserInputRequest,
|
||||
UserInputResponsePacket,
|
||||
ScreenLinesType,
|
||||
RemoteViewType,
|
||||
CommandRtnType,
|
||||
@ -902,6 +904,10 @@ class Model {
|
||||
this.getScreenById_single(snc.screenid)?.setNumRunningCmds(snc.num);
|
||||
}
|
||||
}
|
||||
if ("userinputrequest" in update) {
|
||||
let userInputRequest: UserInputRequest = update.userinputrequest;
|
||||
this.modalsModel.pushModal(appconst.USER_INPUT, userInputRequest);
|
||||
}
|
||||
}
|
||||
|
||||
updateRemotes(remotes: RemoteType[]): void {
|
||||
@ -1309,6 +1315,10 @@ class Model {
|
||||
this.ws.pushMessage(inputPacket);
|
||||
}
|
||||
|
||||
sendUserInput(userInputResponsePacket: UserInputResponsePacket) {
|
||||
this.ws.pushMessage(userInputResponsePacket);
|
||||
}
|
||||
|
||||
sendCmdInputText(screenId: string, sp: StrWithPos) {
|
||||
let pk: CmdInputTextPacketType = {
|
||||
type: "cmdinputtext",
|
||||
|
@ -51,6 +51,8 @@ import {
|
||||
HistoryViewDataType,
|
||||
AlertMessageType,
|
||||
HistorySearchParams,
|
||||
UserInputRequest,
|
||||
UserInputResponsePacket,
|
||||
FocusTypeStrs,
|
||||
ScreenLinesType,
|
||||
HistoryTypeStrs,
|
||||
@ -3376,14 +3378,14 @@ class RemotesModel {
|
||||
}
|
||||
|
||||
class ModalsModel {
|
||||
store: OArr<T.ModalStoreEntry> = mobx.observable.array([], { name: "ModalsModel-store" });
|
||||
store: OArr<T.ModalStoreEntry> = mobx.observable.array([], { name: "ModalsModel-store", deep: false });
|
||||
|
||||
pushModal(modalId: string) {
|
||||
pushModal(modalId: string, props?: any) {
|
||||
const modalFactory = modalsRegistry[modalId];
|
||||
|
||||
if (modalFactory && !this.store.some((modal) => modal.id === modalId)) {
|
||||
mobx.action(() => {
|
||||
this.store.push({ id: modalId, component: modalFactory, uniqueKey: uuidv4() });
|
||||
this.store.push({ id: modalId, component: modalFactory, uniqueKey: uuidv4(), props: props });
|
||||
})();
|
||||
}
|
||||
}
|
||||
@ -4184,6 +4186,10 @@ class Model {
|
||||
this.getScreenById_single(snc.screenid)?.setNumRunningCmds(snc.num);
|
||||
}
|
||||
}
|
||||
if ("userinputrequest" in update) {
|
||||
let userInputRequest: UserInputRequest = update.userinputrequest;
|
||||
this.modalsModel.pushModal(appconst.USER_INPUT, userInputRequest);
|
||||
}
|
||||
}
|
||||
|
||||
updateRemotes(remotes: RemoteType[]): void {
|
||||
@ -4591,6 +4597,10 @@ class Model {
|
||||
this.ws.pushMessage(inputPacket);
|
||||
}
|
||||
|
||||
sendUserInput(userInputResponsePacket: UserInputResponsePacket) {
|
||||
this.ws.pushMessage(userInputResponsePacket);
|
||||
}
|
||||
|
||||
sendCmdInputText(screenId: string, sp: T.StrWithPos) {
|
||||
let pk: T.CmdInputTextPacketType = {
|
||||
type: "cmdinputtext",
|
||||
|
@ -333,6 +333,7 @@ type ModelUpdateType = {
|
||||
alertmessage?: AlertMessageType;
|
||||
screenstatusindicators?: ScreenStatusIndicatorUpdateType[];
|
||||
screennumrunningcommands?: ScreenNumRunningCommandsUpdateType[];
|
||||
userinputrequest?: UserInputRequest;
|
||||
};
|
||||
|
||||
type HistoryViewDataType = {
|
||||
@ -595,6 +596,23 @@ type HistorySearchParams = {
|
||||
filterCmds?: boolean;
|
||||
};
|
||||
|
||||
type UserInputRequest = {
|
||||
requestid: string;
|
||||
querytext: string;
|
||||
responsetype: string;
|
||||
title: string;
|
||||
markdown: boolean;
|
||||
timeoutms: number;
|
||||
};
|
||||
|
||||
type UserInputResponsePacket = {
|
||||
type: string;
|
||||
requestid: string;
|
||||
text?: string;
|
||||
confirm?: boolean;
|
||||
errormsg?: string;
|
||||
};
|
||||
|
||||
type RenderModeType = "normal" | "collapsed" | "expanded";
|
||||
|
||||
type WebScreen = {
|
||||
@ -730,6 +748,7 @@ type ModalStoreEntry = {
|
||||
id: string;
|
||||
component: React.ComponentType;
|
||||
uniqueKey: string;
|
||||
props?: any;
|
||||
};
|
||||
|
||||
type StrWithPos = {
|
||||
@ -809,6 +828,8 @@ export type {
|
||||
RenderModeType,
|
||||
AlertMessageType,
|
||||
HistorySearchParams,
|
||||
UserInputRequest,
|
||||
UserInputResponsePacket,
|
||||
ScreenLinesType,
|
||||
FocusTypeStrs,
|
||||
HistoryTypeStrs,
|
||||
|
@ -40,7 +40,7 @@ import (
|
||||
"golang.org/x/mod/semver"
|
||||
)
|
||||
|
||||
const UseSshLibrary = false
|
||||
const UseSshLibrary = true
|
||||
|
||||
const RemoteTypeMShell = "mshell"
|
||||
const DefaultTerm = "xterm-256color"
|
||||
|
@ -1,43 +1,117 @@
|
||||
// Copyright 2024, Command Line Inc.
|
||||
// Copyright 2023-2024, Command Line Inc.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package remote
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"os"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/kevinburke/ssh_config"
|
||||
"github.com/wavetermdev/waveterm/waveshell/pkg/base"
|
||||
"github.com/wavetermdev/waveterm/wavesrv/pkg/scpacket"
|
||||
"github.com/wavetermdev/waveterm/wavesrv/pkg/sstore"
|
||||
"golang.org/x/crypto/ssh"
|
||||
"golang.org/x/crypto/ssh/knownhosts"
|
||||
)
|
||||
|
||||
func createPublicKeyAuth(identityFile string, passphrase string) (ssh.AuthMethod, error) {
|
||||
type UserInputCancelError struct {
|
||||
Err error
|
||||
}
|
||||
|
||||
func (uice UserInputCancelError) Error() string {
|
||||
return uice.Err.Error()
|
||||
}
|
||||
|
||||
func createPublicKeyAuth(identityFile string, passphrase string) (ssh.Signer, error) {
|
||||
privateKey, err := os.ReadFile(base.ExpandHomeDir(identityFile))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read ssh key file. err: %+v", err)
|
||||
}
|
||||
signer, err := ssh.ParsePrivateKey(privateKey)
|
||||
if err != nil {
|
||||
if errors.Is(err, &ssh.PassphraseMissingError{}) {
|
||||
signer, err = ssh.ParsePrivateKeyWithPassphrase(privateKey, []byte(passphrase))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse private ssh key with passphrase. err: %+v", err)
|
||||
if err == nil {
|
||||
return signer, err
|
||||
}
|
||||
} else {
|
||||
if _, ok := err.(*ssh.PassphraseMissingError); !ok {
|
||||
return nil, fmt.Errorf("failed to parse private ssh key. err: %+v", err)
|
||||
}
|
||||
|
||||
signer, err = ssh.ParsePrivateKeyWithPassphrase(privateKey, []byte(passphrase))
|
||||
if err == nil {
|
||||
return signer, err
|
||||
}
|
||||
return ssh.PublicKeys(signer), nil
|
||||
if err != x509.IncorrectPasswordError && err.Error() != "bcrypt_pbkdf: empty password" {
|
||||
log.Printf("qwerty: %+v", err)
|
||||
return nil, fmt.Errorf("failed to parse private ssh key. err: %+v", err)
|
||||
}
|
||||
request := &sstore.UserInputRequestType{
|
||||
ResponseType: "text",
|
||||
QueryText: fmt.Sprintf("Enter passphrase for the SSH key: %s", identityFile),
|
||||
Title: "Publickey Auth + Passphrase",
|
||||
}
|
||||
ctx, cancelFn := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancelFn()
|
||||
response, err := sstore.MainBus.GetUserInput(ctx, request)
|
||||
if err != nil {
|
||||
return nil, UserInputCancelError{Err: err}
|
||||
}
|
||||
return ssh.ParsePrivateKeyWithPassphrase(privateKey, []byte(response.Text))
|
||||
}
|
||||
|
||||
func createKeyboardInteractiveAuth(password string) ssh.AuthMethod {
|
||||
challenge := func(name, instruction string, questions []string, echos []bool) (answers []string, err error) {
|
||||
func createDefaultPasswordCallbackPrompt(password string) func() (secret string, err error) {
|
||||
return func() (secret string, err error) {
|
||||
// this should be modified to return an error if no password is stored
|
||||
// but an empty password is not sufficient because some systems allow
|
||||
// empty passwords
|
||||
return password, nil
|
||||
}
|
||||
}
|
||||
|
||||
func createInteractivePasswordCallbackPrompt() func() (secret string, err error) {
|
||||
return func() (secret string, err error) {
|
||||
// limited to 15 seconds for some reason. this should be investigated more
|
||||
// in the future
|
||||
ctx, cancelFn := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancelFn()
|
||||
request := &sstore.UserInputRequestType{
|
||||
ResponseType: "text",
|
||||
QueryText: "Password:",
|
||||
Title: "Password Authentication",
|
||||
}
|
||||
response, err := sstore.MainBus.GetUserInput(ctx, request)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return response.Text, nil
|
||||
}
|
||||
}
|
||||
|
||||
func createCombinedPasswordCallbackPrompt(password string) func() (secret string, err error) {
|
||||
var once sync.Once
|
||||
return func() (secret string, err error) {
|
||||
var prompt func() (secret string, err error)
|
||||
once.Do(func() { prompt = createDefaultPasswordCallbackPrompt(password) })
|
||||
if prompt == nil {
|
||||
prompt = createInteractivePasswordCallbackPrompt()
|
||||
}
|
||||
return prompt()
|
||||
}
|
||||
}
|
||||
|
||||
func createNaiveKbdInteractiveChallenge(password string) func(name, instruction string, questions []string, echos []bool) (answers []string, err error) {
|
||||
return func(name, instruction string, questions []string, echos []bool) (answers []string, err error) {
|
||||
for _, q := range questions {
|
||||
if strings.Contains(strings.ToLower(q), "password") {
|
||||
answers = append(answers, password)
|
||||
@ -47,7 +121,291 @@ func createKeyboardInteractiveAuth(password string) ssh.AuthMethod {
|
||||
}
|
||||
return answers, nil
|
||||
}
|
||||
return ssh.KeyboardInteractive(challenge)
|
||||
}
|
||||
|
||||
func createInteractiveKbdInteractiveChallenge() func(name, instruction string, questions []string, echos []bool) (answers []string, err error) {
|
||||
return func(name, instruction string, questions []string, echos []bool) (answers []string, err error) {
|
||||
if len(questions) != len(echos) {
|
||||
return nil, fmt.Errorf("bad response from server: questions has len %d, echos has len %d", len(questions), len(echos))
|
||||
}
|
||||
for i, question := range questions {
|
||||
echo := echos[i]
|
||||
answer, err := promptChallengeQuestion(question, echo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
answers = append(answers, answer)
|
||||
}
|
||||
return answers, nil
|
||||
}
|
||||
}
|
||||
|
||||
func promptChallengeQuestion(question string, echo bool) (answer string, err error) {
|
||||
// limited to 15 seconds for some reason. this should be investigated more
|
||||
// in the future
|
||||
ctx, cancelFn := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancelFn()
|
||||
request := &sstore.UserInputRequestType{
|
||||
ResponseType: "text",
|
||||
QueryText: question,
|
||||
Title: "Keyboard Interactive Authentication",
|
||||
}
|
||||
response, err := sstore.MainBus.GetUserInput(ctx, request)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return response.Text, nil
|
||||
}
|
||||
|
||||
func createCombinedKbdInteractiveChallenge(password string) ssh.KeyboardInteractiveChallenge {
|
||||
var once sync.Once
|
||||
return func(name, instruction string, questions []string, echos []bool) (answers []string, err error) {
|
||||
var challenge ssh.KeyboardInteractiveChallenge
|
||||
once.Do(func() { challenge = createNaiveKbdInteractiveChallenge(password) })
|
||||
if challenge == nil {
|
||||
challenge = createInteractiveKbdInteractiveChallenge()
|
||||
}
|
||||
return challenge(name, instruction, questions, echos)
|
||||
}
|
||||
}
|
||||
|
||||
func openKnownHostsForEdit(knownHostsFilename string) (*os.File, error) {
|
||||
path, _ := filepath.Split(knownHostsFilename)
|
||||
err := os.MkdirAll(path, 0700)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return os.OpenFile(knownHostsFilename, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0644)
|
||||
}
|
||||
|
||||
func writeToKnownHosts(knownHostsFile string, newLine string, getUserVerification func() (*scpacket.UserInputResponsePacketType, error)) error {
|
||||
if getUserVerification == nil {
|
||||
getUserVerification = func() (*scpacket.UserInputResponsePacketType, error) {
|
||||
return &scpacket.UserInputResponsePacketType{
|
||||
Type: "confirm",
|
||||
Confirm: true,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
path, _ := filepath.Split(knownHostsFile)
|
||||
err := os.MkdirAll(path, 0700)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
f, err := os.OpenFile(knownHostsFile, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// do not close writeable files with defer
|
||||
|
||||
// this file works, so let's ask the user for permission
|
||||
response, err := getUserVerification()
|
||||
if err != nil {
|
||||
f.Close()
|
||||
return UserInputCancelError{Err: err}
|
||||
}
|
||||
if !response.Confirm {
|
||||
f.Close()
|
||||
return UserInputCancelError{Err: fmt.Errorf("Canceled by the user")}
|
||||
}
|
||||
|
||||
_, err = f.WriteString(newLine)
|
||||
return f.Close()
|
||||
}
|
||||
|
||||
func createUnknownKeyVerifier(knownHostsFile string, hostname string, remote string, key ssh.PublicKey) func() (*scpacket.UserInputResponsePacketType, error) {
|
||||
base64Key := base64.StdEncoding.EncodeToString(key.Marshal())
|
||||
queryText := fmt.Sprintf(
|
||||
"The authenticity of host '%s (%s)' can't be established "+
|
||||
"as it **does not exist in any checked known_hosts files**. "+
|
||||
"The host you are attempting to connect to provides this %s key: \n"+
|
||||
"%s.\n\n"+
|
||||
"**Would you like to continue connecting?** If so, the key will be permanently "+
|
||||
"added to the file %s "+
|
||||
"to protect from future man-in-the-middle attacks.", hostname, remote, key.Type(), base64Key, knownHostsFile)
|
||||
request := &sstore.UserInputRequestType{
|
||||
ResponseType: "confirm",
|
||||
QueryText: queryText,
|
||||
Markdown: true,
|
||||
Title: "Known Hosts Key Missing",
|
||||
}
|
||||
return func() (*scpacket.UserInputResponsePacketType, error) {
|
||||
ctx, cancelFn := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancelFn()
|
||||
return sstore.MainBus.GetUserInput(ctx, request)
|
||||
}
|
||||
}
|
||||
|
||||
func createMissingKnownHostsVerifier(knownHostsFile string, hostname string, remote string, key ssh.PublicKey) func() (*scpacket.UserInputResponsePacketType, error) {
|
||||
base64Key := base64.StdEncoding.EncodeToString(key.Marshal())
|
||||
queryText := fmt.Sprintf(
|
||||
"The authenticity of host '%s (%s)' can't be established "+
|
||||
"as **no known_hosts files could be found**. "+
|
||||
"The host you are attempting to connect to provides this %s key: \n"+
|
||||
"%s.\n\n"+
|
||||
"**Would you like to continue connecting?** If so: \n"+
|
||||
"- %s will be created \n"+
|
||||
"- the key will be added to %s\n\n"+
|
||||
"This will protect from future man-in-the-middle attacks.", hostname, remote, key.Type(), base64Key, knownHostsFile, knownHostsFile)
|
||||
request := &sstore.UserInputRequestType{
|
||||
ResponseType: "confirm",
|
||||
QueryText: queryText,
|
||||
Markdown: true,
|
||||
Title: "Known Hosts File Missing",
|
||||
}
|
||||
return func() (*scpacket.UserInputResponsePacketType, error) {
|
||||
ctx, cancelFn := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancelFn()
|
||||
return sstore.MainBus.GetUserInput(ctx, request)
|
||||
}
|
||||
}
|
||||
|
||||
func lineContainsMatch(line []byte, matches [][]byte) bool {
|
||||
for _, match := range matches {
|
||||
if bytes.Contains(line, match) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func createHostKeyCallback(opts *sstore.SSHOpts) (ssh.HostKeyCallback, error) {
|
||||
rawUserKnownHostsFiles, _ := ssh_config.GetStrict(opts.SSHHost, "UserKnownHostsFile")
|
||||
userKnownHostsFiles := strings.Fields(rawUserKnownHostsFiles) // TODO - smarter splitting escaped spaces and quotes
|
||||
rawGlobalKnownHostsFiles, _ := ssh_config.GetStrict(opts.SSHHost, "GlobalKnownHostsFile")
|
||||
globalKnownHostsFiles := strings.Fields(rawGlobalKnownHostsFiles) // TODO - smarter splitting escaped spaces and quotes
|
||||
unexpandedKnownHostsFiles := append(userKnownHostsFiles, globalKnownHostsFiles...)
|
||||
var knownHostsFiles []string
|
||||
for _, filename := range unexpandedKnownHostsFiles {
|
||||
knownHostsFiles = append(knownHostsFiles, base.ExpandHomeDir(filename))
|
||||
}
|
||||
|
||||
// there are no good known hosts files
|
||||
if len(knownHostsFiles) == 0 {
|
||||
return nil, fmt.Errorf("no known_hosts files provided by ssh. defaults are overridden")
|
||||
}
|
||||
|
||||
var unreadableFiles []string
|
||||
|
||||
// the library we use isn't very forgiving about files that are formatted
|
||||
// incorrectly. if a problem file is found, it is removed from our list
|
||||
// and we try again
|
||||
var basicCallback ssh.HostKeyCallback
|
||||
for basicCallback == nil && len(knownHostsFiles) > 0 {
|
||||
var err error
|
||||
basicCallback, err = knownhosts.New(knownHostsFiles...)
|
||||
if serr, ok := err.(*os.PathError); ok {
|
||||
badFile := serr.Path
|
||||
unreadableFiles = append(unreadableFiles, badFile)
|
||||
var okFiles []string
|
||||
for _, filename := range knownHostsFiles {
|
||||
if filename != badFile {
|
||||
okFiles = append(okFiles, filename)
|
||||
}
|
||||
}
|
||||
if len(okFiles) >= len(knownHostsFiles) {
|
||||
return nil, fmt.Errorf("problem file (%s) doesn't exist. this should not be possible", badFile)
|
||||
}
|
||||
knownHostsFiles = okFiles
|
||||
} else if err != nil {
|
||||
// TODO handle obscure problems if possible
|
||||
return nil, fmt.Errorf("known_hosts formatting error: %+v", err)
|
||||
}
|
||||
}
|
||||
|
||||
waveHostKeyCallback := func(hostname string, remote net.Addr, key ssh.PublicKey) error {
|
||||
err := basicCallback(hostname, remote, key)
|
||||
if err == nil {
|
||||
// success
|
||||
return nil
|
||||
} else if _, ok := err.(*knownhosts.RevokedError); ok {
|
||||
// revoked credentials are refused outright
|
||||
return err
|
||||
} else if _, ok := err.(*knownhosts.KeyError); !ok {
|
||||
// this is an unknown error (note the !ok is opposite of usual)
|
||||
return err
|
||||
}
|
||||
serr, _ := err.(*knownhosts.KeyError)
|
||||
if len(serr.Want) == 0 {
|
||||
// the key was not found
|
||||
|
||||
// try to write to a file that could be parsed
|
||||
var err error
|
||||
for _, filename := range knownHostsFiles {
|
||||
newLine := knownhosts.Line([]string{knownhosts.Normalize(hostname)}, key)
|
||||
getUserVerification := createUnknownKeyVerifier(filename, hostname, remote.String(), key)
|
||||
err = writeToKnownHosts(filename, newLine, getUserVerification)
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
if serr, ok := err.(UserInputCancelError); ok {
|
||||
return serr
|
||||
}
|
||||
}
|
||||
|
||||
// try to write to a file that could not be read (file likely doesn't exist)
|
||||
// should catch cases where there is no known_hosts file
|
||||
if err != nil {
|
||||
for _, filename := range unreadableFiles {
|
||||
newLine := knownhosts.Line([]string{knownhosts.Normalize(hostname)}, key)
|
||||
getUserVerification := createMissingKnownHostsVerifier(filename, hostname, remote.String(), key)
|
||||
err = writeToKnownHosts(filename, newLine, getUserVerification)
|
||||
if err == nil {
|
||||
knownHostsFiles = []string{filename}
|
||||
break
|
||||
}
|
||||
if serr, ok := err.(UserInputCancelError); ok {
|
||||
return serr
|
||||
}
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
// the key changed
|
||||
correctKeyFingerprint := base64.StdEncoding.EncodeToString(key.Marshal())
|
||||
var bulletListKnownHosts []string
|
||||
for _, knownHostName := range knownHostsFiles {
|
||||
withBulletPoint := "- " + knownHostName
|
||||
bulletListKnownHosts = append(bulletListKnownHosts, withBulletPoint)
|
||||
}
|
||||
var offendingKeysFmt []string
|
||||
for _, badKey := range serr.Want {
|
||||
formattedKey := "- " + base64.StdEncoding.EncodeToString(badKey.Key.Marshal())
|
||||
offendingKeysFmt = append(offendingKeysFmt, formattedKey)
|
||||
}
|
||||
alertText := fmt.Sprintf("**WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED!**\n\n"+
|
||||
"If this is not expected, it is possible that someone could be trying to "+
|
||||
"eavesdrop on you via a man-in-the-middle attack. "+
|
||||
"Alternatively, the host you are connecting to may have changed its key. "+
|
||||
"The %s key sent by the remote hist has the fingerprint: \n"+
|
||||
"%s\n\n"+
|
||||
"If you are sure this is correct, please update your known_hosts files to "+
|
||||
"remove the lines with the offending before trying to connect again. \n"+
|
||||
"**Known Hosts Files** \n"+
|
||||
"%s\n\n"+
|
||||
"**Offending Keys** \n"+
|
||||
"%s", key.Type(), correctKeyFingerprint, strings.Join(bulletListKnownHosts, " \n"), strings.Join(offendingKeysFmt, " \n"))
|
||||
update := &sstore.ModelUpdate{AlertMessage: &sstore.AlertMessageType{
|
||||
Markdown: true,
|
||||
Title: "Known Hosts Key Changed",
|
||||
Message: alertText,
|
||||
}}
|
||||
sstore.MainBus.SendUpdate(update)
|
||||
return fmt.Errorf("remote host identification has changed")
|
||||
}
|
||||
|
||||
updatedCallback, err := knownhosts.New(knownHostsFiles...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// try one final time
|
||||
return updatedCallback(hostname, remote, key)
|
||||
}
|
||||
|
||||
return waveHostKeyCallback, nil
|
||||
}
|
||||
|
||||
func ConnectToClient(opts *sstore.SSHOpts) (*ssh.Client, error) {
|
||||
@ -60,14 +418,17 @@ func ConnectToClient(opts *sstore.SSHOpts) (*ssh.Client, error) {
|
||||
identityFile = configIdentity
|
||||
}
|
||||
|
||||
hostKeyCallback := ssh.InsecureIgnoreHostKey()
|
||||
var authMethods []ssh.AuthMethod
|
||||
publicKeyAuth, err := createPublicKeyAuth(identityFile, opts.SSHPassword)
|
||||
if err == nil {
|
||||
authMethods = append(authMethods, publicKeyAuth)
|
||||
hostKeyCallback, err := createHostKeyCallback(opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
authMethods = append(authMethods, createKeyboardInteractiveAuth(opts.SSHPassword))
|
||||
authMethods = append(authMethods, ssh.Password(opts.SSHPassword))
|
||||
var authMethods []ssh.AuthMethod
|
||||
publicKeySigner, err := createPublicKeyAuth(identityFile, opts.SSHPassword)
|
||||
if err == nil {
|
||||
authMethods = append(authMethods, ssh.PublicKeys(publicKeySigner))
|
||||
}
|
||||
authMethods = append(authMethods, ssh.RetryableAuthMethod(ssh.KeyboardInteractive(createCombinedKbdInteractiveChallenge(opts.SSHPassword)), 2))
|
||||
authMethods = append(authMethods, ssh.RetryableAuthMethod(ssh.PasswordCallback(createCombinedPasswordCallbackPrompt(opts.SSHPassword)), 2))
|
||||
|
||||
configUser, _ := ssh_config.GetStrict(opts.SSHHost, "User")
|
||||
configHostName, _ := ssh_config.GetStrict(opts.SSHHost, "HostName")
|
||||
|
@ -6,20 +6,84 @@ package scpacket
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/alessio/shellescape"
|
||||
"github.com/google/uuid"
|
||||
"github.com/wavetermdev/waveterm/waveshell/pkg/base"
|
||||
"github.com/wavetermdev/waveterm/waveshell/pkg/packet"
|
||||
"github.com/wavetermdev/waveterm/waveshell/pkg/utilfn"
|
||||
"github.com/wavetermdev/waveterm/wavesrv/pkg/sstore"
|
||||
)
|
||||
|
||||
var RemoteNameRe = regexp.MustCompile("^\\*?[a-zA-Z0-9_-]+$")
|
||||
|
||||
type RemotePtrType struct {
|
||||
OwnerId string `json:"ownerid"`
|
||||
RemoteId string `json:"remoteid"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
func (r RemotePtrType) IsSessionScope() bool {
|
||||
return strings.HasPrefix(r.Name, "*")
|
||||
}
|
||||
|
||||
func (rptr *RemotePtrType) GetDisplayName(baseDisplayName string) string {
|
||||
name := baseDisplayName
|
||||
if rptr == nil {
|
||||
return name
|
||||
}
|
||||
if rptr.Name != "" {
|
||||
name = name + ":" + rptr.Name
|
||||
}
|
||||
if rptr.OwnerId != "" {
|
||||
name = "@" + rptr.OwnerId + ":" + name
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
func (r RemotePtrType) Validate() error {
|
||||
if r.OwnerId != "" {
|
||||
if _, err := uuid.Parse(r.OwnerId); err != nil {
|
||||
return fmt.Errorf("invalid ownerid format: %v", err)
|
||||
}
|
||||
}
|
||||
if r.RemoteId != "" {
|
||||
if _, err := uuid.Parse(r.RemoteId); err != nil {
|
||||
return fmt.Errorf("invalid remoteid format: %v", err)
|
||||
}
|
||||
}
|
||||
if r.Name != "" {
|
||||
ok := RemoteNameRe.MatchString(r.Name)
|
||||
if !ok {
|
||||
return fmt.Errorf("invalid remote name")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r RemotePtrType) MakeFullRemoteRef() string {
|
||||
if r.RemoteId == "" {
|
||||
return ""
|
||||
}
|
||||
if r.OwnerId == "" && r.Name == "" {
|
||||
return r.RemoteId
|
||||
}
|
||||
if r.OwnerId != "" && r.Name == "" {
|
||||
return fmt.Sprintf("@%s:%s", r.OwnerId, r.RemoteId)
|
||||
}
|
||||
if r.OwnerId == "" && r.Name != "" {
|
||||
return fmt.Sprintf("%s:%s", r.RemoteId, r.Name)
|
||||
}
|
||||
return fmt.Sprintf("@%s:%s:%s", r.OwnerId, r.RemoteId, r.Name)
|
||||
}
|
||||
|
||||
const FeCommandPacketStr = "fecmd"
|
||||
const WatchScreenPacketStr = "watchscreen"
|
||||
const FeInputPacketStr = "feinput"
|
||||
const RemoteInputPacketStr = "remoteinput"
|
||||
const CmdInputTextPacketStr = "cmdinputtext"
|
||||
const UserInputResponsePacketStr = "userinputresp"
|
||||
|
||||
type FeCommandPacketType struct {
|
||||
Type string `json:"type"`
|
||||
@ -57,7 +121,7 @@ func (pk *FeCommandPacketType) GetRawStr() string {
|
||||
type UIContextType struct {
|
||||
SessionId string `json:"sessionid"`
|
||||
ScreenId string `json:"screenid"`
|
||||
Remote *sstore.RemotePtrType `json:"remote,omitempty"`
|
||||
Remote *RemotePtrType `json:"remote,omitempty"`
|
||||
WinSize *packet.WinSize `json:"winsize,omitempty"`
|
||||
Build string `json:"build,omitempty"`
|
||||
}
|
||||
@ -65,7 +129,7 @@ type UIContextType struct {
|
||||
type FeInputPacketType struct {
|
||||
Type string `json:"type"`
|
||||
CK base.CommandKey `json:"ck"`
|
||||
Remote sstore.RemotePtrType `json:"remote"`
|
||||
Remote RemotePtrType `json:"remote"`
|
||||
InputData64 string `json:"inputdata64"`
|
||||
SigName string `json:"signame,omitempty"`
|
||||
WinSize *packet.WinSize `json:"winsize,omitempty"`
|
||||
@ -92,12 +156,21 @@ type CmdInputTextPacketType struct {
|
||||
Text utilfn.StrWithPos `json:"text"`
|
||||
}
|
||||
|
||||
type UserInputResponsePacketType struct {
|
||||
Type string `json:"type"`
|
||||
RequestId string `json:"requestid"`
|
||||
Text string `json:"text,omitempty"`
|
||||
Confirm bool `json:"confirm,omitempty"`
|
||||
ErrorMsg string `json:"errormsg,omitempty"`
|
||||
}
|
||||
|
||||
func init() {
|
||||
packet.RegisterPacketType(FeCommandPacketStr, reflect.TypeOf(FeCommandPacketType{}))
|
||||
packet.RegisterPacketType(WatchScreenPacketStr, reflect.TypeOf(WatchScreenPacketType{}))
|
||||
packet.RegisterPacketType(FeInputPacketStr, reflect.TypeOf(FeInputPacketType{}))
|
||||
packet.RegisterPacketType(RemoteInputPacketStr, reflect.TypeOf(RemoteInputPacketType{}))
|
||||
packet.RegisterPacketType(CmdInputTextPacketStr, reflect.TypeOf(CmdInputTextPacketType{}))
|
||||
packet.RegisterPacketType(UserInputResponsePacketStr, reflect.TypeOf(UserInputResponsePacketType{}))
|
||||
}
|
||||
|
||||
func (*CmdInputTextPacketType) GetType() string {
|
||||
@ -139,3 +212,7 @@ func MakeRemoteInputPacket() *RemoteInputPacketType {
|
||||
func (*RemoteInputPacketType) GetType() string {
|
||||
return RemoteInputPacketStr
|
||||
}
|
||||
|
||||
func (*UserInputResponsePacketType) GetType() string {
|
||||
return UserInputResponsePacketStr
|
||||
}
|
||||
|
@ -281,6 +281,18 @@ func (ws *WSState) processMessage(msgBytes []byte) error {
|
||||
sstore.ScreenMemSetCmdInputText(cmdInputPk.ScreenId, cmdInputPk.Text, cmdInputPk.SeqNum)
|
||||
return nil
|
||||
}
|
||||
if pk.GetType() == scpacket.UserInputResponsePacketStr {
|
||||
userInputRespPk := pk.(*scpacket.UserInputResponsePacketType)
|
||||
uich, ok := sstore.MainBus.GetUserInputChannel(userInputRespPk.RequestId)
|
||||
if !ok {
|
||||
return fmt.Errorf("received User Input Response with invalid Id (%s): %v\n", userInputRespPk.RequestId, err)
|
||||
}
|
||||
select {
|
||||
case uich <- userInputRespPk:
|
||||
default:
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("got ws bad message: %v", pk.GetType())
|
||||
}
|
||||
|
||||
|
@ -15,7 +15,6 @@ import (
|
||||
"os"
|
||||
"os/user"
|
||||
"path"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@ -28,10 +27,13 @@ import (
|
||||
"github.com/wavetermdev/waveterm/waveshell/pkg/shellenv"
|
||||
"github.com/wavetermdev/waveterm/wavesrv/pkg/dbutil"
|
||||
"github.com/wavetermdev/waveterm/wavesrv/pkg/scbase"
|
||||
"github.com/wavetermdev/waveterm/wavesrv/pkg/scpacket"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
type RemotePtrType = scpacket.RemotePtrType
|
||||
|
||||
const LineNoHeight = -1
|
||||
const DBFileName = "waveterm.db"
|
||||
const DBWALFileName = "waveterm.db-wal"
|
||||
@ -367,68 +369,6 @@ type SessionStatsType struct {
|
||||
DiskStats SessionDiskSizeType `json:"diskstats"`
|
||||
}
|
||||
|
||||
var RemoteNameRe = regexp.MustCompile("^\\*?[a-zA-Z0-9_-]+$")
|
||||
|
||||
type RemotePtrType struct {
|
||||
OwnerId string `json:"ownerid"`
|
||||
RemoteId string `json:"remoteid"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
func (r RemotePtrType) IsSessionScope() bool {
|
||||
return strings.HasPrefix(r.Name, "*")
|
||||
}
|
||||
|
||||
func (rptr *RemotePtrType) GetDisplayName(baseDisplayName string) string {
|
||||
name := baseDisplayName
|
||||
if rptr == nil {
|
||||
return name
|
||||
}
|
||||
if rptr.Name != "" {
|
||||
name = name + ":" + rptr.Name
|
||||
}
|
||||
if rptr.OwnerId != "" {
|
||||
name = "@" + rptr.OwnerId + ":" + name
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
func (r RemotePtrType) Validate() error {
|
||||
if r.OwnerId != "" {
|
||||
if _, err := uuid.Parse(r.OwnerId); err != nil {
|
||||
return fmt.Errorf("invalid ownerid format: %v", err)
|
||||
}
|
||||
}
|
||||
if r.RemoteId != "" {
|
||||
if _, err := uuid.Parse(r.RemoteId); err != nil {
|
||||
return fmt.Errorf("invalid remoteid format: %v", err)
|
||||
}
|
||||
}
|
||||
if r.Name != "" {
|
||||
ok := RemoteNameRe.MatchString(r.Name)
|
||||
if !ok {
|
||||
return fmt.Errorf("invalid remote name")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r RemotePtrType) MakeFullRemoteRef() string {
|
||||
if r.RemoteId == "" {
|
||||
return ""
|
||||
}
|
||||
if r.OwnerId == "" && r.Name == "" {
|
||||
return r.RemoteId
|
||||
}
|
||||
if r.OwnerId != "" && r.Name == "" {
|
||||
return fmt.Sprintf("@%s:%s", r.OwnerId, r.RemoteId)
|
||||
}
|
||||
if r.OwnerId == "" && r.Name != "" {
|
||||
return fmt.Sprintf("%s:%s", r.RemoteId, r.Name)
|
||||
}
|
||||
return fmt.Sprintf("@%s:%s:%s", r.OwnerId, r.RemoteId, r.Name)
|
||||
}
|
||||
|
||||
func (h *HistoryItemType) ToMap() map[string]interface{} {
|
||||
rtn := make(map[string]interface{})
|
||||
rtn["historyid"] = h.HistoryId
|
||||
|
@ -4,12 +4,16 @@
|
||||
package sstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/wavetermdev/waveterm/waveshell/pkg/packet"
|
||||
"github.com/wavetermdev/waveterm/waveshell/pkg/utilfn"
|
||||
"github.com/wavetermdev/waveterm/wavesrv/pkg/scpacket"
|
||||
)
|
||||
|
||||
var MainBus *UpdateBus = MakeUpdateBus()
|
||||
@ -65,6 +69,7 @@ type ModelUpdate struct {
|
||||
AlertMessage *AlertMessageType `json:"alertmessage,omitempty"`
|
||||
ScreenStatusIndicators []*ScreenStatusIndicatorType `json:"screenstatusindicators,omitempty"`
|
||||
ScreenNumRunningCommands []*ScreenNumRunningCommandsType `json:"screennumrunningcommands,omitempty"`
|
||||
UserInputRequest *UserInputRequestType `json:"userinputrequest,omitempty"`
|
||||
}
|
||||
|
||||
func (*ModelUpdate) UpdateType() string {
|
||||
@ -160,6 +165,15 @@ type HistoryInfoType struct {
|
||||
Show bool `json:"show"`
|
||||
}
|
||||
|
||||
type UserInputRequestType struct {
|
||||
RequestId string `json:"requestid"`
|
||||
QueryText string `json:"querytext"`
|
||||
ResponseType string `json:"responsetype"`
|
||||
Title string `json:"title"`
|
||||
Markdown bool `json:"markdown"`
|
||||
TimeoutMs int `json:"timeoutms"`
|
||||
}
|
||||
|
||||
type UpdateChannel struct {
|
||||
ScreenId string
|
||||
ClientId string
|
||||
@ -176,12 +190,14 @@ func (uch UpdateChannel) Match(screenId string) bool {
|
||||
type UpdateBus struct {
|
||||
Lock *sync.Mutex
|
||||
Channels map[string]UpdateChannel
|
||||
UserInputCh map[string](chan *scpacket.UserInputResponsePacketType)
|
||||
}
|
||||
|
||||
func MakeUpdateBus() *UpdateBus {
|
||||
return &UpdateBus{
|
||||
Lock: &sync.Mutex{},
|
||||
Channels: make(map[string]UpdateChannel),
|
||||
UserInputCh: make(map[string](chan *scpacket.UserInputResponsePacketType)),
|
||||
}
|
||||
}
|
||||
|
||||
@ -273,3 +289,57 @@ type ScreenNumRunningCommandsType struct {
|
||||
ScreenId string `json:"screenid"`
|
||||
Num int `json:"num"`
|
||||
}
|
||||
|
||||
func (bus *UpdateBus) registerUserInputChannel() (string, chan *scpacket.UserInputResponsePacketType) {
|
||||
bus.Lock.Lock()
|
||||
defer bus.Lock.Unlock()
|
||||
|
||||
id := uuid.New().String()
|
||||
uich := make(chan *scpacket.UserInputResponsePacketType, 1)
|
||||
|
||||
bus.UserInputCh[id] = uich
|
||||
return id, uich
|
||||
}
|
||||
|
||||
func (bus *UpdateBus) unregisterUserInputChannel(id string) {
|
||||
bus.Lock.Lock()
|
||||
defer bus.Lock.Unlock()
|
||||
|
||||
delete(bus.UserInputCh, id)
|
||||
}
|
||||
|
||||
func (bus *UpdateBus) GetUserInputChannel(id string) (chan *scpacket.UserInputResponsePacketType, bool) {
|
||||
bus.Lock.Lock()
|
||||
defer bus.Lock.Unlock()
|
||||
|
||||
uich, ok := bus.UserInputCh[id]
|
||||
return uich, ok
|
||||
}
|
||||
|
||||
func (bus *UpdateBus) GetUserInput(ctx context.Context, userInputRequest *UserInputRequestType) (*scpacket.UserInputResponsePacketType, error) {
|
||||
id, uich := bus.registerUserInputChannel()
|
||||
defer bus.unregisterUserInputChannel(id)
|
||||
|
||||
userInputRequest.RequestId = id
|
||||
deadline, _ := ctx.Deadline()
|
||||
userInputRequest.TimeoutMs = int(time.Until(deadline).Milliseconds()) - 500
|
||||
update := &ModelUpdate{UserInputRequest: userInputRequest}
|
||||
bus.SendUpdate(update)
|
||||
log.Printf("test: %+v", userInputRequest)
|
||||
|
||||
var response *scpacket.UserInputResponsePacketType
|
||||
var err error
|
||||
// prepare to receive response
|
||||
select {
|
||||
case resp := <-uich:
|
||||
response = resp
|
||||
case <-ctx.Done():
|
||||
return nil, fmt.Errorf("Timed out waiting for user input")
|
||||
}
|
||||
|
||||
if response.ErrorMsg != "" {
|
||||
err = fmt.Errorf(response.ErrorMsg)
|
||||
}
|
||||
|
||||
return response, err
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user