diff --git a/CHANGELOG.md b/CHANGELOG.md
index 46d092d78..d119c679e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,7 @@
## What's New
+* [ZAC Bootstrapping CLI](#zac-bootstrapping-cli) - CLI commands to download, configure, and serve the Ziti Admin Console without hand-editing YAML
* [Cluster Quorum Recover](#cluster_quorum_recovery) - A mechanism for recovering clusters that have irrevocably lost the ability to form a quorum
* [Quickstart Cluster](#quickstart-cluster) - `ziti run quickstart cluster` brings up a multi-node HA cluster in a single command for testing and development and learning
* [Fully Connected Controller Mesh](#fully-connected-controller-mesh) - Controllers now proactively keep the cluster mesh fully connected
@@ -12,6 +13,50 @@
* [Multiple Resolver Addresses for tproxy](#multiple-resolver-addresses-for-tproxy) - `resolver` now accepts a single address or a list of addresses
* [Logging Now Uses slog with an Async Handler](#logging-now-uses-slog-with-an-async-handler) - Logging moves to Go's `log/slog` behind an asynchronous sink; output is unchanged by default, with new flags to tune buffering
+## ZAC Bootstrapping CLI
+
+Three new commands make it easy to get the Ziti Admin Console running without manual file editing.
+
+**Download ZAC:**
+
+```
+ziti ops console download [version] --location
+```
+
+Downloads a ZAC release from GitHub and extracts it into the given directory. `version` defaults to
+`latest`. The target directory must be empty or not yet exist; the command never removes or overwrites
+existing files.
+
+**Configure a controller to serve it:**
+
+```
+ziti ops console configure --all --location
+```
+
+Edits the controller config YAML in place, adding or updating the `spa` web-listener binding so the
+controller serves ZAC from the given directory. Selects listeners with `--all` or `--name`; prompts
+interactively when neither is given. The file's comments and structure are preserved.
+
+**Serve it locally:**
+
+If you prefer to serve the console locally rather than have a controller host it, you can serve ZAC
+straight from the ziti CLI:
+
+```
+ziti run console --location
+ziti run console --version latest
+```
+
+Serves the ZAC SPA over HTTPS on `127.0.0.1:8443`. Generates a self-signed cert automatically if none
+is supplied. The browser points ZAC at whatever controller you choose inside the console itself.
+
+To serve with your own certificate on a specific address and port:
+
+```
+ziti run console --location --bind-address 0.0.0.0 --port 9443 \
+ --tls-cert ./server.pem --tls-key ./server.key
+```
+
## Cluster Quorum Recovery
A new offline CLI command, `ziti ops cluster recover `, lets
diff --git a/README.md b/README.md
index 8100b3627..023a063e7 100644
--- a/README.md
+++ b/README.md
@@ -150,6 +150,21 @@ ziti edge quickstart
This brings up a local development network: controller, router, and a default admin identity. Ideal for testing and learning.
+To add the Ziti Admin Console (ZAC) to a running controller:
+
+```bash
+ziti ops console download --location /opt/openziti/console
+ziti ops console configure /path/to/controller.yml --all --location /opt/openziti/console
+# restart the controller, then open https:///zac/
+```
+
+Or serve ZAC locally without touching the controller config:
+
+```bash
+ziti run console --version latest
+# opens https://127.0.0.1:8443. point it at any controller from the browser
+```
+
### Learn More
| Resource | Description |
diff --git a/tests/cli_tests/console_ops_test.go b/tests/cli_tests/console_ops_test.go
new file mode 100644
index 000000000..889ba8ca6
--- /dev/null
+++ b/tests/cli_tests/console_ops_test.go
@@ -0,0 +1,304 @@
+//go:build cli_tests
+
+/*
+Copyright NetFoundry Inc.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+https://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+package cli_tests
+
+import (
+ "bytes"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/openziti/ziti/v2/ziti/cmd"
+ "github.com/stretchr/testify/require"
+ "gopkg.in/yaml.v3"
+)
+
+// runZiti drives the ziti command tree in-process with an explicit arg slice (so paths with
+// spaces are not mangled) and returns command output plus the command error. Output is
+// captured through a buffer wired into the command rather than by swapping os.Stdout, which
+// avoids the os.Pipe deadlock that occurs when output exceeds the pipe buffer.
+func runZiti(args ...string) (string, error) {
+ var buf bytes.Buffer
+ root := cmd.NewRootCommand(os.Stdin, &buf, &buf)
+ root.SetArgs(args)
+ root.SetOut(&buf)
+ root.SetErr(&buf)
+
+ err := root.Execute()
+ return buf.String(), err
+}
+
+const sampleControllerConfig = `# top-level controller config
+v: 3
+
+# web listeners hosted by the controller
+web:
+ - name: client-management
+ bindPoints:
+ - interface: 0.0.0.0:1280
+ address: localhost:1280
+ # APIs bound to this listener
+ apis:
+ - binding: edge-management
+ options: { }
+ - binding: edge-client
+ options: { }
+ - name: dark-apis
+ bindPoints:
+ - interface: 0.0.0.0:1281
+ address: localhost:1281
+ apis:
+ - binding: edge-client
+ options: { }
+`
+
+// parsedConfig mirrors the slice of web listeners and their api bindings for assertions.
+type parsedConfig struct {
+ Web []struct {
+ Name string `yaml:"name"`
+ Apis []struct {
+ Binding string `yaml:"binding"`
+ Options struct {
+ Path string `yaml:"path"`
+ Location string `yaml:"location"`
+ IndexFile string `yaml:"indexFile"`
+ } `yaml:"options"`
+ } `yaml:"apis"`
+ } `yaml:"web"`
+}
+
+func parseConfig(t *testing.T, path string) parsedConfig {
+ t.Helper()
+ data, err := os.ReadFile(path)
+ require.NoError(t, err)
+ var cfg parsedConfig
+ require.NoError(t, yaml.Unmarshal(data, &cfg))
+ return cfg
+}
+
+// spaCount returns how many spa bindings the named listener has, and the first spa options.
+func spaForListener(t *testing.T, cfg parsedConfig, name string) (count int, path, location, indexFile string) {
+ t.Helper()
+ for _, l := range cfg.Web {
+ if l.Name != name {
+ continue
+ }
+ for _, a := range l.Apis {
+ if a.Binding == "spa" {
+ if count == 0 {
+ path = a.Options.Path
+ location = a.Options.Location
+ indexFile = a.Options.IndexFile
+ }
+ count++
+ }
+ }
+ }
+ return count, path, location, indexFile
+}
+
+func writeSampleConfig(t *testing.T) string {
+ t.Helper()
+ dir := t.TempDir()
+ cfgPath := filepath.Join(dir, "controller.yaml")
+ require.NoError(t, os.WriteFile(cfgPath, []byte(sampleControllerConfig), 0o644))
+ return cfgPath
+}
+
+// fakeConsoleDir creates a directory that looks like an installed console (has index.html), so
+// `ziti ops console configure` treats the assets as present and does not try to download.
+func fakeConsoleDir(t *testing.T) string {
+ t.Helper()
+ dir := filepath.Join(t.TempDir(), "console")
+ require.NoError(t, os.MkdirAll(dir, 0o755))
+ require.NoError(t, os.WriteFile(filepath.Join(dir, "index.html"), []byte(""), 0o644))
+ return dir
+}
+
+// Test_Console_Ops exercises `ziti ops console configure` and the no-network validation
+// paths of `ziti ops console download`. It does not require a running controller, a built
+// binary, or network access, so it stays a reliable guard that these commands keep working.
+func Test_Console_Ops(t *testing.T) {
+ t.Run("commands are registered", func(t *testing.T) {
+ for _, args := range [][]string{
+ {"ops", "console", "--help"},
+ {"ops", "console", "download", "--help"},
+ {"ops", "console", "configure", "--help"},
+ } {
+ _, err := runZiti(args...)
+ require.NoError(t, err, "help for %v", args)
+ }
+ })
+
+ t.Run("configure single listener by name", func(t *testing.T) {
+ cfgPath := writeSampleConfig(t)
+ location := fakeConsoleDir(t)
+
+ _, err := runZiti("ops", "console", "configure", cfgPath, "--name", "client-management", "--location", location)
+ require.NoError(t, err)
+
+ cfg := parseConfig(t, cfgPath)
+
+ count, path, loc, index := spaForListener(t, cfg, "client-management")
+ require.Equal(t, 1, count, "client-management should have exactly one spa binding")
+ require.Equal(t, "zac", path)
+ require.Equal(t, location, loc)
+ require.Equal(t, "index.html", index)
+
+ darkCount, _, _, _ := spaForListener(t, cfg, "dark-apis")
+ require.Equal(t, 0, darkCount, "dark-apis should be untouched")
+ })
+
+ t.Run("configure all is idempotent", func(t *testing.T) {
+ cfgPath := writeSampleConfig(t)
+ location := fakeConsoleDir(t)
+
+ _, err := runZiti("ops", "console", "configure", cfgPath, "--all", "--location", location)
+ require.NoError(t, err)
+ // second run must update in place, not append a duplicate
+ _, err = runZiti("ops", "console", "configure", cfgPath, "--all", "--location", location)
+ require.NoError(t, err)
+
+ cfg := parseConfig(t, cfgPath)
+ for _, name := range []string{"client-management", "dark-apis"} {
+ count, _, loc, _ := spaForListener(t, cfg, name)
+ require.Equal(t, 1, count, "%s should have exactly one spa binding after two --all runs", name)
+ require.Equal(t, location, loc)
+ }
+ })
+
+ t.Run("configure multiple names", func(t *testing.T) {
+ cfgPath := writeSampleConfig(t)
+ location := fakeConsoleDir(t)
+
+ _, err := runZiti("ops", "console", "configure", cfgPath,
+ "--name", "client-management", "--name", "dark-apis", "--location", location)
+ require.NoError(t, err)
+
+ cfg := parseConfig(t, cfgPath)
+ for _, name := range []string{"client-management", "dark-apis"} {
+ count, _, _, _ := spaForListener(t, cfg, name)
+ require.Equal(t, 1, count, "%s should have a spa binding", name)
+ }
+ })
+
+ t.Run("configure custom path and index file", func(t *testing.T) {
+ cfgPath := writeSampleConfig(t)
+ location := fakeConsoleDir(t)
+
+ _, err := runZiti("ops", "console", "configure", cfgPath, "--name", "client-management",
+ "--location", location, "--path", "admin", "--index-file", "main.html")
+ require.NoError(t, err)
+
+ cfg := parseConfig(t, cfgPath)
+ _, path, _, index := spaForListener(t, cfg, "client-management")
+ require.Equal(t, "admin", path)
+ require.Equal(t, "main.html", index)
+ })
+
+ t.Run("configure unknown name errors and leaves file unchanged", func(t *testing.T) {
+ cfgPath := writeSampleConfig(t)
+ before, err := os.ReadFile(cfgPath)
+ require.NoError(t, err)
+
+ _, err = runZiti("ops", "console", "configure", cfgPath, "--name", "nope", "--location", fakeConsoleDir(t))
+ require.Error(t, err)
+
+ after, readErr := os.ReadFile(cfgPath)
+ require.NoError(t, readErr)
+ require.Equal(t, string(before), string(after), "file must not change on error")
+ })
+
+ t.Run("configure rejects --all with --name", func(t *testing.T) {
+ cfgPath := writeSampleConfig(t)
+ _, err := runZiti("ops", "console", "configure", cfgPath, "--all", "--name", "client-management",
+ "--location", fakeConsoleDir(t))
+ require.Error(t, err)
+ })
+
+ t.Run("configure with no selector applies to all", func(t *testing.T) {
+ cfgPath := writeSampleConfig(t)
+ // -y assumes all when neither --all nor --name is given
+ _, err := runZiti("ops", "console", "configure", cfgPath, "--location", fakeConsoleDir(t), "-y")
+ require.NoError(t, err)
+
+ cfg := parseConfig(t, cfgPath)
+ for _, name := range []string{"client-management", "dark-apis"} {
+ count, _, _, _ := spaForListener(t, cfg, name)
+ require.Equal(t, 1, count, "%s should have a spa binding", name)
+ }
+ })
+
+ t.Run("configure requires location with --yes", func(t *testing.T) {
+ cfgPath := writeSampleConfig(t)
+ // --yes cannot prompt for the location, so it must be supplied
+ _, err := runZiti("ops", "console", "configure", cfgPath, "--all", "-y")
+ require.Error(t, err)
+ })
+
+ t.Run("download requires location", func(t *testing.T) {
+ _, err := runZiti("ops", "console", "download", "4.3.0")
+ require.Error(t, err)
+ })
+
+ t.Run("download refuses a non-empty directory", func(t *testing.T) {
+ dir := t.TempDir()
+ require.NoError(t, os.WriteFile(filepath.Join(dir, "keep.txt"), []byte("keep"), 0o644))
+
+ _, err := runZiti("ops", "console", "download", "4.3.0", "--location", dir)
+ require.Error(t, err)
+
+ // the pre-existing file must be untouched
+ _, statErr := os.Stat(filepath.Join(dir, "keep.txt"))
+ require.NoError(t, statErr)
+ })
+
+ // These hit the openziti/ziti-console GitHub releases over the network on purpose: they
+ // are the guard that "latest" resolution and concrete-version downloads keep working.
+ t.Run("download latest installs a usable console", func(t *testing.T) {
+ dir := filepath.Join(t.TempDir(), "console")
+
+ _, err := runZiti("ops", "console", "download", "--location", dir)
+ require.NoError(t, err)
+
+ // a usable SPA must have an index.html
+ _, statErr := os.Stat(filepath.Join(dir, "index.html"))
+ require.NoError(t, statErr, "downloaded console must contain index.html")
+
+ // the install records the resolved version
+ version, vErr := os.ReadFile(filepath.Join(dir, ".version"))
+ require.NoError(t, vErr)
+ require.NotEmpty(t, string(version))
+ })
+
+ t.Run("download a specific version installs that version", func(t *testing.T) {
+ const wantVersion = "4.3.0"
+ dir := filepath.Join(t.TempDir(), "console")
+
+ _, err := runZiti("ops", "console", "download", wantVersion, "--location", dir)
+ require.NoError(t, err)
+
+ _, statErr := os.Stat(filepath.Join(dir, "index.html"))
+ require.NoError(t, statErr, "downloaded console must contain index.html")
+
+ version, vErr := os.ReadFile(filepath.Join(dir, ".version"))
+ require.NoError(t, vErr)
+ require.Equal(t, wantVersion, strings.TrimSpace(string(version)))
+ })
+}
diff --git a/ziti/cmd/cmd.go b/ziti/cmd/cmd.go
index 9b04128a5..55f9f0b23 100644
--- a/ziti/cmd/cmd.go
+++ b/ziti/cmd/cmd.go
@@ -41,6 +41,7 @@ import (
"github.com/openziti/ziti/v2/ziti/cmd/agentcli"
"github.com/openziti/ziti/v2/ziti/cmd/ascode/exporter"
"github.com/openziti/ziti/v2/ziti/cmd/common"
+ "github.com/openziti/ziti/v2/ziti/cmd/console"
"github.com/openziti/ziti/v2/ziti/cmd/create"
"github.com/openziti/ziti/v2/ziti/cmd/demo"
"github.com/openziti/ziti/v2/ziti/cmd/edge"
@@ -245,6 +246,7 @@ func NewV1CmdRoot(in io.Reader, out, err io.Writer, cmd *cobra.Command) *cobra.C
opsCommands.AddCommand(verify.NewVerifyCommand(out, err, context.Background()))
opsCommands.AddCommand(exporter.NewExportCmd(out, err))
opsCommands.AddCommand(importer.NewImportCmd(out, err))
+ opsCommands.AddCommand(console.NewConsoleOpsCmd(out, err))
groups := templates.CommandGroups{
{
@@ -430,6 +432,7 @@ func NewV2CmdRoot(in io.Reader, out, err io.Writer, cmd *cobra.Command) *cobra.C
opsCommands.AddCommand(exporter.NewExportCmd(out, err))
opsCommands.AddCommand(importer.NewImportCmd(out, err))
+ opsCommands.AddCommand(console.NewConsoleOpsCmd(out, err))
// Add agent under ops
opsCommands.AddCommand(agentcli.NewAgentCmd(p))
diff --git a/ziti/cmd/console/assets.go b/ziti/cmd/console/assets.go
new file mode 100644
index 000000000..9f36cc9e0
--- /dev/null
+++ b/ziti/cmd/console/assets.go
@@ -0,0 +1,103 @@
+/*
+ Copyright NetFoundry Inc.
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ https://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+
+package console
+
+import (
+ "bufio"
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+ "strings"
+)
+
+// resolveAssets returns a local directory holding the ZAC build to serve. A --location
+// directory is used verbatim, otherwise the requested --version is downloaded (and cached).
+func (o *ConsoleOptions) resolveAssets() (string, error) {
+ if o.Location != "" {
+ if err := validateAssetsDir(o.Location); err != nil {
+ return "", err
+ }
+ return o.Location, nil
+ }
+ if o.Version == "" {
+ return "", fmt.Errorf("no console assets specified: pass --location or --version ")
+ }
+ return o.downloadAssets()
+}
+
+func (o *ConsoleOptions) downloadAssets() (string, error) {
+ version := normalizeVersion(o.Version)
+ if strings.EqualFold(version, "latest") {
+ resolved, err := resolveLatestVersion()
+ if err != nil {
+ return "", fmt.Errorf("failed to resolve latest ZAC version: %w", err)
+ }
+ o.logger().Infof("latest ZAC version resolved to %s", resolved)
+ version = resolved
+ }
+
+ cacheRoot, err := o.cacheDir()
+ if err != nil {
+ return "", err
+ }
+ dest := filepath.Join(cacheRoot, version)
+
+ if validateAssetsDir(dest) == nil {
+ o.logger().Infof("using cached ZAC %s from %s", version, dest)
+ return dest, nil
+ }
+
+ if !o.confirmDownload(version, downloadURL(version)) {
+ return "", fmt.Errorf("download declined; re-run with --yes or supply --location")
+ }
+
+ if _, err = downloadRelease(version, dest); err != nil {
+ return "", err
+ }
+ if err = validateAssetsDir(dest); err != nil {
+ return "", fmt.Errorf("downloaded archive did not contain a usable console: %w", err)
+ }
+ o.logger().Infof("installed ZAC %s to %s", version, dest)
+ return dest, nil
+}
+
+func (o *ConsoleOptions) confirmDownload(version, url string) bool {
+ if o.Yes {
+ return true
+ }
+ _, _ = fmt.Fprintf(o.Out, "Download ZAC %s from %s? [y/N]: ", version, url)
+ reader := bufio.NewReader(o.In)
+ line, err := reader.ReadString('\n')
+ if err != nil && err != io.EOF {
+ return false
+ }
+ answer := strings.ToLower(strings.TrimSpace(line))
+ return answer == "y" || answer == "yes"
+}
+
+func (o *ConsoleOptions) cacheDir() (string, error) {
+ base, err := os.UserCacheDir()
+ if err != nil {
+ return "", fmt.Errorf("failed to determine cache directory: %w", err)
+ }
+ dir := filepath.Join(base, "ziti", "console")
+ if err = os.MkdirAll(dir, 0o755); err != nil {
+ return "", fmt.Errorf("failed to create cache directory '%s': %w", dir, err)
+ }
+ return dir, nil
+}
diff --git a/ziti/cmd/console/console.go b/ziti/cmd/console/console.go
new file mode 100644
index 000000000..2986e2be0
--- /dev/null
+++ b/ziti/cmd/console/console.go
@@ -0,0 +1,252 @@
+/*
+ Copyright NetFoundry Inc.
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ https://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+
+// Package console implements `ziti run console`: it serves the Ziti Admin Console (ZAC)
+// as a local web app over https. The browser connects to whichever controller you configure
+// inside ZAC. The command itself only serves the static console assets.
+package console
+
+import (
+ "context"
+ "crypto/ecdsa"
+ "crypto/elliptic"
+ "crypto/rand"
+ "crypto/tls"
+ "crypto/x509"
+ "crypto/x509/pkix"
+ "encoding/pem"
+ "fmt"
+ "io"
+ "math/big"
+ "net"
+ "net/http"
+ "os"
+ "os/signal"
+ "syscall"
+ "time"
+
+ "github.com/michaelquigley/pfxlog"
+ "github.com/openziti/ziti/v2/controller/webapis"
+ "github.com/spf13/cobra"
+)
+
+type ConsoleOptions struct {
+ Out io.Writer
+ Err io.Writer
+ In io.Reader
+
+ BindAddress string
+ Port uint16
+
+ Location string
+ Version string
+ Yes bool
+
+ TlsCert string
+ TlsKey string
+}
+
+func NewConsoleCmd(out, errOut io.Writer) *cobra.Command {
+ options := &ConsoleOptions{
+ Out: out,
+ Err: errOut,
+ In: os.Stdin,
+ }
+
+ cmd := &cobra.Command{
+ Use: "console",
+ Short: "Serve the Ziti Admin Console (ZAC) locally over https",
+ Long: `Runs the Ziti Admin Console (ZAC) as a local web app served over https. Point ZAC at the
+controller of your choice from within the console's own UI.
+
+The console assets are served from a local directory (--location) or downloaded for a chosen
+--version (use "latest" to track the newest release).
+
+Examples:
+ # download the latest ZAC and serve it
+ ziti run console --version latest
+
+ # serve a console build you already have on disk
+ ziti run console --location ./dist`,
+ Args: cobra.NoArgs,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ return options.Run()
+ },
+ }
+
+ cmd.Flags().StringVarP(&options.BindAddress, "bind-address", "b", "127.0.0.1", "Address the console listens on")
+ cmd.Flags().Uint16VarP(&options.Port, "port", "p", 8443, "Port the console listens on")
+ cmd.Flags().StringVarP(&options.Location, "location", "l", "", "Directory of pre-built ZAC assets to serve; takes precedence over --version")
+ cmd.Flags().StringVar(&options.Version, "version", "", `ZAC version to download and serve (e.g. "4.3.0" or "latest")`)
+ cmd.Flags().BoolVarP(&options.Yes, "yes", "y", false, "Answer yes to prompts (e.g. permission to download ZAC assets)")
+ cmd.Flags().StringVar(&options.TlsCert, "tls-cert", "", "PEM certificate the console serves with; a self-signed one is generated if omitted")
+ cmd.Flags().StringVar(&options.TlsKey, "tls-key", "", "PEM private key for --tls-cert")
+
+ return cmd
+}
+
+func (o *ConsoleOptions) Run() error {
+ assetsDir, err := o.resolveAssets()
+ if err != nil {
+ return err
+ }
+
+ listenAddr := net.JoinHostPort(o.BindAddress, fmt.Sprintf("%d", o.Port))
+ rawLn, err := net.Listen("tcp", listenAddr)
+ if err != nil {
+ return fmt.Errorf("failed to listen on %s: %w", listenAddr, err)
+ }
+
+ cert, err := o.serverCertificate()
+ if err != nil {
+ return err
+ }
+ ln := tls.NewListener(rawLn, &tls.Config{Certificates: []tls.Certificate{cert}})
+
+ // ZAC bundles ship with , so the assets the browser requests are prefixed
+ // with /zac. Serving under that context root strips the prefix back to the on-disk layout, so
+ // both the bare root and /zac/ resolve correctly.
+ server := &http.Server{
+ Addr: listenAddr,
+ Handler: corsWrap(webapis.SpaHandler(assetsDir, "/zac", "index.html")),
+ ReadHeaderTimeout: 10 * time.Second,
+ ReadTimeout: 30 * time.Second,
+ WriteTimeout: 60 * time.Second,
+ IdleTimeout: 120 * time.Second,
+ }
+
+ _, _ = fmt.Fprintf(o.Out, "serving Ziti Admin Console from %s\n", assetsDir)
+ _, _ = fmt.Fprintf(o.Out, "console available at https://%s\n", o.listenOrigin())
+
+ ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
+ defer stop()
+
+ errCh := make(chan error, 1)
+ go func() {
+ if serveErr := server.Serve(ln); serveErr != nil && serveErr != http.ErrServerClosed {
+ errCh <- serveErr
+ }
+ }()
+
+ select {
+ case serveErr := <-errCh:
+ return serveErr
+ case <-ctx.Done():
+ }
+
+ // Restore default signal handling so a second Ctrl-C force-quits if a held-open browser
+ // connection makes graceful shutdown stall.
+ stop()
+ _, _ = fmt.Fprintln(o.Out, "\nshutting down (press Ctrl-C again to force)")
+ shutdownCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
+ defer cancel()
+ if err := server.Shutdown(shutdownCtx); err != nil {
+ _ = server.Close()
+ }
+ return nil
+}
+
+func (o *ConsoleOptions) logger() *pfxlog.Builder {
+ return pfxlog.Logger()
+}
+
+// corsWrap lets the served console assets answer cross-origin requests. The OIDC login bounces
+// a fetch through the controller and back to the console's /auth/callback, by which point the
+// request's Origin is opaque ("null"). A plain static server returns no CORS headers, so the
+// browser blocks it. Reflecting the request Origin (including "null") with credentials, and
+// answering preflight, lets that redirected callback complete.
+func corsWrap(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if origin := r.Header.Get("Origin"); origin != "" {
+ w.Header().Set("Access-Control-Allow-Origin", origin)
+ w.Header().Set("Access-Control-Allow-Credentials", "true")
+ w.Header().Add("Vary", "Origin")
+ } else {
+ w.Header().Set("Access-Control-Allow-Origin", "*")
+ }
+
+ if r.Method == http.MethodOptions {
+ w.Header().Set("Access-Control-Allow-Methods", "GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS")
+ if reqHeaders := r.Header.Get("Access-Control-Request-Headers"); reqHeaders != "" {
+ w.Header().Set("Access-Control-Allow-Headers", reqHeaders)
+ } else {
+ w.Header().Set("Access-Control-Allow-Headers", "content-type, authorization, accept, zt-session")
+ }
+ w.WriteHeader(http.StatusOK)
+ return
+ }
+
+ next.ServeHTTP(w, r)
+ })
+}
+
+// listenOrigin is the host:port a browser uses to reach the console.
+func (o *ConsoleOptions) listenOrigin() string {
+ host := o.BindAddress
+ if host == "" || host == "0.0.0.0" || host == "::" {
+ host = "127.0.0.1"
+ }
+ return net.JoinHostPort(host, fmt.Sprintf("%d", o.Port))
+}
+
+// serverCertificate loads the user-supplied cert/key, or generates a short-lived self-signed
+// certificate covering localhost, 127.0.0.1, and ::1 for the local listener.
+func (o *ConsoleOptions) serverCertificate() (tls.Certificate, error) {
+ if o.TlsCert != "" || o.TlsKey != "" {
+ if o.TlsCert == "" || o.TlsKey == "" {
+ return tls.Certificate{}, fmt.Errorf("--tls-cert and --tls-key must be supplied together")
+ }
+ cert, err := tls.LoadX509KeyPair(o.TlsCert, o.TlsKey)
+ if err != nil {
+ return tls.Certificate{}, fmt.Errorf("failed to load tls cert/key: %w", err)
+ }
+ return cert, nil
+ }
+ return generateSelfSignedCert()
+}
+
+func generateSelfSignedCert() (tls.Certificate, error) {
+ priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+ if err != nil {
+ return tls.Certificate{}, fmt.Errorf("failed to generate key: %w", err)
+ }
+ serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
+ if err != nil {
+ return tls.Certificate{}, fmt.Errorf("failed to generate serial: %w", err)
+ }
+ template := x509.Certificate{
+ SerialNumber: serial,
+ Subject: pkix.Name{CommonName: "ziti console"},
+ NotBefore: time.Now().Add(-time.Hour),
+ NotAfter: time.Now().Add(365 * 24 * time.Hour),
+ KeyUsage: x509.KeyUsageDigitalSignature,
+ ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
+ BasicConstraintsValid: true,
+ DNSNames: []string{"localhost"},
+ IPAddresses: []net.IP{net.IPv4(127, 0, 0, 1), net.IPv6loopback},
+ }
+ der, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv)
+ if err != nil {
+ return tls.Certificate{}, fmt.Errorf("failed to create certificate: %w", err)
+ }
+ keyBytes, err := x509.MarshalECPrivateKey(priv)
+ if err != nil {
+ return tls.Certificate{}, fmt.Errorf("failed to marshal key: %w", err)
+ }
+ certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
+ keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyBytes})
+ return tls.X509KeyPair(certPEM, keyPEM)
+}
diff --git a/ziti/cmd/console/download.go b/ziti/cmd/console/download.go
new file mode 100644
index 000000000..fa498a426
--- /dev/null
+++ b/ziti/cmd/console/download.go
@@ -0,0 +1,291 @@
+/*
+ Copyright NetFoundry Inc.
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ https://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+
+package console
+
+import (
+ "archive/zip"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "path/filepath"
+ "sort"
+ "strconv"
+ "strings"
+ "time"
+)
+
+const (
+ // zacRepo is the GitHub repository that publishes ZAC releases.
+ zacRepo = "openziti/ziti-console"
+ // zacTagPrefix is the release tag prefix used by the SPA app (distinct from library releases).
+ zacTagPrefix = "app-ziti-console-v"
+ // zacAssetName is the release asset that holds the built SPA bundle.
+ zacAssetName = "ziti-console.zip"
+ // zacIndexFile is the SPA entry point used to validate an assets directory.
+ zacIndexFile = "index.html"
+ // zacChecksumFile records the sha256 of a downloaded archive next to the install.
+ zacChecksumFile = ".sha256"
+ // zacVersionFile records the installed version next to the install.
+ zacVersionFile = ".version"
+ // maxEntryBytes caps a single extracted archive entry (512 MiB), guarding against decompression bombs.
+ maxEntryBytes = 512 << 20
+ // maxReleaseListBytes caps the releases JSON response read into memory (4 MiB).
+ maxReleaseListBytes = 4 << 20
+)
+
+// downloadURL returns the release asset URL for a concrete (un-prefixed) version.
+func downloadURL(version string) string {
+ tag := zacTagPrefix + version
+ return fmt.Sprintf("https://github.com/%s/releases/download/%s/%s", zacRepo, tag, zacAssetName)
+}
+
+// normalizeVersion trims whitespace and a leading "v" from a version string.
+func normalizeVersion(version string) string {
+ return strings.TrimPrefix(strings.TrimSpace(version), "v")
+}
+
+// resolveLatestVersion lists releases and returns the highest semver bearing the ZAC app
+// tag prefix. The repo also publishes library releases under a different prefix, so we
+// filter rather than trusting /releases/latest.
+func resolveLatestVersion() (string, error) {
+ url := fmt.Sprintf("https://api.github.com/repos/%s/releases?per_page=100", zacRepo)
+ resp, err := httpGet(url)
+ if err != nil {
+ return "", err
+ }
+ defer func() { _ = resp.Body.Close() }()
+ if resp.StatusCode != http.StatusOK {
+ return "", fmt.Errorf("listing releases failed: %s", resp.Status)
+ }
+
+ var releases []ghRelease
+ if err = json.NewDecoder(io.LimitReader(resp.Body, maxReleaseListBytes)).Decode(&releases); err != nil {
+ return "", fmt.Errorf("failed to parse releases: %w", err)
+ }
+
+ var versions []string
+ for _, r := range releases {
+ if r.Draft || r.Prerelease || !strings.HasPrefix(r.TagName, zacTagPrefix) {
+ continue
+ }
+ versions = append(versions, strings.TrimPrefix(r.TagName, zacTagPrefix))
+ }
+ if len(versions) == 0 {
+ return "", fmt.Errorf("no %s* releases found in %s", zacTagPrefix, zacRepo)
+ }
+ sort.Slice(versions, func(i, j int) bool { return compareVersions(versions[i], versions[j]) > 0 })
+ return versions[0], nil
+}
+
+// downloadRelease fetches the given version (must be concrete, not "latest") and installs it
+// into destDir. The archive is streamed to a temp file, its sha256 is recorded, it is
+// expanded into a staging dir with zip-slip protection, and then atomically swapped into
+// destDir. The returned string is the hex sha256 of the downloaded archive.
+func downloadRelease(version, destDir string) (string, error) {
+ version = normalizeVersion(version)
+ if version == "" {
+ return "", fmt.Errorf("a concrete version is required")
+ }
+ if strings.EqualFold(version, "latest") {
+ return "", fmt.Errorf("a concrete version is required; resolve \"latest\" first")
+ }
+
+ url := downloadURL(version)
+ resp, err := httpGet(url)
+ if err != nil {
+ return "", err
+ }
+ defer func() { _ = resp.Body.Close() }()
+ if resp.StatusCode != http.StatusOK {
+ return "", fmt.Errorf("download of %s failed: %s", url, resp.Status)
+ }
+
+ tmp, err := os.CreateTemp("", "ziti-console-*.zip")
+ if err != nil {
+ return "", fmt.Errorf("failed to create temp file: %w", err)
+ }
+ tmpName := tmp.Name()
+ defer func() { _ = os.Remove(tmpName) }()
+
+ hasher := sha256.New()
+ if _, err = io.Copy(io.MultiWriter(tmp, hasher), resp.Body); err != nil {
+ _ = tmp.Close()
+ return "", fmt.Errorf("failed to download archive: %w", err)
+ }
+ if err = tmp.Close(); err != nil {
+ return "", fmt.Errorf("failed to flush archive: %w", err)
+ }
+ sum := hex.EncodeToString(hasher.Sum(nil))
+
+ // Unique sibling staging dir: a fixed name collides between concurrent installs, and a sibling
+ // keeps the final rename on the same filesystem.
+ staging, err := os.MkdirTemp(filepath.Dir(destDir), filepath.Base(destDir)+".incoming-*")
+ if err != nil {
+ return "", fmt.Errorf("failed to create staging dir: %w", err)
+ }
+ defer func() { _ = os.RemoveAll(staging) }()
+ if err = unzip(tmpName, staging); err != nil {
+ return "", err
+ }
+
+ // Record the checksum and version next to the install so it can be pinned/audited later.
+ _ = os.WriteFile(filepath.Join(staging, zacChecksumFile), []byte(sum+"\n"), 0o644)
+ _ = os.WriteFile(filepath.Join(staging, zacVersionFile), []byte(version+"\n"), 0o644)
+
+ _ = os.RemoveAll(destDir)
+ if err = os.Rename(staging, destDir); err != nil {
+ return "", fmt.Errorf("failed to finalize install at '%s': %w", destDir, err)
+ }
+ return sum, nil
+}
+
+// installedVersion returns the version recorded by a prior downloadRelease into dir, or ""
+// if the marker is absent (dir empty, missing, or not created by this command).
+func installedVersion(dir string) string {
+ b, err := os.ReadFile(filepath.Join(dir, zacVersionFile))
+ if err != nil {
+ return ""
+ }
+ return strings.TrimSpace(string(b))
+}
+
+// validateAssetsDir returns nil if dir exists, is a directory, and holds the SPA index file.
+func validateAssetsDir(dir string) error {
+ info, err := os.Stat(dir)
+ if err != nil {
+ return fmt.Errorf("console assets directory '%s' is not accessible: %w", dir, err)
+ }
+ if !info.IsDir() {
+ return fmt.Errorf("console assets path '%s' is not a directory", dir)
+ }
+ if _, err = os.Stat(filepath.Join(dir, zacIndexFile)); err != nil {
+ return fmt.Errorf("console assets directory '%s' has no %s", dir, zacIndexFile)
+ }
+ return nil
+}
+
+// unzip expands src into dest, guarding against path traversal ("zip slip").
+func unzip(src, dest string) error {
+ reader, err := zip.OpenReader(src)
+ if err != nil {
+ return fmt.Errorf("failed to open archive: %w", err)
+ }
+ defer func() { _ = reader.Close() }()
+
+ if err = os.MkdirAll(dest, 0o755); err != nil {
+ return fmt.Errorf("failed to create '%s': %w", dest, err)
+ }
+ cleanDest := filepath.Clean(dest)
+
+ for _, f := range reader.File {
+ target := filepath.Join(cleanDest, f.Name)
+ rel, relErr := filepath.Rel(cleanDest, target)
+ if relErr != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
+ return fmt.Errorf("archive entry %q escapes destination", f.Name)
+ }
+ if f.FileInfo().IsDir() {
+ if err = os.MkdirAll(target, 0o755); err != nil {
+ return err
+ }
+ continue
+ }
+ if err = os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
+ return err
+ }
+ if err = writeZipEntry(f, target); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func writeZipEntry(f *zip.File, target string) error {
+ if f.UncompressedSize64 > maxEntryBytes {
+ return fmt.Errorf("archive entry %q is too large (%d bytes)", f.Name, f.UncompressedSize64)
+ }
+
+ rc, err := f.Open()
+ if err != nil {
+ return err
+ }
+ defer func() { _ = rc.Close() }()
+
+ out, err := os.OpenFile(target, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644)
+ if err != nil {
+ return err
+ }
+ defer func() { _ = out.Close() }()
+
+ // UncompressedSize64 is attacker-controlled metadata, so the LimitReader enforces the real cap.
+ written, err := io.Copy(out, io.LimitReader(rc, maxEntryBytes+1))
+ if err != nil {
+ return fmt.Errorf("failed to extract %q: %w", f.Name, err)
+ }
+ if written > maxEntryBytes {
+ return fmt.Errorf("archive entry %q exceeded size limit during extraction", f.Name)
+ }
+ return nil
+}
+
+type ghRelease struct {
+ TagName string `json:"tag_name"`
+ Draft bool `json:"draft"`
+ Prerelease bool `json:"prerelease"`
+}
+
+// compareVersions compares dotted numeric versions, returning >0 if a is newer than b.
+// Non-numeric segments sort as 0, as do missing segments.
+func compareVersions(a, b string) int {
+ as := strings.Split(a, ".")
+ bs := strings.Split(b, ".")
+ n := len(as)
+ if len(bs) > n {
+ n = len(bs)
+ }
+ for i := 0; i < n; i++ {
+ av, bv := 0, 0
+ if i < len(as) {
+ av, _ = strconv.Atoi(as[i])
+ }
+ if i < len(bs) {
+ bv, _ = strconv.Atoi(bs[i])
+ }
+ if av != bv {
+ return av - bv
+ }
+ }
+ return 0
+}
+
+func httpGet(url string) (*http.Response, error) {
+ req, err := http.NewRequest(http.MethodGet, url, nil)
+ if err != nil {
+ return nil, err
+ }
+ req.Header.Set("User-Agent", "ziti-cli")
+ req.Header.Set("Accept", "application/octet-stream, application/json")
+ client := &http.Client{Timeout: 5 * time.Minute}
+ resp, err := client.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("request to %s failed: %w", url, err)
+ }
+ return resp, nil
+}
diff --git a/ziti/cmd/console/ops_configure.go b/ziti/cmd/console/ops_configure.go
new file mode 100644
index 000000000..2488967b3
--- /dev/null
+++ b/ziti/cmd/console/ops_configure.go
@@ -0,0 +1,572 @@
+/*
+ Copyright NetFoundry Inc.
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ https://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+
+package console
+
+import (
+ "bufio"
+ "bytes"
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "github.com/spf13/cobra"
+ "gopkg.in/yaml.v3"
+)
+
+type ConfigureOptions struct {
+ Out io.Writer
+ Err io.Writer
+ In io.Reader
+ ConfigFile string
+ All bool
+ Names []string
+ Location string
+ Path string
+ IndexFile string
+ Verbose bool
+ Yes bool
+}
+
+func newConfigureCmd(out, errOut io.Writer) *cobra.Command {
+ options := &ConfigureOptions{
+ Out: out,
+ Err: errOut,
+ In: os.Stdin,
+ Path: "zac",
+ IndexFile: "index.html",
+ }
+
+ cmd := &cobra.Command{
+ Use: "configure ",
+ Short: "Add or update the console (spa) binding in a controller config file",
+ Long: `Edits a controller config (YAML) file in place, adding or updating the console "spa"
+web-listener binding so the controller serves the console from a directory on disk.
+
+Select which web listeners to update with --all, or with one or more --name flags. The file's
+comments and structure are preserved.
+
+Examples:
+ # update every web listener
+ ziti ops console configure ./controller.yaml --all --location /opt/openziti/share/console
+
+ # update a single listener (quickstart default)
+ ziti ops console configure ./controller.yaml --name client-management \
+ --location /opt/openziti/share/console
+
+ # update several named listeners
+ ziti ops console configure ./controller.yaml --name client-management --name dark-apis \
+ --location /opt/openziti/share/console`,
+ Args: cobra.ExactArgs(1),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ options.ConfigFile = args[0]
+ return options.Run()
+ },
+ }
+
+ cmd.Flags().BoolVar(&options.All, "all", false, "Apply to every web listener in the config")
+ cmd.Flags().StringArrayVar(&options.Names, "name", nil, "Name of a web listener to apply to; repeat for multiple")
+ cmd.Flags().StringVarP(&options.Location, "location", "l", "", "Console assets directory the controller should serve; prompted for if omitted")
+ cmd.Flags().StringVar(&options.Path, "path", options.Path, "URL path the console is served under")
+ cmd.Flags().StringVar(&options.IndexFile, "index-file", options.IndexFile, "SPA index file served as the fallback")
+ cmd.Flags().BoolVarP(&options.Verbose, "verbose", "v", false, "Print the resulting binding YAML section")
+ cmd.Flags().BoolVarP(&options.Yes, "yes", "y", false, "Answer yes to prompts (use the location given and download latest ZAC if absent)")
+
+ return cmd
+}
+
+func (o *ConfigureOptions) Run() error {
+ o.Path = normalizeConsolePath(o.Path)
+ if o.Path == "" {
+ return fmt.Errorf("--path must not be empty")
+ }
+
+ reader := bufio.NewReader(o.In)
+ if err := o.ensureLocationAndAssets(reader); err != nil {
+ return err
+ }
+ c := &bindingConfigurator{
+ out: o.Out,
+ in: o.In,
+ reader: reader,
+ configFile: o.ConfigFile,
+ all: o.All,
+ names: o.Names,
+ binding: "spa",
+ matchKey: "path",
+ matchValue: o.Path,
+ verbose: o.Verbose,
+ yes: o.Yes,
+ applyOptions: func(options *yaml.Node) {
+ setMapScalar(options, "path", o.Path)
+ setMapScalar(options, "location", o.Location)
+ setMapScalar(options, "indexFile", o.IndexFile)
+ },
+ }
+ return c.run()
+}
+
+// ensureLocationAndAssets resolves the console assets directory, prompting for it when not
+// supplied, and offers to download ZAC into it when none is present. With --yes it never
+// prompts: it requires --location and downloads the latest if assets are missing.
+func (o *ConfigureOptions) ensureLocationAndAssets(reader *bufio.Reader) error {
+ if o.Location == "" {
+ if o.Yes {
+ return fmt.Errorf("--location is required (cannot prompt for it with --yes)")
+ }
+ loc, err := prompt(o.Out, reader, "--location flag not supplied. Where are the console assets located? ")
+ if err != nil {
+ return err
+ }
+ o.Location = loc
+ if o.Location == "" {
+ return fmt.Errorf("a console assets location is required")
+ }
+ }
+
+ abs, err := filepath.Abs(o.Location)
+ if err != nil {
+ return fmt.Errorf("cannot resolve location '%s' to an absolute path: %w", o.Location, err)
+ }
+ o.Location = abs
+
+ // Assets already present, nothing to download.
+ if validateAssetsDir(o.Location) == nil {
+ return nil
+ }
+
+ if !o.Yes {
+ ok, err := promptYesNo(o.Out, reader, fmt.Sprintf("No console found at '%s'. Download it? [Y/n]: ", o.Location), true)
+ if err != nil {
+ return err
+ }
+ if !ok {
+ return fmt.Errorf("no console assets at '%s'; supply a populated --location or allow the download", o.Location)
+ }
+ }
+
+ // Never wipe a non-empty directory that is not a prior console install.
+ if entries, err := os.ReadDir(o.Location); err == nil && len(entries) > 0 && installedVersion(o.Location) == "" {
+ return fmt.Errorf("location '%s' is not empty and is not a console install; choose an empty directory", o.Location)
+ }
+
+ version := "latest"
+ if !o.Yes {
+ v, err := prompt(o.Out, reader, "Version to download [latest]: ")
+ if err != nil {
+ return err
+ }
+ if v != "" {
+ version = v
+ }
+ }
+
+ // Be lenient about a leading "v": the release tag prefix already supplies it, so strip it.
+ version = normalizeVersion(version)
+ if version == "" || strings.EqualFold(version, "latest") {
+ resolved, err := resolveLatestVersion()
+ if err != nil {
+ return fmt.Errorf("failed to resolve latest ZAC version: %w", err)
+ }
+ version = resolved
+ }
+
+ _, _ = fmt.Fprintf(o.Out, "\nDownloading ZAC %s from %s\n to %s ...\n", version, downloadURL(version), o.Location)
+ sum, err := downloadRelease(version, o.Location)
+ if err != nil {
+ return err
+ }
+ if err = validateAssetsDir(o.Location); err != nil {
+ return fmt.Errorf("downloaded archive did not contain a usable console: %w", err)
+ }
+ _, _ = fmt.Fprintf(o.Out, "Downloaded ZAC %s (sha256 %s)\n", version, sum)
+ return nil
+}
+
+// normalizeConsolePath strips surrounding whitespace and any leading or trailing slashes so
+// "/local", "local/", "//local//", and "local" all resolve to the same path segment.
+func normalizeConsolePath(p string) string {
+ return strings.Trim(strings.TrimSpace(p), "/\\")
+}
+
+func prompt(out io.Writer, reader *bufio.Reader, label string) (string, error) {
+ _, _ = fmt.Fprint(out, label)
+ line, err := reader.ReadString('\n')
+ if err != nil && err != io.EOF {
+ return "", err
+ }
+ return strings.TrimSpace(line), nil
+}
+
+func promptYesNo(out io.Writer, reader *bufio.Reader, label string, defaultYes bool) (bool, error) {
+ ans, err := prompt(out, reader, label)
+ if err != nil {
+ return false, err
+ }
+ ans = strings.ToLower(ans)
+ if ans == "" {
+ return defaultYes, nil
+ }
+ return ans == "y" || ans == "yes", nil
+}
+
+// promptForListeners asks, per named web listener, whether to apply the binding, recording the
+// confirmed names. It is used when neither --all nor --name was given (and not --yes).
+func (c *bindingConfigurator) promptForListeners(web *yaml.Node) error {
+ _, _ = fmt.Fprintf(c.out, "\nNeither --all nor --name was given. Choose listeners for the %s binding:\n\n", c.binding)
+ for _, listener := range web.Content {
+ if listener.Kind != yaml.MappingNode {
+ continue
+ }
+ name := scalarValue(mapValue(listener, "name"))
+ if name == "" {
+ continue
+ }
+ ok, err := promptYesNo(c.out, c.reader,
+ fmt.Sprintf(" Apply the %s binding to the web listener named '%s'? [Y/n]: ", c.binding, name), true)
+ if err != nil {
+ return err
+ }
+ if ok {
+ c.names = append(c.names, name)
+ }
+ }
+ _, _ = fmt.Fprintln(c.out)
+ if len(c.names) == 0 {
+ return fmt.Errorf("no web listeners selected")
+ }
+ return nil
+}
+
+// bindingConfigurator adds or updates a single web-listener api binding across the selected
+// listeners of a controller config, preserving comments and structure.
+type bindingConfigurator struct {
+ out io.Writer
+ in io.Reader
+ reader *bufio.Reader
+ configFile string
+ all bool
+ names []string
+ binding string
+ // matchKey/matchValue disambiguate multiple bindings of the same name by an options field
+ // (e.g. spa bindings keyed by "path"), so distinct paths append and a repeated path updates.
+ // matchKey "" matches on binding name alone.
+ matchKey string
+ matchValue string
+ verbose bool
+ yes bool
+ // applyOptions mutates the binding's options mapping node. nil leaves an empty `options: {}`.
+ applyOptions func(options *yaml.Node)
+}
+
+func (c *bindingConfigurator) run() error {
+ if c.reader == nil {
+ c.reader = bufio.NewReader(c.in)
+ }
+ if c.all && len(c.names) > 0 {
+ return fmt.Errorf("specify either --all or --name, not both")
+ }
+
+ data, err := os.ReadFile(c.configFile)
+ if err != nil {
+ return fmt.Errorf("failed to read config '%s': %w", c.configFile, err)
+ }
+
+ var doc yaml.Node
+ if err = yaml.Unmarshal(data, &doc); err != nil {
+ return fmt.Errorf("failed to parse config '%s': %w", c.configFile, err)
+ }
+ if len(doc.Content) == 0 || doc.Content[0].Kind != yaml.MappingNode {
+ return fmt.Errorf("config '%s' is not a YAML mapping", c.configFile)
+ }
+ root := doc.Content[0]
+
+ web := mapValue(root, "web")
+ if web == nil || web.Kind != yaml.SequenceNode {
+ return fmt.Errorf("config '%s' has no web listeners (no `web:` sequence)", c.configFile)
+ }
+
+ // With no selector, default to all. When interactive, confirm per listener.
+ if !c.all && len(c.names) == 0 {
+ if c.yes {
+ c.all = true
+ } else if err = c.promptForListeners(web); err != nil {
+ return err
+ }
+ }
+
+ // Track requested names so we can report any that were not found.
+ pending := map[string]bool{}
+ for _, n := range c.names {
+ pending[n] = true
+ }
+
+ type touched struct {
+ label string
+ action string
+ node *yaml.Node
+ }
+ var changes []touched
+ changedAny := false
+
+ matched := 0
+ for _, listener := range web.Content {
+ if listener.Kind != yaml.MappingNode {
+ continue
+ }
+ name := scalarValue(mapValue(listener, "name"))
+ if !c.selected(name) {
+ continue
+ }
+ delete(pending, name)
+ matched++
+
+ action, node, changed := ensureBinding(listener, c.binding, c.matchKey, c.matchValue, c.applyOptions)
+ if changed {
+ changedAny = true
+ }
+ label := name
+ if label == "" {
+ label = "(unnamed)"
+ }
+ desc := c.binding
+ if c.matchKey != "" {
+ desc = fmt.Sprintf("%s (%s %q)", c.binding, c.matchKey, c.matchValue)
+ }
+ _, _ = fmt.Fprintf(c.out, "%s %s binding on web listener '%s'\n", action, desc, label)
+ changes = append(changes, touched{label: label, action: action, node: node})
+ }
+
+ if len(pending) > 0 {
+ missing := make([]string, 0, len(pending))
+ for _, n := range c.names {
+ if pending[n] {
+ missing = append(missing, fmt.Sprintf("%q", n))
+ }
+ }
+ return fmt.Errorf("no web listener named %s found in %s", strings.Join(missing, ", "), c.configFile)
+ }
+ if matched == 0 {
+ return fmt.Errorf("no matching web listeners found in %s", c.configFile)
+ }
+
+ var buf bytes.Buffer
+ enc := yaml.NewEncoder(&buf)
+ enc.SetIndent(2)
+ if err = enc.Encode(&doc); err != nil {
+ return fmt.Errorf("failed to render updated config: %w", err)
+ }
+ _ = enc.Close()
+
+ // Reuse the original mode so a config holding secrets isn't widened to the default 0644.
+ mode := os.FileMode(0o644)
+ if info, statErr := os.Stat(c.configFile); statErr == nil {
+ mode = info.Mode().Perm()
+ }
+
+ // CreateTemp in the same dir gives an O_EXCL, non-predictable path (a symlink can't redirect the
+ // write) and keeps the rename on one filesystem.
+ tmpFile, err := os.CreateTemp(filepath.Dir(c.configFile), ".console-config-*.tmp")
+ if err != nil {
+ return fmt.Errorf("failed to create temp file: %w", err)
+ }
+ tmpName := tmpFile.Name()
+ defer func() { _ = os.Remove(tmpName) }()
+ if _, err = tmpFile.Write(buf.Bytes()); err != nil {
+ _ = tmpFile.Close()
+ return fmt.Errorf("failed to write updated config: %w", err)
+ }
+ if err = tmpFile.Close(); err != nil {
+ return fmt.Errorf("failed to flush updated config: %w", err)
+ }
+ if err = os.Chmod(tmpName, mode); err != nil {
+ return fmt.Errorf("failed to set permissions on updated config: %w", err)
+ }
+ if err = os.Rename(tmpName, c.configFile); err != nil {
+ return fmt.Errorf("failed to replace '%s': %w", c.configFile, err)
+ }
+
+ added, updated := 0, 0
+ for _, ch := range changes {
+ if ch.action == "added" {
+ added++
+ } else {
+ updated++
+ }
+ }
+ _, _ = fmt.Fprintf(c.out, "\nsaved %s (%d added, %d updated across %d web listener(s))\n",
+ c.configFile, added, updated, matched)
+
+ if c.verbose {
+ for _, ch := range changes {
+ section, rErr := renderSection(ch.node)
+ if rErr != nil {
+ continue
+ }
+ _, _ = fmt.Fprintf(c.out, "\n%s:\n%s", ch.label, section)
+ }
+ }
+
+ if changedAny {
+ _, _ = fmt.Fprintln(c.out, "restart your controller to pick up changes")
+ }
+ return nil
+}
+
+func (c *bindingConfigurator) selected(name string) bool {
+ if c.all {
+ return true
+ }
+ for _, n := range c.names {
+ if n == name {
+ return true
+ }
+ }
+ return false
+}
+
+// ensureBinding makes sure the listener's apis list holds a binding of the given name with the
+// supplied options, updating an existing one or appending a new one. When matchKey is set, a
+// binding matches only if its options[matchKey] equals matchValue, so several bindings of the
+// same name (e.g. spa bindings on different paths) coexist and only the matching one is updated.
+// It returns "added" or "updated", the binding node that was written, and whether the file
+// content actually changed (an add, or an update that altered the options).
+func ensureBinding(listener *yaml.Node, binding, matchKey, matchValue string, applyOptions func(*yaml.Node)) (string, *yaml.Node, bool) {
+ apis := mapValue(listener, "apis")
+ if apis == nil {
+ apis = &yaml.Node{Kind: yaml.SequenceNode, Tag: "!!seq"}
+ setMapNode(listener, "apis", apis)
+ } else if apis.Kind != yaml.SequenceNode {
+ // apis present but null or empty, so coerce it into a sequence.
+ apis.Kind = yaml.SequenceNode
+ apis.Tag = "!!seq"
+ apis.Value = ""
+ apis.Content = nil
+ }
+
+ for _, api := range apis.Content {
+ if api.Kind != yaml.MappingNode {
+ continue
+ }
+ if scalarValue(mapValue(api, "binding")) != binding {
+ continue
+ }
+ if matchKey != "" && scalarValue(mapValue(mapValue(api, "options"), matchKey)) != matchValue {
+ continue
+ }
+ before, _ := renderSection(api)
+ applyBindingOptions(api, applyOptions)
+ after, _ := renderSection(api)
+ return "updated", api, before != after
+ }
+
+ node := newBindingNode(binding, applyOptions)
+ apis.Content = append(apis.Content, node)
+ return "added", node, true
+}
+
+func newBindingNode(binding string, applyOptions func(*yaml.Node)) *yaml.Node {
+ api := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"}
+ setMapScalar(api, "binding", binding)
+ options := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"}
+ setMapNode(api, "options", options)
+ if applyOptions != nil {
+ applyOptions(options)
+ }
+ return api
+}
+
+func applyBindingOptions(api *yaml.Node, applyOptions func(*yaml.Node)) {
+ if applyOptions == nil {
+ return
+ }
+ options := mapValue(api, "options")
+ if options == nil || options.Kind != yaml.MappingNode {
+ options = &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"}
+ setMapNode(api, "options", options)
+ }
+ applyOptions(options)
+}
+
+// renderSection encodes a single binding node as a YAML list item indented two spaces, for
+// display under its web listener name.
+func renderSection(node *yaml.Node) (string, error) {
+ seq := &yaml.Node{Kind: yaml.SequenceNode, Tag: "!!seq", Content: []*yaml.Node{node}}
+ var buf bytes.Buffer
+ enc := yaml.NewEncoder(&buf)
+ enc.SetIndent(2)
+ if err := enc.Encode(seq); err != nil {
+ return "", err
+ }
+ _ = enc.Close()
+
+ var b strings.Builder
+ for _, line := range strings.Split(strings.TrimRight(buf.String(), "\n"), "\n") {
+ b.WriteString(" ")
+ b.WriteString(line)
+ b.WriteString("\n")
+ }
+ return b.String(), nil
+}
+
+// mapValue returns the value node for key in a mapping node, or nil.
+func mapValue(m *yaml.Node, key string) *yaml.Node {
+ if m == nil || m.Kind != yaml.MappingNode {
+ return nil
+ }
+ for i := 0; i+1 < len(m.Content); i += 2 {
+ if m.Content[i].Value == key {
+ return m.Content[i+1]
+ }
+ }
+ return nil
+}
+
+func scalarValue(n *yaml.Node) string {
+ if n == nil {
+ return ""
+ }
+ return n.Value
+}
+
+// setMapNode sets key to val in a mapping, replacing an existing value or appending.
+func setMapNode(m *yaml.Node, key string, val *yaml.Node) {
+ for i := 0; i+1 < len(m.Content); i += 2 {
+ if m.Content[i].Value == key {
+ m.Content[i+1] = val
+ return
+ }
+ }
+ m.Content = append(m.Content, scalarNode(key), val)
+}
+
+// setMapScalar sets key to a string scalar, updating in place to preserve any comments.
+func setMapScalar(m *yaml.Node, key, value string) {
+ if v := mapValue(m, key); v != nil {
+ v.Kind = yaml.ScalarNode
+ v.Tag = "!!str"
+ v.Value = value
+ v.Content = nil
+ return
+ }
+ m.Content = append(m.Content, scalarNode(key), scalarNode(value))
+}
+
+func scalarNode(s string) *yaml.Node {
+ return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: s}
+}
diff --git a/ziti/cmd/console/ops_download.go b/ziti/cmd/console/ops_download.go
new file mode 100644
index 000000000..e164d491a
--- /dev/null
+++ b/ziti/cmd/console/ops_download.go
@@ -0,0 +1,153 @@
+/*
+ Copyright NetFoundry Inc.
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ https://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+
+package console
+
+import (
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "github.com/spf13/cobra"
+)
+
+// NewConsoleOpsCmd returns the `ziti ops console` command group for managing ZAC assets.
+func NewConsoleOpsCmd(out, errOut io.Writer) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "console",
+ Short: "Manage Ziti Admin Console (ZAC) assets",
+ }
+ cmd.AddCommand(newDownloadCmd(out, errOut))
+ cmd.AddCommand(newConfigureCmd(out, errOut))
+ return cmd
+}
+
+type DownloadOptions struct {
+ Out io.Writer
+ Err io.Writer
+ Version string
+ Location string
+}
+
+func newDownloadCmd(out, errOut io.Writer) *cobra.Command {
+ options := &DownloadOptions{
+ Out: out,
+ Err: errOut,
+ Version: "latest",
+ }
+
+ cmd := &cobra.Command{
+ Use: "download [version]",
+ Short: "Download the Ziti Admin Console and extract it to a directory",
+ Long: `Downloads the Ziti Admin Console (ZAC) release archive and extracts it into a directory.
+With no version, or "latest", the newest release is downloaded.
+
+The target directory must be empty or not yet exist. This command never removes or overwrites
+existing files: if the directory is not empty it fails and leaves it untouched.
+
+Examples:
+ # download the latest ZAC into ./console
+ ziti ops console download --location ./console
+
+ # download a specific version
+ ziti ops console download 4.3.0 --location /opt/openziti/share/console`,
+ Args: cobra.MaximumNArgs(1),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ if len(args) > 0 {
+ options.Version = args[0]
+ }
+ return options.Run()
+ },
+ }
+
+ cmd.Flags().StringVarP(&options.Location, "location", "l", "", "Directory to extract the console into; must be empty or not yet exist (required)")
+
+ return cmd
+}
+
+func (o *DownloadOptions) Run() error {
+ if o.Location == "" {
+ return fmt.Errorf("--location is required")
+ }
+ abs, err := filepath.Abs(o.Location)
+ if err != nil {
+ return fmt.Errorf("cannot resolve location '%s' to an absolute path: %w", o.Location, err)
+ }
+ o.Location = abs
+ if err := o.requireEmptyLocation(); err != nil {
+ return err
+ }
+
+ version := normalizeVersion(o.Version)
+ if version == "" || strings.EqualFold(version, "latest") {
+ resolved, err := resolveLatestVersion()
+ if err != nil {
+ return fmt.Errorf("failed to resolve latest ZAC version: %w", err)
+ }
+ version = resolved
+ }
+
+ _, _ = fmt.Fprintf(o.Out, "Downloading ZAC %s\n source %s\n target %s\n", version, downloadURL(version), o.Location)
+ sum, err := downloadRelease(version, o.Location)
+ if err != nil {
+ return err
+ }
+ if err = validateAssetsDir(o.Location); err != nil {
+ return fmt.Errorf("downloaded archive did not contain a usable console: %w", err)
+ }
+
+ _, _ = fmt.Fprintf(o.Out, "\nInstalled ZAC %s\n location %s\n sha256 %s\n", version, o.Location, sum)
+ o.printNextSteps()
+ return nil
+}
+
+// printNextSteps shows how to make a controller serve the assets that were just installed,
+// both via `ziti ops console configure` and as a manual config snippet.
+func (o *DownloadOptions) printNextSteps() {
+ _, _ = fmt.Fprintf(o.Out, `
+Next steps
+ Configure a controller quickly using this command:
+ ziti ops console configure --all --location %s
+
+ Or add this to the listener's apis list in the controller config:
+ - binding: spa
+ options:
+ path: zac
+ location: %s
+ indexFile: index.html
+`, o.Location, o.Location)
+}
+
+// requireEmptyLocation fails unless the target directory is empty or does not yet exist, so
+// the download never removes or overwrites files the user already placed there.
+func (o *DownloadOptions) requireEmptyLocation() error {
+ entries, err := os.ReadDir(o.Location)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return nil
+ }
+ return fmt.Errorf("cannot inspect location '%s': %w", o.Location, err)
+ }
+ if len(entries) == 0 {
+ return nil
+ }
+ if existing := installedVersion(o.Location); existing != "" {
+ return fmt.Errorf("location '%s' already contains ZAC %s; remove it or choose an empty directory", o.Location, existing)
+ }
+ return fmt.Errorf("location '%s' is not empty; remove it or choose an empty directory", o.Location)
+}
diff --git a/ziti/run/quickstart_cluster.go b/ziti/run/quickstart_cluster.go
index 3cb2af012..c1abfccc4 100644
--- a/ziti/run/quickstart_cluster.go
+++ b/ziti/run/quickstart_cluster.go
@@ -103,7 +103,7 @@ func (o *QuickstartClusterOpts) run(ctx context.Context) error {
pfxlog.GlobalInit(logrus.DebugLevel, pfxlog.DefaultOptions().Color())
}
if o.Size < 3 || o.Size > 9 {
- return fmt.Errorf("when using --size the value must be between 3 and 9. More cluster members cause slower mutations.")
+ return fmt.Errorf("when using --size the value must be between 3 and 9, more cluster members cause slower mutations")
}
// Node i uses CtrlPort+i and RouterPort+i. Validate the derived ranges up front
// so an out-of-range or overlapping port is a clear error rather than a
diff --git a/ziti/run/root.go b/ziti/run/root.go
index 92f44f742..817ce2f22 100644
--- a/ziti/run/root.go
+++ b/ziti/run/root.go
@@ -24,6 +24,7 @@ import (
"github.com/openziti/channel/v5"
"github.com/openziti/ziti/v2/common/logging"
+ "github.com/openziti/ziti/v2/ziti/cmd/console"
"github.com/openziti/ziti/v2/ziti/tunnel"
"github.com/openziti/ziti/v2/ziti/util"
"github.com/sirupsen/logrus"
@@ -117,6 +118,7 @@ func NewRunCmd(out, err io.Writer) *cobra.Command {
cmd.AddCommand(NewRunRouterCmd())
cmd.AddCommand(tunnel.NewTunnelCmd(false))
cmd.AddCommand(NewQuickStartCmd(out, err, context.Background()))
+ cmd.AddCommand(console.NewConsoleCmd(out, err))
return cmd
}