Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: proposal: custom node commands #1007

Open
wants to merge 10 commits into
base: master
Choose a base branch
from
86 changes: 86 additions & 0 deletions api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"net/http"
Expand Down Expand Up @@ -1068,6 +1069,91 @@ func (api *api) Health(ctx context.Context) (*HealthResponse, error) {
return &HealthResponse{Alarms: alarms}, nil
}

func (api *api) GetNodeCommands() (*NodeCommandsResponse, error) {
rdmitr marked this conversation as resolved.
Show resolved Hide resolved
lnClient := api.svc.GetLNClient()
if lnClient == nil {
return nil, errors.New("LNClient not started")
}

allCommandDefs := lnClient.GetCustomCommandDefinitions()
commandDefs := make([]NodeCommandDef, 0, len(allCommandDefs))
for _, commandDef := range allCommandDefs {
argDefs := make([]NodeCommandArgDef, 0, len(commandDef.Args))
for _, argDef := range commandDef.Args {
argDefs = append(argDefs, NodeCommandArgDef{
Name: argDef.Name,
Description: argDef.Description,
})
}
commandDefs = append(commandDefs, NodeCommandDef{
Name: commandDef.Name,
Description: commandDef.Description,
Args: argDefs,
})
}

return &NodeCommandsResponse{Commands: commandDefs}, nil
}

func (api *api) ExecuteNodeCommand(ctx context.Context, command string) ([]byte, error) {
lnClient := api.svc.GetLNClient()
if lnClient == nil {
return nil, errors.New("LNClient not started")
}

// Split command line into arguments. Command name must be the first argument.
parsedArgs, err := utils.ParseCommandLine(command)
if err != nil {
return nil, fmt.Errorf("failed to parse node command: %w", err)
} else if len(parsedArgs) == 0 {
return nil, errors.New("no command provided")
}

// Look up the requested command definition.
allCommandDefs := lnClient.GetCustomCommandDefinitions()
commandDefIdx := slices.IndexFunc(allCommandDefs, func(def lnclient.NodeCommandDef) bool {
return def.Name == parsedArgs[0]
})
if commandDefIdx < 0 {
return nil, fmt.Errorf("unknown command: %q", parsedArgs[0])
}

// Build flag set.
commandDef := allCommandDefs[commandDefIdx]
flagSet := flag.NewFlagSet(commandDef.Name, flag.ContinueOnError)
for _, argDef := range commandDef.Args {
flagSet.String(argDef.Name, "", argDef.Description)
}

if err = flagSet.Parse(parsedArgs[1:]); err != nil {
return nil, fmt.Errorf("failed to parse command arguments: %w", err)
}

// Collect flags that have been set.
argValues := make(map[string]string)
flagSet.Visit(func(f *flag.Flag) {
argValues[f.Name] = f.Value.String()
})

reqArgs := make([]lnclient.NodeCommandArg, 0, len(argValues))
for argName, argValue := range argValues {
reqArgs = append(reqArgs, lnclient.NodeCommandArg{
Name: argName,
Value: argValue,
})
}

nodeResp, err := lnClient.ExecuteCustomCommand(ctx, &lnclient.NodeCommandRequest{
Name: commandDef.Name,
Args: reqArgs,
})
if err != nil {
return nil, fmt.Errorf("node failed to execute custom command: %w", err)
}

return nodeResp.RawJson, nil
}

func (api *api) parseExpiresAt(expiresAtString string) (*time.Time, error) {
var expiresAt *time.Time
if expiresAtString != "" {
Expand Down
21 changes: 21 additions & 0 deletions api/models.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ type API interface {
MigrateNodeStorage(ctx context.Context, to string) error
GetWalletCapabilities(ctx context.Context) (*WalletCapabilitiesResponse, error)
Health(ctx context.Context) (*HealthResponse, error)
GetNodeCommands() (*NodeCommandsResponse, error)
ExecuteNodeCommand(ctx context.Context, command string) ([]byte, error)
}

type App struct {
Expand Down Expand Up @@ -392,3 +394,22 @@ func NewHealthAlarm(kind HealthAlarmKind, rawDetails any) HealthAlarm {
type HealthResponse struct {
Alarms []HealthAlarm `json:"alarms,omitempty"`
}

type NodeCommandArgDef struct {
Name string `json:"name"`
Description string `json:"description"`
}

type NodeCommandDef struct {
Name string `json:"name"`
Description string `json:"description"`
Args []NodeCommandArgDef `json:"args"`
}

type NodeCommandsResponse struct {
Commands []NodeCommandDef `json:"commands"`
}

type ExecuteCommandRequest struct {
Command string `json:"command"`
}
98 changes: 98 additions & 0 deletions frontend/src/components/ExecuteCustomNodeCommandDialogContent.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import React from "react";
import {
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "src/components/ui/alert-dialog";
import { Textarea } from "src/components/ui/textarea";
import { useToast } from "src/components/ui/use-toast";
import { useInfo } from "src/hooks/useInfo";
import { request } from "src/utils/request";

type ExecuteCustomNodeCommandDialogContentProps = {
availableCommands: string;
setCommandResponse: (response: string) => void;
};

export function ExecuteCustomNodeCommandDialogContent({
setCommandResponse,
availableCommands,
}: ExecuteCustomNodeCommandDialogContentProps) {
const { mutate: reloadInfo } = useInfo();
const { toast } = useToast();
const [command, setCommand] = React.useState<string>();

let parsedAvailableCommands = availableCommands;
try {
parsedAvailableCommands = JSON.stringify(
JSON.parse(availableCommands).commands,
null,
2
);
} catch (error) {
// ignore unexpected json
}

async function onSubmit(e: React.FormEvent) {
e.preventDefault();
try {
if (!command) {
throw new Error("No command set");
}
const result = await request("/api/command", {
method: "POST",
body: JSON.stringify({ command }),
headers: {
"Content-Type": "application/json",
},
});
await reloadInfo();

const parsedResponse = JSON.stringify(result);
setCommandResponse(parsedResponse);

toast({ title: "Command executed", description: parsedResponse });
} catch (error) {
console.error(error);
toast({
variant: "destructive",
title: "Something went wrong: " + error,
});
}
}

return (
<AlertDialogContent>
<form onSubmit={onSubmit}>
<AlertDialogHeader>
<AlertDialogTitle>Execute Custom Node Command</AlertDialogTitle>
<AlertDialogDescription className="text-left">
<Textarea
className="h-36 font-mono"
value={command}
onChange={(e) => setCommand(e.target.value)}
placeholder="commandname --arg1=value1"
/>
<p className="mt-2">Available commands</p>
<Textarea
readOnly
className="mt-2 font-mono"
value={parsedAvailableCommands}
rows={10}
/>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter className="mt-4">
<AlertDialogCancel onClick={() => setCommand("")}>
Cancel
</AlertDialogCancel>
<AlertDialogAction type="submit">Execute</AlertDialogAction>
</AlertDialogFooter>
</form>
</AlertDialogContent>
);
}
25 changes: 25 additions & 0 deletions frontend/src/screens/settings/DebugTools.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import React from "react";
import { ExecuteCustomNodeCommandDialogContent } from "src/components/ExecuteCustomNodeCommandDialogContent";
import { ResetRoutingDataDialogContent } from "src/components/ResetRoutingDataDialogContent";
import SettingsHeader from "src/components/SettingsHeader";
import {
Expand Down Expand Up @@ -221,6 +222,7 @@ export default function DebugTools() {
| "getNodeLogs"
| "getNetworkGraph"
| "resetRoutingData"
| "customNodeCommand"
>();

const { data: info } = useInfo();
Expand Down Expand Up @@ -311,6 +313,23 @@ export default function DebugTools() {
</Button>
</AlertDialogTrigger>
)}
<Button
onClick={() => {
apiRequest(`/api/commands`, "GET");
}}
>
Get Node Commands
</Button>
<AlertDialogTrigger asChild>
<Button
onClick={() => {
apiRequest(`/api/commands`, "GET");
setDialog("customNodeCommand");
}}
>
Execute Node Command
</Button>
</AlertDialogTrigger>
{/* probing functions are not useful */}
{/*info?.backendType === "LDK" && (
<AlertDialogTrigger asChild>
Expand Down Expand Up @@ -343,6 +362,12 @@ export default function DebugTools() {
<GetNetworkGraphDialogContent apiRequest={apiRequest} />
)}
{dialog === "resetRoutingData" && <ResetRoutingDataDialogContent />}
{dialog === "customNodeCommand" && (
<ExecuteCustomNodeCommandDialogContent
availableCommands={apiResponse}
setCommandResponse={setApiResponse}
/>
)}
</AlertDialog>
</div>
{apiResponse && (
Expand Down
31 changes: 31 additions & 0 deletions http/http_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,8 @@ func (httpSvc *HttpService) RegisterSharedRoutes(e *echo.Echo) {
restrictedGroup.POST("/api/send-spontaneous-payment-probes", httpSvc.sendSpontaneousPaymentProbesHandler)
restrictedGroup.GET("/api/log/:type", httpSvc.getLogOutputHandler)
restrictedGroup.GET("/api/health", httpSvc.healthHandler)
restrictedGroup.GET("/api/commands", httpSvc.getNodeCommandsHandler)
restrictedGroup.POST("/api/command", httpSvc.execCommandHandler)

httpSvc.albyHttpSvc.RegisterSharedRoutes(restrictedGroup, e)
}
Expand Down Expand Up @@ -999,6 +1001,35 @@ func (httpSvc *HttpService) getLogOutputHandler(c echo.Context) error {
return c.JSON(http.StatusOK, getLogResponse)
}

func (httpSvc *HttpService) getNodeCommandsHandler(c echo.Context) error {
nodeCommandsResponse, err := httpSvc.api.GetNodeCommands()
if err != nil {
return c.JSON(http.StatusInternalServerError, ErrorResponse{
Message: fmt.Sprintf("Failed to get node commands: %v", err),
})
}

return c.JSON(http.StatusOK, nodeCommandsResponse)
}

func (httpSvc *HttpService) execCommandHandler(c echo.Context) error {
var execCommandRequest api.ExecuteCommandRequest
if err := c.Bind(&execCommandRequest); err != nil {
return c.JSON(http.StatusBadRequest, ErrorResponse{
Message: fmt.Sprintf("Bad request: %s", err.Error()),
})
}

execCommandResponse, err := httpSvc.api.ExecuteNodeCommand(c.Request().Context(), execCommandRequest.Command)
if err != nil {
return c.JSON(http.StatusInternalServerError, ErrorResponse{
Message: fmt.Sprintf("Failed to execute command: %v", err),
})
}

return c.JSONBlob(http.StatusOK, execCommandResponse)
rdmitr marked this conversation as resolved.
Show resolved Hide resolved
}

func (httpSvc *HttpService) logoutHandler(c echo.Context) error {
redirectUrl := httpSvc.cfg.GetEnv().FrontendUrl
if redirectUrl == "" {
Expand Down
10 changes: 9 additions & 1 deletion lnclient/breez/breez.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ func NewBreezService(mnemonic, apiKey, inviteCode, workDir string) (result lncli
return nil, errors.New("one or more required breez configuration are missing")
}

//create dir if not exists
// create dir if not exists
newpath := filepath.Join(workDir)
err = os.MkdirAll(newpath, os.ModePerm)
if err != nil {
Expand Down Expand Up @@ -493,3 +493,11 @@ func (bs *BreezService) GetSupportedNIP47NotificationTypes() []string {
func (bs *BreezService) GetPubkey() string {
return bs.pubkey
}

func (bs *BreezService) GetCustomCommandDefinitions() []lnclient.NodeCommandDef {
return nil
}

func (bs *BreezService) ExecuteCustomCommand(ctx context.Context, command *lnclient.NodeCommandRequest) (*lnclient.NodeCommandResponse, error) {
return nil, nil
}
Loading
Loading