ZAC bootstrapping CLI (download, configure, serve) plus ST1005 lint fix

This commit is contained in:
dovholuknf
2026-06-24 11:48:56 -04:00
parent fce8c126c6
commit 265ceca90f
11 changed files with 1741 additions and 1 deletions
+45
View File
@@ -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 <dir>
```
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 <controller-config.yml> --all --location <dir>
```
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 <dir>
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 <dir> --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 <controller-config>`, lets
+15
View File
@@ -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://<controller-address>/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 |
+304
View File
@@ -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("<html></html>"), 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)))
})
}
+3
View File
@@ -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))
+103
View File
@@ -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 <dir> or --version <x.y.z|latest>")
}
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
}
+252
View File
@@ -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 <base href="/zac/">, 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)
}
+291
View File
@@ -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
}
+572
View File
@@ -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 <controller-config-file>",
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}
}
+153
View File
@@ -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 <controller-config.yml> --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)
}
+1 -1
View File
@@ -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
+2
View File
@@ -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
}