diff --git a/api/labels.go b/api/labels.go index 73349c71..c216a5f6 100644 --- a/api/labels.go +++ b/api/labels.go @@ -87,6 +87,32 @@ func ValidateLabels(labels map[string]string) error { return nil } +// ParseLabels parses a comma-separated "key=value" list (the wire/CLI form of +// a label set) into a label map; an empty string means no claims. Parsing is +// syntax only — run the result through ValidateLabels. +func ParseLabels(s string) (map[string]string, error) { + if s == "" { + return nil, nil + } + labels := make(map[string]string) + for _, part := range strings.Split(s, ",") { + part = strings.TrimSpace(part) + if part == "" { + continue + } + k, v, ok := strings.Cut(part, "=") + if !ok { + return nil, fmt.Errorf("invalid label %q: expected key=value", part) + } + key := strings.TrimSpace(k) + if _, exists := labels[key]; exists { + return nil, fmt.Errorf("duplicate label key %q", key) + } + labels[key] = strings.TrimSpace(v) + } + return labels, nil +} + // A node declares its own labels when it enrols, so on its own a label is a // claim rather than an attestation. A role's allowed_labels is what makes it // one: the control plane only signs a label the operator said that role may diff --git a/api/labels_test.go b/api/labels_test.go index 4ad2d80a..9cf0c985 100644 --- a/api/labels_test.go +++ b/api/labels_test.go @@ -107,3 +107,22 @@ func TestValidateLabels_AllKeysValidStaysSorted(t *testing.T) { t.Errorf("ValidateLabels(sorted happy path): unexpected error: %v", err) } } + +func TestParseLabels(t *testing.T) { + labels, err := ParseLabels("region=eu-west-1, team = platform") + if err != nil { + t.Fatalf("ParseLabels failed: %v", err) + } + if labels["region"] != "eu-west-1" || labels["team"] != "platform" { + t.Fatalf("unexpected labels: %v", labels) + } + if labels, err := ParseLabels(""); err != nil || labels != nil { + t.Fatalf("empty string should yield no labels, got %v, %v", labels, err) + } + if _, err := ParseLabels("no-equals"); err == nil { + t.Fatal("expected error for label without =") + } + if _, err := ParseLabels("k=a,k=b"); err == nil { + t.Fatal("expected error for duplicate key") + } +} diff --git a/cmd/sam-node/main.go b/cmd/sam-node/main.go index 111eae8d..a4454105 100644 --- a/cmd/sam-node/main.go +++ b/cmd/sam-node/main.go @@ -259,31 +259,6 @@ func interactiveJoin(ctx context.Context, store *node.Store, targetControlPlane return jwtStr, info, nil } -// parseLabelsFlag parses a comma-separated "key=value" list (see -// api/labels.go) into a label map; an empty string means no claims. -func parseLabelsFlag(s string) (map[string]string, error) { - if s == "" { - return nil, nil - } - labels := make(map[string]string) - for _, part := range strings.Split(s, ",") { - part = strings.TrimSpace(part) - if part == "" { - continue - } - k, v, ok := strings.Cut(part, "=") - if !ok { - return nil, fmt.Errorf("invalid label %q: expected key=value", part) - } - key := strings.TrimSpace(k) - if _, exists := labels[key]; exists { - return nil, fmt.Errorf("duplicate label key %q", key) - } - labels[key] = strings.TrimSpace(v) - } - return labels, nil -} - func main() { rootCmd := &cobra.Command{ Use: "sam-node", @@ -327,7 +302,7 @@ func main() { if jwtFlag != "" { logger.Warn("--jwt passes a secret on the command line; prefer --jwt-path") } - labels, err := parseLabelsFlag(labelsFlag) + labels, err := api.ParseLabels(labelsFlag) if err != nil { logger.Fatalf("Invalid --labels: %v", err) } @@ -746,7 +721,7 @@ func main() { } } - labels, err := parseLabelsFlag(labelsFlag) + labels, err := api.ParseLabels(labelsFlag) if err != nil { logger.Fatalf("Invalid --labels: %v", err) } diff --git a/cmd/sam-node/main_test.go b/cmd/sam-node/main_test.go index 76d97034..ee9cb929 100644 --- a/cmd/sam-node/main_test.go +++ b/cmd/sam-node/main_test.go @@ -62,25 +62,6 @@ func TestResolveSocketPath(t *testing.T) { }) } -func TestParseLabelsFlag(t *testing.T) { - if got, err := parseLabelsFlag(""); got != nil || err != nil { - t.Errorf("empty flag: got %v, %v; want nil, nil", got, err) - } - - got, err := parseLabelsFlag(" region=eu , team=platform ,,") - if err != nil || len(got) != 2 || got["region"] != "eu" || got["team"] != "platform" { - t.Errorf("parse should split key=value pairs: got %v, %v", got, err) - } - - if _, err := parseLabelsFlag("noequals"); err == nil { - t.Error("entry without '=' must be rejected") - } - - if _, err := parseLabelsFlag("region=us-east-1,region=us-west-1"); err == nil { - t.Error("duplicate label key must be rejected") - } -} - func TestNormalizeControlPlaneURL(t *testing.T) { cases := map[string]string{ "bananas.sam-mesh.dev": "https://bananas.sam-mesh.dev", diff --git a/development/kind/dex.yaml b/development/kind/dex.yaml index b068d31b..a5529b6a 100644 --- a/development/kind/dex.yaml +++ b/development/kind/dex.yaml @@ -18,6 +18,13 @@ data: - id: sam-console redirectURIs: - ${CONSOLE_REDIRECT_URI} + # The sam-node CLI and the mobile app log in through Dex's device flow or a + # loopback browser callback; Dex rejects both unless listed here. + - /device/callback + # Fixed ports from internal/node/oidc.go and mobile/sam-node-app/lib/main.dart. + - http://127.0.0.1:13000/callback + - http://127.0.0.1:13001/callback + - http://127.0.0.1:13002/callback name: 'SAM Console' public: true enablePasswordDB: true diff --git a/development/kind/run.sh b/development/kind/run.sh index f6d7cdc8..5523a36d 100755 --- a/development/kind/run.sh +++ b/development/kind/run.sh @@ -104,6 +104,7 @@ deploy_chart() { --set controlPlane.allowedAudiences="${ALLOWED_AUDIENCES//,/\\,}" \ --set controlPlane.insecureSkipTlsVerify=true \ --set 'bootstrap.nodeServices={*}' \ + --set 'bootstrap.nodeLabels={*}' \ --set 'bootstrap.nodeMembers={sam:system:authenticated}' \ --set gateway.enabled=true \ --set gateway.className=cloud-provider-kind \ @@ -131,10 +132,20 @@ tmuxs() { tmux -L samsocket -f /dev/null "$@"; } show_cluster_logs() { tmuxs kill-session -t "${SESSION}" 2>/dev/null || true + # Looked up here, not inherited, so `-l` gets the header too; one direct query rather + # than gateway_ip's polling, so the logs still open when a gateway has no address. + local main_ip dex_ip + main_ip="$(kubectl --context "${KCTX}" -n "${NAMESPACE}" get gateway sam-mesh-gateway -o jsonpath='{.status.addresses[0].value}' 2>/dev/null || true)" + dex_ip="$(kubectl --context "${KCTX}" -n "${NAMESPACE}" get gateway sam-mesh-dex-gateway -o jsonpath='{.status.addresses[0].value}' 2>/dev/null || true)" + tmuxs new-session -d -s "${SESSION}" -n mesh "$(logs control-plane 'deploy/sam-mesh-control-plane')" \; set -t "${SESSION}" destroy-unattached off tmuxs split-window -t "${SESSION}:0" "$(logs router 'statefulset/sam-mesh-router')" tmuxs set-option -t "${SESSION}" -g pane-border-status top tmuxs set-option -t "${SESSION}" -g pane-border-format ' #{pane_title} ' + tmuxs set-option -t "${SESSION}" status-position top + tmuxs set-option -t "${SESSION}" status-left-length 250 + tmuxs set-option -t "${SESSION}" status-right '' + tmuxs set-option -t "${SESSION}" status-left " console http://${main_ip:-?}${CONSOLE_BASE_PATH}/ control plane http://${main_ip:-?} dex http://${dex_ip:-?}/dex " # Title the tmux panes in creation order: control-plane, router. titles=(control-plane router) diff --git a/mobile/sam-node-app/README.md b/mobile/sam-node-app/README.md index 3af03812..94175c2f 100644 --- a/mobile/sam-node-app/README.md +++ b/mobile/sam-node-app/README.md @@ -26,19 +26,21 @@ To build the app, you must first compile the Go FFI library and bundle it inside ### 1. Compile FFI Library -Run one of the following commands from the **repository root directory**: +Run one of the following from the **repository root directory**. The `mobile-ffi-*` targets only build into `bin/`; the `cp` step puts the library where the Flutter project loads it from. (`make mobile-app-apk` and `make mobile-app-apk-emulator` do build, copy and release APK in one go.) -* **For Android ARM64 Devices (Physical Phones)**: +* **For Android ARM64 (physical phones, and emulators on Apple Silicon hosts)**: ```bash make mobile-ffi-android + mkdir -p mobile/sam-node-app/android/app/src/main/jniLibs/arm64-v8a + cp bin/android/libsam.so mobile/sam-node-app/android/app/src/main/jniLibs/arm64-v8a/ ``` - *Copies the binary to `mobile/sam-node-app/android/app/src/main/jniLibs/arm64-v8a/libsam.so`* -* **For Android x86_64 Emulators (AVD)**: +* **For Android x86_64 emulators (Intel and Linux hosts)**: ```bash make mobile-ffi-android-x86_64 + mkdir -p mobile/sam-node-app/android/app/src/main/jniLibs/x86_64 + cp bin/android-x86_64/libsam.so mobile/sam-node-app/android/app/src/main/jniLibs/x86_64/ ``` - *Copies the binary to `mobile/sam-node-app/android/app/src/main/jniLibs/x86_64/libsam.so`* * **For iOS Devices**: ```bash diff --git a/mobile/sam-node-app/analysis_options.yaml b/mobile/sam-node-app/analysis_options.yaml index 0d290213..5671d4c5 100644 --- a/mobile/sam-node-app/analysis_options.yaml +++ b/mobile/sam-node-app/analysis_options.yaml @@ -7,6 +7,10 @@ # The following line activates a set of recommended lints for Flutter apps, # packages, and plugins designed to encourage good coding practices. +analyzer: + exclude: + - build/** + - android/** include: package:flutter_lints/flutter.yaml linter: diff --git a/mobile/sam-node-app/android/app/src/debug/AndroidManifest.xml b/mobile/sam-node-app/android/app/src/debug/AndroidManifest.xml index 399f6981..9ff81738 100644 --- a/mobile/sam-node-app/android/app/src/debug/AndroidManifest.xml +++ b/mobile/sam-node-app/android/app/src/debug/AndroidManifest.xml @@ -4,4 +4,7 @@ to allow setting breakpoints, to provide hot reload, etc. --> + + diff --git a/mobile/sam-node-app/android/app/src/debug/res/xml/network_security_config.xml b/mobile/sam-node-app/android/app/src/debug/res/xml/network_security_config.xml new file mode 100644 index 00000000..2439f15c --- /dev/null +++ b/mobile/sam-node-app/android/app/src/debug/res/xml/network_security_config.xml @@ -0,0 +1,4 @@ + + + + diff --git a/mobile/sam-node-app/integration_test/e2e_test.dart b/mobile/sam-node-app/integration_test/e2e_test.dart index ba7adeaa..a041fe1e 100644 --- a/mobile/sam-node-app/integration_test/e2e_test.dart +++ b/mobile/sam-node-app/integration_test/e2e_test.dart @@ -44,7 +44,7 @@ void main() { // 3. Enroll Node against the host control plane const controlPlaneURL = 'http://127.0.0.1:37001'; - final enrollErr = samLib.enroll(dataDir, controlPlaneURL, jwt, true); + final enrollErr = samLib.enroll(dataDir, controlPlaneURL, jwt, true, ''); expect(enrollErr, isNull); // Start local Mock MCP Server inside the Android emulator. It must be diff --git a/mobile/sam-node-app/lib/main.dart b/mobile/sam-node-app/lib/main.dart index cf6f005e..f5030e45 100644 --- a/mobile/sam-node-app/lib/main.dart +++ b/mobile/sam-node-app/lib/main.dart @@ -17,21 +17,24 @@ import 'package:url_launcher/url_launcher.dart'; import 'sam_ffi.dart'; import 'mcp_server.dart'; -String? _isolatedFetchControlPlaneInfo(String url) { +// Isolate.run lives in these top-level functions, not in State methods: a closure +// there shares its context with sibling setState closures, so `this` and its +// DynamicLibrary would be sent to the isolate and rejected as unsendable. +Future _isolatedFetchControlPlaneInfo(String url) => Isolate.run(() { try { return SamNodeLib().fetchControlPlaneInfoJSON(url); } catch (e) { return jsonEncode({'error': 'FFI_ERROR: ${e.toString()}'}); } -} +}); -String? _isolatedEnroll(String dataDir, String controlPlaneText, String jwtText, bool allowLoopback) { +Future _isolatedEnroll(String dataDir, String controlPlaneText, String jwtText, bool allowLoopback, String labelsText) => Isolate.run(() { try { - return SamNodeLib().enroll(dataDir, controlPlaneText, jwtText, allowLoopback); + return SamNodeLib().enroll(dataDir, controlPlaneText, jwtText, allowLoopback, labelsText); } catch (e) { return e.toString(); } -} +}); void main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -71,7 +74,9 @@ class _NodeControlPageState extends State { TextEditingController(text: 'https://bananas.sam-mesh.dev'); final _jwtController = TextEditingController(); final _tokenController = TextEditingController(text: 'secret-token'); - + // Labels are attested at enrollment; changing them requires re-enrolling. + final _labelsController = TextEditingController(); + static const _exposeChannel = MethodChannel('com.example.sam_agent/mesh_expose'); late SamNodeLib _samLib; @@ -92,11 +97,12 @@ class _NodeControlPageState extends State { // External MCP Bridging State: read when the node starts, since services // are declared in the start configuration. - final _externalMcpUrlController = TextEditingController(text: 'http://127.0.0.1:8080'); - final _externalMcpNameController = TextEditingController(text: 'android-remote'); - final _externalMcpDescController = TextEditingController(text: 'External Android Remote Control MCP'); + final _externalMcpUrlController = TextEditingController(); + final _externalMcpNameController = TextEditingController(); + final _externalMcpDescController = TextEditingController(); late SamDartMcpServer _embeddedMcpServer; + bool _starting = false; int _selectedTab = 0; // 0 = Dashboard, 1 = Services @override @@ -116,6 +122,7 @@ class _NodeControlPageState extends State { _controlPlaneController.dispose(); _jwtController.dispose(); _tokenController.dispose(); + _labelsController.dispose(); _externalMcpUrlController.dispose(); _externalMcpNameController.dispose(); _externalMcpDescController.dispose(); @@ -160,7 +167,7 @@ class _NodeControlPageState extends State { try { final controlPlaneUrl = _controlPlaneController.text.trim(); debugPrint('DEBUG: Fetching control plane info from $controlPlaneUrl'); - final infoJson = await Isolate.run(() => _isolatedFetchControlPlaneInfo(controlPlaneUrl)); + final infoJson = await _isolatedFetchControlPlaneInfo(controlPlaneUrl); debugPrint('DEBUG: Control plane info JSON: $infoJson'); if (infoJson == null) { throw Exception('Failed to fetch control plane info'); @@ -363,7 +370,7 @@ class _NodeControlPageState extends State { try { final controlPlaneUrl = _controlPlaneController.text.trim(); debugPrint('DEBUG: Device Login: Fetching control plane info from $controlPlaneUrl'); - final infoJson = await Isolate.run(() => _isolatedFetchControlPlaneInfo(controlPlaneUrl)); + final infoJson = await _isolatedFetchControlPlaneInfo(controlPlaneUrl); if (infoJson == null) throw Exception('Failed to fetch control plane info'); final info = jsonDecode(infoJson); @@ -574,9 +581,8 @@ class _NodeControlPageState extends State { final dataDir = '${appDir.path}/sam_data'; final controlPlaneText = _controlPlaneController.text; final jwtText = _jwtController.text; - final err = await Isolate.run(() { - return _isolatedEnroll(dataDir, controlPlaneText, jwtText, true); - }); + final labelsText = _labelsController.text.trim(); + final err = await _isolatedEnroll(dataDir, controlPlaneText, jwtText, true, labelsText); setState(() { if (err != null) { @@ -622,13 +628,37 @@ class _NodeControlPageState extends State { } Future _start() async { + // Backstop for the disabled button while a start is in flight. + if (_starting) return; + setState(() { + _starting = true; + }); + try { + await _startNode(); + } finally { + if (mounted) { + setState(() { + _starting = false; + }); + } + } + } + + Future _startNode() async { final appDir = await getApplicationDocumentsDirectory(); final dataDir = '${appDir.path}/sam_data'; // The embedded MCP backend must be listening before the node starts: // services are declared in the start configuration and probed at startup, // there is no runtime registration. - await _embeddedMcpServer.start(port: 9090); + try { + await _embeddedMcpServer.start(port: 9090); + } catch (e) { + setState(() { + _status = 'Start failed: embedded MCP server: $e'; + }); + return; + } final services = >[ { @@ -656,6 +686,7 @@ class _NodeControlPageState extends State { 'apiToken': _tokenController.text, 'allowLoopback': true, 'enableRelay': false, + 'labels': _labelsController.text.trim(), 'services': services, }); @@ -688,7 +719,7 @@ class _NodeControlPageState extends State { _pollingTimer?.cancel(); _embeddedMcpServer.stop(); final err = _samLib.stop(); - + // Stop Android Foreground Service try { _exposeChannel.invokeMethod('stopBackgroundService'); @@ -895,6 +926,7 @@ class _NodeControlPageState extends State { enabled: !isRunning, decoration: const InputDecoration( labelText: 'Description', + hintText: 'External Android Remote Control MCP', border: OutlineInputBorder(), ), ), @@ -929,6 +961,15 @@ class _NodeControlPageState extends State { ), ), const SizedBox(height: 20), + TextField( + controller: _labelsController, + decoration: const InputDecoration( + labelText: 'Labels (key=value, comma-separated)', + border: OutlineInputBorder(), + hintText: 'region=eu-west-1', + ), + ), + const SizedBox(height: 20), ElevatedButton.icon( onPressed: _loggingIn ? null : _loginAndEnroll, icon: _loggingIn @@ -1098,9 +1139,9 @@ class _NodeControlPageState extends State { children: [ Expanded( child: ElevatedButton.icon( - onPressed: isRunning ? null : _start, + onPressed: (isRunning || _starting) ? null : _start, icon: const Icon(Icons.play_arrow), - label: const Text('Start'), + label: Text(_starting ? 'Starting…' : 'Start'), style: ElevatedButton.styleFrom( backgroundColor: Colors.green, foregroundColor: Colors.white, diff --git a/mobile/sam-node-app/lib/mcp_server.dart b/mobile/sam-node-app/lib/mcp_server.dart index fd8b32f8..a111e8d0 100644 --- a/mobile/sam-node-app/lib/mcp_server.dart +++ b/mobile/sam-node-app/lib/mcp_server.dart @@ -21,30 +21,30 @@ class SamDartMcpServer { /// backs is declared in the node's start configuration; there is no /// runtime registration, so this server must be listening before the node /// starts and probes it. + /// + /// Throws if the port cannot be bound. Future start({int port = 9090}) async { - try { - _server = await HttpServer.bind(InternetAddress.loopbackIPv4, port); - debugPrint('SAM Dart MCP Server listening on port $port'); - - _server!.listen((HttpRequest request) async { - // Handle CORS if needed, but since it's loopback and called by Go, maybe not strict - if (request.method == 'GET') { - _handleSse(request); - } else if (request.method == 'POST') { - _handlePost(request); - } else { - request.response.statusCode = HttpStatus.methodNotAllowed; - await request.response.close(); - } - }); - } catch (e) { - debugPrint('Failed to start Dart MCP Server: $e'); - } + if (_server != null) throw StateError('already started'); + _server = await HttpServer.bind(InternetAddress.loopbackIPv4, port); + debugPrint('SAM Dart MCP Server listening on port $port'); + + _server!.listen((HttpRequest request) async { + // Handle CORS if needed, but since it's loopback and called by Go, maybe not strict + if (request.method == 'GET') { + _handleSse(request); + } else if (request.method == 'POST') { + _handlePost(request); + } else { + request.response.statusCode = HttpStatus.methodNotAllowed; + await request.response.close(); + } + }); } /// Stops the server Future stop() async { await _server?.close(force: true); + _server = null; _sseClients.clear(); debugPrint('SAM Dart MCP Server stopped'); } diff --git a/mobile/sam-node-app/lib/sam_ffi.dart b/mobile/sam-node-app/lib/sam_ffi.dart index 7b7aca10..e6cf9118 100644 --- a/mobile/sam-node-app/lib/sam_ffi.dart +++ b/mobile/sam-node-app/lib/sam_ffi.dart @@ -17,12 +17,14 @@ typedef EnrollNodeC = ffi.Pointer Function( ffi.Pointer dataDir, ffi.Pointer controlPlaneURL, ffi.Pointer jwt, - ffi.Int8 allowLoopback); + ffi.Int8 allowLoopback, + ffi.Pointer labels); typedef EnrollNodeDart = ffi.Pointer Function( ffi.Pointer dataDir, ffi.Pointer controlPlaneURL, ffi.Pointer jwt, - int allowLoopback); + int allowLoopback, + ffi.Pointer labels); typedef FetchControlPlaneInfoJSONC = ffi.Pointer Function(ffi.Pointer controlPlaneURL); typedef FetchControlPlaneInfoJSONDart = ffi.Pointer Function(ffi.Pointer controlPlaneURL); @@ -94,17 +96,19 @@ class SamNodeLib { return goID; } - String? enroll(String dataDir, String controlPlaneURL, String jwt, bool allowLoopback) { + String? enroll(String dataDir, String controlPlaneURL, String jwt, bool allowLoopback, String labels) { final cDataDir = dataDir.toNativeUtf8(); final cControlPlaneURL = controlPlaneURL.toNativeUtf8(); final cJWT = jwt.toNativeUtf8(); final cAllowLoopback = allowLoopback ? 1 : 0; + final cLabels = labels.toNativeUtf8(); - final cErr = _enrollNode(cDataDir, cControlPlaneURL, cJWT, cAllowLoopback); + final cErr = _enrollNode(cDataDir, cControlPlaneURL, cJWT, cAllowLoopback, cLabels); calloc.free(cDataDir); calloc.free(cControlPlaneURL); calloc.free(cJWT); + calloc.free(cLabels); if (cErr.address == 0) return null; final goErr = cErr.toDartString(); diff --git a/mobile/sam-node-ffi/ffi/ffi.go b/mobile/sam-node-ffi/ffi/ffi.go index ea26a847..af245243 100644 --- a/mobile/sam-node-ffi/ffi/ffi.go +++ b/mobile/sam-node-ffi/ffi/ffi.go @@ -56,8 +56,11 @@ type MobileConfig struct { LogLevel string `json:"logLevel"` DiscoveryInterval string `json:"discoveryInterval"` ListenAddrs string `json:"listenAddrs"` // comma-separated - AllowLoopback bool `json:"allowLoopback"` - EnableRelay bool `json:"enableRelay"` + // Labels are comma-separated key=value claims (same syntax as the CLI + // --labels flag); they are attested only at enrollment. + Labels string `json:"labels"` + AllowLoopback bool `json:"allowLoopback"` + EnableRelay bool `json:"enableRelay"` // Services this node exposes, declared at start like the node config // file's services block; there is no runtime registration. Services []MobileService `json:"services,omitempty"` @@ -85,6 +88,18 @@ func StartNode(configJSON string) error { return fmt.Errorf("failed to parse config JSON: %w", err) } + labels, err := api.ParseLabels(config.Labels) + if err != nil { + return fmt.Errorf("invalid labels: %w", err) + } + // The app only shows the labels field on its enrollment screen, so an + // empty start config reuses the labels the node enrolled with. + if len(labels) == 0 { + if labels, err = loadEnrolledLabels(config.DataDir); err != nil { + return err + } + } + lvl := golog.LevelInfo if config.LogLevel != "" { if l, err := golog.LevelFromString(config.LogLevel); err == nil { @@ -193,6 +208,7 @@ func StartNode(configJSON string) error { Store: store, BannedPeerIDs: bannedPeerIDs, MeshID: meshID, + Labels: labels, DiscoveryInterval: discoveryInterval, ListenAddrs: listenAddrs, EnableRelay: config.EnableRelay, @@ -311,9 +327,33 @@ func GetNodeID() string { return "" } -// EnrollNode enrolls a node. -func EnrollNode(dataDir string, controlPlaneURL string, jwt string, allowLoopback bool) error { +// labelsFile keeps the enrolled labels in the app's data directory. The CLI +// has no equivalent: --labels is passed on every run. +const labelsFile = "labels" + +func loadEnrolledLabels(dataDir string) (map[string]string, error) { + raw, err := os.ReadFile(filepath.Join(dataDir, labelsFile)) + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("failed to read labels: %w", err) + } + return api.ParseLabels(string(raw)) +} + +// EnrollNode enrolls a node. Labels use the CLI --labels syntax and are +// minted into the node's Biscuit here — changing them requires re-enrolling. +func EnrollNode(dataDir string, controlPlaneURL string, jwt string, allowLoopback bool, labels string) error { + parsedLabels, err := api.ParseLabels(labels) + if err != nil { + return fmt.Errorf("invalid labels: %w", err) + } + _ = os.MkdirAll(dataDir, 0700) + if err := os.WriteFile(filepath.Join(dataDir, labelsFile), []byte(labels), 0600); err != nil { + return fmt.Errorf("failed to save labels: %w", err) + } logFilePath := filepath.Join(dataDir, "node.log") golog.SetupLogging(golog.Config{ File: logFilePath, @@ -354,6 +394,7 @@ func EnrollNode(dataDir string, controlPlaneURL string, jwt string, allowLoopbac Store: store, AllowLoopback: allowLoopback, ListenAddrs: listenAddrs, + Labels: parsedLabels, }) if err != nil { return fmt.Errorf("failed to create node for enrollment: %w", err) diff --git a/mobile/sam-node-ffi/ffi/ffi_test.go b/mobile/sam-node-ffi/ffi/ffi_test.go index 559db114..37186b72 100644 --- a/mobile/sam-node-ffi/ffi/ffi_test.go +++ b/mobile/sam-node-ffi/ffi/ffi_test.go @@ -72,11 +72,13 @@ func TestMobileFFILifecycle(t *testing.T) { println("--- MOCK ROUTER: wrote AuthResponse success with valid biscuit") }) + var enrolledLabels map[string]string mux := http.NewServeMux() mux.HandleFunc("/register", func(w http.ResponseWriter, r *http.Request) { body, _ := io.ReadAll(r.Body) var req api.EnrollRequest _ = proto.Unmarshal(body, &req) + enrolledLabels = req.Labels biscuitBytes := mintMockBiscuit(t, req.PeerId, cpPrivKey, api.RoleNode) resp := &api.EnrollResponse{ @@ -104,10 +106,16 @@ func TestMobileFFILifecycle(t *testing.T) { // 2. Mobile Enrollment tmpDir := t.TempDir() - err = EnrollNode(tmpDir, httpServer.URL, "dummy-jwt", true) + err = EnrollNode(tmpDir, httpServer.URL, "dummy-jwt", true, "region=eu-west-1") if err != nil { t.Fatalf("EnrollNode failed: %v", err) } + if enrolledLabels["region"] != "eu-west-1" { + t.Fatalf("Expected label region=eu-west-1 in enroll request, got %v", enrolledLabels) + } + if got, err := loadEnrolledLabels(tmpDir); err != nil || got["region"] != "eu-west-1" { + t.Fatalf("Expected enrolled labels persisted for StartNode, got %v, %v", got, err) + } // 3. Mobile Node Start cfg := MobileConfig{ @@ -117,6 +125,7 @@ func TestMobileFFILifecycle(t *testing.T) { BindAddr: "127.0.0.1:0", // random free port ApiToken: "test-token", AllowLoopback: true, + // No labels: StartNode must fall back to the enrolled ones. } cfgBytes, _ := json.Marshal(cfg) @@ -136,6 +145,13 @@ func TestMobileFFILifecycle(t *testing.T) { } } +func TestStartNodeRejectsInvalidLabels(t *testing.T) { + if err := StartNode(`{"labels": "no-equals"}`); err == nil { + _ = StopNode() + t.Fatal("expected StartNode to reject invalid labels") + } +} + func mintMockBiscuit(t *testing.T, peerID string, priv ed25519.PrivateKey, role string) []byte { builder := biscuit.NewBuilder(priv) if err := builder.AddAuthorityFact(biscuit.Fact{ diff --git a/mobile/sam-node-ffi/main.go b/mobile/sam-node-ffi/main.go index a99d4718..622526f5 100644 --- a/mobile/sam-node-ffi/main.go +++ b/mobile/sam-node-ffi/main.go @@ -55,13 +55,14 @@ func GetNodeID() *C.char { } //export EnrollNode -func EnrollNode(dataDir *C.char, controlPlaneURL *C.char, jwt *C.char, allowLoopback C.char) *C.char { +func EnrollNode(dataDir *C.char, controlPlaneURL *C.char, jwt *C.char, allowLoopback C.char, labels *C.char) *C.char { goDataDir := C.GoString(dataDir) goControlPlaneURL := C.GoString(controlPlaneURL) goJWT := C.GoString(jwt) goAllowLoopback := allowLoopback != 0 + goLabels := C.GoString(labels) - err := ffi.EnrollNode(goDataDir, goControlPlaneURL, goJWT, goAllowLoopback) + err := ffi.EnrollNode(goDataDir, goControlPlaneURL, goJWT, goAllowLoopback, goLabels) if err != nil { return C.CString(err.Error()) } diff --git a/site/content/docs/development/mobile.md b/site/content/docs/development/mobile.md index 67ae9d64..91c53dbb 100644 --- a/site/content/docs/development/mobile.md +++ b/site/content/docs/development/mobile.md @@ -41,7 +41,7 @@ To run `sam-node` on mobile with near-zero codebase maintenance, we avoid rewrit 1. **Go FFI Binding Package (`mobile/sam-node-ffi/`)**: Contains CGO-exported functions (`StartNode`, `StopNode`, `EnrollNode`, `GetNodeID`, `FreeString`) which compile into a C-shared library (`.so`) or static archive (`.a`). 2. **Flutter App (`mobile/sam-node-app/`)**: A cross-platform app containing: - `lib/sam_ffi.dart`: The Dart FFI wrapper loading the Go library and exposing Dart methods. - - `lib/main.dart`: Simple control UI to enroll and start/stop the background node. + - `lib/main.dart`: Simple control UI to enroll and start/stop the background node. The enrollment screen accepts optional labels (comma-separated `key=value`, same syntax as the CLI `--labels` flag). The control plane mints them into the node's Biscuit at enrollment; the app keeps a copy in its data directory and sends it again on every start and Biscuit renewal, so changing labels means re-enrolling. Unlike the CLI, where `sam-node run --labels` has to be passed on every run, the app remembers them. 3. **Local Loopback Communication**: - The Flutter Dart environment controls the node lifecycle (construction, starting, stopping) via FFI. - Any actual tool registration, discovery, or mesh API queries are performed using standard HTTP JSON-RPC calls over local loopback (`127.0.0.1`) to the `sam-node` sidecar API. @@ -65,7 +65,7 @@ make mobile-ffi-android ``` ### 3. Build Android Emulator FFI Library -Compiles `bin/android/libsam.so` targeting Android x86_64 emulator environments: +Compiles `bin/android-x86_64/libsam.so` for x86_64 emulator images (Intel and Linux hosts). Emulators on Apple Silicon run arm64-v8a images; use target 2 for those: ```bash make mobile-ffi-android-x86_64 ``` @@ -89,6 +89,8 @@ make mobile-app-apk To make changes to `sam-node` and run them on a mobile device: ### Android Setup +The `mobile-ffi-*` targets only build into `bin/`; copy the library into `jniLibs/` yourself (or use `make mobile-app-apk`, which does both and builds the APK). For an x86_64 emulator, swap in `mobile-ffi-android-x86_64`, `bin/android-x86_64/libsam.so` and `jniLibs/x86_64`. + 1. Compile the Android ARM64 FFI shared library: ```bash make mobile-ffi-android