网络覆盖层

通过 SOCKS5、Tailscale 或 I2P 覆盖层路由出站 HTTP 调用和生成的进程。

概述

Wippy 支持覆盖网络,可透明地承载来自函数、进程和 HTTP 客户端的流量。每个覆盖层是一个注册表条目;代码在每次调用时选择加入,并且该选择会继承到内层调用,直到某个后代显式覆盖为止。

支持的覆盖层:

  • network.socks5 — 通用 SOCKS5 代理(也兼容 Tor 的 SOCKS5 监听器)
  • network.tailscale — tsnet 覆盖节点
  • network.i2p — I2P SAM v3 桥接

项目结构

netdemo/
├── wippy.lock
└── src/
    ├── _index.yaml
    └── probe.lua

步骤 1:定义覆盖层

创建 src/_index.yaml:

version: "1.0"
namespace: app

entries:
  - name: net_policy
    kind: security.policy
    policy:
      actions:
        - http_client.request
        - network.select
      resources: "*"
      effect: allow

  - name: processes
    kind: process.host
    lifecycle:
      auto_start: true

  - name: terminal
    kind: terminal.host
    lifecycle:
      auto_start: true

  # SOCKS5 代理条目(Tor 默认在 127.0.0.1:9050 上暴露一个)
  - name: tor
    kind: network.socks5
    host: 127.0.0.1
    port: 9050
    isolate_streams: true

  - name: probe
    kind: process.lua
    meta:
      command:
        name: probe
        short: Check outbound IP through overlays
        security:
          actor:
            id: system.probe
          policies:
            - app:net_policy
    source: file://probe.lua
    method: main
    modules:
      - io
      - http_client
      - json

isolate_streams: true 使 SOCKS5 驱动在每次连接时生成随机凭据,从而让 Tor 为每次拨号开启新的回路。

严格模式默认开启,因此该命令携带其启动时运行所依据的执行者和策略。http_client.request 覆盖出站调用,network.select 覆盖显式的覆盖层选择;缺少它们时每次检查都会失败关闭。

步骤 2:路由出站调用

创建 src/probe.lua:

local io = require("io")
local http_client = require("http_client")
local json = require("json")

local function fetch_ip(overlay)
    local options = { timeout = "15s" }
    if overlay then
        options.overlay_network = overlay
    end

    local resp, err = http_client.get("https://api.ipify.org?format=json", options)
    if err then
        return nil, tostring(err)
    end
    if resp.status_code ~= 200 then
        return nil, "HTTP " .. resp.status_code
    end

    local body = json.decode(resp.body or "")
    return body and body.ip, nil
end

local function main()
    local direct, d_err = fetch_ip(nil)
    if d_err then
        io.print("direct failed: " .. d_err)
    else
        io.print("direct IP: " .. direct)
    end

    local routed, r_err = fetch_ip("app:tor")
    if r_err then
        io.print("tor failed: " .. r_err)
    else
        io.print("tor IP:    " .. routed)
    end

    return 0
end

return { main = main }

http_client 上的 overlay_network 选项仅为该次调用选择覆盖层。不设置时,拨号使用进程默认值(.wippy.yaml 中 network_service.default_network 所指定的值,或直连)。

步骤 3:运行

wippy init
wippy run probe

在本地运行 Tor 时:

direct IP: 203.0.113.42
tor IP:    185.220.101.61

如果 Tor 未运行,tor IP 行将报告拨号错误 — SOCKS5 覆盖层不会静默回退到直连。

继承

覆盖层选择会在嵌套调用中传递。在 funcs.call 或 process.spawn 边界处选择一次覆盖层,其下的所有内层 HTTP 调用、嵌套 funcs.call 和 process.spawn 均会使用该覆盖层,直到显式覆盖为止:

local funcs = require("funcs")

local result, err = funcs.new()
    :with_options({ network = "app:tor" })
    :call("app:scrape_site", url)
local pid, err = process.with_options({ network = "app:tor" })
    :spawn_monitored("app.workers:probe", "app:processes")

嵌套函数或生成的进程无需显式传递,即可在每次出站拨号时使用该覆盖层。

绑定监听器

支持入站流量的覆盖层(Tailscale、I2P)也可以接受 HTTP 监听器。将覆盖层附加到 http.service 而非客户端:

  - name: bind_policy
    kind: security.policy
    policy:
      actions: "network.bind"
      resources: "*"
      effect: allow

  - name: tailnet
    kind: network.tailscale
    hostname: wippy-node
    auth_key: ${env:TS_AUTHKEY}
    ephemeral: true

  - name: gateway
    kind: http.service
    addr: ":8080"
    network: app:tailnet
    lifecycle:
      auto_start: true
      security:
        actor:
          id: system.gateway
        policies:
          - app:bind_policy

auth_key 通过 env 注册表解析,因此 TS_AUTHKEY 是一个已注册的变量 — 操作系统中的值需要一个由 env.storage.os 支撑的 env.variable。

通过覆盖层绑定受 network.bind 管控,在监听器启动时检查,因此该服务声明了一个允许它的作用域。服务器绑定在 tailnet 接口上;客户端通过 Tailscale 地址访问。SOCKS5 仅支持出站 — 将其分配给 http.service 会使监听器以 inbound listeners are not exposed over SOCKS5 失败。

应用级默认值

在 .wippy.yaml 中设置默认覆盖层,使所有调用都使用它,除非被覆盖:

network_service:
  state_dir: .wippy/net
  default_network: app:tor

权限

network.select 动作控制显式覆盖层选择。在某个作用域中拒绝该权限可阻止代码选择覆盖层:

  - name: deny_network
    kind: security.policy
    policy:
      actions: "network.select"
      resources: "*"
      effect: deny
    groups:
      - untrusted

继承的覆盖层绕过此检查 — 它们已在调用方边界处获得授权。只有在 Lua 边界处的显式重新选择才会受到控制。

下一步