Repository: caddyserver/caddy (2025-08-20T20:16:55Z)

Issue:
`anonymous blocks are not supported` when importing site-level snippet with block
For following Caddyfile:
```caddyfile
(site) {
  http://{args[0]} https://{args[0]} {
    {block}
  }
}

import site test.domain {
  reverse_proxy http://192.168.1.1:8080 { 
    header_up Host {host}
    header_up X-Real-IP {remote_host}
  }
}
```
`caddy run -c Caddyfile` yields
```
Error: adapting config using caddyfile: anonymous blocks are not supported, at Caddyfile:12
```

Following Caddyfile will work:
```caddyfile
(site) {
  http://{args[0]} https://{args[0]} {
    {block}
  }
}

import site test.domain {
  reverse_proxy http://192.168.1.1:8080
}
```

Both of followings won't work:
```caddyfile
(site) {
  http://{args[0]} https://{args[0]} {
    {block}
  }
}

import site test.domain {
  reverse_proxy http://192.168.1.1:8080 { 
    header_up Host {host}
    header_up X-Real-IP {remote_host}
  }
  reverse_proxy http://192.168.1.1:8080
}
```
```caddyfile
(site) {
  http://{args[0]} https://{args[0]} {
    {block}
  }
}

import site test.domain {
  reverse_proxy http://192.168.1.1:8080 { 
  }
}
```


Discussion:
Same thing for me, it should be working as per docs:
https://caddyserver.com/docs/caddyfile/directives/import
(very bottom)

I tried switching to keyed blocks, but that got me this error: `unrecognized subdirective {block.keyname}`.

v2.10.0 aarch64
Greetings,

I would like to try my hand on this issue. I was looking for something with "good first issue" tag, but I didn't find any recent ones here and a parsing issue is something I should be able to handle, perhaps with a followup question if it comes up.
Okay, I just completed a solution, I just don't have time to upload a PR right now, since I didn't properly set a fork up, etc. 
I'll do my best to do it tomorrow.



Patch:
diff --git a/caddyconfig/caddyfile/dispenser.go b/caddyconfig/caddyfile/dispenser.go
index 325bb54d3f3..d95196e4898 100644
--- a/caddyconfig/caddyfile/dispenser.go
+++ b/caddyconfig/caddyfile/dispenser.go
@@ -308,9 +308,9 @@ func (d *Dispenser) CountRemainingArgs() int {
 }
 
 // RemainingArgs loads any more arguments (tokens on the same line)
-// into a slice and returns them. Open curly brace tokens also indicate
-// the end of arguments, and the curly brace is not included in
-// the return value nor is it loaded.
+// into a slice of strings and returns them. Open curly brace tokens
+// also indicate the end of arguments, and the curly brace is not
+// included in the return value nor is it loaded.
 func (d *Dispenser) RemainingArgs() []string {
 	var args []string
 	for d.NextArg() {
@@ -320,9 +320,9 @@ func (d *Dispenser) RemainingArgs() []string {
 }
 
 // RemainingArgsRaw loads any more arguments (tokens on the same line,
-// retaining quotes) into a slice and returns them. Open curly brace
-// tokens also indicate the end of arguments, and the curly brace is
-// not included in the return value nor is it loaded.
+// retaining quotes) into a slice of strings and returns them.
+// Open curly brace tokens also indicate the end of arguments,
+// and the curly brace is not included in the return value nor is it loaded.
 func (d *Dispenser) RemainingArgsRaw() []string {
 	var args []string
 	for d.NextArg() {
@@ -331,6 +331,18 @@ func (d *Dispenser) RemainingArgsRaw() []string {
 	return args
 }
 
+// RemainingArgsAsTokens loads any more arguments (tokens on the same line)
+// into a slice of Token-structs and returns them. Open curly brace tokens
+// also indicate the end of arguments, and the curly brace is not included
+// in the return value nor is it loaded.
+func (d *Dispenser) RemainingArgsAsTokens() []Token {
+	var args []Token
+	for d.NextArg() {
+		args = append(args, d.Token())
+	}
+	return args
+}
+
 // NewFromNextSegment returns a new dispenser with a copy of
 // the tokens from the current token until the end of the
 // "directive" whether that be to the end of the line or
diff --git a/caddyconfig/caddyfile/parse.go b/caddyconfig/caddyfile/parse.go
index d04a1ac4672..2bc52adedd8 100644
--- a/caddyconfig/caddyfile/parse.go
+++ b/caddyconfig/caddyfile/parse.go
@@ -379,28 +379,23 @@ func (p *parser) doImport(nesting int) error {
 	if len(blockTokens) > 0 {
 		// use such tokens to create a new dispenser, and then use it to parse each block
 		bd := NewDispenser(blockTokens)
+
+		// one iteration processes one sub-block inside the import
 		for bd.Next() {
-			// see if we can grab a key
-			var currentMappingKey string
-			if bd.Val() == "{" {
+			currentMappingKey := bd.Val()
+
+			if currentMappingKey == "{" {
 				return p.Err("anonymous blocks are not supported")
 			}
-			currentMappingKey = bd.Val()
-			currentMappingTokens := []Token{}
-			// read all args until end of line / {
-			if bd.NextArg() {
+
+			// load up all arguments (if there even are any)
+			currentMappingTokens := bd.RemainingArgsAsTokens()
+
+			// load up the entire block
+			for mappingNesting := bd.Nesting(); bd.NextBlock(mappingNesting); {
 				currentMappingTokens = append(currentMappingTokens, bd.Token())
-				for bd.NextArg() {
-					currentMappingTokens = append(currentMappingTokens, bd.Token())
-				}
-				// TODO(elee1766): we don't enter another mapping here because it's annoying to extract the { and } properly.
-				// maybe someone can do that in the future
-			} else {
-				// attempt to enter a block and add tokens to the currentMappingTokens
-				for mappingNesting := bd.Nesting(); bd.NextBlock(mappingNesting); {
-					currentMappingTokens = append(currentMappingTokens, bd.Token())
-				}
 			}
+
 			blockMapping[currentMappingKey] = currentMappingTokens
 		}
 	}


Test patch:
diff --git a/caddyconfig/caddyfile/dispenser_test.go b/caddyconfig/caddyfile/dispenser_test.go
index 0f6ee5043f4..f5d2260055f 100644
--- a/caddyconfig/caddyfile/dispenser_test.go
+++ b/caddyconfig/caddyfile/dispenser_test.go
@@ -274,6 +274,66 @@ func TestDispenser_RemainingArgs(t *testing.T) {
 	}
 }
 
+func TestDispenser_RemainingArgsAsTokens(t *testing.T) {
+	input := `dir1 arg1 arg2 arg3
+			  dir2 arg4 arg5
+			  dir3 arg6 { arg7
+			  dir4`
+	d := NewTestDispenser(input)
+
+	d.Next() // dir1
+
+	args := d.RemainingArgsAsTokens()
+
+	tokenTexts := make([]string, 0, len(args))
+	for _, arg := range args {
+		tokenTexts = append(tokenTexts, arg.Text)
+	}
+
+	if expected := []string{"arg1", "arg2", "arg3"}; !reflect.DeepEqual(tokenTexts, expected) {
+		t.Errorf("RemainingArgsAsTokens(): Expected %v, got %v", expected, tokenTexts)
+	}
+
+	d.Next() // dir2
+
+	args = d.RemainingArgsAsTokens()
+
+	tokenTexts = tokenTexts[:0]
+	for _, arg := range args {
+		tokenTexts = append(tokenTexts, arg.Text)
+	}
+
+	if expected := []string{"arg4", "arg5"}; !reflect.DeepEqual(tokenTexts, expected) {
+		t.Errorf("RemainingArgsAsTokens(): Expected %v, got %v", expected, tokenTexts)
+	}
+
+	d.Next() // dir3
+
+	args = d.RemainingArgsAsTokens()
+	tokenTexts = tokenTexts[:0]
+	for _, arg := range args {
+		tokenTexts = append(tokenTexts, arg.Text)
+	}
+
+	if expected := []string{"arg6"}; !reflect.DeepEqual(tokenTexts, expected) {
+		t.Errorf("RemainingArgsAsTokens(): Expected %v, got %v", expected, tokenTexts)
+	}
+
+	d.Next() // {
+	d.Next() // arg7
+	d.Next() // dir4
+
+	args = d.RemainingArgsAsTokens()
+	tokenTexts = tokenTexts[:0]
+	for _, arg := range args {
+		tokenTexts = append(tokenTexts, arg.Text)
+	}
+
+	if len(args) != 0 {
+		t.Errorf("RemainingArgsAsTokens(): Expected %v, got %v", []string{}, tokenTexts)
+	}
+}
+
 func TestDispenser_ArgErr_Err(t *testing.T) {
 	input := `dir1 {
 			  }
diff --git a/caddyconfig/caddyfile/parse_test.go b/caddyconfig/caddyfile/parse_test.go
index d3fada4e026..bf149e63545 100644
--- a/caddyconfig/caddyfile/parse_test.go
+++ b/caddyconfig/caddyfile/parse_test.go
@@ -18,6 +18,7 @@ import (
 	"bytes"
 	"os"
 	"path/filepath"
+	"strings"
 	"testing"
 )
 
@@ -884,6 +885,51 @@ func TestRejectsGlobalMatcher(t *testing.T) {
 	}
 }
 
+func TestRejectAnonymousImportBlock(t *testing.T) {
+	p := testParser(`
+		(site) {
+			http://{args[0]} https://{args[0]} {
+				{block}
+			}
+		}
+
+		import site test.domain {
+			{ 
+				header_up Host {host}
+				header_up X-Real-IP {remote_host}
+			}
+		}
+	`)
+	_, err := p.parseAll()
+	if err == nil {
+		t.Fatal("Expected an error, but got nil")
+	}
+	expected := "anonymous blocks are not supported"
+	if !strings.HasPrefix(err.Error(), "anonymous blocks are not supported") {
+		t.Errorf("Expected error to start with '%s' but got '%v'", expected, err)
+	}
+}
+
+func TestAcceptSiteImportWithBraces(t *testing.T) {
+	p := testParser(`
+		(site) {
+			http://{args[0]} https://{args[0]} {
+				{block}
+			}
+		}
+
+		import site test.domain {
+			reverse_proxy http://192.168.1.1:8080 { 
+				header_up Host {host}
+			}
+		}
+	`)
+	_, err := p.parseAll()
+	if err != nil {
+		t.Errorf("Expected error to be nil but got '%v'", err)
+	}
+}
+
 func testParser(input string) parser {
 	return parser{Dispenser: NewTestDispenser(input)}
 }
diff --git a/caddytest/integration/caddyfile_adapt/import_block_anonymous.caddyfiletest b/caddytest/integration/caddyfile_adapt/import_block_anonymous.caddyfiletest
new file mode 100644
index 00000000000..3ab1745e95d
--- /dev/null
+++ b/caddytest/integration/caddyfile_adapt/import_block_anonymous.caddyfiletest
@@ -0,0 +1,13 @@
+(site) {
+    http://{args[0]} https://{args[0]} {
+        {block}
+    }
+}
+import site test.domain {
+    { 
+        header_up Host {host}
+        header_up X-Real-IP {remote_host}
+    }
+}
+----------
+anonymous blocks are not supported
\ No newline at end of file
diff --git a/caddytest/integration/caddyfile_adapt/import_block_with_site_block.caddyfiletest b/caddytest/integration/caddyfile_adapt/import_block_with_site_block.caddyfiletest
new file mode 100644
index 00000000000..7527ce4d6e7
--- /dev/null
+++ b/caddytest/integration/caddyfile_adapt/import_block_with_site_block.caddyfiletest
@@ -0,0 +1,65 @@
+(site) {
+	https://{args[0]} {
+		{block}
+	}
+}
+
+import site test.domain {
+	reverse_proxy http://192.168.1.1:8080 {
+		header_up Host {host}
+	}
+}
+----------
+{
+	"apps": {
+		"http": {
+			"servers": {
+				"srv0": {
+					"listen": [
+						":443"
+					],
+					"routes": [
+						{
+							"match": [
+								{
+									"host": [
+										"test.domain"
+									]
+								}
+							],
+							"handle": [
+								{
+									"handler": "subroute",
+									"routes": [
+										{
+											"handle": [
+												{
+													"handler": "reverse_proxy",
+													"headers": {
+														"request": {
+															"set": {
+																"Host": [
+																	"{http.request.host}"
+																]
+															}
+														}
+													},
+													"upstreams": [
+														{
+															"dial": "192.168.1.1:8080"
+														}
+													]
+												}
+											]
+										}
+									]
+								}
+							],
+							"terminal": true
+						}
+					]
+				}
+			}
+		}
+	}
+}