Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 122 additions & 0 deletions cmd/cloudemu/admin_client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
//go:build unix

package main

import (
"context"
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
)

// adminTokenFileName is where `cloudemu start` asks serve to write the admin
// token, next to endpoints.json in the run dir.
const adminTokenFileName = "admin-token"

// adminTokenEnv overrides the token file, e.g. for a server started by hand
// with --admin-token.
const adminTokenEnv = "CLOUDEMU_ADMIN_TOKEN" //nolint:gosec // G101 false positive: an env var name, not a credential

var errAdminUnauthorized = errors.New("the server requires the admin token (it runs with --enforce-auth): " +
"set CLOUDEMU_ADMIN_TOKEN, or start it with `cloudemu start` so the token is written to the run dir")

func adminTokenPath(dir string) string { return filepath.Join(dir, adminTokenFileName) }

// adminAPI is a running daemon's control plane: its plain-HTTP base URL and
// the admin token to send ("" when there is none, i.e. auth is off).
type adminAPI struct {
base string
token string
}

// newAdminAPI resolves the daemon's control plane from the run dir. The token
// comes from CLOUDEMU_ADMIN_TOKEN, else the run dir's admin-token file.
func newAdminAPI(dir string) (adminAPI, error) {
base, err := adminBaseURL(dir)
if err != nil {
return adminAPI{}, err
}

return adminAPI{base: base, token: adminToken(dir)}, nil
}

func adminToken(dir string) string {
if t := strings.TrimSpace(os.Getenv(adminTokenEnv)); t != "" {
return t
}

b, err := os.ReadFile(adminTokenPath(dir))
if err != nil {
return ""
}

return strings.TrimSpace(string(b))
}

// adminBaseURL reads the daemon's endpoints file and returns a plain-HTTP base
// URL for the control plane (avoids the self-signed HTTPS endpoints).
func adminBaseURL(dir string) (string, error) {
eps, err := readEndpoints(endpointsPath(dir))
if errors.Is(err, os.ErrNotExist) {
return "", errSnapDaemonDown
}

if err != nil {
return "", err
}

// Azure and Kubernetes are HTTPS, so this picks aws, then gcp.
for _, k := range endpointOrder() {
if ep := eps[k]; strings.HasPrefix(ep, "http://") {
return strings.TrimRight(ep, "/"), nil
}
}

return "", errSnapNoEndpoint
}

// do calls /_cloudemu/<path> with the admin token and returns the status and
// body. A 401 (missing or wrong token) and a 501 (control plane off) map to
// clear errors here so every subcommand reports them the same way.
func (a adminAPI) do(method, path string, body io.Reader, contentType string) (status int, respBody []byte, err error) {
ctx, cancel := context.WithTimeout(context.Background(), snapHTTPTimeout)
defer cancel()

if body == nil {
body = http.NoBody
}

req, err := http.NewRequestWithContext(ctx, method, a.base+"/_cloudemu/"+path, body)
if err != nil {
return 0, nil, err
}

if contentType != "" {
req.Header.Set("Content-Type", contentType)
}

if a.token != "" {
req.Header.Set("Authorization", "Bearer "+a.token)
}

resp, err := http.DefaultClient.Do(req)
if err != nil {
return 0, nil, fmt.Errorf("%w: %w", errSnapDaemonDown, err)
}
defer resp.Body.Close()

rb, _ := io.ReadAll(resp.Body)

switch resp.StatusCode {
case http.StatusUnauthorized:
return resp.StatusCode, rb, errAdminUnauthorized
case http.StatusNotImplemented:
return resp.StatusCode, rb, errSnapAdminOff
}

return resp.StatusCode, rb, nil
}
100 changes: 100 additions & 0 deletions cmd/cloudemu/admin_client_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
//go:build unix

package main

import (
"context"
"errors"
"io"
"os"
"testing"
"time"

"github.com/stackshy/cloudemu/v2/server/serverkit"
)

// startEnforceAuthServer runs an in-process --enforce-auth server wired like
// `cloudemu start` does it: endpoints and admin token land in dir.
func startEnforceAuthServer(t *testing.T, dir string) {
t.Helper()

app, err := serverkit.New(&serverkit.Config{
Providers: []string{"aws"},
Host: "127.0.0.1",
Ports: map[string]string{"aws": freePort(t)},
Admin: true,
EnforceAuth: true,
AdminTokenFile: adminTokenPath(dir),
EndpointsFile: endpointsPath(dir),
Quiet: true,
Out: io.Discard,
})
if err != nil {
t.Fatalf("serverkit.New: %v", err)
}

ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)

go func() { done <- app.Serve(ctx) }()

t.Cleanup(func() {
cancel()
<-done
})

if _, err := waitForEndpoints(endpointsPath(dir), 10*time.Second); err != nil {
t.Fatalf("server not ready: %v", err)
}
}

// TestCLIAdminCommandsUnderEnforceAuth drives snapshot save/load and cost
// against an --enforce-auth server: they work with the run-dir token or
// CLOUDEMU_ADMIN_TOKEN, and fail with a clear error without one.
func TestCLIAdminCommandsUnderEnforceAuth(t *testing.T) {
t.Setenv(adminTokenEnv, "")

dir := t.TempDir()
startEnforceAuthServer(t, dir)

if err := snapshotSave(dir, "s1", false); err != nil {
t.Fatalf("snapshot save with the run-dir token: %v", err)
}

if err := snapshotLoad(dir, "s1"); err != nil {
t.Fatalf("snapshot load with the run-dir token: %v", err)
}

if err := runCost([]string{"--home", dir, "--json"}); err != nil {
t.Fatalf("cost with the run-dir token: %v", err)
}

token := adminToken(dir)
if token == "" {
t.Fatal("serve did not write the admin token to the run dir")
}

if err := os.Remove(adminTokenPath(dir)); err != nil {
t.Fatal(err)
}

if err := snapshotSave(dir, "s2", false); !errors.Is(err, errAdminUnauthorized) {
t.Fatalf("snapshot save without a token = %v, want errAdminUnauthorized", err)
}

if err := runCost([]string{"--home", dir}); !errors.Is(err, errAdminUnauthorized) {
t.Fatalf("cost without a token = %v, want errAdminUnauthorized", err)
}

t.Setenv(adminTokenEnv, "wrong")

if err := snapshotLoad(dir, "s1"); !errors.Is(err, errAdminUnauthorized) {
t.Fatalf("snapshot load with a wrong token = %v, want errAdminUnauthorized", err)
}

t.Setenv(adminTokenEnv, token)

if err := snapshotSave(dir, "s2", false); err != nil {
t.Fatalf("snapshot save with CLOUDEMU_ADMIN_TOKEN: %v", err)
}
}
4 changes: 2 additions & 2 deletions cmd/cloudemu/cost.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,12 @@ func runCost(args []string) error {
return err
}

base, err := adminBaseURL(dir)
api, err := newAdminAPI(dir)
if err != nil {
return err
}

body, err := netGET(base, "cost", url.Values{})
body, err := netGET(api, "cost", url.Values{})
if err != nil {
return err
}
Expand Down
10 changes: 9 additions & 1 deletion cmd/cloudemu/lifecycle.go
Original file line number Diff line number Diff line change
Expand Up @@ -389,12 +389,17 @@ func runStart(args []string) error {
// start also manages the persistence snapshot path under the run dir; drop a
// user --state-file so it can't point elsewhere.
rest = stripFlag(rest, "state-file", true)
// start also owns where the admin token goes, so the snapshot, net and cost
// commands can read it from the run dir under --enforce-auth.
rest = stripFlag(rest, "admin-token-file", true)

dir, err := runDir(home)
if err != nil {
return err
}

rest = append(rest, "--admin-token-file", adminTokenPath(dir))

// Opt-in persistence: if the user asked to persist, point serve at a snapshot
// file in the run dir (and imply --persist when only --persist-metadata-only
// is given).
Expand Down Expand Up @@ -427,6 +432,9 @@ func runStart(args []string) error {

epPath := endpointsPath(dir)
_ = os.Remove(epPath) // drop a stale file so waitForEndpoints sees the fresh one
// A token left by an earlier --enforce-auth run must not outlive it; serve
// writes a fresh one only when auth is on.
_ = os.Remove(adminTokenPath(dir))

eps, err := spawnServe(dir, rest, epPath)
if err != nil {
Expand Down Expand Up @@ -618,7 +626,7 @@ func runDelete(args []string) error {
return err
}

for _, p := range []string{statePath(dir), logPath(dir), endpointsPath(dir), persistPath(dir)} {
for _, p := range []string{statePath(dir), logPath(dir), endpointsPath(dir), persistPath(dir), adminTokenPath(dir)} {
if rmErr := os.Remove(p); rmErr != nil && !os.IsNotExist(rmErr) {
return rmErr
}
Expand Down
42 changes: 12 additions & 30 deletions cmd/cloudemu/net.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,9 @@
package main

import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
Expand Down Expand Up @@ -72,22 +70,22 @@ func runNet(args []string) error {
return err
}

base, err := adminBaseURL(dir)
api, err := newAdminAPI(dir)
if err != nil {
return err
}

switch args[0] {
case "can-connect":
return netCanConnect(base, pos[0], pos[1], port, proto, jsonOut)
return netCanConnect(api, pos[0], pos[1], port, proto, jsonOut)
case "trace":
return netTrace(base, pos[0], pos[1], jsonOut)
return netTrace(api, pos[0], pos[1], jsonOut)
default:
return errNetUsage
}
}

func netCanConnect(base, from, to, port, proto string, jsonOut bool) error {
func netCanConnect(api adminAPI, from, to, port, proto string, jsonOut bool) error {
q := url.Values{}
q.Set("from", from)
q.Set("to", to)
Expand All @@ -100,7 +98,7 @@ func netCanConnect(base, from, to, port, proto string, jsonOut bool) error {
q.Set("protocol", proto)
}

body, err := netGET(base, "net/can-connect", q)
body, err := netGET(api, "net/can-connect", q)
if err != nil {
return err
}
Expand All @@ -127,12 +125,12 @@ func netCanConnect(base, from, to, port, proto string, jsonOut bool) error {
return nil
}

func netTrace(base, from, dest string, jsonOut bool) error {
func netTrace(api adminAPI, from, dest string, jsonOut bool) error {
q := url.Values{}
q.Set("from", from)
q.Set("to", dest)

body, err := netGET(base, "net/trace", q)
body, err := netGET(api, "net/trace", q)
if err != nil {
return err
}
Expand Down Expand Up @@ -175,34 +173,18 @@ func printHops(hops []topology.RouteHop) {

// netGET calls a /_cloudemu/<endpoint> control path and returns the body,
// mapping the control plane's error statuses to clear CLI errors.
func netGET(base, endpoint string, q url.Values) ([]byte, error) {
ctx, cancel := context.WithTimeout(context.Background(), snapHTTPTimeout)
defer cancel()

u := base + "/_cloudemu/" + endpoint
func netGET(api adminAPI, endpoint string, q url.Values) ([]byte, error) {
if len(q) > 0 {
u += "?" + q.Encode()
endpoint += "?" + q.Encode()
}

req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, http.NoBody)
status, b, err := api.do(http.MethodGet, endpoint, nil, "")
if err != nil {
return nil, err
}

resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("%w: %w", errSnapDaemonDown, err)
}
defer resp.Body.Close()

b, _ := io.ReadAll(resp.Body)

if resp.StatusCode == http.StatusNotImplemented {
return nil, errSnapAdminOff
}

if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("%w: %s", errNetServer, serverErrMsg(b, resp.Status))
if status != http.StatusOK {
return nil, fmt.Errorf("%w: %s", errNetServer, serverErrMsg(b, fmt.Sprintf("%d %s", status, http.StatusText(status))))
}

return b, nil
Expand Down
Loading
Loading