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
6 changes: 6 additions & 0 deletions cmd/cloudemu/lifecycle_other.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,9 @@ func runLifecycle(_ string, _ []string) error {
func runSnapshot(_ []string) error {
return errors.New("snapshot is only supported on Unix/macOS")
}

// runNet is unavailable off Unix for the same reason: it talks to the
// background daemon via its run directory.
func runNet(_ []string) error {
return errors.New("net is only supported on Unix/macOS")
}
7 changes: 7 additions & 0 deletions cmd/cloudemu/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ Usage:
cloudemu logs [-f] Print (or follow) the background emulator's log
cloudemu delete Stop the emulator and remove its run directory
cloudemu snapshot ... Save/load/list/delete named state snapshots
cloudemu net ... Check network reachability (can-connect, trace)
cloudemu serve [flags] Run the server in the foreground (see: cloudemu serve -h)
cloudemu version Print the version
cloudemu help Show this message
Expand All @@ -38,6 +39,7 @@ const (
cmdLogs = "logs"
cmdDelete = "delete"
cmdSnapshot = "snapshot"
cmdNet = "net"
)

// version is overridable at build time with
Expand Down Expand Up @@ -66,6 +68,11 @@ func main() {
fmt.Fprintln(os.Stderr, "cloudemu:", err)
os.Exit(1)
}
case cmdNet:
if err := runNet(os.Args[2:]); err != nil {
fmt.Fprintln(os.Stderr, "cloudemu:", err)
os.Exit(1)
}
case "version", "-v", "--version":
fmt.Println("cloudemu", version)
case "help", "-h", "--help":
Expand Down
220 changes: 220 additions & 0 deletions cmd/cloudemu/net.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
//go:build unix

package main

import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"

"github.com/stackshy/cloudemu/v2/features/topology"
)

// netPositionalArgs is the number of positional args both subcommands take
// (can-connect A B; trace A destIP).
const netPositionalArgs = 2

var (
errNetUsage = errors.New("usage: cloudemu net <can-connect <A> <B> [--port N] [--protocol tcp] | " +
"trace <A> <destIP>> [--json] [--home dir]")
errNetServer = errors.New("network query failed")
)

// parseNetFlags splits --home/--port/--protocol/--json out of args, returning
// the remaining positional args.
func parseNetFlags(args []string) (home, port, proto string, jsonOut bool, pos []string) {
home, rest := splitHomeFlag(args)
pos = make([]string, 0, len(rest))

for i := 0; i < len(rest); i++ {
switch a := rest[i]; {
case a == "--json":
jsonOut = true
case a == "--port" && i+1 < len(rest):
port = rest[i+1]
i++
case strings.HasPrefix(a, "--port="):
port = strings.TrimPrefix(a, "--port=")
case a == "--protocol" && i+1 < len(rest):
proto = rest[i+1]
i++
case strings.HasPrefix(a, "--protocol="):
proto = strings.TrimPrefix(a, "--protocol=")
default:
pos = append(pos, a)
}
}

return home, port, proto, jsonOut, pos
}

// runNet dispatches the net can-connect / trace subcommands.
func runNet(args []string) error {
if len(args) == 0 {
return errNetUsage
}

home, port, proto, jsonOut, pos := parseNetFlags(args[1:])
if len(pos) != netPositionalArgs {
return errNetUsage
}

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

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

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

func netCanConnect(base, from, to, port, proto string, jsonOut bool) error {
q := url.Values{}
q.Set("from", from)
q.Set("to", to)

if port != "" {
q.Set("port", port)
}

if proto != "" {
q.Set("protocol", proto)
}

body, err := netGET(base, "net/can-connect", q)
if err != nil {
return err
}

if jsonOut {
fmt.Println(string(body))

return nil
}

var res topology.ConnectivityResult
if err := json.Unmarshal(body, &res); err != nil {
return err
}

verdict := "NO"
if res.Allowed {
verdict = "YES"
}

fmt.Printf("%s — %s\n", verdict, res.Reason)
printHops(res.Path)

return nil
}

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

body, err := netGET(base, "net/trace", q)
if err != nil {
return err
}

if jsonOut {
fmt.Println(string(body))

return nil
}

var out struct {
Hops []topology.RouteHop `json:"hops"`
}

if err := json.Unmarshal(body, &out); err != nil {
return err
}

printHops(out.Hops)

return nil
}

func printHops(hops []topology.RouteHop) {
for i := range hops {
h := &hops[i]
line := " → " + h.Type

if h.ResourceID != "" {
line += " " + h.ResourceID
}

if h.Detail != "" {
line += " (" + h.Detail + ")"
}

fmt.Println(line)
}
}

// 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
if len(q) > 0 {
u += "?" + q.Encode()
}

req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, http.NoBody)
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))
}

return b, nil
}

// serverErrMsg extracts the {"error":...} message from a control-plane response,
// falling back to the HTTP status.
func serverErrMsg(body []byte, status string) string {
var e struct {
Error string `json:"error"`
}

if err := json.Unmarshal(body, &e); err == nil && e.Error != "" {
return e.Error
}

return status
}
75 changes: 75 additions & 0 deletions cmd/cloudemu/net_serve_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package main

import (
"net/http"
"net/http/httptest"
"strings"
"testing"

"github.com/stackshy/cloudemu/v2/config"
"github.com/stackshy/cloudemu/v2/features/topology"
"github.com/stackshy/cloudemu/v2/providers/aws/ec2"
"github.com/stackshy/cloudemu/v2/providers/aws/route53"
"github.com/stackshy/cloudemu/v2/providers/aws/vpc"
)

func emptyNetEngine() *topology.Engine {
o := config.NewOptions()

return topology.New(ec2.New(o), vpc.New(o), route53.New(o))
}

func TestNetPort(t *testing.T) {
if p, err := netPort(""); err != nil || p != 0 {
t.Fatalf("netPort(empty) = %d, %v", p, err)
}
if p, err := netPort("5432"); err != nil || p != 5432 {
t.Fatalf("netPort(5432) = %d, %v", p, err)
}
if _, err := netPort("nope"); err == nil {
t.Fatal("netPort(nope) = nil error, want error")
}
}

func TestServeCanConnectValidation(t *testing.T) {
eng := emptyNetEngine()

// Missing from/to → 400.
rec := httptest.NewRecorder()
serveCanConnect(rec, httptest.NewRequest(http.MethodGet, "/_cloudemu/net/can-connect", nil), eng)
if rec.Code != http.StatusBadRequest {
t.Fatalf("missing params = %d, want 400", rec.Code)
}

// Invalid port → 400.
rec = httptest.NewRecorder()
serveCanConnect(rec, httptest.NewRequest(http.MethodGet, "/_cloudemu/net/can-connect?from=i-a&to=i-b&port=x", nil), eng)
if rec.Code != http.StatusBadRequest {
t.Fatalf("bad port = %d, want 400", rec.Code)
}

// Unknown instance → engine NotFound → 400 with an error body.
rec = httptest.NewRecorder()
serveCanConnect(rec, httptest.NewRequest(http.MethodGet, "/_cloudemu/net/can-connect?from=i-a&to=i-b&port=80", nil), eng)
if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "error") {
t.Fatalf("unknown instance = %d %q, want 400 error", rec.Code, rec.Body.String())
}
}

func TestServeTraceValidation(t *testing.T) {
eng := emptyNetEngine()

// Missing destination IP → 400.
rec := httptest.NewRecorder()
serveTrace(rec, httptest.NewRequest(http.MethodGet, "/_cloudemu/net/trace?from=i-a", nil), eng)
if rec.Code != http.StatusBadRequest {
t.Fatalf("missing dest = %d, want 400", rec.Code)
}

// Unknown instance → engine error → 400.
rec = httptest.NewRecorder()
serveTrace(rec, httptest.NewRequest(http.MethodGet, "/_cloudemu/net/trace?from=i-a&to=10.0.0.5", nil), eng)
if rec.Code != http.StatusBadRequest {
t.Fatalf("unknown instance trace = %d, want 400", rec.Code)
}
}
Loading
Loading