Skip to content

Commit 3f8dce8

Browse files
feat(tmpl): replace "auto" sentinel with {{AUTO_*}} placeholders
Using explicit {{AUTO_PORT}}, {{AUTO_UUID}}, {{AUTO_PASSWORD}}, etc. prevents false matches when a config legitimately contains the word "auto". - resolveAuto checks {{AUTO_PORT/TCP_PORT/UDP_PORT/QUIC_PORT/SOCKS_PORT/UPSTREAM_PORT}} - {{AUTO_UUID}} and {{AUTO_PASSWORD}} for credential generation - examples/run.json updated to new placeholder syntax - New test: TestRender_AutoLiteralPreserved verifies "auto" passthrough - Docs updated in run-json.md and templates.md Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent b311d32 commit 3f8dce8

6 files changed

Lines changed: 92 additions & 51 deletions

File tree

docs/run-json.md

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -167,13 +167,21 @@ to the template.
167167

168168
### Auto-resolution
169169

170-
`"auto"` in a vars value triggers runtime resolution:
170+
Use `{{AUTO_*}}` placeholders as var values to trigger runtime resolution:
171171

172-
| Key | Resolution |
172+
| Value | Resolution |
173173
|---|---|
174-
| `PORT`, `TCP_PORT`, `UDP_PORT`, `QUIC_PORT`, `SOCKS_PORT`, `UPSTREAM_PORT` | Random free port |
175-
| `UUID` | `uuid.New()` v4 |
176-
| `PASSWORD` | 16 random bytes, hex-encoded |
174+
| `{{AUTO_PORT}}` | Random free TCP port |
175+
| `{{AUTO_TCP_PORT}}` | Random free TCP port |
176+
| `{{AUTO_UDP_PORT}}` | Random free TCP port |
177+
| `{{AUTO_QUIC_PORT}}` | Random free TCP port |
178+
| `{{AUTO_SOCKS_PORT}}` | Random free TCP port |
179+
| `{{AUTO_UPSTREAM_PORT}}` | Random free TCP port |
180+
| `{{AUTO_UUID}}` | `uuid.New()` v4 |
181+
| `{{AUTO_PASSWORD}}` | 16 random bytes, hex-encoded |
182+
183+
Unlike the old `"auto"` string, these placeholders are unambiguous — a config
184+
value that legitimately contains the word `"auto"` is left untouched.
177185

178186
Full placeholder reference: [placeholders.md](placeholders.md).
179187

docs/templates.md

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -217,16 +217,28 @@ same directory as the original.
217217

218218
## Auto-resolved vars
219219

220-
Set a var to `"auto"` in `run.json` and the runner fills it in:
220+
Use `{{AUTO_*}}` placeholders as var values in `run.json` and the runner fills them in:
221221

222-
| Var | Resolved to |
222+
```json5
223+
"vars": {
224+
"PORT": "{{AUTO_PORT}}",
225+
"SOCKS_PORT": "{{AUTO_SOCKS_PORT}}",
226+
"UUID": "{{AUTO_UUID}}",
227+
"PASSWORD": "{{AUTO_PASSWORD}}"
228+
}
229+
```
230+
231+
| Placeholder | Resolved to |
223232
|---|---|
224-
| `PORT`, `TCP_PORT`, `UDP_PORT`, `QUIC_PORT` | Random free port (each) |
225-
| `SOCKS_PORT`, `UPSTREAM_PORT` | Random free port |
226-
| `UUID` | UUID v4 (`uuid.New()`) |
227-
| `PASSWORD` | 16 random bytes, hex-encoded |
228-
| `LISTEN_SERVER` | Copied from `SERVER` when unset |
229-
| `LOG_LEVEL` | `"error"` when unset |
233+
| `{{AUTO_PORT}}`, `{{AUTO_TCP_PORT}}`, `{{AUTO_UDP_PORT}}`, `{{AUTO_QUIC_PORT}}` | Random free port |
234+
| `{{AUTO_SOCKS_PORT}}`, `{{AUTO_UPSTREAM_PORT}}` | Random free port |
235+
| `{{AUTO_UUID}}` | UUID v4 (`uuid.New()`) |
236+
| `{{AUTO_PASSWORD}}` | 16 random bytes, hex-encoded |
237+
| `LISTEN_SERVER` | Copied from `SERVER` when unset (automatic) |
238+
| `LOG_LEVEL` | `"error"` when unset (automatic) |
239+
240+
Using explicit placeholders means a var value that legitimately contains the
241+
word `"auto"` is never misinterpreted.
230242

231243
Full list: [placeholders.md](placeholders.md).
232244

examples/run.json

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,21 +8,21 @@
88
{
99
"TITLE": "local",
1010
"SERVER": "127.0.0.1",
11-
"PORT": "auto",
12-
"SOCKS_PORT": "auto",
13-
"UUID": "auto",
14-
"PASSWORD": "auto",
11+
"PORT": "{{AUTO_PORT}}",
12+
"SOCKS_PORT": "{{AUTO_SOCKS_PORT}}",
13+
"UUID": "{{AUTO_UUID}}",
14+
"PASSWORD": "{{AUTO_PASSWORD}}",
1515
// "HOST_NAME": "example.com",
1616
// "SNI_NAME": "example.com",
1717
},
1818
// Add more entries to test against additional servers, e.g.:
1919
// {
2020
// "TITLE": "remote",
2121
// "SERVER": "203.0.113.10",
22-
// "PORT": "auto",
23-
// "SOCKS_PORT": "auto",
24-
// "UUID": "auto",
25-
// "PASSWORD": "auto",
22+
// "PORT": "{{AUTO_PORT}}",
23+
// "SOCKS_PORT": "{{AUTO_SOCKS_PORT}}",
24+
// "UUID": "{{AUTO_UUID}}",
25+
// "PASSWORD": "{{AUTO_PASSWORD}}",
2626
// },
2727
],
2828

internal/runner/config_test.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ func TestLoadRunConfig_InheritanceVars(t *testing.T) {
3131
"checks": []string{"dns", "http"},
3232
"timeout_sec": 30,
3333
"vars": []interface{}{
34-
map[string]interface{}{"TITLE": "T1", "SERVER": "127.0.0.1", "PORT": "auto", "SOCKS_PORT": "auto"},
34+
map[string]interface{}{"TITLE": "T1", "SERVER": "127.0.0.1", "PORT": "{{AUTO_PORT}}", "SOCKS_PORT": "{{AUTO_SOCKS_PORT}}"},
3535
},
3636
})
3737

@@ -48,8 +48,8 @@ func TestLoadRunConfig_InheritanceVars(t *testing.T) {
4848
"server_config": "server.json",
4949
"client_config": "client.json",
5050
"vars": []interface{}{
51-
map[string]interface{}{"TITLE": "plain-tls", "TLS": "1", "UUID": "auto"},
52-
map[string]interface{}{"TITLE": "vless-flow", "TLS": "1", "UUID": "auto", "VLESS_FLOW": "xtls-rprx-vision"},
51+
map[string]interface{}{"TITLE": "plain-tls", "TLS": "1", "UUID": "{{AUTO_UUID}}"},
52+
map[string]interface{}{"TITLE": "vless-flow", "TLS": "1", "UUID": "{{AUTO_UUID}}", "VLESS_FLOW": "xtls-rprx-vision"},
5353
},
5454
})
5555

internal/tmpl/tmpl.go

Lines changed: 25 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,12 @@
2424
//
2525
// # Auto-resolved vars
2626
//
27-
// Set a var's value to "auto" in run.json and the runner picks:
27+
// Set a var's value to an AUTO placeholder in run.json and the runner picks:
2828
//
29-
// PORT, SOCKS_PORT, UPSTREAM_PORT → random free TCP port
30-
// UUID → new UUID v4
31-
// PASSWORD → 16 random bytes, hex-encoded
29+
// {{AUTO_PORT}}, {{AUTO_TCP_PORT}}, {{AUTO_UDP_PORT}},
30+
// {{AUTO_QUIC_PORT}}, {{AUTO_SOCKS_PORT}}, {{AUTO_UPSTREAM_PORT}} → random free TCP port
31+
// {{AUTO_UUID}} → new UUID v4
32+
// {{AUTO_PASSWORD}} → 16 random bytes, hex-encoded
3233
package tmpl
3334

3435
import (
@@ -85,8 +86,8 @@ func init() {
8586
})
8687
}
8788

88-
// Render renders src as a Pongo2/Jinja2 template against vars, resolves "auto"
89-
// values, then optionally strips JSON5 extensions.
89+
// Render renders src as a Pongo2/Jinja2 template against vars, resolves {{AUTO_*}}
90+
// sentinels, then optionally strips JSON5 extensions.
9091
//
9192
// stripJSON5 (optional, default true): when false, JSON5 comments and trailing
9293
// commas are kept in the output — useful for cores that accept JSON5, or for
@@ -95,7 +96,7 @@ func init() {
9596
// Returns: rendered bytes, fully-resolved vars map, error.
9697
func Render(src []byte, vars map[string]string, stripJSON5 ...bool) ([]byte, map[string]string, error) {
9798
doStrip := len(stripJSON5) == 0 || stripJSON5[0]
98-
// 1. Resolve "auto" vars before rendering so the template sees real values.
99+
// 1. Resolve {{AUTO_*}} sentinels before rendering so the template sees real values.
99100
resolved, err := resolveAuto(vars)
100101
if err != nil {
101102
return nil, nil, err
@@ -206,16 +207,22 @@ func buildContext(resolved map[string]string) pongo2.Context {
206207
return ctx
207208
}
208209

209-
// resolveAuto returns a copy of vars with "auto" values resolved.
210+
// autoPlaceholder returns the {{AUTO_KEY}} sentinel for a given var name.
211+
// Used when checking if a var value requests auto-resolution.
212+
func autoPlaceholder(key string) string { return "{{AUTO_" + key + "}}" }
213+
214+
// resolveAuto returns a copy of vars with {{AUTO_*}} sentinels resolved.
215+
// Using explicit placeholders (instead of the bare string "auto") avoids
216+
// false matches when a config legitimately contains the word "auto".
210217
func resolveAuto(vars map[string]string) (map[string]string, error) {
211218
out := make(map[string]string, len(vars))
212219
for k, v := range vars {
213220
out[k] = v
214221
}
215222

216-
// All recognized port vars — "auto" gets a random free port each.
223+
// Port vars — {{AUTO_PORT}} etc. get a random free port each.
217224
for _, k := range []string{"PORT", "TCP_PORT", "UDP_PORT", "SOCKS_PORT", "UPSTREAM_PORT", "QUIC_PORT"} {
218-
if out[k] == "auto" {
225+
if out[k] == autoPlaceholder(k) {
219226
p, err := freePort()
220227
if err != nil {
221228
return nil, fmt.Errorf("tmpl: free port for %s: %w", k, err)
@@ -238,24 +245,24 @@ func resolveAuto(vars map[string]string) (map[string]string, error) {
238245

239246
// Built-in protocol defaults — override in vars if needed.
240247
builtinDefaults := map[string]string{
241-
"LOG_LEVEL": "error",
242-
"VLESS_ENC": "none",
243-
"VLESS_DEC": "none",
244-
"VLESS_FLOW": "",
245-
"HOST_NAME": "example.com",
246-
"SNI_NAME": "example.com",
248+
"LOG_LEVEL": "error",
249+
"VLESS_ENC": "none",
250+
"VLESS_DEC": "none",
251+
"VLESS_FLOW": "",
252+
"HOST_NAME": "example.com",
253+
"SNI_NAME": "example.com",
247254
}
248255
for k, def := range builtinDefaults {
249256
if out[k] == "" {
250257
out[k] = def
251258
}
252259
}
253260

254-
if out["UUID"] == "auto" {
261+
if out["UUID"] == autoPlaceholder("UUID") {
255262
out["UUID"] = uuid.New().String()
256263
}
257264

258-
if out["PASSWORD"] == "auto" {
265+
if out["PASSWORD"] == autoPlaceholder("PASSWORD") {
259266
b := make([]byte, 16)
260267
if _, err := rand.Read(b); err != nil {
261268
return nil, fmt.Errorf("tmpl: rand password: %w", err)

internal/tmpl/tmpl_test.go

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -98,29 +98,43 @@ func TestRender_CommentInsideString(t *testing.T) {
9898
// --- auto resolution ---
9999

100100
func TestRender_AutoPort(t *testing.T) {
101-
out, resolved, _ := Render([]byte(`{"port":{{PORT}}}`), map[string]string{"PORT": "auto"})
102-
if bytes.Contains(out, []byte("auto")) {
103-
t.Errorf("auto not resolved: %s", out)
101+
out, resolved, _ := Render([]byte(`{"port":{{PORT}}}`), map[string]string{"PORT": "{{AUTO_PORT}}"})
102+
if bytes.Contains(out, []byte("AUTO_PORT")) {
103+
t.Errorf("AUTO_PORT not resolved: %s", out)
104104
}
105-
if resolved["PORT"] == "auto" || resolved["PORT"] == "" {
105+
if resolved["PORT"] == "{{AUTO_PORT}}" || resolved["PORT"] == "" {
106106
t.Errorf("PORT not resolved: %q", resolved["PORT"])
107107
}
108108
}
109109

110110
func TestRender_AutoUUID(t *testing.T) {
111-
out, _, _ := Render([]byte(`{"uuid":"{{UUID}}"}`), map[string]string{"UUID": "auto"})
112-
if bytes.Contains(out, []byte("auto")) {
111+
out, _, _ := Render([]byte(`{"uuid":"{{UUID}}"}`), map[string]string{"UUID": "{{AUTO_UUID}}"})
112+
if bytes.Contains(out, []byte("AUTO_UUID")) {
113113
t.Errorf("UUID not resolved: %s", out)
114114
}
115115
}
116116

117117
func TestRender_AutoPassword(t *testing.T) {
118-
out, _, _ := Render([]byte(`{"pass":"{{PASSWORD}}"}`), map[string]string{"PASSWORD": "auto"})
119-
if bytes.Contains(out, []byte("auto")) {
118+
out, _, _ := Render([]byte(`{"pass":"{{PASSWORD}}"}`), map[string]string{"PASSWORD": "{{AUTO_PASSWORD}}"})
119+
if bytes.Contains(out, []byte("AUTO_PASSWORD")) {
120120
t.Errorf("PASSWORD not resolved: %s", out)
121121
}
122122
}
123123

124+
func TestRender_AutoLiteralPreserved(t *testing.T) {
125+
// The word "auto" in a real config value must NOT be resolved.
126+
out, resolved, err := Render([]byte(`{"mode":"{{ MODE }}"}`), map[string]string{"MODE": "auto"})
127+
if err != nil {
128+
t.Fatalf("Render: %v", err)
129+
}
130+
if !bytes.Contains(out, []byte("auto")) {
131+
t.Errorf("literal 'auto' value was incorrectly resolved: %s", out)
132+
}
133+
if resolved["MODE"] != "auto" {
134+
t.Errorf("MODE should stay 'auto', got %q", resolved["MODE"])
135+
}
136+
}
137+
124138
// --- combined: JSON5 + pongo2 + auto ---
125139

126140
func TestRender_Combined(t *testing.T) {
@@ -132,5 +146,5 @@ func TestRender_Combined(t *testing.T) {
132146
/* optional fields */
133147
"network": ["tcp","udp"],
134148
}`
135-
renderOK(t, src, map[string]string{"PORT": "auto", "PASSWORD": "auto"})
149+
renderOK(t, src, map[string]string{"PORT": "{{AUTO_PORT}}", "PASSWORD": "{{AUTO_PASSWORD}}"})
136150
}

0 commit comments

Comments
 (0)