diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..d2b1d8a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +.git +.github +.vscode +.worktrees +bin +dist diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..c673d26 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,14 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: docker/setup-buildx-action@v3 + - run: docker buildx build --target test . diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..d16d173 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,34 @@ +name: Release + +on: + release: + types: [published] + +permissions: + contents: write + +jobs: + binaries: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: docker/setup-buildx-action@v3 + - name: Build + run: >- + docker buildx build + --platform darwin/arm64,darwin/amd64,linux/arm64,linux/amd64 + --build-arg VERSION="${TAG#v}" + --output dist . + env: + TAG: ${{ github.event.release.tag_name }} + # Raw binaries named ceryx__: github.com/marcosnils/bin picks the asset + # by OS/arch in the name and strips them to install it as "ceryx". It skips .txt. + - name: Upload + run: | + mkdir assets + for f in dist/*/ceryx; do cp "$f" "assets/ceryx_$(basename "$(dirname "$f")")"; done + (cd assets && sha256sum ceryx_* > checksums.txt) + gh release upload "$TAG" assets/* --clobber + env: + TAG: ${{ github.event.release.tag_name }} + GH_TOKEN: ${{ github.token }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..77af47e --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.worktrees/ +/bin/ +/dist/ diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..42d2d11 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["golang.go"] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..9bc7ff7 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,21 @@ +{ + "window.title": "ceryx-cli", + // Editor support only; build and test via the Docker tasks in tasks.json. + "[go]": { + "editor.formatOnSave": true, + "editor.codeActionsOnSave": { + "source.organizeImports": "explicit" + } + }, + // ./bin/ceryx from the "build" task is on PATH in VS Code terminals. + "terminal.integrated.env.osx": { + "PATH": "${workspaceFolder}/bin:${env:PATH}" + }, + "terminal.integrated.env.linux": { + "PATH": "${workspaceFolder}/bin:${env:PATH}" + }, + "search.exclude": { + "bin/**": true, + "dist/**": true + } +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..91e3753 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,30 @@ +{ + // Everything runs through docker buildx, so only Docker is needed on the host. + "version": "2.0.0", + "tasks": [ + { + "label": "build", + "detail": "Build ./bin/ceryx for this machine (on PATH in VS Code terminals)", + "type": "shell", + "command": "docker buildx build --platform \"$(uname -s | tr A-Z a-z)/$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/')\" --output bin .", + "group": { "kind": "build", "isDefault": true }, + "problemMatcher": [] + }, + { + "label": "build: release binaries", + "detail": "Build dist/_/ceryx for macOS and Linux", + "type": "shell", + "command": "docker buildx build --platform darwin/arm64,darwin/amd64,linux/arm64,linux/amd64 --output dist .", + "group": "build", + "problemMatcher": [] + }, + { + "label": "test", + "detail": "go vet + go test in Docker", + "type": "shell", + "command": "docker buildx build --target test --no-cache-filter test .", + "group": { "kind": "test", "isDefault": true }, + "problemMatcher": [] + } + ] +} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..02c4eee --- /dev/null +++ b/Dockerfile @@ -0,0 +1,23 @@ +# syntax=docker/dockerfile:1 + +# Builds and tests run here so the host needs only Docker. The build stage runs on the +# host platform and cross-compiles for --platform (e.g. darwin/arm64). +FROM --platform=$BUILDPLATFORM golang:1.27-alpine AS base +WORKDIR /src +ENV CGO_ENABLED=0 +COPY go.mod go.sum ./ +RUN --mount=type=cache,target=/go/pkg/mod go mod download +COPY . . + +FROM base AS test +RUN --mount=type=cache,target=/go/pkg/mod --mount=type=cache,target=/root/.cache/go-build \ + go vet ./... && go test ./... + +FROM base AS build +ARG TARGETOS TARGETARCH VERSION=dev +RUN --mount=type=cache,target=/go/pkg/mod --mount=type=cache,target=/root/.cache/go-build \ + GOOS=$TARGETOS GOARCH=$TARGETARCH go build -ldflags "-s -w -X main.version=$VERSION" -o /out/ceryx ./cmd/ceryx + +# Export with: docker buildx build --platform darwin/arm64 --output bin . +FROM scratch AS binary +COPY --from=build /out/ceryx / diff --git a/README.md b/README.md index 288fb96..6a7d499 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,231 @@ -# ceryx-cli -Command line client for the Ceryx API. +# Ceryx CLI + +Command line client for the [Ceryx](https://github.com/withlogicco/ceryx) API. + +## Install + +### Install script + +Installs the latest release to `/usr/local/bin` (uses `sudo` if needed): + +```sh +curl -fsSL https://raw.githubusercontent.com/withlogicco/ceryx-cli/main/install.sh | sh +``` + +Set `CERYX_VERSION=v0.1.0` for a specific release or `INSTALL_DIR=~/.local/bin` for another directory, e.g. `curl -fsSL … | INSTALL_DIR=~/.local/bin sh`. + +### Download from the latest release + +Binaries for macOS and Linux (arm64, amd64) are on the [releases](https://github.com/withlogicco/ceryx-cli/releases) page as `ceryx__`: + +```sh +os=$(uname -s | tr A-Z a-z); arch=$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/') +curl -fsSL -o ceryx "https://github.com/withlogicco/ceryx-cli/releases/latest/download/ceryx_${os}_${arch}" +chmod +x ceryx && sudo mv ceryx /usr/local/bin/ +``` + +### Install with Go + +```sh +go install github.com/withlogicco/ceryx-cli/cmd/ceryx@latest +``` + +### Install with bin + +With [bin](https://github.com/marcosnils/bin): + +```sh +bin install github.com/withlogicco/ceryx-cli +``` + +## Configure + +The CLI talks to `http://localhost:8080` by default. Point it elsewhere with `--api-url` or `CERYX_API_URL`: + +```sh +export CERYX_API_URL=ceryx:8080 +``` + +Responses are the API's JSON on stdout; errors go to stderr. Exit codes: `0` ok, `1` error, `2` usage, `3` not found. + +## Routes + +### Create a route + +Proxy `app.example.com` to the `app` service on port `8000`: + +```console +$ ceryx routes set app.example.com app:8000 +{ + "settings": { + "certificate_path": null, + "enforce_https": false, + "key_path": null, + "mode": "proxy", + "mtls_require_client_certificate": false, + "mtls_trusted_ca": null, + "mtls_verify_depth": null + }, + "source": "app.example.com", + "target": "app:8000" +} +``` + +`set` creates the route or replaces it whole: settings you leave out get server defaults. + +### Patch a route + +Turn on HTTPS enforcement and keep everything else: + +```console +$ ceryx routes patch app.example.com --enforce-https +{ + "settings": { + "certificate_path": null, + "enforce_https": true, + "key_path": null, + "mode": "proxy", + "mtls_require_client_certificate": false, + "mtls_trusted_ca": null, + "mtls_verify_depth": null + }, + "source": "app.example.com", + "target": "app:8000" +} +``` + +`patch` changes only the flags you pass and fails if the route does not exist: + +```console +$ ceryx routes patch typo.example.com --enforce-https +error: No route found for typo.example.com (HTTP 404) +$ echo $? +3 +``` + +Pass `--certificate-path ""` or `--key-path ""` to clear a value. + +### Create a redirect + +```console +$ ceryx routes set old.example.com https://app.example.com --mode redirect +{ + "settings": { + "certificate_path": null, + "enforce_https": false, + "key_path": null, + "mode": "redirect", + "mtls_require_client_certificate": false, + "mtls_trusted_ca": null, + "mtls_verify_depth": null + }, + "source": "old.example.com", + "target": "https://app.example.com" +} +``` + +### List routes + +```console +$ ceryx routes list +[ + { + "settings": { + "certificate_path": null, + "enforce_https": true, + "key_path": null, + "mode": "proxy", + "mtls_require_client_certificate": false, + "mtls_trusted_ca": null, + "mtls_verify_depth": null + }, + "source": "app.example.com", + "target": "app:8000" + }, + { + "settings": { + "certificate_path": null, + "enforce_https": false, + "key_path": null, + "mode": "redirect", + "mtls_require_client_certificate": false, + "mtls_trusted_ca": null, + "mtls_verify_depth": null + }, + "source": "old.example.com", + "target": "https://app.example.com" + } +] +``` + +### Delete a route + +Deleting prints nothing and succeeds even if the route is already gone: + +```console +$ ceryx routes delete old.example.com +$ ceryx routes get old.example.com +error: No route found for old.example.com (HTTP 404) +$ echo $? +3 +``` + +## Certificates + +### Upload a certificate + +```console +$ ceryx certs set '*.example.com' --cert cert.pem --key key.pem --meta '{"issuer":"internal"}' +{ + "domain": "*.example.com", + "has_certificate": true, + "has_chain": false, + "meta": { + "issued_at": "2026-11-10T22:13:33Z", + "issuer": "internal", + "stored_at": 1794348813 + } +} +``` + +Add `--chain chain.pem` for intermediates. Any file flag accepts `-` to read stdin. + +### See which certificate a domain gets + +```console +$ ceryx certs get app.example.com +{ + "domain": "app.example.com", + "has_certificate": true, + "has_chain": false, + "meta": { + "issued_at": "2026-11-10T22:13:33Z", + "issuer": "internal", + "stored_at": 1794348813 + } +} +``` + +### Patch, list and delete + +```console +$ ceryx certs patch '*.example.com' --meta '{"issuer":"acme"}' +$ ceryx certs list +$ ceryx certs delete '*.example.com' +``` + +`certs patch` changes a stored certificate without uploading it again. `--cert` and `--key` go together, and a new certificate drops the old chain unless you pass `--chain`. + +Run `ceryx --help` for all flags. + +## Development + +Builds and tests run in Docker, so only Docker with buildx is needed: + +```sh +docker buildx build --target test . # go vet + go test +docker buildx build --platform darwin/arm64 --output bin . # bin/ceryx for an Apple Silicon Mac +docker buildx build --platform darwin/arm64,linux/amd64 --output dist . # dist/_/ceryx +``` + +In VS Code these are the `build`, `build: release binaries` and `test` tasks; `bin/` is on the `PATH` of VS Code terminals. diff --git a/cmd/ceryx/certificates.go b/cmd/ceryx/certificates.go new file mode 100644 index 0000000..7adfdcb --- /dev/null +++ b/cmd/ceryx/certificates.go @@ -0,0 +1,106 @@ +package main + +import ( + "encoding/json" + "errors" + "io" + "os" + + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +var errNothingToChange = errors.New("nothing to change; pass at least one flag") + +func certificatesCmd(call caller) *cobra.Command { + cmd := &cobra.Command{ + Use: "certificates", + Aliases: []string{"certs", "cert"}, + Short: "Manage TLS certificates (domain may be a wildcard like *.example.com)", + RunE: group, + } + + set := &cobra.Command{ + Use: "set --cert --key ", + Aliases: []string{"create", "add"}, + Short: "Create or replace a whole certificate", + Args: nargs(1), + RunE: func(cmd *cobra.Command, a []string) error { + f := cmd.Flags() + if !f.Changed("cert") || !f.Changed("key") { + return usageError{errors.New("--cert and --key are required")} + } + body, err := certBody(f) + if err != nil { + return err + } + body["domain"] = a[0] + return call("POST", "/api/certificates", body) + }, + } + certFlags(set.Flags()) + + patch := &cobra.Command{ + Use: "patch ", + Short: "Change a stored certificate (exact domain); --cert and --key go together and drop the old chain unless --chain is given", + Args: nargs(1), + RunE: func(cmd *cobra.Command, a []string) error { + f := cmd.Flags() + if f.Changed("cert") != f.Changed("key") { + return usageError{errors.New("--cert and --key must be given together")} + } + body, err := certBody(f) + if err != nil { + return err + } + if len(body) == 0 { + return usageError{errNothingToChange} + } + return call("PUT", resource("certificates", a[0]), body) + }, + } + certFlags(patch.Flags()) + + get := getCmd(call, "certificates", "domain") + get.Short = "Show the certificate served for a domain (exact, wildcard or route files)" + cmd.AddCommand(listCmd(call, "/api/certificates"), get, set, patch, deleteCmd(call, "certificates", "domain")) + return cmd +} + +func certFlags(f *pflag.FlagSet) { + f.String("cert", "", "PEM certificate file (- for stdin)") + f.String("key", "", "PEM private key file (- for stdin)") + f.String("chain", "", "PEM intermediate chain file (- for stdin)") + f.String("meta", "", `metadata JSON object, e.g. '{"vendor":"acme"}'`) +} + +func certBody(f *pflag.FlagSet) (map[string]any, error) { + body := map[string]any{} + for flag, key := range map[string]string{"cert": "certificate", "key": "private_key", "chain": "chain"} { + if !f.Changed(flag) { + continue + } + path, _ := f.GetString(flag) + data, err := readFile(path) + if err != nil { + return nil, err + } + body[key] = string(data) + } + if f.Changed("meta") { + raw, _ := f.GetString("meta") + var meta map[string]any + if err := json.Unmarshal([]byte(raw), &meta); err != nil { + return nil, usageError{errors.New("--meta must be a JSON object: " + err.Error())} + } + body["meta"] = meta + } + return body, nil +} + +func readFile(path string) ([]byte, error) { + if path == "-" { + return io.ReadAll(os.Stdin) + } + return os.ReadFile(path) +} diff --git a/cmd/ceryx/client.go b/cmd/ceryx/client.go new file mode 100644 index 0000000..f560102 --- /dev/null +++ b/cmd/ceryx/client.go @@ -0,0 +1,74 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +const defaultAPIURL = "http://localhost:8080" + +type apiError struct { + Status int + Detail string +} + +func (e *apiError) Error() string { return fmt.Sprintf("%s (HTTP %d)", e.Detail, e.Status) } + +type client struct { + base string + http *http.Client +} + +func newClient(base string) *client { + if !strings.Contains(base, "://") { + base = "http://" + base + } + return &client{base: strings.TrimRight(base, "/"), http: &http.Client{Timeout: 30 * time.Second}} +} + +// do calls the API and returns the raw response body. Non-2xx responses become *apiError. +func (c *client) do(method, path string, body any) ([]byte, error) { + var r io.Reader + if body != nil { + b, err := json.Marshal(body) + if err != nil { + return nil, err + } + r = bytes.NewReader(b) + } + req, err := http.NewRequest(method, c.base+path, r) + if err != nil { + return nil, err + } + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + resp, err := c.http.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + if resp.StatusCode >= 300 { + var e struct{ Detail string } + if json.Unmarshal(data, &e) != nil || e.Detail == "" { + e.Detail = strings.TrimSpace(string(data)) + if e.Detail == "" { + e.Detail = http.StatusText(resp.StatusCode) + } + } + return nil, &apiError{Status: resp.StatusCode, Detail: e.Detail} + } + return data, nil +} + +func resource(kind, name string) string { return "/api/" + kind + "/" + url.PathEscape(name) } diff --git a/cmd/ceryx/main.go b/cmd/ceryx/main.go new file mode 100644 index 0000000..a53c34a --- /dev/null +++ b/cmd/ceryx/main.go @@ -0,0 +1,148 @@ +// Command ceryx manages routes and certificates of a Ceryx gateway through its API. +package main + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "os" + + "github.com/spf13/cobra" +) + +var version = "dev" + +// Exit codes are part of the CLI contract; agents branch on them. +const ( + exitError = 1 + exitUsage = 2 + exitNotFound = 3 +) + +type usageError struct{ error } + +func main() { os.Exit(run(os.Args[1:], os.Stdout, os.Stderr)) } + +func run(args []string, stdout, stderr io.Writer) int { + root := newRoot(stdout) + root.SetArgs(args) + root.SetOut(stdout) + root.SetErr(stderr) + err := root.Execute() + if err == nil { + return 0 + } + fmt.Fprintln(stderr, "error:", err) + var ae *apiError + var ue usageError + switch { + case errors.As(err, &ae) && ae.Status == 404: + return exitNotFound + case errors.As(err, &ue): + return exitUsage + } + return exitError +} + +func newRoot(stdout io.Writer) *cobra.Command { + apiURL := os.Getenv("CERYX_API_URL") + if apiURL == "" { + apiURL = defaultAPIURL + } + var c *client + root := &cobra.Command{ + Use: "ceryx", + Short: "Manage Ceryx routes and certificates", + Long: `Manage Ceryx routes and certificates through the Ceryx API. + +Output is the API's JSON on stdout; errors go to stderr. +Exit codes: 0 ok, 1 error, 2 usage, 3 not found.`, + Example: ` ceryx routes set app.example.com app:8000 --enforce-https + ceryx routes patch app.example.com --mode redirect --target https://example.com + ceryx certs set example.com --cert cert.pem --key key.pem + CERYX_API_URL=ceryx:8080 ceryx routes list`, + Version: version, + Args: cobra.ArbitraryArgs, // let group report unknown commands as usage errors + RunE: group, + SilenceUsage: true, + SilenceErrors: true, + PersistentPreRun: func(*cobra.Command, []string) { + c = newClient(apiURL) + }, + } + root.PersistentFlags().StringVar(&apiURL, "api-url", apiURL, "Ceryx API URL (env CERYX_API_URL)") + root.SetFlagErrorFunc(func(_ *cobra.Command, err error) error { return usageError{err} }) + root.CompletionOptions.DisableDefaultCmd = true + + call := func(method, path string, body any) error { + data, err := c.do(method, path, body) + if err != nil { + return err + } + return printJSON(stdout, data) + } + root.AddCommand(routesCmd(call), certificatesCmd(call)) + return root +} + +type caller func(method, path string, body any) error + +// printJSON re-encodes the API response: the server emits keys in random order and +// escapes slashes, so decoding and encoding gives sorted keys and readable URLs. +func printJSON(w io.Writer, data []byte) error { + if data = bytes.TrimSpace(data); len(data) == 0 { + return nil + } + var v any + dec := json.NewDecoder(bytes.NewReader(data)) + dec.UseNumber() + if err := dec.Decode(&v); err != nil { + _, err = fmt.Fprintf(w, "%s\n", data) + return err + } + enc := json.NewEncoder(w) + enc.SetEscapeHTML(false) + enc.SetIndent("", " ") + return enc.Encode(v) +} + +// group runs command groups. Without it cobra prints help and exits 0 on unknown +// subcommands, hiding typos from agents. +func group(cmd *cobra.Command, args []string) error { + if len(args) > 0 { + return usageError{fmt.Errorf("unknown command %q for %q", args[0], cmd.CommandPath())} + } + return cmd.Help() +} + +func nargs(n int) cobra.PositionalArgs { + return func(cmd *cobra.Command, args []string) error { + if err := cobra.ExactArgs(n)(cmd, args); err != nil { + return usageError{err} + } + return nil + } +} + +func listCmd(call caller, path string) *cobra.Command { + return &cobra.Command{ + Use: "list", Aliases: []string{"ls"}, Short: "List all", Args: nargs(0), + RunE: func(*cobra.Command, []string) error { return call("GET", path, nil) }, + } +} + +func getCmd(call caller, kind, arg string) *cobra.Command { + return &cobra.Command{ + Use: "get <" + arg + ">", Short: "Show one", Args: nargs(1), + RunE: func(_ *cobra.Command, a []string) error { return call("GET", resource(kind, a[0]), nil) }, + } +} + +func deleteCmd(call caller, kind, arg string) *cobra.Command { + return &cobra.Command{ + Use: "delete <" + arg + ">", Aliases: []string{"rm"}, Short: "Delete (succeeds if missing)", Args: nargs(1), + RunE: func(_ *cobra.Command, a []string) error { return call("DELETE", resource(kind, a[0]), nil) }, + } +} diff --git a/cmd/ceryx/main_test.go b/cmd/ceryx/main_test.go new file mode 100644 index 0000000..2054779 --- /dev/null +++ b/cmd/ceryx/main_test.go @@ -0,0 +1,48 @@ +package main + +import ( + "bytes" + "io" + "net/http" + "net/http/httptest" + "testing" +) + +func TestRun(t *testing.T) { + var method, path, body string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + b, _ := io.ReadAll(r.Body) + method, path, body = r.Method, r.URL.EscapedPath(), string(b) + if r.Method == "GET" { + w.WriteHeader(404) + w.Write([]byte(`{"detail":"Route not found"}`)) + return + } + w.Write([]byte(`{"ok":true}`)) + })) + defer srv.Close() + + tests := []struct { + args []string + code int + method, path, body string + }{ + {[]string{"routes", "set", "a.com", "app:80", "--enforce-https"}, 0, "POST", "/api/routes", + `{"settings":{"enforce_https":true},"source":"a.com","target":"app:80"}`}, + {[]string{"routes", "patch", "$wildcard", "--certificate-path", "", "--mtls-verify-depth", "2"}, 0, "PUT", "/api/routes/$wildcard", + `{"settings":{"certificate_path":null,"mtls_verify_depth":2}}`}, + {[]string{"certs", "patch", "*.a.com", "--meta", `{"vendor":"x"}`}, 0, "PUT", "/api/certificates/%2A.a.com", + `{"meta":{"vendor":"x"}}`}, + {[]string{"routes", "get", "missing.com"}, exitNotFound, "GET", "/api/routes/missing.com", ""}, + {[]string{"routes", "patch", "a.com"}, exitUsage, "", "", ""}, + {[]string{"routes", "update", "a.com"}, exitUsage, "", "", ""}, + } + for _, tt := range tests { + method, path, body = "", "", "" + var stderr bytes.Buffer + code := run(append(tt.args, "--api-url", srv.URL), io.Discard, &stderr) + if code != tt.code || method != tt.method || path != tt.path || body != tt.body { + t.Errorf("%v: got %d %s %s %s (%s)", tt.args, code, method, path, body, stderr.String()) + } + } +} diff --git a/cmd/ceryx/routes.go b/cmd/ceryx/routes.go new file mode 100644 index 0000000..c9f4a1f --- /dev/null +++ b/cmd/ceryx/routes.go @@ -0,0 +1,96 @@ +package main + +import ( + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +func routesCmd(call caller) *cobra.Command { + cmd := &cobra.Command{ + Use: "routes", + Aliases: []string{"route"}, + Short: "Manage routes (host is a hostname or $wildcard)", + RunE: group, + } + + set := &cobra.Command{ + Use: "set ", + Aliases: []string{"create", "add"}, + Short: "Create or replace a whole route; unset settings get server defaults", + Args: nargs(2), + RunE: func(cmd *cobra.Command, a []string) error { + body := map[string]any{"source": a[0], "target": a[1]} + if s := routeSettings(cmd.Flags()); len(s) > 0 { + body["settings"] = s + } + return call("POST", "/api/routes", body) + }, + } + routeSettingsFlags(set.Flags()) + + // Named patch, not update: the API's PUT merges. Kept separate from set (no PUT→POST + // fallback) so each command is one atomic request and a typo'd host fails with 404. + patch := &cobra.Command{ + Use: "patch ", + Short: "Change an existing route; only given flags change, settings are merged", + Args: nargs(1), + RunE: func(cmd *cobra.Command, a []string) error { + body := map[string]any{} + if f := cmd.Flags().Lookup("target"); f.Changed { + body["target"] = f.Value.String() + } + if s := routeSettings(cmd.Flags()); len(s) > 0 { + body["settings"] = s + } + if len(body) == 0 { + return usageError{errNothingToChange} + } + return call("PUT", resource("routes", a[0]), body) + }, + } + patch.Flags().String("target", "", "upstream to proxy or redirect to") + routeSettingsFlags(patch.Flags()) + + cmd.AddCommand(listCmd(call, "/api/routes"), getCmd(call, "routes", "host"), set, patch, deleteCmd(call, "routes", "host")) + return cmd +} + +func routeSettingsFlags(f *pflag.FlagSet) { + f.String("mode", "", "proxy or redirect") + f.Bool("enforce-https", false, "redirect HTTP to HTTPS") + f.String("certificate-path", "", `certificate file path in the container ("" clears)`) + f.String("key-path", "", `private key file path in the container ("" clears)`) + f.Bool("mtls-require-client-certificate", false, "require a client certificate (needs --enforce-https)") + f.String("mtls-trusted-ca", "", "CA bundle path in the container for client certificates") + f.Int("mtls-verify-depth", 0, "max intermediates between client certificate and CA") +} + +// routeSettings returns only the settings whose flags were given, so the server keeps +// stored values (patch) or applies its defaults (set) for the rest. +func routeSettings(f *pflag.FlagSet) map[string]any { + s := map[string]any{} + str := func(name, key string, emptyIsNull bool) { + if fl := f.Lookup(name); fl.Changed { + v := fl.Value.String() + if v == "" && emptyIsNull { + s[key] = nil + } else { + s[key] = v + } + } + } + str("mode", "mode", false) + str("certificate-path", "certificate_path", true) + str("key-path", "key_path", true) + str("mtls-trusted-ca", "mtls_trusted_ca", false) + if f.Changed("enforce-https") { + s["enforce_https"], _ = f.GetBool("enforce-https") + } + if f.Changed("mtls-require-client-certificate") { + s["mtls_require_client_certificate"], _ = f.GetBool("mtls-require-client-certificate") + } + if f.Changed("mtls-verify-depth") { + s["mtls_verify_depth"], _ = f.GetInt("mtls-verify-depth") + } + return s +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..77e28b9 --- /dev/null +++ b/go.mod @@ -0,0 +1,10 @@ +module github.com/withlogicco/ceryx-cli + +go 1.27.3 + +require ( + github.com/spf13/cobra v1.10.2 + github.com/spf13/pflag v1.0.9 +) + +require github.com/inconshreveable/mousetrap v1.1.0 // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..a6ee3e0 --- /dev/null +++ b/go.sum @@ -0,0 +1,10 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/install.sh b/install.sh new file mode 100644 index 0000000..e19174f --- /dev/null +++ b/install.sh @@ -0,0 +1,34 @@ +#!/bin/sh +# Installs the Ceryx CLI from GitHub releases: +# curl -fsSL https://raw.githubusercontent.com/withlogicco/ceryx-cli/main/install.sh | sh +# Env: CERYX_VERSION (tag, default latest), INSTALL_DIR (default /usr/local/bin). +set -eu + +repo=withlogicco/ceryx-cli +dir=${INSTALL_DIR:-/usr/local/bin} + +os=$(uname -s | tr A-Z a-z) +case $os in darwin | linux) ;; *) echo "unsupported OS: $os" >&2; exit 1 ;; esac +arch=$(uname -m) +case $arch in +x86_64 | amd64) arch=amd64 ;; +aarch64 | arm64) arch=arm64 ;; +*) echo "unsupported architecture: $arch" >&2; exit 1 ;; +esac + +if [ -n "${CERYX_VERSION:-}" ]; then + url=https://github.com/$repo/releases/download/$CERYX_VERSION/ceryx_${os}_${arch} +else + url=https://github.com/$repo/releases/latest/download/ceryx_${os}_${arch} +fi + +tmp=$(mktemp) +trap 'rm -f "$tmp"' EXIT +curl -fsSL -o "$tmp" "$url" +chmod 755 "$tmp" + +sudo= +[ -w "$dir" ] || sudo=sudo +$sudo mkdir -p "$dir" +$sudo mv "$tmp" "$dir/ceryx" +echo "installed $("$dir/ceryx" --version) to $dir/ceryx"