diff --git a/Makefile b/Makefile index 247f7d0..689c9d7 100644 --- a/Makefile +++ b/Makefile @@ -5,6 +5,9 @@ LDFLAGS := -trimpath -ldflags="-s -w -X main.version=$(shell git describe --tags mc-proxy: go build $(LDFLAGS) -o mc-proxy ./cmd/mc-proxy +mcproxyctl: + go build -trimpath -ldflags="-s -w" -o mcproxyctl ./cmd/mcproxyctl + build: go build ./... @@ -27,7 +30,7 @@ proto-lint: buf breaking --against '.git#branch=master,subdir=proto' clean: - rm -f mc-proxy + rm -f mc-proxy mcproxyctl docker: docker build --build-arg VERSION=$(shell git describe --tags --always --dirty) -t mc-proxy -f Dockerfile . diff --git a/cmd/mcproxyctl/firewall.go b/cmd/mcproxyctl/firewall.go new file mode 100644 index 0000000..5160828 --- /dev/null +++ b/cmd/mcproxyctl/firewall.go @@ -0,0 +1,131 @@ +package main + +import ( + "context" + "fmt" + "time" + + "github.com/spf13/cobra" + + "git.wntrmute.dev/kyle/mc-proxy/client/mcproxy" +) + +func firewallCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "firewall", + Short: "Manage firewall rules", + Long: "Manage firewall rules for mc-proxy.", + } + + cmd.AddCommand(firewallListCmd()) + cmd.AddCommand(firewallAddCmd()) + cmd.AddCommand(firewallRemoveCmd()) + + return cmd +} + +func firewallListCmd() *cobra.Command { + return &cobra.Command{ + Use: "list", + Short: "List all firewall rules", + Long: "List all configured firewall rules.", + RunE: func(cmd *cobra.Command, args []string) error { + client := clientFromContext(cmd.Context()) + + ctx, cancel := context.WithTimeout(cmd.Context(), 5*time.Second) + defer cancel() + + rules, err := client.GetFirewallRules(ctx) + if err != nil { + return fmt.Errorf("listing firewall rules: %w", err) + } + + if len(rules) == 0 { + fmt.Println("No firewall rules configured") + return nil + } + + // Find max type length for alignment + maxTypeLen := 0 + for _, r := range rules { + if len(r.Type) > maxTypeLen { + maxTypeLen = len(string(r.Type)) + } + } + + fmt.Println("Firewall rules:") + for _, r := range rules { + fmt.Printf(" %-*s %s\n", maxTypeLen, r.Type, r.Value) + } + + return nil + }, + } +} + +func firewallAddCmd() *cobra.Command { + return &cobra.Command{ + Use: "add TYPE VALUE", + Short: "Add a firewall rule", + Long: "Add a firewall rule. TYPE must be one of: ip, cidr, country.", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + ruleType, err := parseRuleType(args[0]) + if err != nil { + return err + } + value := args[1] + client := clientFromContext(cmd.Context()) + + ctx, cancel := context.WithTimeout(cmd.Context(), 5*time.Second) + defer cancel() + + if err := client.AddFirewallRule(ctx, ruleType, value); err != nil { + return fmt.Errorf("adding firewall rule: %w", err) + } + + fmt.Printf("Added firewall rule: %s %s\n", ruleType, value) + return nil + }, + } +} + +func firewallRemoveCmd() *cobra.Command { + return &cobra.Command{ + Use: "remove TYPE VALUE", + Short: "Remove a firewall rule", + Long: "Remove a firewall rule. TYPE must be one of: ip, cidr, country.", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + ruleType, err := parseRuleType(args[0]) + if err != nil { + return err + } + value := args[1] + client := clientFromContext(cmd.Context()) + + ctx, cancel := context.WithTimeout(cmd.Context(), 5*time.Second) + defer cancel() + + if err := client.RemoveFirewallRule(ctx, ruleType, value); err != nil { + return fmt.Errorf("removing firewall rule: %w", err) + } + + fmt.Printf("Removed firewall rule: %s %s\n", ruleType, value) + return nil + }, + } +} + +func parseRuleType(s string) (mcproxy.FirewallRuleType, error) { + switch s { + case "ip": + return mcproxy.FirewallRuleIP, nil + case "cidr": + return mcproxy.FirewallRuleCIDR, nil + case "country": + return mcproxy.FirewallRuleCountry, nil + default: + return "", fmt.Errorf("invalid rule type %q: must be one of: ip, cidr, country", s) + } +} diff --git a/cmd/mcproxyctl/health.go b/cmd/mcproxyctl/health.go new file mode 100644 index 0000000..007d078 --- /dev/null +++ b/cmd/mcproxyctl/health.go @@ -0,0 +1,38 @@ +package main + +import ( + "context" + "fmt" + "time" + + "github.com/spf13/cobra" + + "git.wntrmute.dev/kyle/mc-proxy/client/mcproxy" +) + +func healthCmd() *cobra.Command { + return &cobra.Command{ + Use: "health", + Short: "Check server health", + Long: "Check the health status of the mc-proxy server.", + RunE: func(cmd *cobra.Command, args []string) error { + client := clientFromContext(cmd.Context()) + + ctx, cancel := context.WithTimeout(cmd.Context(), 5*time.Second) + defer cancel() + + status, err := client.CheckHealth(ctx) + if err != nil { + return fmt.Errorf("checking health: %w", err) + } + + fmt.Println(status) + + if status != mcproxy.HealthServing { + return fmt.Errorf("server is not healthy") + } + + return nil + }, + } +} diff --git a/cmd/mcproxyctl/main.go b/cmd/mcproxyctl/main.go new file mode 100644 index 0000000..6d0a745 --- /dev/null +++ b/cmd/mcproxyctl/main.go @@ -0,0 +1,11 @@ +package main + +import ( + "os" +) + +func main() { + if err := rootCmd().Execute(); err != nil { + os.Exit(1) + } +} diff --git a/cmd/mcproxyctl/root.go b/cmd/mcproxyctl/root.go new file mode 100644 index 0000000..af5042b --- /dev/null +++ b/cmd/mcproxyctl/root.go @@ -0,0 +1,71 @@ +package main + +import ( + "context" + "os" + + "github.com/spf13/cobra" + + "git.wntrmute.dev/kyle/mc-proxy/client/mcproxy" +) + +const defaultSocketPath = "/var/run/mc-proxy.sock" + +type contextKey string + +const clientKey contextKey = "client" + +func rootCmd() *cobra.Command { + var socketPath string + + cmd := &cobra.Command{ + Use: "mcproxyctl", + Short: "Control a running mc-proxy instance", + Long: "mcproxyctl is a command-line tool for administrating a running mc-proxy instance via the gRPC admin API.", + PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + // Skip client setup for help commands + if cmd.Name() == "help" || cmd.Name() == "completion" { + return nil + } + + // Environment variable override + if envSocket := os.Getenv("MCPROXY_SOCKET"); envSocket != "" && socketPath == defaultSocketPath { + socketPath = envSocket + } + + client, err := mcproxy.Dial(socketPath) + if err != nil { + return err + } + + ctx := context.WithValue(cmd.Context(), clientKey, client) + cmd.SetContext(ctx) + return nil + }, + PersistentPostRunE: func(cmd *cobra.Command, args []string) error { + client := clientFromContext(cmd.Context()) + if client != nil { + return client.Close() + } + return nil + }, + SilenceUsage: true, + } + + cmd.PersistentFlags().StringVarP(&socketPath, "socket", "s", defaultSocketPath, "Unix socket path") + + cmd.AddCommand(statusCmd()) + cmd.AddCommand(healthCmd()) + cmd.AddCommand(routesCmd()) + cmd.AddCommand(firewallCmd()) + + return cmd +} + +func clientFromContext(ctx context.Context) *mcproxy.Client { + if ctx == nil { + return nil + } + client, _ := ctx.Value(clientKey).(*mcproxy.Client) + return client +} diff --git a/cmd/mcproxyctl/routes.go b/cmd/mcproxyctl/routes.go new file mode 100644 index 0000000..5e5526f --- /dev/null +++ b/cmd/mcproxyctl/routes.go @@ -0,0 +1,113 @@ +package main + +import ( + "context" + "fmt" + "time" + + "github.com/spf13/cobra" +) + +func routesCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "routes", + Short: "Manage routes", + Long: "Manage routes for mc-proxy listeners.", + } + + cmd.AddCommand(routesListCmd()) + cmd.AddCommand(routesAddCmd()) + cmd.AddCommand(routesRemoveCmd()) + + return cmd +} + +func routesListCmd() *cobra.Command { + return &cobra.Command{ + Use: "list LISTENER", + Short: "List routes for a listener", + Long: "List all routes configured for the specified listener address.", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + listenerAddr := args[0] + client := clientFromContext(cmd.Context()) + + ctx, cancel := context.WithTimeout(cmd.Context(), 5*time.Second) + defer cancel() + + routes, err := client.ListRoutes(ctx, listenerAddr) + if err != nil { + return fmt.Errorf("listing routes: %w", err) + } + + if len(routes) == 0 { + fmt.Printf("No routes for %s\n", listenerAddr) + return nil + } + + // Find max hostname length for alignment + maxHostLen := 0 + for _, r := range routes { + if len(r.Hostname) > maxHostLen { + maxHostLen = len(r.Hostname) + } + } + + fmt.Printf("Routes for %s:\n", listenerAddr) + for _, r := range routes { + fmt.Printf(" %-*s -> %s\n", maxHostLen, r.Hostname, r.Backend) + } + + return nil + }, + } +} + +func routesAddCmd() *cobra.Command { + return &cobra.Command{ + Use: "add LISTENER HOSTNAME BACKEND", + Short: "Add a route", + Long: "Add a route mapping a hostname to a backend for the specified listener.", + Args: cobra.ExactArgs(3), + RunE: func(cmd *cobra.Command, args []string) error { + listenerAddr := args[0] + hostname := args[1] + backend := args[2] + client := clientFromContext(cmd.Context()) + + ctx, cancel := context.WithTimeout(cmd.Context(), 5*time.Second) + defer cancel() + + if err := client.AddRoute(ctx, listenerAddr, hostname, backend); err != nil { + return fmt.Errorf("adding route: %w", err) + } + + fmt.Printf("Added route: %s -> %s on %s\n", hostname, backend, listenerAddr) + return nil + }, + } +} + +func routesRemoveCmd() *cobra.Command { + return &cobra.Command{ + Use: "remove LISTENER HOSTNAME", + Short: "Remove a route", + Long: "Remove a route for the specified hostname from the listener.", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + listenerAddr := args[0] + hostname := args[1] + client := clientFromContext(cmd.Context()) + + ctx, cancel := context.WithTimeout(cmd.Context(), 5*time.Second) + defer cancel() + + if err := client.RemoveRoute(ctx, listenerAddr, hostname); err != nil { + return fmt.Errorf("removing route: %w", err) + } + + fmt.Printf("Removed route: %s from %s\n", hostname, listenerAddr) + return nil + }, + } +} diff --git a/cmd/mcproxyctl/status.go b/cmd/mcproxyctl/status.go new file mode 100644 index 0000000..c74fcb3 --- /dev/null +++ b/cmd/mcproxyctl/status.go @@ -0,0 +1,42 @@ +package main + +import ( + "context" + "fmt" + "time" + + "github.com/spf13/cobra" +) + +func statusCmd() *cobra.Command { + return &cobra.Command{ + Use: "status", + Short: "Show server status", + Long: "Show server status including version, uptime, and listener information.", + RunE: func(cmd *cobra.Command, args []string) error { + client := clientFromContext(cmd.Context()) + + ctx, cancel := context.WithTimeout(cmd.Context(), 5*time.Second) + defer cancel() + + status, err := client.GetStatus(ctx) + if err != nil { + return fmt.Errorf("getting status: %w", err) + } + + fmt.Printf("mc-proxy %s\n", status.Version) + if !status.StartedAt.IsZero() { + uptime := time.Since(status.StartedAt).Truncate(time.Second) + fmt.Printf("uptime: %s\n", uptime) + } + fmt.Printf("connections: %d\n", status.TotalConnections) + fmt.Println() + + for _, ls := range status.Listeners { + fmt.Printf(" %s routes=%d active=%d\n", ls.Addr, ls.RouteCount, ls.ActiveConnections) + } + + return nil + }, + } +} diff --git a/mcproxyctl b/mcproxyctl new file mode 100755 index 0000000..4a4a6d7 Binary files /dev/null and b/mcproxyctl differ