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.