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
+195
View File
@@ -0,0 +1,195 @@
// SPDX-License-Identifier: MIT
package cli
import (
"context"
"encoding/json"
"fmt"
"strings"
"github.com/spf13/cobra"
"prx/internal/config"
"prx/internal/engine"
"prx/internal/execx"
"prx/internal/iprule"
"prx/internal/nft"
"prx/internal/state"
)
type statusReport struct {
Enabled bool `json:"enabled"`
WANInterface string `json:"wan_interface,omitempty"`
Gateway4 string `json:"gateway_v4,omitempty"`
Gateway6 string `json:"gateway_v6,omitempty"`
Mark string `json:"mark"`
RuleV4 string `json:"ipv4_rule"`
TableV4 string `json:"ipv4_table"`
RuleV6 string `json:"ipv6_rule"`
TableV6 string `json:"ipv6_table"`
Engine string `json:"engine"`
NftTable string `json:"nft_table"`
EnabledAt string `json:"enabled_at,omitempty"`
// StateError reports a state file that could not be read, if any.
StateError string `json:"state_error,omitempty"`
}
func (a *app) newStatusCmd() *cobra.Command {
var asJSON bool
cmd := &cobra.Command{
Use: "status",
Short: "Show current proxy routing status",
RunE: func(cmd *cobra.Command, _ []string) error {
cfg, err := a.loadConfig()
if err != nil {
return err
}
r := execx.OSRunner{}
ctx := cmd.Context()
// A corrupt or unreadable state file must not hide the rest of
// the report: show what we know and flag the problem.
st, stateErr := state.Load(cfg.State.Path)
rep := statusReport{
Enabled: st.Enabled,
WANInterface: firstNonEmptyStr(st.WANInterface, cfg.Network.WANInterface),
Gateway4: st.Gateway4,
Gateway6: st.Gateway6,
Mark: fmt.Sprintf("0x%x", cfg.Marks.InboundMark),
RuleV4: ruleStatus(ctx, r, 4, cfg.Marks.InboundMark, cfg.Routing.TableV4, cfg.Routing.PriorityV4),
TableV4: tableStatus(ctx, r, 4, cfg.Routing.TableV4),
RuleV6: ruleStatus(ctx, r, 6, cfg.Marks.InboundMark, cfg.Routing.TableV6, cfg.Routing.PriorityV6),
TableV6: tableStatus(ctx, r, 6, cfg.Routing.TableV6),
Engine: engineStatus(ctx, r, cfg),
NftTable: nftStatus(ctx, r),
StateError: errorText(stateErr),
}
if !st.EnabledAt.IsZero() {
rep.EnabledAt = st.EnabledAt.Format("2006-01-02 15:04:05 MST")
}
if asJSON {
enc := json.NewEncoder(a.out)
enc.SetIndent("", " ")
return enc.Encode(rep)
}
stateStr := "disabled"
if rep.Enabled {
stateStr = "enabled"
}
fmt.Fprintf(a.out, "prx: %s\n", stateStr)
fmt.Fprintf(a.out, " mark: %s\n", rep.Mark)
if rep.WANInterface != "" {
fmt.Fprintf(a.out, " interface: %s (gw4=%s gw6=%s)\n", rep.WANInterface, orDash(rep.Gateway4), orDash(rep.Gateway6))
}
fmt.Fprintf(a.out, " ipv4 rule: %s\n", rep.RuleV4)
fmt.Fprintf(a.out, " ipv4 table: %s\n", rep.TableV4)
fmt.Fprintf(a.out, " ipv6 rule: %s\n", rep.RuleV6)
fmt.Fprintf(a.out, " ipv6 table: %s\n", rep.TableV6)
fmt.Fprintf(a.out, " nft table: %s\n", rep.NftTable)
fmt.Fprintf(a.out, " engine: %s\n", rep.Engine)
if rep.EnabledAt != "" {
fmt.Fprintf(a.out, " since: %s\n", rep.EnabledAt)
}
if rep.StateError != "" {
fmt.Fprintf(a.out, " warning: state file unreadable: %s\n", rep.StateError)
}
return nil
},
}
cmd.Flags().BoolVar(&asJSON, "json", false, "output JSON")
return cmd
}
// errorText returns the error message, or an empty string when err is nil.
func errorText(err error) string {
if err == nil {
return ""
}
return err.Error()
}
func ruleStatus(ctx context.Context, r execx.Runner, family int, mark uint32, table, prio int) string {
spec := iprule.Spec{Family: family, Mark: mark, Table: table, Priority: prio}
ok, err := iprule.RuleExists(ctx, r, spec)
switch {
case err != nil:
return "unknown (" + err.Error() + ")"
case ok:
return fmt.Sprintf("present (fwmark 0x%x -> table %d prio %d)", mark, table, prio)
default:
return "absent"
}
}
func tableStatus(ctx context.Context, r execx.Runner, family, table int) string {
ok, err := iprule.TableHasRoutes(ctx, r, family, table)
switch {
case err != nil:
return "unknown (" + err.Error() + ")"
case ok:
return fmt.Sprintf("table %d populated", table)
default:
return fmt.Sprintf("table %d empty", table)
}
}
func engineStatus(ctx context.Context, r execx.Runner, cfg *config.Config) string {
if !cfg.Engine.Manage || cfg.Engine.ServiceName == "" {
return "not managed"
}
active, err := engine.IsActive(ctx, r, cfg.Engine.ServiceName)
if err != nil {
return "unknown (" + err.Error() + ")"
}
if active {
return cfg.Engine.ServiceName + " active"
}
return cfg.Engine.ServiceName + " inactive"
}
func nftStatus(ctx context.Context, r execx.Runner) string {
ok, err := nft.Exists(ctx, r)
switch {
case err != nil:
if isPermissionError(err) {
return nft.Table() + " unknown (needs root)"
}
return nft.Table() + " unknown (" + err.Error() + ")"
case ok:
return nft.Table() + " present"
default:
return nft.Table() + " absent"
}
}
// isPermissionError reports whether err was caused by missing privileges,
// as opposed to a missing binary or a malformed invocation.
func isPermissionError(err error) bool {
msg := err.Error()
for _, marker := range []string{"Operation not permitted", "Permission denied", "not permitted"} {
if strings.Contains(msg, marker) {
return true
}
}
return false
}
func orDash(s string) string {
if s == "" {
return "-"
}
return s
}
func firstNonEmptyStr(vals ...string) string {
for _, v := range vals {
if v != "" {
return v
}
}
return ""
}