Skip to main content

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": []
}
FieldTypeRequiredNotes
namestringyesHuman-readable name for this config file.
authorstringnoDefaults to "EMPTY_AUTHOR" when omitted.
config-versionintegernoUser-controlled config version.
core-minimum-versionintegernoUser-controlled minimum core version marker.
encryptedbooleannoParsed by the config loader for encrypted config workflows.
nodesarrayyesMust be a non-empty array of node objects.
variablesobjectnoOptional 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"
}
FieldTypeRequiredNotes
namestringyesMust be unique inside the config file.
typestringyesMust exactly match a loaded node type, such as "TcpListener".
versionintegernoDefaults to 0 when omitted.
settingsobjectdepends on nodeNode-specific configuration.
nextstringdepends on positionName 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 next value 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.