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 }