Skip to content

Commit 7ea3509

Browse files
gsmlgGSMLG-BOT
andauthored
feat: expand dynamic config with matchers, env vars, routes, and plugins (#6)
Co-authored-by: Jonathan Gao <[email protected]>
1 parent 7bd089e commit 7ea3509

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

47 files changed

+5223
-4
lines changed

.github/workflows/ci.yml

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -121,10 +121,15 @@ jobs:
121121

122122
- name: Restore Dialyzer PLT cache
123123
uses: actions/cache@v4
124+
id: plt-cache
124125
with:
125126
path: priv/plts
126-
key: ${{ runner.os }}-dialyzer-${{ hashFiles('**/mix.lock') }}
127-
restore-keys: ${{ runner.os }}-dialyzer-
127+
key: ${{ runner.os }}-plt-otp28-elixir1.18-${{ hashFiles('**/mix.lock') }}
128+
restore-keys: |
129+
${{ runner.os }}-plt-otp28-elixir1.18-
130+
131+
- name: Create PLTs directory
132+
run: mkdir -p priv/plts
128133

129134
- name: Run Dialyzer
130-
run: mix dialyzer --halt-exit-status
135+
run: mix dialyzer

.github/workflows/test.yml

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,17 @@ jobs:
2626
otp-version: ${{ matrix.otp }}
2727

2828
- name: Download Caddy
29+
env:
30+
GH_TOKEN: ${{ github.token }}
2931
run: |
30-
curl -L "https://github.com/caddyserver/caddy/releases/latest/download/caddy_$(curl -s https://api.github.com/repos/caddyserver/caddy/releases/latest | grep -oP '\"tag_name\": \"v\K[0-9.]+')_linux_amd64.tar.gz" -o caddy.tar.gz
32+
# Use GitHub CLI with authentication to avoid rate limits
33+
CADDY_VERSION=$(gh release view --repo caddyserver/caddy --json tagName -q '.tagName' | sed 's/^v//')
34+
if [ -z "$CADDY_VERSION" ] || [ "$CADDY_VERSION" = "null" ]; then
35+
echo "Failed to get latest version, using fallback"
36+
CADDY_VERSION="2.9.1"
37+
fi
38+
echo "Downloading Caddy version: $CADDY_VERSION"
39+
curl -fsSL "https://github.com/caddyserver/caddy/releases/download/v${CADDY_VERSION}/caddy_${CADDY_VERSION}_linux_amd64.tar.gz" -o caddy.tar.gz
3140
tar -xzf caddy.tar.gz
3241
sudo mv caddy /usr/bin/caddy
3342
sudo chmod +x /usr/bin/caddy

lib/caddy/config/env_var.ex

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
defmodule Caddy.Config.EnvVar do
2+
@moduledoc """
3+
Represents a Caddy environment variable placeholder.
4+
5+
Environment variables in Caddy use `{$VAR}` or `{$VAR:default}` syntax.
6+
7+
## Examples
8+
9+
# Simple environment variable
10+
env = EnvVar.new("DATABASE_URL")
11+
# Renders as: {$DATABASE_URL}
12+
13+
# With default value
14+
env = EnvVar.new("PORT", "8080")
15+
# Renders as: {$PORT:8080}
16+
17+
# In configuration
18+
port_env = EnvVar.new("APP_PORT", "3000")
19+
site_address = "localhost:\#{Caddyfile.to_caddyfile(port_env)}"
20+
# Results in: localhost:{$APP_PORT:3000}
21+
22+
## Usage
23+
24+
Environment variables are resolved by Caddy at runtime from the process
25+
environment. When a default value is provided, Caddy uses that value
26+
if the variable is not set.
27+
28+
"""
29+
30+
@type t :: %__MODULE__{
31+
name: String.t(),
32+
default: String.t() | nil
33+
}
34+
35+
defstruct [:name, default: nil]
36+
37+
@doc """
38+
Create a new environment variable placeholder.
39+
40+
## Parameters
41+
42+
- `name` - Environment variable name (without `$`)
43+
- `default` - Optional default value if variable is unset
44+
45+
## Examples
46+
47+
iex> EnvVar.new("DATABASE_URL")
48+
%EnvVar{name: "DATABASE_URL", default: nil}
49+
50+
iex> EnvVar.new("PORT", "8080")
51+
%EnvVar{name: "PORT", default: "8080"}
52+
53+
"""
54+
@spec new(String.t(), String.t() | nil) :: t()
55+
def new(name, default \\ nil) when is_binary(name) do
56+
%__MODULE__{name: name, default: default}
57+
end
58+
59+
@doc """
60+
Validate an environment variable.
61+
62+
## Examples
63+
64+
iex> EnvVar.validate(%EnvVar{name: "PORT"})
65+
{:ok, %EnvVar{name: "PORT"}}
66+
67+
iex> EnvVar.validate(%EnvVar{name: ""})
68+
{:error, "name cannot be empty"}
69+
70+
iex> EnvVar.validate(%EnvVar{name: "INVALID-NAME"})
71+
{:error, "name must contain only alphanumeric characters and underscores"}
72+
73+
"""
74+
@spec validate(t()) :: {:ok, t()} | {:error, String.t()}
75+
def validate(%__MODULE__{name: name, default: default} = env_var) do
76+
cond do
77+
name == "" or name == nil ->
78+
{:error, "name cannot be empty"}
79+
80+
not is_binary(name) ->
81+
{:error, "name must be a string"}
82+
83+
not valid_name?(name) ->
84+
{:error, "name must contain only alphanumeric characters and underscores"}
85+
86+
default != nil and not is_binary(default) ->
87+
{:error, "default must be a string or nil"}
88+
89+
true ->
90+
{:ok, env_var}
91+
end
92+
end
93+
94+
@doc """
95+
Check if a string is a valid environment variable name.
96+
97+
Valid names contain only alphanumeric characters and underscores,
98+
and cannot start with a digit.
99+
100+
## Examples
101+
102+
iex> EnvVar.valid_name?("DATABASE_URL")
103+
true
104+
105+
iex> EnvVar.valid_name?("my-var")
106+
false
107+
108+
"""
109+
@spec valid_name?(String.t()) :: boolean()
110+
def valid_name?(name) when is_binary(name) do
111+
Regex.match?(~r/^[A-Za-z_][A-Za-z0-9_]*$/, name)
112+
end
113+
114+
def valid_name?(_), do: false
115+
end
116+
117+
defimpl Caddy.Caddyfile, for: Caddy.Config.EnvVar do
118+
@moduledoc """
119+
Caddyfile protocol implementation for EnvVar.
120+
"""
121+
122+
def to_caddyfile(%{name: name, default: nil}) do
123+
start_time = System.monotonic_time()
124+
result = "{$#{name}}"
125+
duration = System.monotonic_time() - start_time
126+
127+
Caddy.Telemetry.emit_config_change(:render, %{duration: duration}, %{
128+
module: Caddy.Config.EnvVar,
129+
result_size: byte_size(result)
130+
})
131+
132+
result
133+
end
134+
135+
def to_caddyfile(%{name: name, default: default}) do
136+
start_time = System.monotonic_time()
137+
result = "{$#{name}:#{default}}"
138+
duration = System.monotonic_time() - start_time
139+
140+
Caddy.Telemetry.emit_config_change(:render, %{duration: duration}, %{
141+
module: Caddy.Config.EnvVar,
142+
result_size: byte_size(result)
143+
})
144+
145+
result
146+
end
147+
end
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
defmodule Caddy.Config.Matcher.ClientIp do
2+
@moduledoc """
3+
Represents a Caddy client_ip matcher.
4+
5+
Matches requests by the client IP address, respecting X-Forwarded-For headers
6+
when behind trusted proxies.
7+
8+
## Examples
9+
10+
# Match single IP
11+
matcher = ClientIp.new(["192.168.1.1"])
12+
# Renders as: client_ip 192.168.1.1
13+
14+
# Match CIDR range
15+
matcher = ClientIp.new(["192.168.0.0/16"])
16+
# Renders as: client_ip 192.168.0.0/16
17+
18+
# Match private ranges
19+
matcher = ClientIp.new(["private_ranges"])
20+
# Renders as: client_ip private_ranges
21+
22+
## Shortcuts
23+
24+
- `private_ranges` - Matches all RFC 1918 private ranges
25+
- `forwarded` - Uses the X-Forwarded-For header
26+
27+
"""
28+
29+
@type t :: %__MODULE__{
30+
ranges: [String.t()]
31+
}
32+
33+
defstruct ranges: []
34+
35+
@doc """
36+
Create a new client_ip matcher.
37+
38+
## Parameters
39+
40+
- `ranges` - List of IP addresses, CIDR ranges, or shortcuts
41+
42+
## Examples
43+
44+
iex> ClientIp.new(["192.168.1.1"])
45+
%ClientIp{ranges: ["192.168.1.1"]}
46+
47+
iex> ClientIp.new(["private_ranges"])
48+
%ClientIp{ranges: ["private_ranges"]}
49+
50+
"""
51+
@spec new([String.t()]) :: t()
52+
def new(ranges) when is_list(ranges) do
53+
%__MODULE__{ranges: ranges}
54+
end
55+
56+
@doc """
57+
Validate a client_ip matcher.
58+
59+
## Examples
60+
61+
iex> ClientIp.validate(%ClientIp{ranges: ["192.168.1.1"]})
62+
{:ok, %ClientIp{ranges: ["192.168.1.1"]}}
63+
64+
iex> ClientIp.validate(%ClientIp{ranges: []})
65+
{:error, "ranges cannot be empty"}
66+
67+
"""
68+
@spec validate(t()) :: {:ok, t()} | {:error, String.t()}
69+
def validate(%__MODULE__{ranges: ranges} = matcher) do
70+
cond do
71+
ranges == [] ->
72+
{:error, "ranges cannot be empty"}
73+
74+
not Enum.all?(ranges, &is_binary/1) ->
75+
{:error, "all ranges must be strings"}
76+
77+
true ->
78+
{:ok, matcher}
79+
end
80+
end
81+
end
82+
83+
defimpl Caddy.Caddyfile, for: Caddy.Config.Matcher.ClientIp do
84+
@moduledoc """
85+
Caddyfile protocol implementation for ClientIp matcher.
86+
"""
87+
88+
def to_caddyfile(%{ranges: ranges}) do
89+
start_time = System.monotonic_time()
90+
result = "client_ip #{Enum.join(ranges, " ")}"
91+
duration = System.monotonic_time() - start_time
92+
93+
Caddy.Telemetry.emit_config_change(:render, %{duration: duration}, %{
94+
module: Caddy.Config.Matcher.ClientIp,
95+
result_size: byte_size(result)
96+
})
97+
98+
result
99+
end
100+
end
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
defmodule Caddy.Config.Matcher.Expression do
2+
@moduledoc """
3+
Represents a Caddy expression matcher.
4+
5+
Matches requests using CEL (Common Expression Language) expressions.
6+
7+
## Examples
8+
9+
# Match POST/PUT/PATCH methods
10+
matcher = Expression.new("{method}.startsWith(\"P\")")
11+
# Renders as: expression {method}.startsWith("P")
12+
13+
# Complex condition
14+
matcher = Expression.new("{path}.startsWith(\"/api\") && {method} == \"GET\"")
15+
# Renders as: expression {path}.startsWith("/api") && {method} == "GET"
16+
17+
## CEL Syntax
18+
19+
CEL expressions can use Caddy placeholders and support:
20+
- String operations: `.startsWith()`, `.endsWith()`, `.contains()`
21+
- Comparisons: `==`, `!=`, `<`, `>`, `<=`, `>=`
22+
- Logical operators: `&&`, `||`, `!`
23+
24+
"""
25+
26+
@type t :: %__MODULE__{
27+
expression: String.t()
28+
}
29+
30+
defstruct [:expression]
31+
32+
@doc """
33+
Create a new expression matcher.
34+
35+
## Parameters
36+
37+
- `expression` - CEL expression string
38+
39+
## Examples
40+
41+
iex> Expression.new("{method} == \"GET\"")
42+
%Expression{expression: "{method} == \"GET\""}
43+
44+
iex> Expression.new("{path}.startsWith(\"/api\")")
45+
%Expression{expression: "{path}.startsWith(\"/api\")"}
46+
47+
"""
48+
@spec new(String.t()) :: t()
49+
def new(expression) when is_binary(expression) do
50+
%__MODULE__{expression: expression}
51+
end
52+
53+
@doc """
54+
Validate an expression matcher.
55+
56+
## Examples
57+
58+
iex> Expression.validate(%Expression{expression: "{x} == 1"})
59+
{:ok, %Expression{expression: "{x} == 1"}}
60+
61+
iex> Expression.validate(%Expression{expression: ""})
62+
{:error, "expression cannot be empty"}
63+
64+
"""
65+
@spec validate(t()) :: {:ok, t()} | {:error, String.t()}
66+
def validate(%__MODULE__{expression: expression} = matcher) do
67+
cond do
68+
expression == "" or expression == nil ->
69+
{:error, "expression cannot be empty"}
70+
71+
not is_binary(expression) ->
72+
{:error, "expression must be a string"}
73+
74+
true ->
75+
{:ok, matcher}
76+
end
77+
end
78+
end
79+
80+
defimpl Caddy.Caddyfile, for: Caddy.Config.Matcher.Expression do
81+
@moduledoc """
82+
Caddyfile protocol implementation for Expression matcher.
83+
"""
84+
85+
def to_caddyfile(%{expression: expression}) do
86+
start_time = System.monotonic_time()
87+
result = "expression #{expression}"
88+
duration = System.monotonic_time() - start_time
89+
90+
Caddy.Telemetry.emit_config_change(:render, %{duration: duration}, %{
91+
module: Caddy.Config.Matcher.Expression,
92+
result_size: byte_size(result)
93+
})
94+
95+
result
96+
end
97+
end

0 commit comments

Comments
 (0)