Add support for config inheritance
This commit is contained in:
@@ -93,6 +93,7 @@ Flags:
|
||||
-m, --monitor Monitor mode (shows blocked requests and violations only)
|
||||
-p, --port Expose port for inbound connections (can be repeated)
|
||||
-s, --settings Path to settings file (default: ~/.fence.json)
|
||||
-t, --template Use built-in template (e.g., code, local-dev-server)
|
||||
-v, --version Show version information
|
||||
-h, --help Help for fence
|
||||
```
|
||||
@@ -104,6 +105,13 @@ Flags:
|
||||
fence curl https://example.com
|
||||
# Output: curl: (56) CONNECT tunnel failed, response 403
|
||||
|
||||
# Use a built-in template
|
||||
fence -t code -- claude
|
||||
|
||||
# Extend a template in your config (adds private registry to 'code' template)
|
||||
# ~/.fence.json: {"extends": "code", "network": {"allowedDomains": ["private.company.com"]}}
|
||||
fence npm install
|
||||
|
||||
# Use a custom config
|
||||
fence --settings ./my-config.json npm install
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
@@ -170,6 +171,11 @@ func runCommand(cmd *cobra.Command, args []string) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load config: %w", err)
|
||||
}
|
||||
absPath, _ := filepath.Abs(settingsPath)
|
||||
cfg, err = templates.ResolveExtendsWithBaseDir(cfg, filepath.Dir(absPath))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to resolve extends: %w", err)
|
||||
}
|
||||
default:
|
||||
configPath := config.DefaultConfigPath()
|
||||
cfg, err = config.Load(configPath)
|
||||
@@ -181,6 +187,11 @@ func runCommand(cmd *cobra.Command, args []string) error {
|
||||
fmt.Fprintf(os.Stderr, "[fence] No config found at %s, using default (block all network)\n", configPath)
|
||||
}
|
||||
cfg = config.Default()
|
||||
} else {
|
||||
cfg, err = templates.ResolveExtendsWithBaseDir(cfg, filepath.Dir(configPath))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to resolve extends: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,66 @@ Example config:
|
||||
}
|
||||
```
|
||||
|
||||
## Config Inheritance
|
||||
|
||||
You can extend built-in templates or other config files using the `extends` field. This reduces boilerplate by inheriting settings from a base and only specifying your overrides.
|
||||
|
||||
### Extending a template
|
||||
|
||||
```json
|
||||
{
|
||||
"extends": "code",
|
||||
"network": {
|
||||
"allowedDomains": ["private-registry.company.com"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This config:
|
||||
|
||||
- Inherits all settings from the `code` template (LLM providers, package registries, filesystem protections, command restrictions)
|
||||
- Adds `private-registry.company.com` to the allowed domains list
|
||||
|
||||
### Extending a file
|
||||
|
||||
You can also extend other config files using absolute or relative paths:
|
||||
|
||||
```json
|
||||
{
|
||||
"extends": "./base-config.json",
|
||||
"network": {
|
||||
"allowedDomains": ["extra-domain.com"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"extends": "/etc/fence/company-base.json",
|
||||
"filesystem": {
|
||||
"denyRead": ["~/company-secrets/**"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Relative paths are resolved relative to the config file's directory. The extended file is validated before merging.
|
||||
|
||||
### Detection
|
||||
|
||||
The `extends` value is treated as a file path if it contains `/` or `\`, or starts with `.`. Otherwise it's treated as a template name.
|
||||
|
||||
### Merge behavior
|
||||
|
||||
- Slice fields (domains, paths, commands) are appended and deduplicated
|
||||
- Boolean fields use OR logic (true if either base or override enables it)
|
||||
- Integer fields (ports) use override-wins semantics (0 keeps base value)
|
||||
|
||||
### Chaining
|
||||
|
||||
Extends chains are supported—a file can extend a template, and another file can extend that file. Circular extends are detected and rejected. Maximum chain depth is 10.
|
||||
|
||||
See [templates.md](templates.md) for available templates.
|
||||
|
||||
## Network Configuration
|
||||
|
||||
| Field | Description |
|
||||
|
||||
@@ -19,6 +19,67 @@ fence --list-templates
|
||||
|
||||
You can also copy and customize templates from [`internal/templates/`](/internal/templates/).
|
||||
|
||||
## Extending templates
|
||||
|
||||
Instead of copying and modifying templates, you can extend them in your config file using the `extends` field:
|
||||
|
||||
```json
|
||||
{
|
||||
"extends": "code",
|
||||
"network": {
|
||||
"allowedDomains": ["private-registry.company.com"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This inherits all settings from the `code` template and adds your private registry. Settings are merged:
|
||||
|
||||
- Slice fields (domains, paths, commands): Appended and deduplicated
|
||||
- Boolean fields: OR logic (true if either enables it)
|
||||
- Integer fields (ports): Override wins (0 keeps base value)
|
||||
|
||||
### Extending files
|
||||
|
||||
You can also extend other config files using file paths:
|
||||
|
||||
```json
|
||||
{
|
||||
"extends": "./shared/base-config.json",
|
||||
"network": {
|
||||
"allowedDomains": ["extra-domain.com"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The `extends` value is treated as a file path if it contains `/` or `\`, or starts with `.`. Relative paths are resolved relative to the config file's directory. The extended file is validated before merging.
|
||||
|
||||
Chains are supported: a file can extend a template, and another file can extend that file. Circular extends are detected and rejected.
|
||||
|
||||
### Example: Company-specific AI agent config
|
||||
|
||||
```json
|
||||
{
|
||||
"extends": "code",
|
||||
"network": {
|
||||
"allowedDomains": [
|
||||
"internal-npm.company.com",
|
||||
"artifactory.company.com"
|
||||
],
|
||||
"deniedDomains": ["competitor-analytics.com"]
|
||||
},
|
||||
"filesystem": {
|
||||
"denyRead": ["~/.company-secrets/**"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This config:
|
||||
|
||||
- Extends the battle-tested `code` template
|
||||
- Adds company-specific package registries
|
||||
- Adds additional telemetry/analytics to deny list
|
||||
- Protects company-specific secret directories
|
||||
|
||||
## Available Templates
|
||||
|
||||
| Template | Description |
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
|
||||
// Config is the main configuration for fence.
|
||||
type Config struct {
|
||||
Extends string `json:"extends,omitempty"`
|
||||
Network NetworkConfig `json:"network"`
|
||||
Filesystem FilesystemConfig `json:"filesystem"`
|
||||
Command CommandConfig `json:"command"`
|
||||
@@ -247,3 +248,109 @@ func MatchesDomain(hostname, pattern string) bool {
|
||||
// Exact match
|
||||
return hostname == pattern
|
||||
}
|
||||
|
||||
// Merge combines a base config with an override config.
|
||||
// Values in override take precedence. Slice fields are appended (base + override).
|
||||
// The Extends field is cleared in the result since inheritance has been resolved.
|
||||
func Merge(base, override *Config) *Config {
|
||||
if base == nil {
|
||||
if override == nil {
|
||||
return Default()
|
||||
}
|
||||
result := *override
|
||||
result.Extends = ""
|
||||
return &result
|
||||
}
|
||||
if override == nil {
|
||||
result := *base
|
||||
result.Extends = ""
|
||||
return &result
|
||||
}
|
||||
|
||||
result := &Config{
|
||||
// AllowPty: true if either config enables it
|
||||
AllowPty: base.AllowPty || override.AllowPty,
|
||||
|
||||
Network: NetworkConfig{
|
||||
// Append slices (base first, then override additions)
|
||||
AllowedDomains: mergeStrings(base.Network.AllowedDomains, override.Network.AllowedDomains),
|
||||
DeniedDomains: mergeStrings(base.Network.DeniedDomains, override.Network.DeniedDomains),
|
||||
AllowUnixSockets: mergeStrings(base.Network.AllowUnixSockets, override.Network.AllowUnixSockets),
|
||||
|
||||
// Boolean fields: override wins if set, otherwise base
|
||||
AllowAllUnixSockets: base.Network.AllowAllUnixSockets || override.Network.AllowAllUnixSockets,
|
||||
AllowLocalBinding: base.Network.AllowLocalBinding || override.Network.AllowLocalBinding,
|
||||
|
||||
// Pointer fields: override wins if set, otherwise base
|
||||
AllowLocalOutbound: mergeOptionalBool(base.Network.AllowLocalOutbound, override.Network.AllowLocalOutbound),
|
||||
|
||||
// Port fields: override wins if non-zero
|
||||
HTTPProxyPort: mergeInt(base.Network.HTTPProxyPort, override.Network.HTTPProxyPort),
|
||||
SOCKSProxyPort: mergeInt(base.Network.SOCKSProxyPort, override.Network.SOCKSProxyPort),
|
||||
},
|
||||
|
||||
Filesystem: FilesystemConfig{
|
||||
// Append slices
|
||||
DenyRead: mergeStrings(base.Filesystem.DenyRead, override.Filesystem.DenyRead),
|
||||
AllowWrite: mergeStrings(base.Filesystem.AllowWrite, override.Filesystem.AllowWrite),
|
||||
DenyWrite: mergeStrings(base.Filesystem.DenyWrite, override.Filesystem.DenyWrite),
|
||||
|
||||
// Boolean fields: override wins if set
|
||||
AllowGitConfig: base.Filesystem.AllowGitConfig || override.Filesystem.AllowGitConfig,
|
||||
},
|
||||
|
||||
Command: CommandConfig{
|
||||
// Append slices
|
||||
Deny: mergeStrings(base.Command.Deny, override.Command.Deny),
|
||||
Allow: mergeStrings(base.Command.Allow, override.Command.Allow),
|
||||
|
||||
// Pointer field: override wins if set
|
||||
UseDefaults: mergeOptionalBool(base.Command.UseDefaults, override.Command.UseDefaults),
|
||||
},
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// mergeStrings appends two string slices, removing duplicates.
|
||||
func mergeStrings(base, override []string) []string {
|
||||
if len(base) == 0 {
|
||||
return override
|
||||
}
|
||||
if len(override) == 0 {
|
||||
return base
|
||||
}
|
||||
|
||||
seen := make(map[string]bool, len(base))
|
||||
result := make([]string, 0, len(base)+len(override))
|
||||
|
||||
for _, s := range base {
|
||||
if !seen[s] {
|
||||
seen[s] = true
|
||||
result = append(result, s)
|
||||
}
|
||||
}
|
||||
for _, s := range override {
|
||||
if !seen[s] {
|
||||
seen[s] = true
|
||||
result = append(result, s)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// mergeOptionalBool returns override if non-nil, otherwise base.
|
||||
func mergeOptionalBool(base, override *bool) *bool {
|
||||
if override != nil {
|
||||
return override
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
// mergeInt returns override if non-zero, otherwise base.
|
||||
func mergeInt(base, override int) int {
|
||||
if override != 0 {
|
||||
return override
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
@@ -291,3 +291,192 @@ func TestDefaultConfigPath(t *testing.T) {
|
||||
t.Errorf("DefaultConfigPath() = %q, expected to end with .fence.json", path)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMerge(t *testing.T) {
|
||||
t.Run("nil base", func(t *testing.T) {
|
||||
override := &Config{
|
||||
AllowPty: true,
|
||||
Network: NetworkConfig{
|
||||
AllowedDomains: []string{"example.com"},
|
||||
},
|
||||
}
|
||||
result := Merge(nil, override)
|
||||
if !result.AllowPty {
|
||||
t.Error("expected AllowPty to be true")
|
||||
}
|
||||
if len(result.Network.AllowedDomains) != 1 || result.Network.AllowedDomains[0] != "example.com" {
|
||||
t.Error("expected AllowedDomains to be [example.com]")
|
||||
}
|
||||
if result.Extends != "" {
|
||||
t.Error("expected Extends to be cleared")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nil override", func(t *testing.T) {
|
||||
base := &Config{
|
||||
AllowPty: true,
|
||||
Network: NetworkConfig{
|
||||
AllowedDomains: []string{"example.com"},
|
||||
},
|
||||
}
|
||||
result := Merge(base, nil)
|
||||
if !result.AllowPty {
|
||||
t.Error("expected AllowPty to be true")
|
||||
}
|
||||
if len(result.Network.AllowedDomains) != 1 {
|
||||
t.Error("expected AllowedDomains to be [example.com]")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("both nil", func(t *testing.T) {
|
||||
result := Merge(nil, nil)
|
||||
if result == nil {
|
||||
t.Fatal("expected non-nil result")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("merge allowed domains", func(t *testing.T) {
|
||||
base := &Config{
|
||||
Network: NetworkConfig{
|
||||
AllowedDomains: []string{"github.com", "api.github.com"},
|
||||
},
|
||||
}
|
||||
override := &Config{
|
||||
Extends: "base-template",
|
||||
Network: NetworkConfig{
|
||||
AllowedDomains: []string{"private-registry.company.com"},
|
||||
},
|
||||
}
|
||||
result := Merge(base, override)
|
||||
|
||||
// Should have all three domains
|
||||
if len(result.Network.AllowedDomains) != 3 {
|
||||
t.Errorf("expected 3 allowed domains, got %d: %v", len(result.Network.AllowedDomains), result.Network.AllowedDomains)
|
||||
}
|
||||
|
||||
// Extends should be cleared
|
||||
if result.Extends != "" {
|
||||
t.Errorf("expected Extends to be cleared, got %q", result.Extends)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("deduplicate merged domains", func(t *testing.T) {
|
||||
base := &Config{
|
||||
Network: NetworkConfig{
|
||||
AllowedDomains: []string{"github.com", "example.com"},
|
||||
},
|
||||
}
|
||||
override := &Config{
|
||||
Network: NetworkConfig{
|
||||
AllowedDomains: []string{"github.com", "new.com"},
|
||||
},
|
||||
}
|
||||
result := Merge(base, override)
|
||||
|
||||
// Should deduplicate
|
||||
if len(result.Network.AllowedDomains) != 3 {
|
||||
t.Errorf("expected 3 domains (deduped), got %d: %v", len(result.Network.AllowedDomains), result.Network.AllowedDomains)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("merge boolean flags", func(t *testing.T) {
|
||||
base := &Config{
|
||||
AllowPty: false,
|
||||
Network: NetworkConfig{
|
||||
AllowLocalBinding: true,
|
||||
},
|
||||
}
|
||||
override := &Config{
|
||||
AllowPty: true,
|
||||
Network: NetworkConfig{
|
||||
AllowLocalOutbound: boolPtr(true),
|
||||
},
|
||||
}
|
||||
result := Merge(base, override)
|
||||
|
||||
if !result.AllowPty {
|
||||
t.Error("expected AllowPty to be true (from override)")
|
||||
}
|
||||
if !result.Network.AllowLocalBinding {
|
||||
t.Error("expected AllowLocalBinding to be true (from base)")
|
||||
}
|
||||
if result.Network.AllowLocalOutbound == nil || !*result.Network.AllowLocalOutbound {
|
||||
t.Error("expected AllowLocalOutbound to be true (from override)")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("merge command config", func(t *testing.T) {
|
||||
base := &Config{
|
||||
Command: CommandConfig{
|
||||
Deny: []string{"git push", "rm -rf"},
|
||||
},
|
||||
}
|
||||
override := &Config{
|
||||
Command: CommandConfig{
|
||||
Deny: []string{"sudo"},
|
||||
Allow: []string{"git status"},
|
||||
},
|
||||
}
|
||||
result := Merge(base, override)
|
||||
|
||||
if len(result.Command.Deny) != 3 {
|
||||
t.Errorf("expected 3 denied commands, got %d", len(result.Command.Deny))
|
||||
}
|
||||
if len(result.Command.Allow) != 1 {
|
||||
t.Errorf("expected 1 allowed command, got %d", len(result.Command.Allow))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("merge filesystem config", func(t *testing.T) {
|
||||
base := &Config{
|
||||
Filesystem: FilesystemConfig{
|
||||
AllowWrite: []string{"."},
|
||||
DenyRead: []string{"~/.ssh/**"},
|
||||
},
|
||||
}
|
||||
override := &Config{
|
||||
Filesystem: FilesystemConfig{
|
||||
AllowWrite: []string{"/tmp"},
|
||||
DenyWrite: []string{".env"},
|
||||
},
|
||||
}
|
||||
result := Merge(base, override)
|
||||
|
||||
if len(result.Filesystem.AllowWrite) != 2 {
|
||||
t.Errorf("expected 2 write paths, got %d", len(result.Filesystem.AllowWrite))
|
||||
}
|
||||
if len(result.Filesystem.DenyRead) != 1 {
|
||||
t.Errorf("expected 1 deny read path, got %d", len(result.Filesystem.DenyRead))
|
||||
}
|
||||
if len(result.Filesystem.DenyWrite) != 1 {
|
||||
t.Errorf("expected 1 deny write path, got %d", len(result.Filesystem.DenyWrite))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("override ports", func(t *testing.T) {
|
||||
base := &Config{
|
||||
Network: NetworkConfig{
|
||||
HTTPProxyPort: 8080,
|
||||
SOCKSProxyPort: 1080,
|
||||
},
|
||||
}
|
||||
override := &Config{
|
||||
Network: NetworkConfig{
|
||||
HTTPProxyPort: 9090, // override
|
||||
// SOCKSProxyPort not set, should keep base
|
||||
},
|
||||
}
|
||||
result := Merge(base, override)
|
||||
|
||||
if result.Network.HTTPProxyPort != 9090 {
|
||||
t.Errorf("expected HTTPProxyPort 9090, got %d", result.Network.HTTPProxyPort)
|
||||
}
|
||||
if result.Network.SOCKSProxyPort != 1080 {
|
||||
t.Errorf("expected SOCKSProxyPort 1080, got %d", result.Network.SOCKSProxyPort)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func boolPtr(b bool) *bool {
|
||||
return &b
|
||||
}
|
||||
|
||||
@@ -1,130 +1,8 @@
|
||||
{
|
||||
"allowPty": true,
|
||||
"extends": "code",
|
||||
"network": {
|
||||
"allowLocalBinding": true,
|
||||
"allowLocalOutbound": true,
|
||||
"allowedDomains": ["*"],
|
||||
"deniedDomains": [
|
||||
// Cloud metadata APIs (prevent credential theft)
|
||||
"169.254.169.254",
|
||||
"metadata.google.internal",
|
||||
"instance-data.ec2.internal",
|
||||
|
||||
// Telemetry (optional, can be removed if needed)
|
||||
"statsig.anthropic.com",
|
||||
"*.sentry.io"
|
||||
]
|
||||
},
|
||||
|
||||
"filesystem": {
|
||||
"allowWrite": [
|
||||
".",
|
||||
// Temp files
|
||||
"/tmp",
|
||||
|
||||
// Local cache, needed by tools like `uv`
|
||||
"~/.cache/**",
|
||||
|
||||
// Claude Code state/config
|
||||
"~/.claude*",
|
||||
"~/.claude/**",
|
||||
|
||||
// Codex state/config
|
||||
"~/.codex/**",
|
||||
|
||||
// Cursor state/config
|
||||
"~/.cursor/**",
|
||||
|
||||
// Package manager caches
|
||||
"~/.npm/_cacache",
|
||||
"~/.cache",
|
||||
"~/.bun/**",
|
||||
|
||||
// Cargo cache (Rust, used by Codex)
|
||||
"~/.cargo/registry/**",
|
||||
"~/.cargo/git/**",
|
||||
"~/.cargo/.package-cache",
|
||||
|
||||
// Shell completion cache
|
||||
"~/.zcompdump*",
|
||||
|
||||
// XDG directories for app configs/data
|
||||
"~/.local/share/**",
|
||||
"~/.config/**",
|
||||
|
||||
// OpenCode state
|
||||
"~/.opencode/**"
|
||||
],
|
||||
|
||||
"denyWrite": [
|
||||
// Protect environment files with secrets
|
||||
".env",
|
||||
".env.*",
|
||||
"**/.env",
|
||||
"**/.env.*",
|
||||
|
||||
// Protect key/certificate files
|
||||
"*.key",
|
||||
"*.pem",
|
||||
"*.p12",
|
||||
"*.pfx",
|
||||
"**/*.key",
|
||||
"**/*.pem",
|
||||
"**/*.p12",
|
||||
"**/*.pfx"
|
||||
],
|
||||
|
||||
"denyRead": [
|
||||
// SSH private keys and config
|
||||
"~/.ssh/id_*",
|
||||
"~/.ssh/config",
|
||||
"~/.ssh/*.pem",
|
||||
|
||||
// GPG keys
|
||||
"~/.gnupg/**",
|
||||
|
||||
// Cloud provider credentials
|
||||
"~/.aws/**",
|
||||
"~/.config/gcloud/**",
|
||||
"~/.kube/**",
|
||||
|
||||
// Docker config (may contain registry auth)
|
||||
"~/.docker/**",
|
||||
|
||||
// GitHub CLI auth
|
||||
"~/.config/gh/**",
|
||||
|
||||
// Package manager auth tokens
|
||||
"~/.pypirc",
|
||||
"~/.netrc",
|
||||
"~/.git-credentials",
|
||||
"~/.cargo/credentials",
|
||||
"~/.cargo/credentials.toml"
|
||||
]
|
||||
},
|
||||
|
||||
"command": {
|
||||
"useDefaults": true,
|
||||
"deny": [
|
||||
// Git commands that modify remote state
|
||||
"git push",
|
||||
"git reset",
|
||||
"git clean",
|
||||
"git checkout --",
|
||||
"git rebase",
|
||||
"git merge",
|
||||
|
||||
// Package publishing commands
|
||||
"npm publish",
|
||||
"pnpm publish",
|
||||
"yarn publish",
|
||||
"cargo publish",
|
||||
"twine upload",
|
||||
"gem push",
|
||||
|
||||
// Privilege escalation
|
||||
"sudo"
|
||||
]
|
||||
// Allow all domains directly (for apps that ignore HTTP_PROXY)
|
||||
// The "*" wildcard bypasses proxy-based domain filtering
|
||||
"allowedDomains": ["*"]
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
@@ -13,6 +14,15 @@ import (
|
||||
"github.com/tidwall/jsonc"
|
||||
)
|
||||
|
||||
// maxExtendsDepth limits inheritance chain depth to prevent infinite loops.
|
||||
const maxExtendsDepth = 10
|
||||
|
||||
// isPath returns true if the extends value looks like a file path rather than a template name.
|
||||
// A value is considered a path if it contains a path separator or starts with ".".
|
||||
func isPath(s string) bool {
|
||||
return strings.ContainsAny(s, "/\\") || strings.HasPrefix(s, ".")
|
||||
}
|
||||
|
||||
//go:embed *.json
|
||||
var templatesFS embed.FS
|
||||
|
||||
@@ -63,11 +73,30 @@ func List() []Template {
|
||||
}
|
||||
|
||||
// Load loads a template by name and returns the parsed config.
|
||||
// If the template uses "extends", the inheritance chain is resolved.
|
||||
func Load(name string) (*config.Config, error) {
|
||||
return loadWithDepth(name, 0, nil)
|
||||
}
|
||||
|
||||
// loadWithDepth loads a template with cycle and depth tracking.
|
||||
func loadWithDepth(name string, depth int, seen map[string]bool) (*config.Config, error) {
|
||||
if depth > maxExtendsDepth {
|
||||
return nil, fmt.Errorf("extends chain too deep (max %d)", maxExtendsDepth)
|
||||
}
|
||||
|
||||
// Normalize name (remove .json if present)
|
||||
name = strings.TrimSuffix(name, ".json")
|
||||
filename := name + ".json"
|
||||
|
||||
// Check for cycles
|
||||
if seen == nil {
|
||||
seen = make(map[string]bool)
|
||||
}
|
||||
if seen[name] {
|
||||
return nil, fmt.Errorf("circular extends detected: %q", name)
|
||||
}
|
||||
seen[name] = true
|
||||
|
||||
filename := name + ".json"
|
||||
data, err := templatesFS.ReadFile(filename)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("template %q not found", name)
|
||||
@@ -78,6 +107,15 @@ func Load(name string) (*config.Config, error) {
|
||||
return nil, fmt.Errorf("failed to parse template %q: %w", name, err)
|
||||
}
|
||||
|
||||
// If this template extends another, resolve the chain
|
||||
if cfg.Extends != "" {
|
||||
baseCfg, err := loadWithDepth(cfg.Extends, depth+1, seen)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load base template %q: %w", cfg.Extends, err)
|
||||
}
|
||||
return config.Merge(baseCfg, &cfg), nil
|
||||
}
|
||||
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
@@ -95,3 +133,120 @@ func GetPath(name string) string {
|
||||
name = strings.TrimSuffix(name, ".json")
|
||||
return filepath.Join("internal/templates", name+".json")
|
||||
}
|
||||
|
||||
// ResolveExtends resolves the extends field in a config by loading and merging
|
||||
// the base template or config file. If the config has no extends field, it is returned as-is.
|
||||
// Relative paths are resolved relative to the current working directory.
|
||||
// Use ResolveExtendsWithBaseDir if you need to resolve relative to a specific directory.
|
||||
func ResolveExtends(cfg *config.Config) (*config.Config, error) {
|
||||
return ResolveExtendsWithBaseDir(cfg, "")
|
||||
}
|
||||
|
||||
// ResolveExtendsWithBaseDir resolves the extends field in a config.
|
||||
// The baseDir is used to resolve relative paths in the extends field.
|
||||
// If baseDir is empty, relative paths will be resolved relative to the current working directory.
|
||||
//
|
||||
// The extends field can be:
|
||||
// - A template name (e.g., "code", "npm-install")
|
||||
// - An absolute path (e.g., "/path/to/base.json")
|
||||
// - A relative path (e.g., "./base.json", "../shared/base.json")
|
||||
//
|
||||
// Paths are detected by the presence of "/" or "\" or a leading ".".
|
||||
func ResolveExtendsWithBaseDir(cfg *config.Config, baseDir string) (*config.Config, error) {
|
||||
if cfg == nil || cfg.Extends == "" {
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
return resolveExtendsWithDepth(cfg, baseDir, 0, nil)
|
||||
}
|
||||
|
||||
// resolveExtendsWithDepth resolves extends with cycle and depth tracking.
|
||||
func resolveExtendsWithDepth(cfg *config.Config, baseDir string, depth int, seen map[string]bool) (*config.Config, error) {
|
||||
if cfg == nil || cfg.Extends == "" {
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
if depth > maxExtendsDepth {
|
||||
return nil, fmt.Errorf("extends chain too deep (max %d)", maxExtendsDepth)
|
||||
}
|
||||
|
||||
if seen == nil {
|
||||
seen = make(map[string]bool)
|
||||
}
|
||||
|
||||
var baseCfg *config.Config
|
||||
var newBaseDir string
|
||||
var err error
|
||||
|
||||
// Handle file path or template name extends
|
||||
if isPath(cfg.Extends) {
|
||||
baseCfg, newBaseDir, err = loadConfigFile(cfg.Extends, baseDir, seen)
|
||||
} else {
|
||||
baseCfg, err = loadWithDepth(cfg.Extends, depth+1, seen)
|
||||
newBaseDir = ""
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// If the base config also has extends, resolve it recursively
|
||||
if baseCfg.Extends != "" {
|
||||
baseCfg, err = resolveExtendsWithDepth(baseCfg, newBaseDir, depth+1, seen)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return config.Merge(baseCfg, cfg), nil
|
||||
}
|
||||
|
||||
// loadConfigFile loads a config from a file path with cycle detection.
|
||||
// Returns the loaded config, the directory of the loaded file (for resolving nested extends), and any error.
|
||||
func loadConfigFile(path, baseDir string, seen map[string]bool) (*config.Config, string, error) {
|
||||
var resolvedPath string
|
||||
switch {
|
||||
case filepath.IsAbs(path):
|
||||
resolvedPath = path
|
||||
case baseDir != "":
|
||||
resolvedPath = filepath.Join(baseDir, path)
|
||||
default:
|
||||
var err error
|
||||
resolvedPath, err = filepath.Abs(path)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("failed to resolve path %q: %w", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Clean and normalize the path for cycle detection
|
||||
resolvedPath = filepath.Clean(resolvedPath)
|
||||
|
||||
if seen[resolvedPath] {
|
||||
return nil, "", fmt.Errorf("circular extends detected: %q", path)
|
||||
}
|
||||
seen[resolvedPath] = true
|
||||
|
||||
data, err := os.ReadFile(resolvedPath) //nolint:gosec // user-provided config path - intentional
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, "", fmt.Errorf("extends file not found: %q", path)
|
||||
}
|
||||
return nil, "", fmt.Errorf("failed to read extends file %q: %w", path, err)
|
||||
}
|
||||
|
||||
// Handle empty file
|
||||
if len(strings.TrimSpace(string(data))) == 0 {
|
||||
return nil, "", fmt.Errorf("extends file is empty: %q", path)
|
||||
}
|
||||
|
||||
var cfg config.Config
|
||||
if err := json.Unmarshal(jsonc.ToJSON(data), &cfg); err != nil {
|
||||
return nil, "", fmt.Errorf("invalid JSON in extends file %q: %w", path, err)
|
||||
}
|
||||
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return nil, "", fmt.Errorf("invalid configuration in extends file %q: %w", path, err)
|
||||
}
|
||||
|
||||
return &cfg, filepath.Dir(resolvedPath), nil
|
||||
}
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
package templates
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/Use-Tusk/fence/internal/config"
|
||||
)
|
||||
|
||||
func TestList(t *testing.T) {
|
||||
@@ -121,3 +125,437 @@ func TestCodeTemplate(t *testing.T) {
|
||||
t.Error("code template should have denied commands")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodeRelaxedTemplate(t *testing.T) {
|
||||
cfg, err := Load("code-relaxed")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to load code-relaxed template: %v", err)
|
||||
}
|
||||
|
||||
// Should inherit AllowPty from code template
|
||||
if !cfg.AllowPty {
|
||||
t.Error("code-relaxed should inherit AllowPty=true from code")
|
||||
}
|
||||
|
||||
// Should have wildcard in allowed domains
|
||||
hasWildcard := false
|
||||
for _, domain := range cfg.Network.AllowedDomains {
|
||||
if domain == "*" {
|
||||
hasWildcard = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasWildcard {
|
||||
t.Error("code-relaxed should have '*' in allowed domains")
|
||||
}
|
||||
|
||||
// Should inherit denied domains from code
|
||||
if len(cfg.Network.DeniedDomains) == 0 {
|
||||
t.Error("code-relaxed should inherit denied domains from code")
|
||||
}
|
||||
|
||||
// Should inherit filesystem config from code
|
||||
if len(cfg.Filesystem.AllowWrite) == 0 {
|
||||
t.Error("code-relaxed should inherit allowWrite from code")
|
||||
}
|
||||
if len(cfg.Filesystem.DenyRead) == 0 {
|
||||
t.Error("code-relaxed should inherit denyRead from code")
|
||||
}
|
||||
if len(cfg.Filesystem.DenyWrite) == 0 {
|
||||
t.Error("code-relaxed should inherit denyWrite from code")
|
||||
}
|
||||
|
||||
// Should inherit command config from code
|
||||
if len(cfg.Command.Deny) == 0 {
|
||||
t.Error("code-relaxed should inherit command deny list from code")
|
||||
}
|
||||
|
||||
// Extends should be cleared after resolution
|
||||
if cfg.Extends != "" {
|
||||
t.Error("extends should be cleared after loading")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveExtends(t *testing.T) {
|
||||
t.Run("nil config", func(t *testing.T) {
|
||||
result, err := ResolveExtends(nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if result != nil {
|
||||
t.Error("expected nil result for nil input")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no extends", func(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
AllowPty: true,
|
||||
Network: config.NetworkConfig{
|
||||
AllowedDomains: []string{"example.com"},
|
||||
},
|
||||
}
|
||||
result, err := ResolveExtends(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if result != cfg {
|
||||
t.Error("expected same config when no extends")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("extends code template", func(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
Extends: "code",
|
||||
Network: config.NetworkConfig{
|
||||
AllowedDomains: []string{"private-registry.company.com"},
|
||||
},
|
||||
}
|
||||
result, err := ResolveExtends(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Should have merged config
|
||||
if result.Extends != "" {
|
||||
t.Error("extends should be cleared after resolution")
|
||||
}
|
||||
|
||||
// Should have AllowPty from base template
|
||||
if !result.AllowPty {
|
||||
t.Error("should inherit AllowPty from code template")
|
||||
}
|
||||
|
||||
// Should have domains from both
|
||||
hasPrivateRegistry := false
|
||||
hasAnthropic := false
|
||||
for _, domain := range result.Network.AllowedDomains {
|
||||
if domain == "private-registry.company.com" {
|
||||
hasPrivateRegistry = true
|
||||
}
|
||||
if domain == "*.anthropic.com" {
|
||||
hasAnthropic = true
|
||||
}
|
||||
}
|
||||
if !hasPrivateRegistry {
|
||||
t.Error("should have private-registry.company.com from override")
|
||||
}
|
||||
if !hasAnthropic {
|
||||
t.Error("should have *.anthropic.com from base template")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("extends nonexistent template", func(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
Extends: "nonexistent-template",
|
||||
}
|
||||
_, err := ResolveExtends(cfg)
|
||||
if err == nil {
|
||||
t.Error("expected error for nonexistent template")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestExtendsChainDepth(t *testing.T) {
|
||||
// This tests that the maxExtendsDepth limit is respected.
|
||||
// We can't easily create a deep chain with embedded templates,
|
||||
// but we can test that the code template (which has no extends)
|
||||
// loads correctly.
|
||||
cfg, err := Load("code")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if cfg == nil {
|
||||
t.Error("expected non-nil config")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsPath(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want bool
|
||||
}{
|
||||
// Template names (not paths)
|
||||
{"code", false},
|
||||
{"npm-install", false},
|
||||
{"my-template", false},
|
||||
|
||||
// Absolute paths
|
||||
{"/path/to/config.json", true},
|
||||
{"/etc/fence/base.json", true},
|
||||
|
||||
// Relative paths
|
||||
{"./base.json", true},
|
||||
{"../shared/base.json", true},
|
||||
{"configs/base.json", true},
|
||||
|
||||
// Windows-style paths
|
||||
{"C:\\path\\to\\config.json", true},
|
||||
{".\\base.json", true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
got := isPath(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("isPath(%q) = %v, want %v", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtendsFilePath(t *testing.T) {
|
||||
// Create temp directory for test files
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
t.Run("extends absolute path", func(t *testing.T) {
|
||||
// Create base config file
|
||||
baseContent := `{
|
||||
"network": {
|
||||
"allowedDomains": ["base.example.com"]
|
||||
},
|
||||
"filesystem": {
|
||||
"allowWrite": ["/tmp"]
|
||||
}
|
||||
}`
|
||||
basePath := filepath.Join(tmpDir, "base.json")
|
||||
if err := os.WriteFile(basePath, []byte(baseContent), 0o600); err != nil {
|
||||
t.Fatalf("failed to write base config: %v", err)
|
||||
}
|
||||
|
||||
// Config that extends the base via absolute path
|
||||
cfg := &config.Config{
|
||||
Extends: basePath,
|
||||
Network: config.NetworkConfig{
|
||||
AllowedDomains: []string{"override.example.com"},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := ResolveExtendsWithBaseDir(cfg, "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Should have merged domains
|
||||
if len(result.Network.AllowedDomains) != 2 {
|
||||
t.Errorf("expected 2 domains, got %d: %v", len(result.Network.AllowedDomains), result.Network.AllowedDomains)
|
||||
}
|
||||
|
||||
// Should have filesystem from base
|
||||
if len(result.Filesystem.AllowWrite) != 1 || result.Filesystem.AllowWrite[0] != "/tmp" {
|
||||
t.Errorf("expected AllowWrite [/tmp], got %v", result.Filesystem.AllowWrite)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("extends relative path", func(t *testing.T) {
|
||||
// Create base config in subdir
|
||||
subDir := filepath.Join(tmpDir, "configs")
|
||||
if err := os.MkdirAll(subDir, 0o750); err != nil {
|
||||
t.Fatalf("failed to create subdir: %v", err)
|
||||
}
|
||||
|
||||
baseContent := `{
|
||||
"allowPty": true,
|
||||
"network": {
|
||||
"allowedDomains": ["relative-base.example.com"]
|
||||
}
|
||||
}`
|
||||
basePath := filepath.Join(subDir, "base.json")
|
||||
if err := os.WriteFile(basePath, []byte(baseContent), 0o600); err != nil {
|
||||
t.Fatalf("failed to write base config: %v", err)
|
||||
}
|
||||
|
||||
// Config that extends via relative path
|
||||
cfg := &config.Config{
|
||||
Extends: "./configs/base.json",
|
||||
Network: config.NetworkConfig{
|
||||
AllowedDomains: []string{"child.example.com"},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := ResolveExtendsWithBaseDir(cfg, tmpDir)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Should inherit AllowPty
|
||||
if !result.AllowPty {
|
||||
t.Error("should inherit AllowPty from base")
|
||||
}
|
||||
|
||||
// Should have merged domains
|
||||
if len(result.Network.AllowedDomains) != 2 {
|
||||
t.Errorf("expected 2 domains, got %d", len(result.Network.AllowedDomains))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("extends nonexistent file", func(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
Extends: "/nonexistent/path/config.json",
|
||||
}
|
||||
|
||||
_, err := ResolveExtendsWithBaseDir(cfg, "")
|
||||
if err == nil {
|
||||
t.Error("expected error for nonexistent file")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("extends invalid JSON file", func(t *testing.T) {
|
||||
invalidPath := filepath.Join(tmpDir, "invalid.json")
|
||||
if err := os.WriteFile(invalidPath, []byte("{invalid json}"), 0o600); err != nil {
|
||||
t.Fatalf("failed to write invalid config: %v", err)
|
||||
}
|
||||
|
||||
cfg := &config.Config{
|
||||
Extends: invalidPath,
|
||||
}
|
||||
|
||||
_, err := ResolveExtendsWithBaseDir(cfg, "")
|
||||
if err == nil {
|
||||
t.Error("expected error for invalid JSON")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("extends file with invalid config", func(t *testing.T) {
|
||||
// Create config with invalid domain pattern
|
||||
invalidContent := `{
|
||||
"network": {
|
||||
"allowedDomains": ["*.com"]
|
||||
}
|
||||
}`
|
||||
invalidPath := filepath.Join(tmpDir, "invalid-domain.json")
|
||||
if err := os.WriteFile(invalidPath, []byte(invalidContent), 0o600); err != nil {
|
||||
t.Fatalf("failed to write config: %v", err)
|
||||
}
|
||||
|
||||
cfg := &config.Config{
|
||||
Extends: invalidPath,
|
||||
}
|
||||
|
||||
_, err := ResolveExtendsWithBaseDir(cfg, "")
|
||||
if err == nil {
|
||||
t.Error("expected error for invalid config")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("circular extends via files", func(t *testing.T) {
|
||||
// Create two files that extend each other
|
||||
fileA := filepath.Join(tmpDir, "a.json")
|
||||
fileB := filepath.Join(tmpDir, "b.json")
|
||||
|
||||
contentA := `{"extends": "` + fileB + `"}`
|
||||
contentB := `{"extends": "` + fileA + `"}`
|
||||
|
||||
if err := os.WriteFile(fileA, []byte(contentA), 0o600); err != nil {
|
||||
t.Fatalf("failed to write a.json: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(fileB, []byte(contentB), 0o600); err != nil {
|
||||
t.Fatalf("failed to write b.json: %v", err)
|
||||
}
|
||||
|
||||
cfg := &config.Config{
|
||||
Extends: fileA,
|
||||
}
|
||||
|
||||
_, err := ResolveExtendsWithBaseDir(cfg, "")
|
||||
if err == nil {
|
||||
t.Error("expected error for circular extends")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nested extends chain", func(t *testing.T) {
|
||||
// Create a chain: child -> middle -> base
|
||||
baseContent := `{
|
||||
"network": {
|
||||
"allowedDomains": ["base.com"]
|
||||
}
|
||||
}`
|
||||
basePath := filepath.Join(tmpDir, "chain-base.json")
|
||||
if err := os.WriteFile(basePath, []byte(baseContent), 0o600); err != nil {
|
||||
t.Fatalf("failed to write base: %v", err)
|
||||
}
|
||||
|
||||
middleContent := `{
|
||||
"extends": "` + basePath + `",
|
||||
"network": {
|
||||
"allowedDomains": ["middle.com"]
|
||||
}
|
||||
}`
|
||||
middlePath := filepath.Join(tmpDir, "chain-middle.json")
|
||||
if err := os.WriteFile(middlePath, []byte(middleContent), 0o600); err != nil {
|
||||
t.Fatalf("failed to write middle: %v", err)
|
||||
}
|
||||
|
||||
cfg := &config.Config{
|
||||
Extends: middlePath,
|
||||
Network: config.NetworkConfig{
|
||||
AllowedDomains: []string{"child.com"},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := ResolveExtendsWithBaseDir(cfg, "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Should have all three domains
|
||||
if len(result.Network.AllowedDomains) != 3 {
|
||||
t.Errorf("expected 3 domains, got %d: %v", len(result.Network.AllowedDomains), result.Network.AllowedDomains)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("file extends template", func(t *testing.T) {
|
||||
// Create a file that extends a built-in template
|
||||
fileContent := `{
|
||||
"extends": "code",
|
||||
"network": {
|
||||
"allowedDomains": ["extra.example.com"]
|
||||
}
|
||||
}`
|
||||
filePath := filepath.Join(tmpDir, "extends-template.json")
|
||||
if err := os.WriteFile(filePath, []byte(fileContent), 0o600); err != nil {
|
||||
t.Fatalf("failed to write config: %v", err)
|
||||
}
|
||||
|
||||
// Config that extends this file
|
||||
cfg := &config.Config{
|
||||
Extends: filePath,
|
||||
Network: config.NetworkConfig{
|
||||
AllowedDomains: []string{"top.example.com"},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := ResolveExtendsWithBaseDir(cfg, "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Should have AllowPty from code template
|
||||
if !result.AllowPty {
|
||||
t.Error("should inherit AllowPty from code template")
|
||||
}
|
||||
|
||||
// Should have domains from all levels
|
||||
hasAnthropic := false
|
||||
hasExtra := false
|
||||
hasTop := false
|
||||
for _, domain := range result.Network.AllowedDomains {
|
||||
switch domain {
|
||||
case "*.anthropic.com":
|
||||
hasAnthropic = true
|
||||
case "extra.example.com":
|
||||
hasExtra = true
|
||||
case "top.example.com":
|
||||
hasTop = true
|
||||
}
|
||||
}
|
||||
if !hasAnthropic {
|
||||
t.Error("should have *.anthropic.com from code template")
|
||||
}
|
||||
if !hasExtra {
|
||||
t.Error("should have extra.example.com from middle file")
|
||||
}
|
||||
if !hasTop {
|
||||
t.Error("should have top.example.com from top config")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user