Skip to content

Commit bc17195

Browse files
committed
Add --net-listen-allow CLI flag; add RFC 868 time-server example
Closes the gap flagged right after net.listen shipped (#215): dial's "no CLI policy flag" limitation doesn't matter in practice since dial defaults to allow, but listen defaults to deny, so the same gap left net.listen completely unusable from the CLI, not just unrestricted. --net-listen-allow pattern[:port] (repeatable) adds an allow rule to net.listen's bind policy directly from the CLI, mapping onto the same net_state.addListenPolicyRule the embedding API already uses. Accepts the same pattern shapes as the embedding API (*, exact IPv4/IPv6, CIDR, hostname wildcard) plus an optional :port suffix, bracket-safe for IPv6 literals ("[::1]:8080"). examples/time-server/ is a minimal end-to-end demonstration: RFC 868's Time Protocol is about as simple as a network server gets (connect, receive 4 bytes, done - no request to parse). Verified live: server and client both run under the plain CLI, no host embedding needed, and the decoded time matches actual UTC. Building it surfaced two real script bugs worth noting for future examples: std.io.println does not insert separators between arguments (concatenates directly - "listening on" + address ran together with no space), and net.dial returns a single Conn-or-error value, not a [value, error] pair the way net.listen and http.get/post/fetch do - an easy mix-up given how close the two now look in the same codebase.
1 parent ffe77b3 commit bc17195

6 files changed

Lines changed: 169 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,13 @@ This changelog tracks notable language/runtime changes by implementation date.
99
Adds `net.listen(network, address) [Listener, error]` and `Listener.accept()/close()/local_addr()/set_accept_deadline(ms)`, so a script can bind a port and accept inbound TCP connections — an MQTT listener, an HTTP server, a TCP echo service — purely in Gengo, reusing the exact same `Conn` type and read/write/close/deadline API `net.dial` already produces for the data path.
1010

1111
- **New CLI/embedding mechanism, not just a new function**: `net` is now gated by scope — `--cap net=dial`, `--cap net=listen`, or `--cap net=dial,listen`. Bare `--cap net` (no `=`) still means exactly what it always has (dial-only); upgrading an existing deployment never silently grants listen. The `--cap name=scope1,scope2` syntax is a general CLI mechanism (split on `=` then `,`), not `net`-specific, so any future capability that grows scopes gets the same syntax for free. `cap:net`'s import gate succeeds as long as *some* net scope is granted; each function is refused individually at call time if its own scope wasn't (`ctx.vs.net_scopes`, computed from `Runtime.enabled_capabilities` in `Runtime.activate()`) — the same shape as an `fs` operation against a path with no matching mount.
12-
- **Listen policy defaults to deny-all**, deliberately the opposite of dial's existing default-allow: a host must affirmatively add at least one allow rule (`engine_net_listen_policy_add`, mirroring `engine_net_policy_add`'s shape and LIFO evaluation exactly) before any `net.listen(...)` call succeeds. Separate rule list from dial's — configuring one has no effect on the other. No CLI flag exists to configure it (matching how dial policy has always been host-API-only from the CLI); `--cap net=listen` alone makes `net.listen` compile and import but refuse every call until a host adds a rule.
12+
- **Listen policy defaults to deny-all**, deliberately the opposite of dial's existing default-allow: a host must affirmatively add at least one allow rule before any `net.listen(...)` call succeeds. Separate rule list from dial's — configuring one has no effect on the other. Two ways to add a rule: the embedding API (`engine_net_listen_policy_add`, mirroring `engine_net_policy_add`'s shape and LIFO evaluation exactly), or the CLI's new repeatable `--net-listen-allow pattern[:port]` flag (added same day, after initially shipping this host-API-only to match dial's precedent — dial's identical "no CLI flag" gap doesn't matter in practice since dial defaults to allow, so the asymmetry meant listen's version of that gap left it completely unusable from the CLI, not just unrestricted; see #215).
1313
- **`net.listen`/`Listener.accept` return `[value, error]` pairs**, unlike `dial`'s single "Conn or error" value — matching `http.get`/`post`/`fetch`'s existing convention (`l, err := net.listen(...)`) rather than dial's, since that's the calling shape the language surface actually needs here.
1414
- Native POSIX implementation reuses existing `net_state.zig` machinery rather than inventing new plumbing: `socket()`+`bind()`+`listen()` (fixed backlog, 128) into a new `g_listeners` table (independent ceiling from `MaxConns`, default 8 — a script needing many listening ports is a different shape of program than one needing many connections); accept deadline reuses `posixSetSockOptTimeval`/`SO_RCVTIMEO` applied to the *listening* socket instead of a connection, so `EAGAIN` maps to `error.DeadlineExceeded` exactly like `netRead`'s existing arm; a successful `accept()` produces a plain connected socket wrapped in the same `NetConn` struct `dial` produces, pushed into the existing `g_conns`/`MaxConns` pool — accepted and dialed connections share one ceiling and one accounting path, not a separate parallel budget.
1515
- Host-mediated path (WASI/browser/embedders without POSIX sockets) extends `gengo_net_handlers_t` with optional `listen`/`accept`/`listener_close`/`listener_local_addr`/`set_accept_deadline` callbacks — `null` on a host that supports dial but not listen, reported as `CapabilityNotAvailable` rather than a crash. WASI itself gets the same treatment `dial` already has (`error.CapabilityNotAvailable`): confirmed directly against Zig's std that WASI has no `bind`/`listen`/`accept` syscall wrappers at all (only Linux's raw syscall interface does), so there was a real platform gap to gate, not a guess.
1616
- Tests: `compiler_test.zig` gains scope-gating and default-deny-policy tests, plus a genuine POSIX `bind`+`accept`+`read`+`write` roundtrip test using a `std.Thread`-spawned raw-socket client (kept off `net_state`'s own globals entirely to avoid a real cross-thread data race, since `net_state` has no locking) against the real listener implementation; `engine.zig` gains `engine_net_listen_policy_add`/`_clear` C API tests mirroring the existing dial-policy ones; a new `tests/spec/cap/fail/006_net_listen_scope_not_granted.gengo` conformance case.
17-
- Docs: `capabilities.md` (scope table, `Listener` API, default-deny policy, server-loop shape), `capability-matrix.md`, `security.md` (listen vs. dial policy defaults, why they differ, existing cross-runtime `net_state` isolation gap now also covers listeners), `cli.md` (`--cap net=scope1,scope2` syntax).
17+
- Docs: `capabilities.md` (scope table, `Listener` API, default-deny policy, server-loop shape), `capability-matrix.md`, `security.md` (listen vs. dial policy defaults, why they differ, existing cross-runtime `net_state` isolation gap now also covers listeners), `cli.md` (`--cap net=scope1,scope2` and `--net-listen-allow` syntax).
18+
- New example: `examples/time-server/` — an RFC 868 Time Protocol server and client, about as minimal a demonstration of `net.listen`/`Listener.accept` as exists (connect, receive 4 bytes, done). Runs directly under the CLI, no host embedding needed.
1819
- **Known, explicitly deferred, not fixed here**: `net_state.zig`'s connection/listener tables and both policy lists remain process-wide module state, not yet part of the per-instance activation set #190 tracks for `chunk`/`globals`/`heap`/`vm` — a pre-existing latent gap for dial, now also covering listeners. Not a concern for a single-`Runtime`-per-process embedding (the CLI); a host running multiple independently-untrusted scripts with `listen` enabled in one process should treat this as a real isolation gap until #190 lands, not assume it's already handled. See `dev-docs/design/net-listen-design.md` for the full design rationale.
1920

2021
## 2026-07-22 (v0.5.1-dev)

docs/cli.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,8 @@ script from standard input instead.
3838
| `--test` | Run top-level `test` blocks rather than ordinary script execution. A failed test exits unsuccessfully. |
3939
| `--profile` | With `--test`, print each block's instruction count and peak heap bytes/stack depth/live object count, plus a final peak-across-all-blocks summary line. Does not affect pass/fail behavior or the exit code. Forces per-instruction instruction counting on for the run, which costs real speed — a diagnostic aid, not something to leave on by default. |
4040
| `--cap name` | Enable one named capability. Repeat for several capabilities. See `capabilities.md`; no capability is enabled merely by importing it. |
41-
| `--cap net=scope1,scope2` | Scope the `net` capability instead of granting it unscoped. Scopes are `dial` and `listen`, comma-separated (`--cap net=dial`, `--cap net=listen`, `--cap net=dial,listen`). Bare `--cap net` (no `=`) still means dial-only, unchanged from before scopes existed — upgrading never silently grants listen. `net.listen`'s policy defaults to deny-all regardless of scope; there is no CLI flag to add a listen-policy rule, so `--cap net=listen` alone makes `net.listen(...)` compile and import but refuse every call until a host adds one via the embedding API (`engine_net_listen_policy_add`). This general `name=scope1,scope2` syntax is available for any capability that defines scopes, not just `net`. |
41+
| `--cap net=scope1,scope2` | Scope the `net` capability instead of granting it unscoped. Scopes are `dial` and `listen`, comma-separated (`--cap net=dial`, `--cap net=listen`, `--cap net=dial,listen`). Bare `--cap net` (no `=`) still means dial-only, unchanged from before scopes existed — upgrading never silently grants listen. This general `name=scope1,scope2` syntax is available for any capability that defines scopes, not just `net`. |
42+
| `--net-listen-allow pattern[:port]` | Add an allow rule to `net.listen`'s bind policy, which defaults to deny-all (the opposite of `net.dial`'s default-allow — see `security.md`). Repeatable. `pattern` accepts the same shapes as the embedding API's policy rules (`"*"`, exact IPv4/IPv6, CIDR, hostname wildcard); an optional `:port` suffix restricts to one port (bracket the pattern for a literal IPv6 address with a port, e.g. `"[::1]:8080"`). With no rules, `--cap net=listen` alone still makes `net.listen(...)` compile and import but refuse every call. |
4243
| `--modules path` | Permit source imports from one additional directory. Repeatable, up to eight paths. The script directory remains the default source root. |
4344
| `--max-ops n` | Limit VM instruction execution to `n`. `0` means unlimited. This limit does not account for work inside host callbacks. |
4445
| `--heap size` | Set the GC heap size. A size may be bytes or end in `k`, `m`, or `g`; the default is `1m`. |

examples/time-server/README.md

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# Time server (RFC 868)
2+
3+
The simplest possible demonstration of `net.listen`/`Listener.accept`: an
4+
[RFC 868](https://www.rfc-editor.org/rfc/rfc868) Time Protocol server. A
5+
client connects, the server writes back the current time as a single
6+
32-bit binary number, and closes the connection — no request to parse, no
7+
reply format beyond four bytes. Nothing about this example needs a host
8+
embedding; both scripts run directly under the `gengo` CLI.
9+
10+
`time_server.gengo` binds a port and serves that protocol forever.
11+
`check_time.gengo` is a client that connects, decodes the reply, and
12+
prints it as a calendar time — useful for testing without reaching for
13+
`nc`/`xxd`.
14+
15+
RFC 868's registered port is 37, which (like any port below 1024) requires
16+
root on POSIX. This example binds `7370` instead so it runs without
17+
special permission; change the port in both scripts together if you want
18+
something else.
19+
20+
## Run it
21+
22+
`net.listen` needs both a scope grant and a policy rule — the listen
23+
policy defaults to deny-all, deliberately the opposite of `net.dial`'s
24+
default-allow (see `docs/security.md`).
25+
26+
```bash
27+
gengo --cap net=listen --net-listen-allow "*:7370" examples/time-server/time_server.gengo
28+
```
29+
30+
In another terminal:
31+
32+
```bash
33+
gengo --cap net=dial examples/time-server/check_time.gengo
34+
```
35+
36+
Expected output from the client:
37+
38+
```
39+
server time: 2026 7 25 ...
40+
```
41+
42+
The server keeps running (and printing nothing further) until you stop it
43+
— it's written as a dedicated long-running process, the natural shape for
44+
`gengo server.gengo` as a standalone daemon (see "Execution model" in
45+
`dev-docs/design/net-listen-design.md`).
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
// Client for time_server.gengo: connects, reads the 4-byte RFC 868
2+
// response, and decodes it back to a calendar time.
3+
//
4+
// Run: gengo --cap net=dial check_time.gengo
5+
6+
std := import("std")
7+
net := import("cap:net")
8+
9+
rfc868_epoch_offset := 2208988800
10+
11+
func checkTime() {
12+
conn := net.dial("tcp", "127.0.0.1:7370")
13+
if std.core.is_error(conn) {
14+
std.io.println("dial failed: ", conn)
15+
return
16+
}
17+
18+
data := conn.read(4)
19+
conn.close()
20+
21+
if std.core.bytelen(data) != 4 {
22+
std.io.println("expected 4 bytes, got ", std.core.bytelen(data))
23+
return
24+
}
25+
26+
rfc868_time := std.bytes.u32be_at(data, 0)
27+
unix_time := rfc868_time - rfc868_epoch_offset
28+
t := std.time.from_unix(unix_time)
29+
p := t.parts()
30+
std.io.printf("server time: %d-%d-%d %d:%d:%d UTC\n", p.year, p.month, p.day, p.hour, p.min, p.sec)
31+
}
32+
33+
checkTime()
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
// RFC 868 Time Protocol — about as simple as a network server gets: a
2+
// client connects, the server sends back the current time as a single
3+
// 32-bit binary number, and closes the connection. No request to parse,
4+
// no reply format beyond four bytes.
5+
//
6+
// Run: gengo --cap net=listen --net-listen-allow "*:7370" time_server.gengo
7+
// Test: printf '' | nc 127.0.0.1 7370 | xxd (or see check_time.gengo)
8+
//
9+
// RFC 868's registered port is 37, which requires root on POSIX (any port
10+
// below 1024 does) — this example binds an unprivileged port instead so it
11+
// runs without special permission.
12+
13+
std := import("std")
14+
net := import("cap:net")
15+
16+
// RFC 868 counts seconds from 1900-01-01 00:00:00 UTC; std.time works in
17+
// the Unix epoch (1970-01-01). The gap between the two is exactly this
18+
// many seconds — a fixed, well-known constant, not something to compute.
19+
rfc868_epoch_offset := 2208988800
20+
21+
func serve() {
22+
l, err := net.listen("tcp", "0.0.0.0:7370")
23+
if err != null {
24+
std.io.println("listen failed: ", err)
25+
return
26+
}
27+
std.io.println("time server listening on ", l.local_addr())
28+
29+
for {
30+
conn, aerr := l.accept()
31+
if aerr != null {
32+
continue
33+
}
34+
now := std.time.now()
35+
rfc868_time := now.unix() + rfc868_epoch_offset
36+
conn.write(std.bytes.u32be(rfc868_time))
37+
conn.close()
38+
}
39+
}
40+
41+
serve()

src/main.zig

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ const vms = @import("lang/vm_state.zig");
2323
const cfg = @import("runtime_config");
2424
const heap_rt = @import("runtime/heap.zig");
2525
const fs_state = @import("lang/native/fs_state.zig");
26+
const net_state = @import("lang/native/net_state.zig");
2627
const cap_env = if (build_opts.cap_env) @import("lang/native/cap_env.zig") else struct {};
2728
const disasm = @import("lang/disasm.zig");
2829
const bundle = @import("bundle.zig");
@@ -279,6 +280,30 @@ fn parseHeapSize(s: []const u8) usize {
279280
return n * multiplier;
280281
}
281282

283+
// Splits a --net-listen-allow value into (pattern, port). Bracketed form
284+
// ("[::1]:8080" or bare "[::]") is IPv6-safe; otherwise splits on the last
285+
// ':' only when the tail parses as a port number, so a bare pattern with no
286+
// port (e.g. "*.example.com" or unbracketed "::1") is never misread as
287+
// having one. port 0 means "any port", matching net_state's own rule shape.
288+
fn splitPatternPort(raw: []const u8) struct { pattern: []const u8, port: u16 } {
289+
if (raw.len > 0 and raw[0] == '[') {
290+
if (std.mem.indexOfScalar(u8, raw, ']')) |rb| {
291+
const inner = raw[1..rb];
292+
if (rb + 1 < raw.len and raw[rb + 1] == ':') {
293+
const port = std.fmt.parseUnsigned(u16, raw[rb + 2 ..], 10) catch 0;
294+
return .{ .pattern = inner, .port = port };
295+
}
296+
return .{ .pattern = inner, .port = 0 };
297+
}
298+
}
299+
if (std.mem.lastIndexOfScalar(u8, raw, ':')) |i| {
300+
if (std.fmt.parseUnsigned(u16, raw[i + 1 ..], 10)) |port| {
301+
return .{ .pattern = raw[0..i], .port = port };
302+
} else |_| {}
303+
}
304+
return .{ .pattern = raw, .port = 0 };
305+
}
306+
282307
fn printBundleUsage() void {
283308
io.write("Usage: gengo bundle --entry <archive-path.gengo> -o <bundle.zip> [options] [folder ...]\n");
284309
io.write("\n");
@@ -467,6 +492,9 @@ fn runCli(argv: []const []const u8) void {
467492
io.write(" --profile With --test, report peak ops/heap/stack/objects per block\n");
468493
io.write(" --cap <name> Enable a named capability (repeatable)\n");
469494
io.write(" --cap net=dial,listen Scope the net capability (dial and/or listen)\n");
495+
io.write(" --net-listen-allow pattern[:port] Allow net.listen to bind a matching\n");
496+
io.write(" address (repeatable); listen refuses everything\n");
497+
io.write(" with no rules\n");
470498
io.write(" --modules <path> Allow imports from an extra directory (repeatable)\n");
471499
io.write(" --max-ops <n> Limit instruction count (0 = unlimited)\n");
472500
io.write(" --heap <size> Set GC heap size, e.g. 4m, 512k (default 1m)\n");
@@ -600,6 +628,23 @@ fn runCli(argv: []const []const u8) void {
600628
script_index += 2;
601629
continue;
602630
}
631+
if (std.mem.eql(u8, a, "--net-listen-allow")) {
632+
if (script_index + 1 >= argv.len) {
633+
io.werr("gengo: --net-listen-allow requires a value pattern[:port]\n");
634+
die(1);
635+
}
636+
const v = argv[script_index + 1];
637+
const split = splitPatternPort(v);
638+
const rc = net_state.addListenPolicyRule(.allow, split.pattern, split.port);
639+
if (rc != 0) {
640+
io.werr("gengo: invalid --net-listen-allow value: ");
641+
io.werr(v);
642+
io.werr("\n");
643+
die(1);
644+
}
645+
script_index += 2;
646+
continue;
647+
}
603648
if (std.mem.eql(u8, a, "--heap")) {
604649
if (script_index + 1 >= argv.len) {
605650
io.werr("gengo: --heap requires a size (e.g. 4m, 512k, 8388608)\n");

0 commit comments

Comments
 (0)