feat(cli): add the prx command-line interface

This commit is contained in:
ahp
2026-10-15 14:29:19 +03:30
parent 1e9b0e9aac
commit 940c47030f
7 changed files with 1073 additions and 0 deletions
+163
View File
@@ -0,0 +1,163 @@
// SPDX-License-Identifier: MIT
// Package cli implements the prx command line interface.
package cli
import (
"context"
"errors"
"fmt"
"io"
"log/slog"
"os"
"os/signal"
"syscall"
"github.com/spf13/cobra"
"prx/internal/client"
"prx/internal/config"
"prx/internal/proto"
)
// app carries the state shared by every command.
type app struct {
configPath string
verbose bool
out io.Writer
errOut io.Writer
logger *slog.Logger
}
// Execute builds and runs the root command, cancelling in-flight work when the
// process receives SIGINT or SIGTERM.
func Execute() error {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
a := &app{out: os.Stdout, errOut: os.Stderr}
root := a.newRootCmd()
return root.ExecuteContext(ctx)
}
func (a *app) newRootCmd() *cobra.Command {
root := &cobra.Command{
Use: "prx",
Short: "Safe proxy routing manager",
Long: "prx enables and disables proxy routing while protecting inbound\n" +
"connections (such as SSH) from being pulled into the tunnel.",
SilenceUsage: true,
SilenceErrors: true,
PersistentPreRunE: func(_ *cobra.Command, _ []string) error {
level := slog.LevelInfo
if a.verbose {
level = slog.LevelDebug
}
a.logger = slog.New(slog.NewTextHandler(a.errOut, &slog.HandlerOptions{Level: level}))
return nil
},
}
root.PersistentFlags().StringVar(&a.configPath, "config", config.DefaultPath(), "path to config file")
root.PersistentFlags().BoolVarP(&a.verbose, "verbose", "v", false, "verbose logging")
root.AddCommand(
a.newOnCmd(),
a.newOffCmd(),
a.newToggleCmd(),
a.newStatusCmd(),
a.newDoctorCmd(),
a.newBypassCmd(),
a.newLogsCmd(),
a.newInitCmd(),
a.newVersionCmd(),
)
return root
}
func (a *app) loadConfig() (*config.Config, error) {
return config.Load(a.configPath)
}
func (a *app) buildPlan(cfg *config.Config, action proto.Action) proto.Plan {
return proto.Plan{
Action: action,
WANInterface: cfg.Network.WANInterface,
WANGateway4: cfg.Network.WANGateway,
WANGateway6: cfg.Network.WANGateway6,
Mark: cfg.Marks.InboundMark,
TableV4: cfg.Routing.TableV4,
TableV6: cfg.Routing.TableV6,
PriorityV4: cfg.Routing.PriorityV4,
PriorityV6: cfg.Routing.PriorityV6,
BypassTableV4: cfg.Routing.BypassTableV4,
BypassTableV6: cfg.Routing.BypassTableV6,
BypassPrioV4: cfg.Routing.BypassPrioV4,
BypassPrioV6: cfg.Routing.BypassPrioV6,
BypassCIDRs: cfg.Bypass.ExtraCIDRs,
ManageEngine: cfg.Engine.Manage,
ServiceName: cfg.Engine.ServiceName,
EngineConfig: cfg.Engine.ConfigPath,
StopEngineOnOff: cfg.Engine.StopOnOff,
HealthProbe: cfg.Health.Probe,
StatePath: cfg.State.Path,
}
}
// dispatch sends a plan to the helper, prints the result and turns a failed
// result into an error. mutate, when non-nil, adjusts the plan before it is
// sent.
func (a *app) dispatch(ctx context.Context, cfg *config.Config, action proto.Action, mutate func(*proto.Plan)) error {
plan := a.buildPlan(cfg, action)
if mutate != nil {
mutate(&plan)
}
if a.logger != nil {
a.logger.Debug("dispatching plan", "action", string(action), "state", plan.StatePath)
}
res, err := client.Dispatch(ctx, plan)
if err != nil {
return err
}
a.printResult(res)
if !res.OK {
return formatResultError(res)
}
return nil
}
func (a *app) printResult(res proto.Result) {
for _, m := range res.Messages {
fmt.Fprintln(a.out, " "+m)
}
if len(res.Checks) == 0 {
return
}
fmt.Fprintln(a.out, " checks:")
for _, c := range res.Checks {
line := fmt.Sprintf(" [%s] %s", checkMark(c), c.Name)
if c.Detail != "" {
line += ": " + c.Detail
}
fmt.Fprintln(a.out, line)
}
}
// checkMark renders a diagnostic check as a short status marker.
func checkMark(c proto.Check) string {
switch {
case c.OK:
return "ok"
case c.Warn:
return "warn"
default:
return "FAIL"
}
}
func formatResultError(res proto.Result) error {
if res.Error == "" {
return errors.New("operation failed")
}
return errors.New(res.Error)
}