Part 3: Config Files and Chains
core.json starts WaterWall and lists the config files to load. Each listed
config file defines one or more node chains.
Example from core.json:
{
"configs": [
"configs/server.json"
]
}
Then configs/server.json contains the actual nodes.
Config File Shape
A current WaterWall node config looks like this:
{
"name": "example-config",
"author": "waterwall-user",
"config-version": 1,
"core-minimum-version": 0,
"encrypted": false,
"nodes": []
}
| Field | Type | Required | Notes |
|---|---|---|---|
name | string | yes | Human-readable name for this config file. |
author | string | no | Defaults to "EMPTY_AUTHOR" when omitted. |
config-version | integer | no | User-controlled config version. |
core-minimum-version | integer | no | User-controlled minimum core version marker. |
encrypted | boolean | no | Parsed by the config loader for encrypted config workflows. |
nodes | array | yes | Must be a non-empty array of node objects. |
variables | object | no | Optional values that can be substituted before JSON parsing. |
WaterWall also strips // line comments from config files before parsing, as
long as the comment is outside a JSON string. New shared examples should still
avoid comments so they remain valid JSON for other tools.
Variables
The config loader supports top-level variables:
{
"variables": {
"listen_address": "0.0.0.0",
"listen_port": 8080,
"target_host": "example.com",
"target_port": 80
},
"name": "variable-example",
"nodes": [
{
"name": "in",
"type": "TcpListener",
"settings": {
"address": $listen_address$,
"port": $listen_port$
},
"next": "out"
},
{
"name": "out",
"type": "TcpConnector",
"settings": {
"address": $target_host$,
"port": $target_port$
}
}
]
}
Placeholders use $name$ and are replaced with the JSON value of that variable.
Do not put the placeholder inside quotes. If a placeholder references an
undefined variable, the config fails to load.
Node Shape
Most nodes use this shape:
{
"name": "node-name",
"type": "NodeType",
"version": 0,
"settings": {},
"next": "next-node-name"
}
| Field | Type | Required | Notes |
|---|---|---|---|
name | string | yes | Must be unique inside the config file. |
type | string | yes | Must exactly match a loaded node type, such as "TcpListener". |
version | integer | no | Defaults to 0 when omitted. |
settings | object | depends on node | Node-specific configuration. |
next | string | depends on position | Name of the next node in the chain. |
Node type names are case-sensitive.
Chain Rules
A chain is created by connecting nodes with next:
TcpListener -> EncryptionClient -> TcpConnector
WaterWall validates the graph before running it:
- Each
nextvalue must point to an existing node. - Node names must not be duplicated.
- A config must contain at least one chain head.
- Normal nodes cannot be left unchained.
- A chain must end at a node that is allowed to be a chain end.
- Circular chains are rejected.
In practice, listener nodes are usually chain heads, connector nodes are usually chain ends, and transform nodes sit in the middle.
Simple TCP Forward
This config listens locally on TCP 8080 and connects to example.com:80.
{
"name": "simple-tcp-forward",
"author": "waterwall-user",
"config-version": 1,
"core-minimum-version": 0,
"nodes": [
{
"name": "listen-http",
"type": "TcpListener",
"settings": {
"address": "127.0.0.1",
"port": 8080,
"nodelay": true
},
"next": "connect-example"
},
{
"name": "connect-example",
"type": "TcpConnector",
"settings": {
"address": "example.com",
"port": 80,
"domain-strategy": "prefer-ipv4"
}
}
]
}
Flow:
local client -> TcpListener(127.0.0.1:8080) -> TcpConnector(example.com:80)
Two-Server Encrypted TCP Tunnel
For a real tunnel, the client side and server side usually have separate config files.
Client side:
local app -> TcpListener -> EncryptionClient -> TcpConnector -> public server
Server side:
public server -> TcpListener -> EncryptionServer -> TcpConnector -> private service
Client Config
{
"name": "encrypted-client",
"author": "waterwall-user",
"config-version": 1,
"core-minimum-version": 0,
"nodes": [
{
"name": "local-entry",
"type": "TcpListener",
"settings": {
"address": "127.0.0.1",
"port": 1080,
"nodelay": true
},
"next": "encrypt"
},
{
"name": "encrypt",
"type": "EncryptionClient",
"settings": {
"algorithm": "chacha20-poly1305",
"password": "replace-with-a-strong-secret",
"salt": "chain-a",
"kdf-iterations": 20000
},
"next": "connect-server"
},
{
"name": "connect-server",
"type": "TcpConnector",
"settings": {
"address": "203.0.113.10",
"port": 443,
"nodelay": true
}
}
]
}
Replace 203.0.113.10 with your server IP address.
Server Config
{
"name": "encrypted-server",
"author": "waterwall-user",
"config-version": 1,
"core-minimum-version": 0,
"nodes": [
{
"name": "public-entry",
"type": "TcpListener",
"settings": {
"address": "0.0.0.0",
"port": 443,
"nodelay": true
},
"next": "decrypt"
},
{
"name": "decrypt",
"type": "EncryptionServer",
"settings": {
"algorithm": "chacha20-poly1305",
"password": "replace-with-a-strong-secret",
"salt": "chain-a",
"kdf-iterations": 20000
},
"next": "connect-private-service"
},
{
"name": "connect-private-service",
"type": "TcpConnector",
"settings": {
"address": "127.0.0.1",
"port": 22,
"nodelay": true
}
}
]
}
The EncryptionClient and EncryptionServer settings must match. Use a strong
secret and do not publish configs that contain real passwords.
Common Mistakes
Using the wrong type case:
"TcpListener" is valid. "tcplistener" is not.
Forgetting next in the middle of a chain:
Only a valid chain-end node should omit next.
Binding public services to localhost:
127.0.0.1 is local-only. Use 0.0.0.0 or a specific public interface address
when the listener must accept remote clients.
Mixing client and server nodes:
Paired nodes must face each other. For example, use EncryptionClient on one
side and EncryptionServer on the other side with matching settings.
Putting secrets in examples: Keep real passwords, keys, and tokens outside public repositories.