Skip to main content

xray_inspector 0.1.6 copy "xray_inspector: ^0.1.6" to clipboard
xray_inspector: ^0.1.6 copied to clipboard

Pure Dart inspector package — network, log, and key-value inspectors plus the HTTP server for remote inspection. Works with Flutter and Dart backends (Serverpod, Relic, Dart Frog, Shelf).

xray_inspector #

Core inspector package — network, log, and key-value inspectors plus the HTTP server for remote inspection.

For full documentation and guides, see the xRay docs.

Setting up for debug builds only #

The inspector is a development tool: set it up only in builds that never reach end users. In a Flutter app, guard the whole setup with kReleaseMode. The connection is protected with a pairing code unless you set a password or trusted desktops:

import 'package:flutter/foundation.dart';

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  if (!kReleaseMode) {
    final networkInspector = XRayNetworkInspector(enableHttpOverride: true);
    final logInspector = XRayLogInspector();
    await XRayInspectorServer(
      config: XRayInspectorServerConfig(
        password: const String.fromEnvironment('XRAY_PASSWORD'),
        deviceName: () => 'Pixel 8',
        appName: () => 'Shop',
        inspectors: [networkInspector, logInspector],
      ),
    ).start();
  }
  runApp(const MyApp());
}

In a Dart app without Flutter, use const bool.fromEnvironment('dart.vm.product') instead of kReleaseMode.

Without the guard, the inspectors stay safe but not free: in release and profile builds the server does not start and the inspectors record nothing, see Inspecting release builds.

Inspecting release builds #

In release and profile builds the server only starts when you set allowInReleaseMode: true together with a password or trustedDesktopKeys, for example for an internal QA build:

XRayInspectorServerConfig(
  allowInReleaseMode: true,
  trustedDesktopKeys: [/* ... */],
  inspectors: inspectors,
)
  • Without allowInReleaseMode, or without a password or trusted desktops, start() prints why to the device log and returns without starting. It never throws, so a forgotten guard cannot crash a store build.
  • Until such a server has started, the inspectors record nothing and the HTTP override is not installed, so calls made before start() completes are not shown. XRayNetworkInspector.isRecording tells whether they record.
  • Never enable allowInReleaseMode in builds that reach end users or public beta testers.

Privacy and personal data #

Everything the inspectors capture (requests and responses with their headers, logs, key-value stores such as secure storage, files) stays in the app's memory and goes only to the xRay desktop apps that connect to it on your network. Neither xRay nor impaktfull ever receives it. You decide what is captured and who connects, so for the personal data in it you are the controller.

  • Prefer test accounts over real user accounts.
  • Keep the inspector protected (pairing code, password or trusted desktops); do not set allowUnprotected on shared networks.
  • Redact before you export or share a session: exported files can contain tokens and personal data.
  • Never enable allowInReleaseMode in builds that reach end users or public beta testers.

Capturing all HTTP traffic #

Pass enableHttpOverride: true to capture every HTTP request the app makes through dart:io's HttpClient: Dio, package:http and any other client built on dart:io. You don't have to change those clients:

final networkInspector = XRayNetworkInspector(enableHttpOverride: true);
  • Each call is recorded with its method, URL, request and response headers and bodies, status code, duration and error.
  • Only one XRayNetworkInspector can enable it; a second one throws a StateError.
  • It is ignored on web, where dart:io is not available.
  • Together with xray_network_dio_interceptor, each Dio call is recorded once, by the Dio interceptor.
  • WebSockets opened through dart:io are recorded too, see Recording WebSocket connections.
  • It covers the main isolate only; see Capturing calls from background isolates for the others.
  • Binary bodies are recorded as their size. Protocol Buffers bodies (application/x-protobuf, application/protobuf, application/proto, gRPC-web, gRPC and Connect) are kept, at most 1 MB like text bodies, as a data URI (data:application/x-protobuf;base64,…) that the xRay app decodes. Native gRPC over HTTP/2 (package:grpc's default channel) does not use HttpClient and is not captured.

Masking credentials #

Credential headers are masked before a call is stored, so tokens and session cookies never reach a connected desktop app or an exported session file. By default these are Authorization, Proxy-Authorization, Cookie, Set-Cookie and X-Api-Key, matched case-insensitively. A masked value keeps its scheme and, for long secrets, the last 4 characters, so you can still tell two tokens apart: Bearer …a1b2.

This applies to every call the network inspector records: through the HTTP override, background isolates, WebSockets, xray_network_dio_interceptor and xray_serverpod_interceptor. Choose your own headers, or pass an empty set to record them in full:

final networkInspector = XRayNetworkInspector(
  enableHttpOverride: true,
  redactHeaders: {...XRayNetworkInspector.defaultRedactedHeaders, 'X-Auth-Token'},
);

Bodies are recorded as they are. To hide secrets in them, such as the tokens of a sign-in response, pass redactBody. It gets the call and each body (a protobuf body as its data URI string) and returns what to store, or null to store nothing:

XRayNetworkInspector(
  redactBody: (call, body) => call.uri.endsWith('/oauth/token') ? null : body,
);

Announcing the app #

Every time the inspector server starts, including after a hot restart, the app announces itself on the local network (UDP, port discoveryPort + 10, so 7791 by default) with its HTTP port, device name and app name. The xRay app then lists it in Add connection right away, offers to connect, or reconnects to it automatically when you turned that on for the app.

Announcements are sent as a broadcast and to 127.0.0.1, a few times in the first seconds after start. Like discovery, they are not authenticated. Turn them off with:

XRayInspectorServerConfig(
  announceOnStart: false,
  // ...
)

The xRay app still finds the app when it scans. Changing discoveryPort changes the announce port with it, so set the same discovery port in the xRay app.

Recording WebSocket connections #

With XRayNetworkInspector(enableHttpOverride: true), every WebSocket opened through dart:io — WebSocket.connect, and WebSocketChannel.connect / IOWebSocketChannel from web_socket_channel on mobile and desktop — is recorded automatically: its messages in both directions, open and close times, close code and reason. Create the inspector before the app opens its first WebSocket: dart:io creates the client behind WebSocket.connect once and keeps it.

Other connections (Flutter web, custom transports, a WebSocket opened before xRay started) can be recorded explicitly. Start recording before connecting and report each event:

final recorder = networkInspector.recordWebSocket(
  uri,
  protocols: ['graphql-transport-ws'],
);
final channel = WebSocketChannel.connect(uri, protocols: ['graphql-transport-ws']);
await channel.ready;
recorder.opened();
channel.stream.listen(
  recorder.received,
  onDone: () => recorder.closed(code: channel.closeCode, reason: channel.closeReason),
  onError: recorder.failed,
);
// Wherever the app sends a message:
recorder.sent(message);
channel.sink.add(message);

An explicitly recorded connection is never captured a second time by the override. The xRay app shows the connection as a WS call with its messages; graphql-transport-ws and subscriptions-transport-ws messages are shown as GraphQL subscriptions. The newest 500 messages per connection are kept, each up to 32 KB of text.

Running several apps on one device #

Leave httpPort out of XRayInspectorServerConfig and every app on a device gets its own port: the inspector uses 7780 when it is free, otherwise the next free port of 7782–7790, and otherwise any free port. It prints the port it uses:

[xray_inspector] Inspector server listening on HTTP port 7782 (7780 is in use by another app).

All apps share the discovery port (7781), so one scan in the xRay app lists each of them with its appName. While the server runs, XRayInspectorServer.httpPort is the port it actually listens on.

Setting httpPort yourself, also to 7780, fixes the port: if another app already uses it, start() throws an XRayInspectorPortInUseException (a SocketException) naming the port, and the inspector never moves to another one. XRayInspectorServerConfig.hasExplicitHttpPort tells the two cases apart.

XRayInspectorServerConfig(inspectors: inspectors);                 // automatic
XRayInspectorServerConfig(httpPort: 7790, inspectors: inspectors); // exactly 7790

If the discovery port cannot be opened on a platform, the app keeps running and prints why; connect from the xRay app manually with the device's IP address and the printed HTTP port.

Capturing calls from background isolates #

enableHttpOverride: true captures the HttpClient calls of the main isolate only: every isolate has its own HttpOverrides. To capture the calls of a background isolate (Isolate.run, compute, Isolate.spawn), prepare a capture in the main isolate before spawning, and add one line at the start of the isolate's code:

final xrayCapture = XRayIsolateCapture.prepare();
final users = await Isolate.run(() {
  xrayCapture.install();
  return fetchUsers();
});

With compute or Isolate.spawn and a top-level function, send the capture along in the message:

final users = await compute(parseUsers, (XRayIsolateCapture.prepare(), json));

List<User> parseUsers((XRayIsolateCapture, String) message) {
  final (xrayCapture, json) = message;
  xrayCapture.install();
  ...
}

The calls show up in the same network inspector as all other calls, labelled with the isolate's debugName (or background when it has none). Pass a debugName to Isolate.run to tell your isolates apart.

  • XRayIsolateCapture.prepare() needs an XRayNetworkInspector(enableHttpOverride: true). Without one, for example in a release build without xRay or on the web, install() does nothing.
  • Calling install() more than once in the same isolate records every call once.
  • A call that is still running when its isolate exits is shown as failed.
  • Prepare the capture in the main isolate, not inside the isolate: the isolate has no inspector of its own.

Pairing with the xRay desktop app #

The inspector is protected by default. Without a password or trustedDesktopKeys, the server makes up a pairing code and prints it when it starts:

[xray_inspector] Pairing code: k7q2-m9xa-4ftr. The xRay desktop app asks for it once and remembers it; it stays the same across restarts.
  • The xRay desktop app asks for the pairing code the first time it connects and remembers it per app and device, like a password. Case, spaces and dashes do not matter.
  • The code is kept on the device (in the app's temporary folder, or ~/.config/xray_inspector on Linux), so it stays the same across hot restarts and app restarts. When the operating system clears that folder or the app is reinstalled, the app prints a new code and the desktop app asks again.
  • XRayInspectorServer.pairingCode returns the code while the server runs, for example to show it in a debug menu.
  • Everything else works as with a password: discovery shows the device name, app name and a lock, the traffic is encrypted, and wrong attempts are limited.
  • A pairing code does not count for release builds: those need a password or trustedDesktopKeys.
  • Desktop apps from before pairing codes existed ask for a password: enter the pairing code.

Turning protection off #

To serve the inspector unprotected and unencrypted, as before pairing codes, set allowUnprotected:

XRayInspectorServerConfig(
  allowUnprotected: true,
  inspectors: inspectors,
)

Anyone on the same network can then connect to your app and read and change what the inspectors capture (including Authorization headers and secure storage). The server prints a warning every time it starts. allowUnprotected together with a password or trustedDesktopKeys makes start() throw an ArgumentError.

Protecting the inspector with a password #

A password of your own replaces the pairing code: everyone who connects types the same password, which is handy for a team. On shared networks such as offices, co-working spaces and conferences, use a long one:

final server = XRayInspectorServer(
  config: XRayInspectorServerConfig(
    password: const String.fromEnvironment('XRAY_PASSWORD'),
    deviceName: () => 'Pixel 8',
    appName: () => 'Shop',
    inspectors: [networkInspector, logInspector],
  ),
);

null or an empty string means no password, so the String.fromEnvironment line above uses a pairing code unless you build with --dart-define=XRAY_PASSWORD=....

With a password:

  • The xRay desktop app asks for the password the first time it connects, and remembers it per app and device in the operating system's credential store (Keychain, Windows Credential Manager or the Secret Service on Linux). It can be forgotten from the connection's Server Settings.
  • Discovery still lists the app with its device name, app name and a lock. Nothing else is visible without the password.
  • Every request, response and event between the app and the desktop app is encrypted and authenticated (X25519 key exchange, ChaCha20-Poly1305), including the request paths. The password itself never travels over the network: both sides prove they know a key derived from it with Argon2id.
  • After 5 wrong attempts in a row from one address, that address has to wait 30 seconds, and longer after each further lockout (1, 5, 15 minutes, then an hour) until the right password is entered. After 10 wrong attempts from all addresses together, every address waits 30 seconds, except the ones that already signed in.
  • Each address can start a limited number of handshakes in a row, whether the app uses a password or trusted desktops, so a flood of connection attempts cannot slow the app down.
  • Desktop apps from before passwords existed are refused with a message asking to update them.

Things to know:

  • Use a long, random password: at least 12 characters, or four or more random words. Someone who listens to the network learns nothing from the handshake, but someone who can pretend to be your app towards a desktop app that knows the password can try guesses offline. A long random password makes that impractical. The server prints a warning when it starts with a shorter password.
  • The password protects the connection, not the build. Anyone who has your app binary or build configuration can read it.
  • Request sizes and timing remain visible on the network.
  • Changing the password takes effect when the app restarts; desktop apps that remembered the old one ask for the new one.
  • Starting the server takes a little longer with a password, because the key is derived once in a background isolate.

Only allowing trusted desktops #

Instead of a password, you can list exactly which xRay desktop apps may connect. Every desktop app installation has its own key pair; you add its public key to your app, and nothing secret is ever shared. Adding or removing a person means adding or removing one key.

Collecting keys. Each person opens the xRay desktop app, goes to Settings → Desktop key and clicks Copy public key, then sends you the key. A desktop app that is refused also shows a Copy public key button. The settings show a short fingerprint (3f9a 1c2e …) next to the key, so you can check with the person that the key you received is theirs.

Configuring the app:

final server = XRayInspectorServer(
  config: XRayInspectorServerConfig(
    trustedDesktopKeys: [
      'xray-ed25519:OuEGiu_1uTQf4v0APaRfwkxiBX0kQgHPcSzbHQPBMGc', // Koen, MacBook
      'xray-ed25519:YMtS94PruSXHC7X1IykcsH8dWQTcHTOBvVRx-waNY-s', // Jo, Linux laptop
    ],
    deviceName: () => 'Pixel 8',
    appName: () => 'Shop',
    inspectors: [networkInspector, logInspector],
  ),
);

A key is xray-ed25519: followed by 43 characters. With a list:

  • Listed desktop apps connect without being asked anything, also after a hot restart.
  • Every other desktop app is refused with "This app only accepts trusted desktops" and gets no data.
  • Discovery shows the device name, app name and a lock, nothing else.
  • All traffic is encrypted exactly as with a password (X25519 key exchange, ChaCha20-Poly1305). The desktop app proves it holds its private key by signing that connection's handshake (Ed25519), so a recorded handshake cannot be replayed.
  • Desktop apps from before trusted desktops existed are refused with a message asking to update them.

Errors you can get from start():

  • An ArgumentError naming the entry when a key is not valid (cut off, or not copied from xRay).
  • An ArgumentError when you set both password and trustedDesktopKeys: use one or the other.

An empty list means no list, so the app uses a pairing code.

Things to know:

  • Public keys are not secret: they can live in your repository. Anyone who has your app binary can read them but cannot connect with them.
  • Each computer has its own key. Someone with two computers sends two keys. Installing a new computer means a new key.
  • Updating or reinstalling the desktop app on the same computer and user account keeps the key, because it lives in the operating system's credential store (Keychain, Windows Credential Manager or the Secret Service on Linux) and is never shown or exported. Regenerate key in the same settings creates a new one; the old one stops working wherever only it was listed.
  • Removing a key from the list blocks that desktop app the next time the app starts.
  • The app itself has no key, so a desktop app cannot tell your app from something on the network that pretends to be it. Such an impostor cannot get your app's data, but it could show made-up data.
  • Request sizes and timing remain visible on the network.

Disclaimer: This is a commercial package. To use this package, you need to have either a commercial xRay license or a free xRay Community License. The xRay desktop apps for macOS, Windows and Linux are the authorized UI clients for this library. For more details, please check the LICENSE file.

1
likes
0
points
889
downloads

Documentation

Documentation

Publisher

verified publisherxrayinspector.dev

Weekly Downloads

Pure Dart inspector package — network, log, and key-value inspectors plus the HTTP server for remote inspection. Works with Flutter and Dart backends (Serverpod, Relic, Dart Frog, Shelf).

Repository (GitHub)
View/report issues

License

unknown (license)

Dependencies

cryptography, ffi, relic, xray_inspector_core

More

Packages that depend on xray_inspector