Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -145,9 +145,10 @@ jobs:
with:
mode: enforce
offline: 'true'
allowed-hosts: |
allowed-hosts: |
docker.io
docker.com
**.githubusercontent.com
binary-path: ./bin/cargowall
debug: true

Expand All @@ -156,6 +157,11 @@ jobs:
curl -sf --connect-timeout 10 --max-time 30 https://github.com > /dev/null
echo "✅ github.com allowed"

- name: Test allowed connection via hostname pattern
run: |
curl -sf --connect-timeout 10 --max-time 30 https://raw.githubusercontent.com/github/gitignore/main/README.md > /dev/null
echo "✅ raw.githubusercontent.com allowed via **.githubusercontent.com pattern"

- name: Test blocked connection
run: |
if curl -sf --connect-timeout 5 --max-time 10 https://example.com > /dev/null 2>&1; then
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
.idea
.claude
.omc
node_modules
bin
/cargowall
Expand Down
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,19 @@ Add the [CargoWall GitHub Action](https://github.com/code-cargo/cargowall-action
registry.npmjs.org
```

Hostname rules support **glob patterns** for matching dynamic hostnames:

- `*` matches exactly one DNS label
- `**` matches one or more DNS labels
- Wildcards must be a full dot-separated segment — partial wildcards like `google.co*` are not supported

```yaml
allowed-hosts: |
github.com,
actions.githubusercontent.com.*.*.internal.cloudapp.net,
**.storage.azure.com
```

See the [cargowall-action README](https://github.com/code-cargo/cargowall-action) for full usage, inputs, outputs, and examples.

---
Expand Down
6 changes: 6 additions & 0 deletions config.example.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,12 @@
"value": "api.example.com",
"ports": [{"port": 80, "protocol": "tcp"}, {"port": 443, "protocol": "tcp"}],
"action": "allow"
},
{
"type": "hostname",
"value": "actions.githubusercontent.com.*.*.internal.cloudapp.net",
"ports": [{"port": 443, "protocol": "tcp"}],
"action": "allow"
}
],
"sudoLockdown": {
Expand Down
3 changes: 2 additions & 1 deletion design.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ Dual-stack (IPv4/IPv6) L4 firewall using TC eBPF egress filtering, cgroup socket
- **Dual-Stack L4 Firewall**: Filters TCP and UDP traffic on both IPv4 and IPv6
- **Protocol Handling**: Blocks non-TCP/UDP on IPv4; allows ICMPv6 and IPv6 multicast (`ff00::/8`); passes non-IP traffic (ARP)
- **DNS Proxy with JIT Resolution**: Intercepts DNS queries and updates firewall rules in real-time
- **Hostname Glob Patterns**: `*` (one label) and `**` (one or more labels) wildcards for matching dynamic hostnames
- **DNS Query Filtering**: Blocks queries for non-allowed domains to prevent DNS tunneling
- **Port-Specific Rules**: Granular port-based filtering including wildcard CIDRs (`0.0.0.0/0`, `::/0`)
- **LPM Trie Optimization**: Separate IPv4 and IPv6 longest-prefix-match tries for efficient CIDR lookups
Expand Down Expand Up @@ -362,7 +363,7 @@ sequenceDiagram
- Env vars: `CARGOWALL_DEFAULT_ACTION`, `CARGOWALL_ALLOWED_HOSTS`, `CARGOWALL_ALLOWED_CIDRS`, `CARGOWALL_BLOCKED_HOSTS`, `CARGOWALL_BLOCKED_CIDRS`
- Port format in env: `host:port1;port2` (e.g., `github.com:443;80`)
- Subdomain matching: `lb-140-82-113-22-iad.github.com` matches a `github.com` rule
- Wildcard hostname normalization: `*.github.com` → `github.com` (parent domain matching handles subdomains)
- Glob pattern matching for hostnames: `*` matches one DNS label, `**` matches one or more (e.g., `actions.githubusercontent.com.*.*.internal.cloudapp.net`, `**.storage.azure.com`)
- IP-to-hostname reverse mapping via `UpdateDNSMapping()` with bounded cache (10,000 entries, 24h TTL)
- Rule conflict detection: `CheckIPRuleConflict()` finds most specific CIDR by prefix length, checks port overlap, deny wins
- `EnsureDNSAllowed(ips)` — adds /32 allow rules on port 53 for upstream DNS IPs
Expand Down
95 changes: 68 additions & 27 deletions pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -110,15 +110,25 @@ type SudoLockdownSettings struct {

// ResolvedRule represents a Rule with resolved IP addresses or CIDR blocks
type ResolvedRule struct {
Type RuleType // "hostname" or "cidr"
Value string // Original value (hostname or CIDR string)
IPs []net.IP // For hostnames: resolved IPs. For CIDR: empty
IPNet *net.IPNet // For CIDR blocks only
Type RuleType // "hostname" or "cidr"
Value string // Original value (hostname or CIDR string)
IPs []net.IP // For hostnames: resolved IPs. For CIDR: empty
IPNet *net.IPNet // For CIDR blocks only
Pattern *hostnamePattern // Non-nil for hostname rules with glob wildcards
Ports []Port
Action Action
AutoAddedType AutoAddedType // Why this rule was auto-added (empty for user-configured rules)
}

// MatchesHostname returns true if the hostname matches this hostname rule
// via glob pattern, exact match, or parent domain (subdomain) match.
func (r *ResolvedRule) MatchesHostname(hostname string) bool {
if r.Pattern != nil {
return r.Pattern.Matches(hostname)
}
return hostname == r.Value || strings.HasSuffix(hostname, "."+r.Value)
}

// Manager manages the firewall configuration and hostname resolution
type Manager struct {
mu sync.RWMutex
Expand Down Expand Up @@ -315,7 +325,6 @@ func (cm *Manager) LoadFromEnv() error {
for _, entry := range splitAndTrim(allowedHosts) {
if entry != "" {
host, ports := parseHostWithPorts(entry)
host = normalizeHostname(host)
rules = append(rules, Rule{
Type: RuleTypeHostname,
Value: host,
Expand Down Expand Up @@ -346,7 +355,6 @@ func (cm *Manager) LoadFromEnv() error {
for _, entry := range splitAndTrim(blockedHosts) {
if entry != "" {
host, ports := parseHostWithPorts(entry)
host = normalizeHostname(host)
rules = append(rules, Rule{
Type: RuleTypeHostname,
Value: host,
Expand Down Expand Up @@ -437,13 +445,6 @@ func parseHostWithPorts(entry string) (string, []Port) {
return host, ports
}

// normalizeHostname strips a leading "*." wildcard prefix from a hostname.
// Since parent domain matching already handles subdomains, *.github.com is
// equivalent to github.com.
func normalizeHostname(host string) string {
return strings.TrimPrefix(host, "*.")
}

// GetResolvedRules returns the current resolved rules
func (cm *Manager) GetResolvedRules() []ResolvedRule {
cm.mu.RLock()
Expand Down Expand Up @@ -565,18 +566,41 @@ func (cm *Manager) GetTrackedHostnameAction(hostname string) Action {
return action
}

// Check if it's a subdomain of a tracked hostname
// For example, if "google.com" is tracked, then "www.google.com" should inherit the same action
// Check if it's a subdomain of a tracked hostname.
// Don't return yet — a more specific deny pattern may override.
var parentAction Action
for trackedHost, action := range cm.trackedHostnames {
if strings.HasSuffix(hostname, "."+trackedHost) {
slog.Debug("Found parent domain match",
"hostname", hostname,
"parent", trackedHost,
"action", action)
return action
parentAction = action
break
}
}

// Check hostname patterns (glob matching).
// A deny pattern overrides a parent-domain allow (more specific wins).
for _, rule := range cm.resolvedRules {
if rule.Pattern != nil && rule.Pattern.Matches(hostname) {
slog.Debug("Found pattern match",
"hostname", hostname,
"pattern", rule.Pattern.Raw,
"action", rule.Action)
if rule.Action == ActionDeny {
return ActionDeny
}
if parentAction == "" {
parentAction = rule.Action
}
}
}

if parentAction != "" {
return parentAction
}

slog.Debug("No tracked hostname found", "hostname", hostname)
return ""
}
Expand Down Expand Up @@ -682,6 +706,14 @@ func (cm *Manager) FindTrackedHostname(name string) string {
return trackedHost
}
}

// Pattern match — return the actual hostname (not the pattern string)
// so callers can safely pass it to UpdateDNSMapping / GetTrackedHostnameAction.
for _, rule := range cm.resolvedRules {
if rule.Pattern != nil && rule.Pattern.Matches(name) {
return name
}
}
return ""
}

Expand Down Expand Up @@ -913,7 +945,7 @@ func (cm *Manager) GetAutoAllowedTypeForHostname(hostname string) AutoAddedType
if rule.Type != RuleTypeHostname || rule.AutoAddedType == AutoAddedTypeNone || rule.Action != ActionAllow {
continue
}
if hostname == rule.Value || strings.HasSuffix(hostname, "."+rule.Value) {
if rule.MatchesHostname(hostname) {
return rule.AutoAddedType
}
}
Expand Down Expand Up @@ -964,7 +996,7 @@ func (cm *Manager) GetAutoAllowedType(ip string, port uint16, hostname string) A

switch rule.Type {
case RuleTypeHostname:
if hostname == rule.Value || strings.HasSuffix(hostname, "."+rule.Value) {
if rule.MatchesHostname(hostname) {
return rule.AutoAddedType
}
case RuleTypeCIDR:
Expand Down Expand Up @@ -1012,16 +1044,25 @@ func (cm *Manager) resolveRules() error {

switch rule.Type {
case RuleTypeHostname:
// Track ALL hostnames we have rules for - will resolve JIT when DNS queries arrive
cm.trackedHostnames[rule.Value] = rule.Action

// Check if we already have cached IPs for this hostname (from previous DNS intercepts)
if cachedIPs, ok := cm.hostnameCache[rule.Value]; ok {
resolved.IPs = cachedIPs
if isHostnamePattern(rule.Value) {
pattern, err := compileHostnamePattern(rule.Value)
if err != nil {
slog.Error("Invalid hostname pattern", "value", rule.Value, "error", err)
continue
}
resolved.Pattern = &pattern
} else {
// Initialize the hostnameCache entry so UpdateDNSMapping can append IPs later
cm.hostnameCache[rule.Value] = []net.IP{}
resolved.IPs = []net.IP{}
// Track ALL hostnames we have rules for - will resolve JIT when DNS queries arrive
cm.trackedHostnames[rule.Value] = rule.Action

// Check if we already have cached IPs for this hostname (from previous DNS intercepts)
if cachedIPs, ok := cm.hostnameCache[rule.Value]; ok {
resolved.IPs = cachedIPs
} else {
// Initialize the hostnameCache entry so UpdateDNSMapping can append IPs later
cm.hostnameCache[rule.Value] = []net.IP{}
resolved.IPs = []net.IP{}
}
}

case RuleTypeCIDR:
Expand Down
Loading
Loading