Skip to content

Commit 9694e71

Browse files
committed
Add bridge-ready Iroh worker integration
1 parent f4772e8 commit 9694e71

8 files changed

Lines changed: 227 additions & 3 deletions

File tree

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,9 @@ The `launch:` location scheme starts the worker once behind a flock-coordinated
133133

134134
## Example worker
135135

136+
For authenticated `iroh://` and HTTP-semantics `httpi://` deployments, see
137+
[Iroh workers and clients](docs/iroh.md).
138+
136139
The [`vgi-example-worker`](vgi-example-worker/) module (not published) is a complete worker with 90+ functions — scalar, table, aggregate, table-in/out, buffering, partitioned, multi-branch, transactional — that serves the canonical VGI integration suite. It is the best place to look for working patterns of any feature.
137140

138141
## Related projects

build.gradle.kts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ plugins {
88

99
allprojects {
1010
group = "farm.query"
11-
version = "0.27.0"
11+
version = "0.27.1"
1212

1313
repositories {
1414
mavenCentral()

docs/iroh.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# Iroh workers and clients
2+
3+
Java VGI workers use the language-neutral `vgi-iroh-bridge`. Both integration
4+
styles are one worker call:
5+
6+
```java
7+
worker.runIrohTcpUpstream(
8+
"127.0.0.1", 9400, 0, new IrohBridgeOptions("production"));
9+
10+
worker.runHttp(
11+
"127.0.0.1", 9401, new IrohBridgeOptions("production"));
12+
```
13+
14+
The ordinary `runFromArgs` entry point accepts the same portable flags as the
15+
other VGI frameworks:
16+
17+
```console
18+
worker --iroh-raw-upstream 127.0.0.1:9400 --iroh-issuer production
19+
worker --http --port 9401 --iroh-issuer production
20+
```
21+
22+
Repeat `--iroh-trusted-proxy <exact-IP>` to replace the loopback trust list.
23+
Use `--iroh-observe` to expose verified EndpointId evidence without promoting
24+
it to the application principal. The worker upstream must remain unreachable
25+
except through that trusted bridge.
26+
27+
For clients, add the optional `farm.query:vgirpc-iroh` module and use
28+
`HttpRpcConnection.irohBuilder("httpi://<endpoint-id>", options)` for HTTP
29+
semantics, or `IrohRpcConnection.connect(...)` for stateful Arrow-mux. Both use
30+
the official JVM Iroh bindings and accept stable identity, custom/private relay,
31+
direct-address, cancellation, and timeout configuration.

settings.gradle.kts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,12 @@ if (vgiRpcJavaDir.isDirectory) {
2121
// (group=farm.query, see ../Development/vgi-rpc-java/build.gradle.kts).
2222
substitute(module("farm.query:vgirpc")).using(project(":vgirpc"))
2323
substitute(module("farm.query:vgirpc-oauth")).using(project(":vgirpc-oauth"))
24+
substitute(module("farm.query:vgirpc-iroh")).using(project(":vgirpc-iroh"))
2425
// Back-compat with callers that still use the legacy
2526
// farm.query.vgirpc:* coordinate naming.
2627
substitute(module("farm.query.vgirpc:vgirpc")).using(project(":vgirpc"))
2728
substitute(module("farm.query.vgirpc:vgirpc-oauth")).using(project(":vgirpc-oauth"))
29+
substitute(module("farm.query.vgirpc:vgirpc-iroh")).using(project(":vgirpc-iroh"))
2830
}
2931
}
3032
}

vgi/build.gradle.kts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ plugins {
55

66
dependencies {
77
// farm.query is the published group (see ../build.gradle.kts allprojects).
8-
api("farm.query:vgirpc:0.23.0")
8+
api("farm.query:vgirpc:0.24.0")
99
implementation("org.slf4j:slf4j-api:2.0.17")
1010
// Cross-process aggregate state store. DuckDB spawns multiple worker
1111
// subprocesses for parallel aggregation; SQLite's file locking gives us
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
// Copyright 2026 Query Farm LLC - https://query.farm
2+
3+
package farm.query.vgi;
4+
5+
import farm.query.vgirpc.http.HttpServer;
6+
import farm.query.vgirpc.http.IrohPeerIdentityProviders;
7+
import farm.query.vgirpc.identity.PeerAuthenticationPolicies;
8+
import farm.query.vgirpc.transport.TcpServerOptions;
9+
10+
import java.util.LinkedHashSet;
11+
import java.util.List;
12+
import java.util.Set;
13+
14+
/** Trust boundary between a loopback VGI worker and {@code vgi-iroh-bridge}. */
15+
public record IrohBridgeOptions(
16+
String issuer,
17+
Set<String> trustedProxyAddresses,
18+
boolean authenticate) {
19+
20+
/** Authenticate bridge-verified EndpointIds from a loopback bridge. */
21+
public IrohBridgeOptions(String issuer) {
22+
this(issuer, Set.of("127.0.0.1"), true);
23+
}
24+
25+
public IrohBridgeOptions {
26+
if (issuer == null || issuer.isBlank()) {
27+
throw new IllegalArgumentException("Iroh bridge issuer is required");
28+
}
29+
trustedProxyAddresses = trustedProxyAddresses == null || trustedProxyAddresses.isEmpty()
30+
? Set.of("127.0.0.1")
31+
: Set.copyOf(trustedProxyAddresses);
32+
}
33+
34+
/** Apply identity forwarding to an HTTP worker configuration. */
35+
public HttpServer.Config.Builder apply(HttpServer.Config.Builder builder) {
36+
var provider = IrohPeerIdentityProviders.forwarded(issuer, trustedProxyAddresses);
37+
return builder
38+
.peerIdentityProviders(List.of(provider))
39+
.peerAuthenticationPolicy(authenticate
40+
? PeerAuthenticationPolicies.primary("iroh")
41+
: PeerAuthenticationPolicies::observe);
42+
}
43+
44+
/** Build the strict PROXY-v2 configuration used by the raw bridge upstream. */
45+
TcpServerOptions tcpServerOptions() {
46+
return TcpServerOptions.builder()
47+
.proxyProtocolV2Required(true)
48+
.trustedProxyAddresses(trustedProxyAddresses)
49+
.irohProxyIssuer(issuer)
50+
.peerAuthenticationPolicy(authenticate
51+
? PeerAuthenticationPolicies.primary("iroh")
52+
: PeerAuthenticationPolicies::observe)
53+
.build();
54+
}
55+
56+
static IrohBridgeOptions fromArgs(
57+
String issuer, List<String> trustedProxyAddresses, boolean authenticate) {
58+
return new IrohBridgeOptions(
59+
issuer,
60+
trustedProxyAddresses.isEmpty()
61+
? Set.of("127.0.0.1")
62+
: new LinkedHashSet<>(trustedProxyAddresses),
63+
authenticate);
64+
}
65+
}

vgi/src/main/java/farm/query/vgi/Worker.java

Lines changed: 92 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1158,6 +1158,33 @@ public void runTcp(String host, int port, long idleTimeoutMs) throws IOException
11581158
});
11591159
}
11601160

1161+
/**
1162+
* Serve the identity-preserving raw upstream consumed by
1163+
* {@code vgi-iroh-bridge}. The upstream is loopback-only and requires the
1164+
* bridge's EndpointId-bearing PROXY-v2 preamble on every connection.
1165+
*
1166+
* @param host loopback bind host
1167+
* @param port bind port; {@code 0} selects a free port
1168+
* @param idleTimeoutMs idle watchdog in milliseconds; {@code <= 0} disables it
1169+
* @param bridge trusted bridge identity configuration
1170+
* @throws IOException if the socket cannot be bound or served
1171+
*/
1172+
public void runIrohTcpUpstream(
1173+
String host, int port, long idleTimeoutMs, IrohBridgeOptions bridge) throws IOException {
1174+
requireLoopback(host, "Iroh raw bridge upstream");
1175+
if (bridge == null) throw new IllegalArgumentException("Iroh bridge options are required");
1176+
TcpSocketTransport.serveForever(
1177+
host,
1178+
port,
1179+
buildServer(false),
1180+
idleTimeoutMs,
1181+
(boundHost, boundPort) -> {
1182+
System.out.println("TCP:" + boundHost + ":" + boundPort);
1183+
System.out.flush();
1184+
},
1185+
bridge.tcpServerOptions());
1186+
}
1187+
11611188
/**
11621189
* Parsed {@code [HOST:]PORT} TCP bind spec. Host defaults to loopback.
11631190
*
@@ -1195,13 +1222,32 @@ public void runHttp(String host, int port) throws Exception {
11951222
runHttp(HttpServer.Config.builder().host(host).port(port).build());
11961223
}
11971224

1225+
/**
1226+
* Run the ordinary VGI HTTP server behind {@code vgi-iroh-bridge}, retaining
1227+
* HTTP limits, continuations, externalized batches, and the authenticated
1228+
* client EndpointId.
1229+
*
1230+
* @param host loopback bind host
1231+
* @param port bind port; {@code 0} selects a free port
1232+
* @param bridge trusted bridge identity configuration
1233+
* @throws Exception if the server fails to start or serve
1234+
*/
1235+
public void runHttp(String host, int port, IrohBridgeOptions bridge) throws Exception {
1236+
requireLoopback(host, "Iroh HTTP bridge upstream");
1237+
if (bridge == null) throw new IllegalArgumentException("Iroh bridge options are required");
1238+
runHttp(bridge.apply(HttpServer.Config.builder().host(host).port(port)).build());
1239+
}
1240+
11981241
/**
11991242
* Canonical CLI dispatcher used by worker {@code main} methods. Parses
1200-
* the four flags every VGI worker accepts and runs the matching transport:
1243+
* the transport flags every VGI worker accepts and runs the matching transport:
12011244
* <ul>
12021245
* <li>{@code --unix <path>}: AF_UNIX socket (launcher protocol)
12031246
* <li>{@code --tcp [<host>:]<port>}: TCP socket (launcher protocol)
12041247
* <li>{@code --http} with optional {@code --host}, {@code --port}: HTTP
1248+
* <li>{@code --iroh-raw-upstream [<host>:]<port>}: trusted raw bridge upstream
1249+
* <li>{@code --iroh-issuer}, repeated {@code --iroh-trusted-proxy}, and
1250+
* {@code --iroh-observe}: Iroh bridge trust and authentication mode
12051251
* <li>{@code --idle-timeout <seconds>}: passed to {@code runUnixSocket} / {@code runTcp}
12061252
* <li>(default): stdio
12071253
* </ul>
@@ -1231,6 +1277,10 @@ public void runFromArgs(String[] args,
12311277
int port = 0;
12321278
String unixSocket = null;
12331279
String tcpAddr = null;
1280+
String irohRawUpstream = null;
1281+
String irohIssuer = null;
1282+
List<String> irohTrustedProxies = new ArrayList<>();
1283+
boolean irohObserve = false;
12341284
long idleTimeoutMs = 0;
12351285
for (int i = 0; i < args.length; i++) {
12361286
switch (args[i]) {
@@ -1239,20 +1289,55 @@ public void runFromArgs(String[] args,
12391289
case "--port" -> port = Integer.parseInt(args[++i]);
12401290
case "--unix" -> unixSocket = args[++i];
12411291
case "--tcp" -> tcpAddr = args[++i];
1292+
case "--iroh-raw-upstream" -> irohRawUpstream = args[++i];
1293+
case "--iroh-issuer" -> irohIssuer = args[++i];
1294+
case "--iroh-trusted-proxy" -> irohTrustedProxies.add(args[++i]);
1295+
case "--iroh-observe" -> irohObserve = true;
12421296
case "--idle-timeout" -> idleTimeoutMs =
12431297
(long) (Double.parseDouble(args[++i]) * 1000.0);
12441298
default -> { System.err.println("unknown arg: " + args[i]); System.exit(2); }
12451299
}
12461300
}
1301+
int selectedTransports = (http ? 1 : 0)
1302+
+ (unixSocket != null ? 1 : 0)
1303+
+ (tcpAddr != null ? 1 : 0)
1304+
+ (irohRawUpstream != null ? 1 : 0);
1305+
if (selectedTransports > 1) {
1306+
throw new IllegalArgumentException(
1307+
"--http, --unix, --tcp, and --iroh-raw-upstream are mutually exclusive");
1308+
}
1309+
if ((irohIssuer != null || !irohTrustedProxies.isEmpty() || irohObserve)
1310+
&& irohRawUpstream == null && !http) {
1311+
throw new IllegalArgumentException(
1312+
"Iroh bridge options require --http or --iroh-raw-upstream");
1313+
}
1314+
if ((irohRawUpstream != null || !irohTrustedProxies.isEmpty() || irohObserve)
1315+
&& irohIssuer == null) {
1316+
throw new IllegalArgumentException("Iroh bridge options require --iroh-issuer");
1317+
}
12471318
try {
12481319
if (unixSocket != null) {
12491320
runUnixSocket(Path.of(unixSocket), idleTimeoutMs);
12501321
} else if (tcpAddr != null) {
12511322
TcpAddr a = parseTcpAddr(tcpAddr);
12521323
runTcp(a.host(), a.port(), idleTimeoutMs);
1324+
} else if (irohRawUpstream != null) {
1325+
if (irohIssuer == null) {
1326+
throw new IllegalArgumentException(
1327+
"--iroh-raw-upstream requires --iroh-issuer");
1328+
}
1329+
TcpAddr a = parseTcpAddr(irohRawUpstream);
1330+
runIrohTcpUpstream(a.host(), a.port(), idleTimeoutMs,
1331+
IrohBridgeOptions.fromArgs(
1332+
irohIssuer, irohTrustedProxies, !irohObserve));
12531333
} else if (http) {
12541334
HttpServer.Config.Builder b = HttpServer.Config.builder().host(host).port(port);
12551335
if (httpCustomizer != null) b = httpCustomizer.apply(b);
1336+
if (irohIssuer != null) {
1337+
requireLoopback(host, "Iroh HTTP bridge upstream");
1338+
b = IrohBridgeOptions.fromArgs(
1339+
irohIssuer, irohTrustedProxies, !irohObserve).apply(b);
1340+
}
12561341
runHttp(b.build());
12571342
} else {
12581343
runStdio();
@@ -1309,4 +1394,10 @@ public void runHttp(HttpServer.Config config) throws Exception {
13091394
}, "vgi-http-shutdown"));
13101395
http.join();
13111396
}
1397+
1398+
private static void requireLoopback(String host, String label) {
1399+
if (!("127.0.0.1".equals(host) || "::1".equals(host) || "localhost".equals(host))) {
1400+
throw new IllegalArgumentException(label + " must bind loopback, got " + host);
1401+
}
1402+
}
13121403
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
// Copyright 2026 Query Farm LLC - https://query.farm
2+
3+
package farm.query.vgi;
4+
5+
import org.junit.jupiter.api.Test;
6+
7+
import java.util.Set;
8+
9+
import static org.junit.jupiter.api.Assertions.assertEquals;
10+
import static org.junit.jupiter.api.Assertions.assertThrows;
11+
import static org.junit.jupiter.api.Assertions.assertTrue;
12+
13+
final class IrohBridgeOptionsTest {
14+
@Test
15+
void defaultsToAuthenticatedLoopbackBridge() {
16+
var options = new IrohBridgeOptions("test-mesh");
17+
assertEquals(Set.of("127.0.0.1"), options.trustedProxyAddresses());
18+
assertTrue(options.authenticate());
19+
}
20+
21+
@Test
22+
void rejectsMissingIssuerAndNonLoopbackWorkerBinds() {
23+
assertThrows(IllegalArgumentException.class, () -> new IrohBridgeOptions(" "));
24+
var worker = Worker.builder();
25+
assertThrows(IllegalArgumentException.class,
26+
() -> worker.runIrohTcpUpstream(
27+
"0.0.0.0", 9400, 0, new IrohBridgeOptions("test-mesh")));
28+
assertThrows(IllegalArgumentException.class,
29+
() -> worker.runHttp(
30+
"0.0.0.0", 9401, new IrohBridgeOptions("test-mesh")));
31+
}
32+
}

0 commit comments

Comments
 (0)