feat(cli): add the prx command-line interface
This commit is contained in:
@@ -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 ""
|
||||
}
|
||||
Reference in New Issue
Block a user