mirror of
https://github.com/openziti/ziti.git
synced 2026-09-10 00:35:41 +00:00
ZAC bootstrapping CLI (download, configure, serve) plus ST1005 lint fix
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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)))
|
||||
})
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user