mirror of
https://github.com/wavetermdev/waveterm.git
synced 2025-01-21 21:32:13 +01:00
User Input (#119)
Port the User Input feature from the previous version of the app. This is currently being used to verify a few different prompts for ssh.
This commit is contained in:
parent
01d61dabec
commit
f3743f90ec
@ -6,7 +6,7 @@
|
|||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
width: 100vw;
|
width: 100vw;
|
||||||
height: 100vh;
|
height: 100%;
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
background-color: rgba(21, 23, 21, 0.7);
|
background-color: rgba(21, 23, 21, 0.7);
|
||||||
|
|
||||||
@ -15,8 +15,7 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
width: 80vw;
|
width: 80%;
|
||||||
height: 80vh;
|
|
||||||
margin-top: 25vh;
|
margin-top: 25vh;
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
margin-right: auto;
|
margin-right: auto;
|
||||||
|
@ -70,7 +70,7 @@ interface WaveModalProps {
|
|||||||
|
|
||||||
function WaveModal({ title, description, onSubmit, onCancel, buttonLabel = "Ok", children }: WaveModalProps) {
|
function WaveModal({ title, description, onSubmit, onCancel, buttonLabel = "Ok", children }: WaveModalProps) {
|
||||||
return (
|
return (
|
||||||
<Modal id="text-box-modal" onClickOut={onCancel}>
|
<Modal onClickOut={onCancel}>
|
||||||
<ModalHeader title={title} description={description} />
|
<ModalHeader title={title} description={description} />
|
||||||
<ModalContent>{children}</ModalContent>
|
<ModalContent>{children}</ModalContent>
|
||||||
<ModalFooter>
|
<ModalFooter>
|
||||||
|
37
frontend/app/element/userinputmodal.less
Normal file
37
frontend/app/element/userinputmodal.less
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
// Copyright 2024, Command Line Inc.
|
||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
|
.userinput-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
|
||||||
|
font: var(--fixed-font);
|
||||||
|
color: var(--main-text-color);
|
||||||
|
|
||||||
|
.userinput-markdown {
|
||||||
|
color: var(--main-text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.userinput-text {
|
||||||
|
}
|
||||||
|
|
||||||
|
.userinput-inputbox {
|
||||||
|
resize: none;
|
||||||
|
background-color: var(--panel-bg-color);
|
||||||
|
border-radius: 6px;
|
||||||
|
margin: 0;
|
||||||
|
border: var(--border-color);
|
||||||
|
padding: 5px 0 5px 16px;
|
||||||
|
min-height: 30px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
cursor: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline-color: var(--accent-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
133
frontend/app/element/userinputmodal.tsx
Normal file
133
frontend/app/element/userinputmodal.tsx
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
// Copyright 2024, Command Line Inc.
|
||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
|
import { Markdown } from "@/element/markdown";
|
||||||
|
import { WaveModal } from "@/element/modal";
|
||||||
|
import { atoms } from "@/store/global";
|
||||||
|
import * as keyutil from "@/util/keyutil";
|
||||||
|
import * as jotai from "jotai";
|
||||||
|
import * as React from "react";
|
||||||
|
import { UserInputService } from "../store/services";
|
||||||
|
|
||||||
|
import "./userinputmodal.less";
|
||||||
|
|
||||||
|
export const UserInputModal = (userInputRequest: UserInputRequest) => {
|
||||||
|
const setModals = jotai.useSetAtom(atoms.userInput);
|
||||||
|
const [responseText, setResponseText] = React.useState("");
|
||||||
|
const [countdown, setCountdown] = React.useState(Math.floor(userInputRequest.timeoutms / 1000));
|
||||||
|
const checkboxStatus = React.useRef(false);
|
||||||
|
|
||||||
|
const handleSendCancel = React.useCallback(() => {
|
||||||
|
UserInputService.SendUserInputResponse({
|
||||||
|
type: "userinputresp",
|
||||||
|
requestid: userInputRequest.requestid,
|
||||||
|
errormsg: "Canceled by the user",
|
||||||
|
});
|
||||||
|
setModals((prev) => {
|
||||||
|
prev.pop();
|
||||||
|
return [...prev];
|
||||||
|
});
|
||||||
|
}, [responseText, userInputRequest]);
|
||||||
|
|
||||||
|
const handleSendText = React.useCallback(() => {
|
||||||
|
UserInputService.SendUserInputResponse({
|
||||||
|
type: "userinputresp",
|
||||||
|
requestid: userInputRequest.requestid,
|
||||||
|
text: responseText,
|
||||||
|
checkboxstat: checkboxStatus.current,
|
||||||
|
});
|
||||||
|
setModals((prev) => {
|
||||||
|
prev.pop();
|
||||||
|
return [...prev];
|
||||||
|
});
|
||||||
|
}, [responseText, userInputRequest]);
|
||||||
|
|
||||||
|
const handleSendConfirm = React.useCallback(() => {
|
||||||
|
UserInputService.SendUserInputResponse({
|
||||||
|
type: "userinputresp",
|
||||||
|
requestid: userInputRequest.requestid,
|
||||||
|
confirm: true,
|
||||||
|
checkboxstat: checkboxStatus.current,
|
||||||
|
});
|
||||||
|
setModals((prev) => {
|
||||||
|
prev.pop();
|
||||||
|
return [...prev];
|
||||||
|
});
|
||||||
|
}, [userInputRequest]);
|
||||||
|
|
||||||
|
const handleSubmit = React.useCallback(() => {
|
||||||
|
switch (userInputRequest.responsetype) {
|
||||||
|
case "text":
|
||||||
|
handleSendText();
|
||||||
|
break;
|
||||||
|
case "confirm":
|
||||||
|
handleSendConfirm();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}, [handleSendConfirm, handleSendText, userInputRequest.responsetype]);
|
||||||
|
|
||||||
|
const handleKeyDown = React.useCallback(
|
||||||
|
(waveEvent: WaveKeyboardEvent): boolean => {
|
||||||
|
if (keyutil.checkKeyPressed(waveEvent, "Escape")) {
|
||||||
|
handleSendCancel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (keyutil.checkKeyPressed(waveEvent, "Enter")) {
|
||||||
|
handleSubmit();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[handleSendCancel, handleSubmit]
|
||||||
|
);
|
||||||
|
|
||||||
|
const queryText = React.useMemo(() => {
|
||||||
|
if (userInputRequest.markdown) {
|
||||||
|
return <Markdown text={userInputRequest.querytext} className="userinput-markdown" />;
|
||||||
|
}
|
||||||
|
return <span className="userinput-text">{userInputRequest.querytext}</span>;
|
||||||
|
}, [userInputRequest.markdown, userInputRequest.querytext]);
|
||||||
|
|
||||||
|
const inputBox = React.useMemo(() => {
|
||||||
|
if (userInputRequest.responsetype === "confirm") {
|
||||||
|
return <></>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={userInputRequest.publictext ? "text" : "password"}
|
||||||
|
onChange={(e) => setResponseText(e.target.value)}
|
||||||
|
value={responseText}
|
||||||
|
maxLength={400}
|
||||||
|
className="userinput-inputbox"
|
||||||
|
autoFocus={true}
|
||||||
|
onKeyDown={(e) => keyutil.keydownWrapper(handleKeyDown)(e)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}, [userInputRequest.responsetype, userInputRequest.publictext, responseText, handleKeyDown, setResponseText]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
let timeout: ReturnType<typeof setTimeout>;
|
||||||
|
if (countdown == 0) {
|
||||||
|
timeout = setTimeout(() => {
|
||||||
|
handleSendCancel();
|
||||||
|
}, 300);
|
||||||
|
} else {
|
||||||
|
timeout = setTimeout(() => {
|
||||||
|
setCountdown(countdown - 1);
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
return () => clearTimeout(timeout);
|
||||||
|
}, [countdown]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<WaveModal
|
||||||
|
title={userInputRequest.title + ` (${countdown}s)`}
|
||||||
|
onSubmit={() => handleSubmit()}
|
||||||
|
onCancel={() => handleSendCancel()}
|
||||||
|
>
|
||||||
|
<div className="userinput-body">
|
||||||
|
{queryText}
|
||||||
|
{inputBox}
|
||||||
|
</div>
|
||||||
|
</WaveModal>
|
||||||
|
);
|
||||||
|
};
|
@ -81,6 +81,7 @@ const activeTabIdAtom: jotai.Atom<string> = jotai.atom((get) => {
|
|||||||
}
|
}
|
||||||
return windowData.activetabid;
|
return windowData.activetabid;
|
||||||
});
|
});
|
||||||
|
const userInputAtom = jotai.atom([]) as jotai.PrimitiveAtom<Array<UserInputRequest>>;
|
||||||
|
|
||||||
const atoms = {
|
const atoms = {
|
||||||
// initialized in wave.ts (will not be null inside of application)
|
// initialized in wave.ts (will not be null inside of application)
|
||||||
@ -93,6 +94,7 @@ const atoms = {
|
|||||||
settingsConfigAtom: settingsConfigAtom,
|
settingsConfigAtom: settingsConfigAtom,
|
||||||
tabAtom: tabAtom,
|
tabAtom: tabAtom,
|
||||||
activeTabId: activeTabIdAtom,
|
activeTabId: activeTabIdAtom,
|
||||||
|
userInput: userInputAtom,
|
||||||
};
|
};
|
||||||
|
|
||||||
// key is "eventType" or "eventType|oref"
|
// key is "eventType" or "eventType|oref"
|
||||||
@ -208,6 +210,13 @@ function handleWSEventMessage(msg: WSEventType) {
|
|||||||
console.log("config", data);
|
console.log("config", data);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (msg.eventtype == "userinput") {
|
||||||
|
// handle user input
|
||||||
|
const data: UserInputRequest = msg.data;
|
||||||
|
console.log(data);
|
||||||
|
globalStore.set(userInputAtom, (prev) => [...prev, data]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (msg.eventtype == "blockfile") {
|
if (msg.eventtype == "blockfile") {
|
||||||
const fileData: WSFileEventData = msg.data;
|
const fileData: WSFileEventData = msg.data;
|
||||||
const fileSubject = getFileSubject(fileData.zoneid, fileData.filename);
|
const fileSubject = getFileSubject(fileData.zoneid, fileData.filename);
|
||||||
|
@ -126,6 +126,15 @@ class ObjectServiceType {
|
|||||||
|
|
||||||
export const ObjectService = new ObjectServiceType();
|
export const ObjectService = new ObjectServiceType();
|
||||||
|
|
||||||
|
// userinputservice.UserInputService (userinput)
|
||||||
|
class UserInputServiceType {
|
||||||
|
SendUserInputResponse(arg1: UserInputResponse): Promise<void> {
|
||||||
|
return WOS.callBackendService("userinput", "SendUserInputResponse", Array.from(arguments))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const UserInputService = new UserInputServiceType();
|
||||||
|
|
||||||
// windowservice.WindowService (window)
|
// windowservice.WindowService (window)
|
||||||
class WindowServiceType {
|
class WindowServiceType {
|
||||||
// @returns object updates
|
// @returns object updates
|
||||||
|
@ -3,6 +3,7 @@
|
|||||||
|
|
||||||
import { TabBar } from "@/app/tab/tabbar";
|
import { TabBar } from "@/app/tab/tabbar";
|
||||||
import { TabContent } from "@/app/tab/tabcontent";
|
import { TabContent } from "@/app/tab/tabcontent";
|
||||||
|
import { UserInputModal } from "@/element/userinputmodal";
|
||||||
import { atoms, createBlock } from "@/store/global";
|
import { atoms, createBlock } from "@/store/global";
|
||||||
import * as services from "@/store/services";
|
import * as services from "@/store/services";
|
||||||
import * as util from "@/util/util";
|
import * as util from "@/util/util";
|
||||||
@ -106,9 +107,11 @@ const Widgets = React.memo(() => {
|
|||||||
const WorkspaceElem = React.memo(() => {
|
const WorkspaceElem = React.memo(() => {
|
||||||
const windowData = jotai.useAtomValue(atoms.waveWindow);
|
const windowData = jotai.useAtomValue(atoms.waveWindow);
|
||||||
const activeTabId = windowData?.activetabid;
|
const activeTabId = windowData?.activetabid;
|
||||||
|
const modals = jotai.useAtomValue(atoms.userInput);
|
||||||
const ws = jotai.useAtomValue(atoms.workspace);
|
const ws = jotai.useAtomValue(atoms.workspace);
|
||||||
return (
|
return (
|
||||||
<div className="workspace">
|
<div className="workspace">
|
||||||
|
{modals.length > 0 && <UserInputModal {...modals[modals.length - 1]} />}
|
||||||
<TabBar key={ws.oid} workspace={ws} />
|
<TabBar key={ws.oid} workspace={ws} />
|
||||||
<div className="workspace-tabcontent">
|
<div className="workspace-tabcontent">
|
||||||
{activeTabId == "" ? (
|
{activeTabId == "" ? (
|
||||||
|
22
frontend/types/gotypes.d.ts
vendored
22
frontend/types/gotypes.d.ts
vendored
@ -262,6 +262,28 @@ declare global {
|
|||||||
activetabid: string;
|
activetabid: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// userinput.UserInputRequest
|
||||||
|
type UserInputRequest = {
|
||||||
|
requestid: string;
|
||||||
|
querytext: string;
|
||||||
|
responsetype: string;
|
||||||
|
title: string;
|
||||||
|
markdown: boolean;
|
||||||
|
timeoutms: number;
|
||||||
|
checkboxmsg: string;
|
||||||
|
publictext: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
// userinput.UserInputResponse
|
||||||
|
type UserInputResponse = {
|
||||||
|
type: string;
|
||||||
|
requestid: string;
|
||||||
|
text?: string;
|
||||||
|
confirm?: boolean;
|
||||||
|
errormsg?: string;
|
||||||
|
checkboxstat?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
type WSCommandType = {
|
type WSCommandType = {
|
||||||
wscommand: string;
|
wscommand: string;
|
||||||
} & ( SetBlockTermSizeWSCommand | BlockInputWSCommand | WSRpcCommand );
|
} & ( SetBlockTermSizeWSCommand | BlockInputWSCommand | WSRpcCommand );
|
||||||
|
@ -18,6 +18,7 @@ const (
|
|||||||
WSEvent_WaveObjUpdate = "waveobj:update"
|
WSEvent_WaveObjUpdate = "waveobj:update"
|
||||||
WSEvent_BlockFile = "blockfile"
|
WSEvent_BlockFile = "blockfile"
|
||||||
WSEvent_Config = "config"
|
WSEvent_Config = "config"
|
||||||
|
WSEvent_UserInput = "userinput"
|
||||||
WSEvent_BlockControllerStatus = "blockcontroller:status"
|
WSEvent_BlockControllerStatus = "blockcontroller:status"
|
||||||
WSEvent_LayoutAction = "layoutaction"
|
WSEvent_LayoutAction = "layoutaction"
|
||||||
WSEvent_ElectronNewWindow = "electron:newwindow"
|
WSEvent_ElectronNewWindow = "electron:newwindow"
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
// Copyright 2023-2024, Command Line Inc.
|
// Copyright 2024, Command Line Inc.
|
||||||
// SPDX-License-Identifier: Apache-2.0
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
package remote
|
package remote
|
||||||
@ -19,8 +19,10 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/kevinburke/ssh_config"
|
"github.com/kevinburke/ssh_config"
|
||||||
|
"github.com/wavetermdev/thenextwave/pkg/userinput"
|
||||||
"github.com/wavetermdev/thenextwave/pkg/wavebase"
|
"github.com/wavetermdev/thenextwave/pkg/wavebase"
|
||||||
"golang.org/x/crypto/ssh"
|
"golang.org/x/crypto/ssh"
|
||||||
"golang.org/x/crypto/ssh/knownhosts"
|
"golang.org/x/crypto/ssh/knownhosts"
|
||||||
@ -115,28 +117,25 @@ func createPublicKeyCallback(connCtx context.Context, sshKeywords *SshKeywords,
|
|||||||
return createDummySigner()
|
return createDummySigner()
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil, fmt.Errorf("unimplemented: userinput createPublicKeyCallback") //todo
|
request := &userinput.UserInputRequest{
|
||||||
/*
|
ResponseType: "text",
|
||||||
request := &userinput.UserInputRequestType{
|
QueryText: fmt.Sprintf("Enter passphrase for the SSH key: %s", identityFile),
|
||||||
ResponseType: "text",
|
Title: "Publickey Auth + Passphrase",
|
||||||
QueryText: fmt.Sprintf("Enter passphrase for the SSH key: %s", identityFile),
|
}
|
||||||
Title: "Publickey Auth + Passphrase",
|
ctx, cancelFn := context.WithTimeout(connCtx, 60*time.Second)
|
||||||
}
|
defer cancelFn()
|
||||||
ctx, cancelFn := context.WithTimeout(connCtx, 60*time.Second)
|
response, err := userinput.GetUserInput(ctx, request)
|
||||||
defer cancelFn()
|
if err != nil {
|
||||||
response, err := userinput.GetUserInput(ctx, scbus.MainRpcBus, request)
|
// this is an error where we actually do want to stop
|
||||||
if err != nil {
|
// trying keys
|
||||||
// this is an error where we actually do want to stop
|
return nil, UserInputCancelError{Err: err}
|
||||||
// trying keys
|
}
|
||||||
return nil, UserInputCancelError{Err: err}
|
signer, err = ssh.ParsePrivateKeyWithPassphrase(privateKey, []byte(response.Text))
|
||||||
}
|
if err != nil {
|
||||||
signer, err = ssh.ParsePrivateKeyWithPassphrase(privateKey, []byte(response.Text))
|
// skip this key and try with the next
|
||||||
if err != nil {
|
return createDummySigner()
|
||||||
// skip this key and try with the next
|
}
|
||||||
return createDummySigner()
|
return []ssh.Signer{signer}, err
|
||||||
}
|
|
||||||
return []ssh.Signer{signer}, err
|
|
||||||
*/
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -151,26 +150,23 @@ func createDefaultPasswordCallbackPrompt(password string) func() (secret string,
|
|||||||
|
|
||||||
func createInteractivePasswordCallbackPrompt(connCtx context.Context, remoteDisplayName string) func() (secret string, err error) {
|
func createInteractivePasswordCallbackPrompt(connCtx context.Context, remoteDisplayName string) func() (secret string, err error) {
|
||||||
return func() (secret string, err error) {
|
return func() (secret string, err error) {
|
||||||
return "", fmt.Errorf("unimplemented: userinput createInteractivePasswordCallbackPrompt") //todo
|
ctx, cancelFn := context.WithTimeout(connCtx, 60*time.Second)
|
||||||
/*
|
defer cancelFn()
|
||||||
ctx, cancelFn := context.WithTimeout(connCtx, 60*time.Second)
|
queryText := fmt.Sprintf(
|
||||||
defer cancelFn()
|
"Password Authentication requested from connection \n"+
|
||||||
queryText := fmt.Sprintf(
|
"%s\n\n"+
|
||||||
"Password Authentication requested from connection \n"+
|
"Password:", remoteDisplayName)
|
||||||
"%s\n\n"+
|
request := &userinput.UserInputRequest{
|
||||||
"Password:", remoteDisplayName)
|
ResponseType: "text",
|
||||||
request := &userinput.UserInputRequestType{
|
QueryText: queryText,
|
||||||
ResponseType: "text",
|
Markdown: true,
|
||||||
QueryText: queryText,
|
Title: "Password Authentication",
|
||||||
Markdown: true,
|
}
|
||||||
Title: "Password Authentication",
|
response, err := userinput.GetUserInput(ctx, request)
|
||||||
}
|
if err != nil {
|
||||||
response, err := userinput.GetUserInput(ctx, scbus.MainRpcBus, request)
|
return "", err
|
||||||
if err != nil {
|
}
|
||||||
return "", err
|
return response.Text, nil
|
||||||
}
|
|
||||||
return response.Text, nil
|
|
||||||
*/
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -219,27 +215,24 @@ func createInteractiveKbdInteractiveChallenge(connCtx context.Context, remoteNam
|
|||||||
func promptChallengeQuestion(connCtx context.Context, question string, echo bool, remoteName string) (answer string, err error) {
|
func promptChallengeQuestion(connCtx context.Context, question string, echo bool, remoteName string) (answer string, err error) {
|
||||||
// limited to 15 seconds for some reason. this should be investigated more
|
// limited to 15 seconds for some reason. this should be investigated more
|
||||||
// in the future
|
// in the future
|
||||||
return "", fmt.Errorf("unimplemented: userinput promptChallengeQuestion") //todo
|
ctx, cancelFn := context.WithTimeout(connCtx, 60*time.Second)
|
||||||
/*
|
defer cancelFn()
|
||||||
ctx, cancelFn := context.WithTimeout(connCtx, 60*time.Second)
|
queryText := fmt.Sprintf(
|
||||||
defer cancelFn()
|
"Keyboard Interactive Authentication requested from connection \n"+
|
||||||
queryText := fmt.Sprintf(
|
"%s\n\n"+
|
||||||
"Keyboard Interactive Authentication requested from connection \n"+
|
"%s", remoteName, question)
|
||||||
"%s\n\n"+
|
request := &userinput.UserInputRequest{
|
||||||
"%s", remoteName, question)
|
ResponseType: "text",
|
||||||
request := &userinput.UserInputRequestType{
|
QueryText: queryText,
|
||||||
ResponseType: "text",
|
Markdown: true,
|
||||||
QueryText: queryText,
|
Title: "Keyboard Interactive Authentication",
|
||||||
Markdown: true,
|
PublicText: echo,
|
||||||
Title: "Keyboard Interactive Authentication",
|
}
|
||||||
PublicText: echo,
|
response, err := userinput.GetUserInput(ctx, request)
|
||||||
}
|
if err != nil {
|
||||||
response, err := userinput.GetUserInput(ctx, scbus.MainRpcBus, request)
|
return "", err
|
||||||
if err != nil {
|
}
|
||||||
return "", err
|
return response.Text, nil
|
||||||
}
|
|
||||||
return response.Text, nil
|
|
||||||
*/
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func createCombinedKbdInteractiveChallenge(connCtx context.Context, password string, remoteName string) ssh.KeyboardInteractiveChallenge {
|
func createCombinedKbdInteractiveChallenge(connCtx context.Context, password string, remoteName string) ssh.KeyboardInteractiveChallenge {
|
||||||
@ -263,11 +256,10 @@ func openKnownHostsForEdit(knownHostsFilename string) (*os.File, error) {
|
|||||||
return os.OpenFile(knownHostsFilename, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0644)
|
return os.OpenFile(knownHostsFilename, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0644)
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
func writeToKnownHosts(knownHostsFile string, newLine string, getUserVerification func() (*userinput.UserInputResponse, error)) error {
|
||||||
func writeToKnownHosts(knownHostsFile string, newLine string, getUserVerification func() (*userinput.UserInputResponsePacketType, error)) error {
|
|
||||||
if getUserVerification == nil {
|
if getUserVerification == nil {
|
||||||
getUserVerification = func() (*userinput.UserInputResponsePacketType, error) {
|
getUserVerification = func() (*userinput.UserInputResponse, error) {
|
||||||
return &userinput.UserInputResponsePacketType{
|
return &userinput.UserInputResponse{
|
||||||
Type: "confirm",
|
Type: "confirm",
|
||||||
Confirm: true,
|
Confirm: true,
|
||||||
}, nil
|
}, nil
|
||||||
@ -303,10 +295,8 @@ func writeToKnownHosts(knownHostsFile string, newLine string, getUserVerificatio
|
|||||||
}
|
}
|
||||||
return f.Close()
|
return f.Close()
|
||||||
}
|
}
|
||||||
*/
|
|
||||||
|
|
||||||
/* todo
|
func createUnknownKeyVerifier(knownHostsFile string, hostname string, remote string, key ssh.PublicKey) func() (*userinput.UserInputResponse, error) {
|
||||||
func createUnknownKeyVerifier(knownHostsFile string, hostname string, remote string, key ssh.PublicKey) func() (*userinput.UserInputResponsePacketType, error) {
|
|
||||||
base64Key := base64.StdEncoding.EncodeToString(key.Marshal())
|
base64Key := base64.StdEncoding.EncodeToString(key.Marshal())
|
||||||
queryText := fmt.Sprintf(
|
queryText := fmt.Sprintf(
|
||||||
"The authenticity of host '%s (%s)' can't be established "+
|
"The authenticity of host '%s (%s)' can't be established "+
|
||||||
@ -316,22 +306,20 @@ func createUnknownKeyVerifier(knownHostsFile string, hostname string, remote str
|
|||||||
"**Would you like to continue connecting?** If so, the key will be permanently "+
|
"**Would you like to continue connecting?** If so, the key will be permanently "+
|
||||||
"added to the file %s "+
|
"added to the file %s "+
|
||||||
"to protect from future man-in-the-middle attacks.", hostname, remote, key.Type(), base64Key, knownHostsFile)
|
"to protect from future man-in-the-middle attacks.", hostname, remote, key.Type(), base64Key, knownHostsFile)
|
||||||
request := &userinput.UserInputRequestType{
|
request := &userinput.UserInputRequest{
|
||||||
ResponseType: "confirm",
|
ResponseType: "confirm",
|
||||||
QueryText: queryText,
|
QueryText: queryText,
|
||||||
Markdown: true,
|
Markdown: true,
|
||||||
Title: "Known Hosts Key Missing",
|
Title: "Known Hosts Key Missing",
|
||||||
}
|
}
|
||||||
return func() (*userinput.UserInputResponsePacketType, error) {
|
return func() (*userinput.UserInputResponse, error) {
|
||||||
ctx, cancelFn := context.WithTimeout(context.Background(), 60*time.Second)
|
ctx, cancelFn := context.WithTimeout(context.Background(), 60*time.Second)
|
||||||
defer cancelFn()
|
defer cancelFn()
|
||||||
return userinput.GetUserInput(ctx, scbus.MainRpcBus, request)
|
return userinput.GetUserInput(ctx, request)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
*/
|
|
||||||
|
|
||||||
/*
|
func createMissingKnownHostsVerifier(knownHostsFile string, hostname string, remote string, key ssh.PublicKey) func() (*userinput.UserInputResponse, error) {
|
||||||
func createMissingKnownHostsVerifier(knownHostsFile string, hostname string, remote string, key ssh.PublicKey) func() (*userinput.UserInputResponsePacketType, error) {
|
|
||||||
base64Key := base64.StdEncoding.EncodeToString(key.Marshal())
|
base64Key := base64.StdEncoding.EncodeToString(key.Marshal())
|
||||||
queryText := fmt.Sprintf(
|
queryText := fmt.Sprintf(
|
||||||
"The authenticity of host '%s (%s)' can't be established "+
|
"The authenticity of host '%s (%s)' can't be established "+
|
||||||
@ -342,19 +330,18 @@ func createMissingKnownHostsVerifier(knownHostsFile string, hostname string, rem
|
|||||||
"- %s will be created \n"+
|
"- %s will be created \n"+
|
||||||
"- the key will be added to %s\n\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)
|
"This will protect from future man-in-the-middle attacks.", hostname, remote, key.Type(), base64Key, knownHostsFile, knownHostsFile)
|
||||||
request := &userinput.UserInputRequestType{
|
request := &userinput.UserInputRequest{
|
||||||
ResponseType: "confirm",
|
ResponseType: "confirm",
|
||||||
QueryText: queryText,
|
QueryText: queryText,
|
||||||
Markdown: true,
|
Markdown: true,
|
||||||
Title: "Known Hosts File Missing",
|
Title: "Known Hosts File Missing",
|
||||||
}
|
}
|
||||||
return func() (*userinput.UserInputResponsePacketType, error) {
|
return func() (*userinput.UserInputResponse, error) {
|
||||||
ctx, cancelFn := context.WithTimeout(context.Background(), 60*time.Second)
|
ctx, cancelFn := context.WithTimeout(context.Background(), 60*time.Second)
|
||||||
defer cancelFn()
|
defer cancelFn()
|
||||||
return userinput.GetUserInput(ctx, scbus.MainRpcBus, request)
|
return userinput.GetUserInput(ctx, request)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
*/
|
|
||||||
|
|
||||||
func lineContainsMatch(line []byte, matches [][]byte) bool {
|
func lineContainsMatch(line []byte, matches [][]byte) bool {
|
||||||
for _, match := range matches {
|
for _, match := range matches {
|
||||||
@ -444,41 +431,38 @@ func createHostKeyCallback(opts *SSHOpts) (ssh.HostKeyCallback, error) {
|
|||||||
// the key was not found
|
// the key was not found
|
||||||
|
|
||||||
// try to write to a file that could be read
|
// try to write to a file that could be read
|
||||||
//err := fmt.Errorf("placeholder, should not be returned") // a null value here can cause problems with empty slice
|
err := fmt.Errorf("placeholder, should not be returned") // a null value here can cause problems with empty slice
|
||||||
return fmt.Errorf("unimplemented: waveHostKeyCallback key not found") //todo
|
for _, filename := range knownHostsFiles {
|
||||||
/*
|
newLine := knownhosts.Line([]string{knownhosts.Normalize(hostname)}, key)
|
||||||
for _, filename := range knownHostsFiles {
|
getUserVerification := createUnknownKeyVerifier(filename, hostname, remote.String(), key)
|
||||||
newLine := knownhosts.Line([]string{knownhosts.Normalize(hostname)}, key)
|
err = writeToKnownHosts(filename, newLine, getUserVerification)
|
||||||
getUserVerification := createUnknownKeyVerifier(filename, hostname, remote.String(), key)
|
if err == nil {
|
||||||
err = writeToKnownHosts(filename, newLine, getUserVerification)
|
break
|
||||||
if err == nil {
|
}
|
||||||
break
|
if serr, ok := err.(UserInputCancelError); ok {
|
||||||
}
|
return serr
|
||||||
if serr, ok := err.(UserInputCancelError); ok {
|
}
|
||||||
return serr
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// try to write to a file that could not be read (file likely doesn't exist)
|
// 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
|
// should catch cases where there is no known_hosts file
|
||||||
if err != nil {
|
if err != nil {
|
||||||
for _, filename := range unreadableFiles {
|
for _, filename := range unreadableFiles {
|
||||||
newLine := knownhosts.Line([]string{knownhosts.Normalize(hostname)}, key)
|
newLine := knownhosts.Line([]string{knownhosts.Normalize(hostname)}, key)
|
||||||
getUserVerification := createMissingKnownHostsVerifier(filename, hostname, remote.String(), key)
|
getUserVerification := createMissingKnownHostsVerifier(filename, hostname, remote.String(), key)
|
||||||
err = writeToKnownHosts(filename, newLine, getUserVerification)
|
err = writeToKnownHosts(filename, newLine, getUserVerification)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
knownHostsFiles = []string{filename}
|
knownHostsFiles = []string{filename}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
if serr, ok := err.(UserInputCancelError); ok {
|
if serr, ok := err.(UserInputCancelError); ok {
|
||||||
return serr
|
return serr
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if err != nil {
|
}
|
||||||
return fmt.Errorf("unable to create new knownhost key: %e", err)
|
if err != nil {
|
||||||
}
|
return fmt.Errorf("unable to create new knownhost key: %e", err)
|
||||||
*/
|
}
|
||||||
} else {
|
} else {
|
||||||
// the key changed
|
// the key changed
|
||||||
correctKeyFingerprint := base64.StdEncoding.EncodeToString(key.Marshal())
|
correctKeyFingerprint := base64.StdEncoding.EncodeToString(key.Marshal())
|
||||||
|
@ -13,6 +13,7 @@ import (
|
|||||||
"github.com/wavetermdev/thenextwave/pkg/service/clientservice"
|
"github.com/wavetermdev/thenextwave/pkg/service/clientservice"
|
||||||
"github.com/wavetermdev/thenextwave/pkg/service/fileservice"
|
"github.com/wavetermdev/thenextwave/pkg/service/fileservice"
|
||||||
"github.com/wavetermdev/thenextwave/pkg/service/objectservice"
|
"github.com/wavetermdev/thenextwave/pkg/service/objectservice"
|
||||||
|
"github.com/wavetermdev/thenextwave/pkg/service/userinputservice"
|
||||||
"github.com/wavetermdev/thenextwave/pkg/service/windowservice"
|
"github.com/wavetermdev/thenextwave/pkg/service/windowservice"
|
||||||
"github.com/wavetermdev/thenextwave/pkg/tsgen/tsgenmeta"
|
"github.com/wavetermdev/thenextwave/pkg/tsgen/tsgenmeta"
|
||||||
"github.com/wavetermdev/thenextwave/pkg/util/utilfn"
|
"github.com/wavetermdev/thenextwave/pkg/util/utilfn"
|
||||||
@ -22,11 +23,12 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var ServiceMap = map[string]any{
|
var ServiceMap = map[string]any{
|
||||||
"block": blockservice.BlockServiceInstance,
|
"block": blockservice.BlockServiceInstance,
|
||||||
"object": &objectservice.ObjectService{},
|
"object": &objectservice.ObjectService{},
|
||||||
"file": &fileservice.FileService{},
|
"file": &fileservice.FileService{},
|
||||||
"client": &clientservice.ClientService{},
|
"client": &clientservice.ClientService{},
|
||||||
"window": &windowservice.WindowService{},
|
"window": &windowservice.WindowService{},
|
||||||
|
"userinput": &userinputservice.UserInputService{},
|
||||||
}
|
}
|
||||||
|
|
||||||
var contextRType = reflect.TypeOf((*context.Context)(nil)).Elem()
|
var contextRType = reflect.TypeOf((*context.Context)(nil)).Elem()
|
||||||
|
18
pkg/service/userinputservice/userinputservice.go
Normal file
18
pkg/service/userinputservice/userinputservice.go
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
// Copyright 2024, Command Line Inc.
|
||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
|
package userinputservice
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/wavetermdev/thenextwave/pkg/userinput"
|
||||||
|
)
|
||||||
|
|
||||||
|
type UserInputService struct {
|
||||||
|
}
|
||||||
|
|
||||||
|
func (uis *UserInputService) SendUserInputResponse(response *userinput.UserInputResponse) {
|
||||||
|
select {
|
||||||
|
case userinput.MainUserInputHandler.Channels[response.RequestId] <- response:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
@ -17,6 +17,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/creack/pty"
|
"github.com/creack/pty"
|
||||||
"github.com/wavetermdev/thenextwave/pkg/remote"
|
"github.com/wavetermdev/thenextwave/pkg/remote"
|
||||||
@ -114,7 +115,7 @@ func checkCwd(cwd string) error {
|
|||||||
var userHostRe = regexp.MustCompile(`^([a-zA-Z0-9][a-zA-Z0-9._@\\-]*@)?([a-z0-9][a-z0-9.-]*)(?::([0-9]+))?$`)
|
var userHostRe = regexp.MustCompile(`^([a-zA-Z0-9][a-zA-Z0-9._@\\-]*@)?([a-z0-9][a-z0-9.-]*)(?::([0-9]+))?$`)
|
||||||
|
|
||||||
func StartRemoteShellProc(termSize TermSize, cmdStr string, cmdOpts CommandOptsType, remoteName string) (*ShellProc, error) {
|
func StartRemoteShellProc(termSize TermSize, cmdStr string, cmdOpts CommandOptsType, remoteName string) (*ShellProc, error) {
|
||||||
ctx, cancelFunc := context.WithCancel(context.Background())
|
ctx, cancelFunc := context.WithTimeout(context.Background(), 60*time.Second)
|
||||||
defer cancelFunc()
|
defer cancelFunc()
|
||||||
|
|
||||||
var shellPath string
|
var shellPath string
|
||||||
|
@ -14,6 +14,7 @@ import (
|
|||||||
"github.com/wavetermdev/thenextwave/pkg/filestore"
|
"github.com/wavetermdev/thenextwave/pkg/filestore"
|
||||||
"github.com/wavetermdev/thenextwave/pkg/service"
|
"github.com/wavetermdev/thenextwave/pkg/service"
|
||||||
"github.com/wavetermdev/thenextwave/pkg/tsgen/tsgenmeta"
|
"github.com/wavetermdev/thenextwave/pkg/tsgen/tsgenmeta"
|
||||||
|
"github.com/wavetermdev/thenextwave/pkg/userinput"
|
||||||
"github.com/wavetermdev/thenextwave/pkg/waveobj"
|
"github.com/wavetermdev/thenextwave/pkg/waveobj"
|
||||||
"github.com/wavetermdev/thenextwave/pkg/wconfig"
|
"github.com/wavetermdev/thenextwave/pkg/wconfig"
|
||||||
"github.com/wavetermdev/thenextwave/pkg/web/webcmd"
|
"github.com/wavetermdev/thenextwave/pkg/web/webcmd"
|
||||||
@ -39,6 +40,7 @@ var ExtraTypes = []any{
|
|||||||
wconfig.WatcherUpdate{},
|
wconfig.WatcherUpdate{},
|
||||||
wshutil.RpcMessage{},
|
wshutil.RpcMessage{},
|
||||||
wshrpc.WshServerCommandMeta{},
|
wshrpc.WshServerCommandMeta{},
|
||||||
|
userinput.UserInputRequest{},
|
||||||
}
|
}
|
||||||
|
|
||||||
// add extra type unions to generate here
|
// add extra type unions to generate here
|
||||||
|
92
pkg/userinput/userinput.go
Normal file
92
pkg/userinput/userinput.go
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
// Copyright 2024, Command Line Inc.
|
||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
|
package userinput
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/wavetermdev/thenextwave/pkg/eventbus"
|
||||||
|
)
|
||||||
|
|
||||||
|
var MainUserInputHandler = UserInputHandler{Channels: make(map[string](chan *UserInputResponse), 1)}
|
||||||
|
|
||||||
|
type UserInputRequest 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"`
|
||||||
|
CheckBoxMsg string `json:"checkboxmsg"`
|
||||||
|
PublicText bool `json:"publictext"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UserInputResponse struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
RequestId string `json:"requestid"`
|
||||||
|
Text string `json:"text,omitempty"`
|
||||||
|
Confirm bool `json:"confirm,omitempty"`
|
||||||
|
ErrorMsg string `json:"errormsg,omitempty"`
|
||||||
|
CheckboxStat bool `json:"checkboxstat,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UserInputHandler struct {
|
||||||
|
Lock sync.Mutex
|
||||||
|
Channels map[string](chan *UserInputResponse)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ui *UserInputHandler) registerChannel() (string, chan *UserInputResponse) {
|
||||||
|
ui.Lock.Lock()
|
||||||
|
defer ui.Lock.Unlock()
|
||||||
|
|
||||||
|
id := uuid.New().String()
|
||||||
|
uich := make(chan *UserInputResponse, 1)
|
||||||
|
|
||||||
|
ui.Channels[id] = uich
|
||||||
|
return id, uich
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ui *UserInputHandler) unregisterChannel(id string) {
|
||||||
|
ui.Lock.Lock()
|
||||||
|
defer ui.Lock.Unlock()
|
||||||
|
|
||||||
|
delete(ui.Channels, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ui *UserInputHandler) sendRequestToFrontend(request *UserInputRequest) {
|
||||||
|
eventbus.SendEvent(eventbus.WSEventType{
|
||||||
|
EventType: eventbus.WSEvent_UserInput,
|
||||||
|
Data: request,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetUserInput(ctx context.Context, request *UserInputRequest) (*UserInputResponse, error) {
|
||||||
|
id, uiCh := MainUserInputHandler.registerChannel()
|
||||||
|
defer MainUserInputHandler.unregisterChannel(id)
|
||||||
|
request.RequestId = id
|
||||||
|
deadline, _ := ctx.Deadline()
|
||||||
|
request.TimeoutMs = int(time.Until(deadline).Milliseconds()) - 500
|
||||||
|
MainUserInputHandler.sendRequestToFrontend(request)
|
||||||
|
|
||||||
|
var response *UserInputResponse
|
||||||
|
var err error
|
||||||
|
select {
|
||||||
|
case resp := <-uiCh:
|
||||||
|
log.Printf("checking received: %v", resp.RequestId)
|
||||||
|
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
|
||||||
|
}
|
@ -40,13 +40,13 @@ type WatcherUpdate struct {
|
|||||||
|
|
||||||
func readFileContents(filePath string, getDefaults func() SettingsConfigType) (*SettingsConfigType, error) {
|
func readFileContents(filePath string, getDefaults func() SettingsConfigType) (*SettingsConfigType, error) {
|
||||||
if getDefaults == nil {
|
if getDefaults == nil {
|
||||||
log.Printf("oopsie")
|
log.Printf("should not happen")
|
||||||
return nil, fmt.Errorf("watcher started without defaults")
|
return nil, fmt.Errorf("watcher started without defaults")
|
||||||
}
|
}
|
||||||
content := getDefaults()
|
content := getDefaults()
|
||||||
data, err := os.ReadFile(filePath)
|
data, err := os.ReadFile(filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("doopsie: %v", err)
|
log.Printf("could not read settings file: %v", err)
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(data, &content); err != nil {
|
if err := json.Unmarshal(data, &content); err != nil {
|
||||||
|
Loading…
Reference in New Issue
Block a user