Skip to content

Commit

Permalink
refactor: Rewrite stunnerctl and turncat to use the CDS API, fix #81
Browse files Browse the repository at this point in the history
So far stunnerctl and turncat have used the ConfigMap rendered by the operator to load the running
config of the requested Gateway. Since the ConfigMap is slated to be removed, this rewrite makes
sure that the CLI tools access dataplane configs from the CDS API served by the operator.
  • Loading branch information
rg0now committed Feb 1, 2024
1 parent 5a55b15 commit 602ed95
Show file tree
Hide file tree
Showing 22 changed files with 1,095 additions and 789 deletions.
31 changes: 12 additions & 19 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -349,30 +349,23 @@ The current STUNner dataplane configuration is always made available in a conven
has the same name and namespace as the Gateway it belongs to (so this is supposed to be
`stunner/udp-gateway` as per our example).

STUNner comes with a small utility to dump the running configuration in human readable format (you
must have [`jq`](https://stedolan.github.io/jq) installed in your PATH to be able to use it). Issue
the below from the main STUNner directory.
STUNner comes with a small CLI utility called [`stunnerctl`](/cmd/stunnerctl/README.md) to dump the
running configuration in human readable format.

```console
cmd/stunnerctl/stunnerctl running-config stunner/udp-gateway
STUN/TURN authentication type: static
STUN/TURN username: user-1
STUN/TURN password: pass-1
Listener 1
Name: stunner/udp-gateway/udp-listener
Listener: stunner/udp-gateway/udp-listener
Protocol: TURN-UDP
Public address: 34.34.150.65
Public port: 3478
stunnerctl -n stunner config udp-gateway
Gateway: stunner/udp-gateway (loglevel: "all:INFO")
Authentication type: ephemeral, shared-secret: my-very-secure-secret
Listeners:
- Name: stunner/udp-gateway/udp-listener
Protocol: TURN-UDP
Public address:port: 34.118.88.91:3478
Routes: [stunner/iperf-server]
Endpoints: [10.76.1.4, 10.80.4.47]
```

As it turns out, STUNner has successfully assigned a public IP and port to our Gateway and set the
STUN/TURN credentials based on the GatewayConfig. You can use the below to dump the entire running
configuration; `jq` is there just to pretty-print JSON.

```console
kubectl get cm -n stunner udp-gateway -o jsonpath="{.data.stunnerd\.conf}" | jq .
```
STUN/TURN credentials based on the GatewayConfig.

### Testing

Expand Down
91 changes: 87 additions & 4 deletions cmd/stunnerctl/README.md
Original file line number Diff line number Diff line change
@@ -1,15 +1,74 @@
# stunnerctl: Command line toolbox for STUNner

A CLI tool to simplify the interaction with STUNner.
A CLI tool to simplify the interaction with STUNner.
The prominent use of `stunnerctl` is to load or watch STUNner dataplane configurations from a Kubernetes cluster for debugging and troubleshooting, or just for checking whether everything is configured the way it should be.

## Installation

Install the `stunnerctl` binary using the standard Go toolchain and add it to `$PATH`.

```console
go install github.com/l7mp/stunner/cmd/stunnerctl@latest
```

You can also enforce a specific OS, CPU architecture, and STUNner version:

```console
GOOS=windows GOARCH=amd64 go install github.com/l7mp/stunner/cmd/[email protected]
```

Building from source is as easy as it usually gets with Go:

```console
cd stunner
go build -o stunnerctl cmd/stunnerctl/main.go
```

## Usage

Dump the running config of a STUNner gateway in human-readable format.
Type `stunnerctl` to get a glimpse of the features provided. Below are some common usage examples.

- Dump a summary of the running config of the STUNner gateway called `udp-gateway` deployed into the `stunner` namespace:

```console
stunnerctl -n stunner config udp-gateway
Gateway: stunner/udp-gateway (loglevel: "all:INFO")
Authentication type: static, username/password: user-1/pass-1
Listeners:
- Name: stunner/udp-gateway/udp-listener
Protocol: TURN-UDP
Public address:port: 34.118.88.91:9001
Routes: [stunner/iperf-server]
Endpoints: [10.76.1.3, 10.80.7.104]
```

- Dump a the running config of all gateways in the `stunner` namespace in JSON format (YAML is also available using `-o yaml`):

The below will select the Gateway called `tcp-gateway` in the `stunner` namespace:
```console
stunnerctl -n stunner config -o json
{"version":"v1","admin":{"name":"stunner/tcp-gateway",...}}
{"version":"v1","admin":{"name":"stunner/udp-gateway",...}}}
```

- Watch all STUNner configs as they are being refreshed and dump only the name of the STUNner gateway whose config changes:

```console
stunnerctl config --all-namespaces -o jsonpath='{.admin.name}' -w
stunner/tcp-gateway
stunner/udp-gateway
...
```

## Fallback

For those who don't have the Go toolchain available to run `go install`, STUNner provides a minimalistic `stunnerctl` replacement called `stunnerctl.sh`.
This script requires nothing else than `bash`, `kubectl`, `curl` and `jq` to work.

The below will dump the running config of `tcp-gateway` deployed into the `stunner` namespace:

```console
cmd/stunnerctl/stunnerctl running-config stunner/stunner-gateway
cd stunner
cmd/stunnerctl/stunnerctl.sh running-config stunner/stunner-gateway
STUN/TURN authentication type: static
STUN/TURN username: user-1
STUN/TURN password: pass-1
Expand All @@ -21,6 +80,30 @@ Listener 1
Public port: 3478
```

## Last resort

You can use `kubectl port-forward` to load or watch STUNner configs manually.
Open a port-forwarded connection to the STUNner gateway operator:

``` console
export CDS_SERVER_NAME=$(kubectl get pods -l stunner.l7mp.io/config-discovery-service=enabled --all-namespaces -o jsonpath='{.items[0].metadata.name}')
export CDS_SERVER_NAMESPACE=$(kubectl get pods -l stunner.l7mp.io/config-discovery-service=enabled --all-namespaces -o jsonpath='{.items[0].metadata.namespace}')
kubectl -n $CDS_SERVER_NAMESPACE port-forward pod/${CDS_SERVER_NAME} 63478:13478 &
```

If all goes well, you can now connect to the STUNner config discovery API served by the gateway operator directly, just using `curl`.
The below will load the config of the `udp-gateway` in the `stunner` namespace:

``` console
curl -s http://127.0.0.1:63478/api/v1/configs/stunner/udp-gateway
```

If you happen to have a WebSocket client like the wonderful [`websocat`](https://github.com/vi/websocat) tool installed, you can also watch the configs as they are being rendered by the operator en live.

``` console
websocat ws://127.0.0.1:63478/api/v1/configs/stunner/udp-gateway?watch=true -
```

## License

Copyright 2021-2023 by its authors. Some rights reserved. See [AUTHORS](../../AUTHORS).
Expand Down
233 changes: 233 additions & 0 deletions cmd/stunnerctl/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,233 @@
package main

import (
"context"
"encoding/json"
"fmt"
"os"
"os/signal"
"regexp"
"strings"
"syscall"

"github.com/pion/logging"
"github.com/spf13/cobra"
cliopt "k8s.io/cli-runtime/pkg/genericclioptions"
"k8s.io/client-go/util/jsonpath"
"sigs.k8s.io/yaml"

stnrv1 "github.com/l7mp/stunner/pkg/apis/v1"
cdsclient "github.com/l7mp/stunner/pkg/config/client"
"github.com/l7mp/stunner/pkg/logger"
)

// list all configs: stunnerctl get config --all-namespaces
// watch all configs in namesapce stunner: stunnerctl -n stunner get config --watch
// get short-form config for stunner/udp-gateway: stunnerctl -n stunner get config udp-gateway
// get config for stunner/udp-gateway in yaml format: stunnerctl -n stunner get config udp-gateway --output yaml

var (
output string
watch, all, verbose bool
jsonQuery *jsonpath.JSONPath
k8sConfigFlags *cliopt.ConfigFlags
cdsConfigFlags *cdsclient.CDSConfigFlags
loggerFactory *logger.LeveledLoggerFactory
log logging.LeveledLogger

rootCmd = &cobra.Command{
Use: "stunnerctl",
Short: "A command line utility to inspect STUNner dataplane configurations.",
Long: "The stunnerctl tool is a CLI for inspecting, watching and troublehssooting the configuration of STUNner gateways",
DisableAutoGenTag: true,
}
)

var (
configCmd = &cobra.Command{
Use: "config",
Aliases: []string{"stunner-config"},
Short: "Gets or watches STUNner configs",
Args: cobra.RangeArgs(0, 1),
DisableAutoGenTag: true,
Run: func(cmd *cobra.Command, args []string) {
if err := runConfig(cmd, args); err != nil {
fmt.Println(err)
os.Exit(1)
}
},
}
)

func init() {
rootCmd.PersistentFlags().BoolVarP(&watch, "watch", "w", false, "Watch for config updates from server")
rootCmd.PersistentFlags().BoolVarP(&all, "all-namespaces", "a", false, "Consider all namespaces")
rootCmd.PersistentFlags().StringVarP(&output, "output", "o", "summary", "Output format")
rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "Enable verbose logging, identical to -l all:DEBUG")

// Kubernetes config flags
k8sConfigFlags = cliopt.NewConfigFlags(true)
k8sConfigFlags.AddFlags(rootCmd.PersistentFlags())

// CDS server discovery flags
cdsConfigFlags = cdsclient.NewCDSConfigFlags()
cdsConfigFlags.AddFlags(rootCmd.PersistentFlags())

rootCmd.AddCommand(configCmd)
}

func main() {
if err := rootCmd.Execute(); err != nil {
fmt.Fprintf(os.Stderr, "Whoops. There was an error while executing your CLI '%s'", err)
os.Exit(1)
}
}

func runConfig(cmd *cobra.Command, args []string) error {
loglevel := "all:WARN"
if verbose {
loglevel = "all:TRACE"
}
loggerFactory = logger.NewLoggerFactory(loglevel)
log = loggerFactory.NewLogger("stunnerctl")

gwNs := "default"
if k8sConfigFlags.Namespace != nil {
gwNs = *k8sConfigFlags.Namespace
}

if strings.HasPrefix(output, "jsonpath") {
as := strings.Split(output, "=")
if len(as) != 2 || as[0] != "jsonpath" {
return fmt.Errorf("invalid jsonpath output definition %q", output)
}

jsonQuery = jsonpath.New("output")

// Parse and print jsonpath
fields, err := RelaxedJSONPathExpression(as[1])
if err != nil {
return fmt.Errorf("invalid jsonpath query %w", err)
}

if err := jsonQuery.Parse(fields); err != nil {
return fmt.Errorf("cannor parse jsonpath query %w", err)
}
output = "jsonpath"
}

ctx, cancel := context.WithCancel(context.Background())
defer cancel()

log.Debug("Searching for CDS server")
cdsAddr, err := cdsclient.DiscoverK8sCDSServer(ctx, k8sConfigFlags, cdsConfigFlags,
loggerFactory.NewLogger("cds-fwd"))
if err != nil {
return fmt.Errorf("error searching for CDS server: %w", err)
}

var cds cdsclient.CdsApi
cdslog := loggerFactory.NewLogger("cds-client")
if all {
cds, err = cdsclient.NewAllConfigsAPI(cdsAddr, cdslog)
} else if len(args) == 0 {
cds, err = cdsclient.NewConfigsNamespaceAPI(cdsAddr, gwNs, cdslog)
} else {
gwName := args[0]
cds, err = cdsclient.NewConfigNamespaceNameAPI(cdsAddr, gwNs, gwName, cdslog)
}

if err != nil {
return fmt.Errorf("error creating CDS client: %w", err)
}

confChan := make(chan *stnrv1.StunnerConfig, 8)
if watch {
err := cds.Watch(ctx, confChan)
if err != nil {
close(confChan)
return err
}

go func() {
sigs := make(chan os.Signal, 1)
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
<-sigs
close(confChan)
}()
} else {
resp, err := cds.Get(ctx)
if err != nil {
close(confChan)
return err
}
for _, c := range resp {
confChan <- c
}

close(confChan)
}

for c := range confChan {
switch output {
case "yaml":
if out, err := yaml.Marshal(c); err != nil {
return err
} else {
fmt.Println(string(out))
}
case "json":
if out, err := json.Marshal(c); err != nil {
return err
} else {
fmt.Println(string(out))
}
case "jsonpath":
values, err := jsonQuery.FindResults(c)
if err != nil {
return err
}

if len(values) == 0 || len(values[0]) == 0 {
fmt.Println("<none>")
}

for arrIx := range values {
for valIx := range values[arrIx] {
fmt.Printf("%v\n", values[arrIx][valIx].Interface())
}
}
case "summary":
fmt.Print(string(c.Summary()))
case "status":
fallthrough
default:
fmt.Println(c.String())
}
}

return nil
}

var jsonRegexp = regexp.MustCompile(`^\{\.?([^{}]+)\}$|^\.?([^{}]+)$`)

// k8s.io/kubectl/pkg/cmd/get
func RelaxedJSONPathExpression(pathExpression string) (string, error) {
if len(pathExpression) == 0 {
return pathExpression, nil
}
submatches := jsonRegexp.FindStringSubmatch(pathExpression)
if submatches == nil {
return "", fmt.Errorf("unexpected path string, expected a 'name1.name2' or '.name1.name2' or '{name1.name2}' or '{.name1.name2}'")
}
if len(submatches) != 3 {
return "", fmt.Errorf("unexpected submatch list: %v", submatches)
}
var fieldSpec string
if len(submatches[1]) != 0 {
fieldSpec = submatches[1]
} else {
fieldSpec = submatches[2]
}
return fmt.Sprintf("{.%s}", fieldSpec), nil
}
Loading

0 comments on commit 602ed95

Please sign in to comment.