An HTTPS man-in-the-middle (MITM) proxy for content filtering written in Go. SWG intercepts HTTPS traffic by dynamically generating certificates, allowing inspection and filtering of encrypted connections.
- SSL/TLS Interception: Decrypt HTTPS traffic using dynamically generated certificates
- Content Filtering: Block requests based on domain names, URL prefixes, and regex patterns
- Custom Block Pages: Fully customizable HTML block pages with template support
- PAC File Generation: Generate Proxy Auto-Configuration files for client setup
- Prometheus Metrics: Built-in instrumentation for monitoring and alerting
- Auto-Reloading Rules: Load blocklists from CSV, HTTP endpoints, or databases with periodic refresh
- Configuration Files: YAML/JSON/TOML config with environment variable overrides
- Health Check Endpoints:
/healthzand/readyzprobes for Kubernetes and load balancers - Structured Access Log: JSON access log with request metadata, timing, and filter decisions
- SIGHUP Reload: Reload config and filter rules without restarting (
kill -HUP <pid>) - Policy Engine: Lifecycle hooks for request/response interception with pluggable identity, group policies, and scanning
- Allow-List Mode: Deny-by-default filtering for kiosk and restricted environments
- Time-Based Rules: Schedule filter activation by hour-of-day and day-of-week with timezone support
- Per-User/Group Policies: Apply different filters based on client identity resolved from IP, CIDR, or custom resolvers
- Content-Type Filtering: Block responses by MIME type (e.g. executable downloads)
- Response Body Scanning: Pluggable AV/DLP scanners with allow, block, and replace verdicts
- Upstream Proxy Chaining: Forward through a parent proxy with CONNECT tunnel and PROXY protocol support
- Connection Pooling: Configurable transport pool with HTTP/2 support and connection statistics
- Rate Limiting: Per-client token-bucket rate limiter with automatic stale bucket cleanup
- Admin API: REST endpoints for runtime rule CRUD, status inspection, and filter reloads via chi
- mTLS Client Auth: Mutual TLS authentication requiring client certificates with identity/group extraction
- Bypass Token: Allow authorized clients to skip filtering for debugging via header token or identity
- Certificate Rotation: Hot-swap CA certificates at runtime without proxy restart
- Cross-Platform: Runs on Linux, macOS, and Windows
brew install acmacalister/tap/swgecho "deb [trusted=yes] https://apt.fury.io/acmacalister/ /" | sudo tee /etc/apt/sources.list.d/swg.list
sudo apt update
sudo apt install swg# Download the latest .apk from releases
sudo apk add --allow-untrusted swg_*.apk# Using yay
yay -S swg-bin
# Or download from releases
sudo pacman -U swg_*.pkg.tar.zstsudo rpm -i swg_*.rpmgo install github.com/acmacalister/swg/cmd@latestgit clone https://github.com/acmacalister/swg.git
cd swg
go build -o swg ./cmddocker pull ghcr.io/acmacalister/swg:latest
docker run -p 8080:8080 -v $(pwd)/certs:/certs ghcr.io/acmacalister/swg:latest \
-ca-cert /certs/ca.crt -ca-key /certs/ca.keyswg -gen-caThis creates ca.crt and ca.key in the current directory.
macOS:
sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain ca.crtLinux (Debian/Ubuntu):
sudo cp ca.crt /usr/local/share/ca-certificates/swg-ca.crt
sudo update-ca-certificatesWindows:
Import-Certificate -FilePath ca.crt -CertStoreLocation Cert:\LocalMachine\Rootswg -addr :8080 -block "ads.example.com,*.tracking.com" -vSet your system or browser to use localhost:8080 as the HTTP/HTTPS proxy.
Usage of swg:
-addr string
proxy listen address (default ":8080")
-block string
comma-separated list of domains to block
-block-page-file string
path to custom block page HTML template
-block-page-url string
URL to redirect blocked requests to
-ca-cert string
path to CA certificate (default "ca.crt")
-ca-key string
path to CA private key (default "ca.key")
-ca-org string
organization name for generated CA (default "SWG Proxy")
-config string
path to config file (default: search ./swg.yaml, ~/.swg/config.yaml, /etc/swg/config.yaml)
-gen-ca
generate a new CA certificate and exit
-gen-config
generate example config file and exit
-gen-pac
generate a PAC file and exit
-metrics
enable Prometheus metrics endpoint
-pac-bypass string
comma-separated domains to bypass proxy in PAC file
-print-block-page
print default block page template and exit
-v verbose logging
-access-log string
access log output: stdout, stderr, or file path (disabled if empty)
-healthz
enable /healthz and /readyz health endpoints
# Basic usage with domain blocking
swg -block "facebook.com,twitter.com,*.ads.com"
# Using a config file
swg -config /etc/swg/swg.yaml
# Generate example config file
swg -gen-config
# Custom block page redirect
swg -block "malware.com" -block-page-url "https://internal.company.com/blocked"
# Custom block page template
swg -block "restricted.com" -block-page-file ./my-block-page.html
# Export default block page template for customization
swg -print-block-page > custom-block.html
# Generate a PAC file for client auto-configuration
swg -gen-pac -pac-bypass "internal.company.com,*.local"
# Enable Prometheus metrics on /metrics
swg -block "ads.com" -metrics -v
# Enable health check endpoints
swg -block "ads.com" -healthz
# Enable structured JSON access log to file
swg -block "ads.com" -access-log /var/log/swg/access.log
# Access log to stdout (useful in containers)
swg -block "ads.com" -access-log stdout
# Reload config/rules without restart
kill -HUP $(pidof swg)SWG supports YAML, JSON, and TOML configuration files. Generate an example config:
swg -gen-configExample swg.yaml:
server:
addr: ":8080"
read_timeout: 30s
write_timeout: 30s
tls:
ca_cert: "ca.crt"
ca_key: "ca.key"
organization: "SWG Proxy"
filter:
enabled: true
domains:
- "ads.example.com"
- "*.tracking.com"
reload_interval: 5m
logging:
level: "info"
format: "json"Config file search paths (in order):
- Explicit path via
-config ./swg.yaml$HOME/.swg/config.yaml/etc/swg/config.yaml
Environment variables override config file values with SWG_ prefix:
SWG_SERVER_ADDR=:9090SWG_TLS_ORGANIZATION="My Org"SWG_FILTER_ENABLED=false
SWG can be used as a Go library for building custom proxy solutions.
package main
import (
"log"
"github.com/acmacalister/swg"
)
func main() {
// Load CA certificate
cm, err := swg.NewCertManager("ca.crt", "ca.key")
if err != nil {
log.Fatal(err)
}
// Create proxy
proxy := swg.NewProxy(":8080", cm)
// Start proxy
log.Fatal(proxy.ListenAndServe())
}// Create domain filter
filter := swg.NewDomainFilter()
filter.AddDomain("blocked.com")
filter.AddDomain("*.ads.example.com") // Wildcard support
filter.AddDomains([]string{"evil.com", "malware.org"})
proxy.Filter = filterRuleSet supports domains, URLs, and regex patterns with categories:
// Create a rule set
rs := swg.NewRuleSet()
// Add domain rules
rs.AddDomain("blocked.com")
rs.AddDomain("*.ads.example.com")
// Add URL prefix rules
rs.AddURL("https://evil.com/malware")
// Add regex patterns
rs.AddRegex(`.*\.tracking\..*`)
// Add rules with full metadata
rs.AddRule(swg.Rule{
Type: "domain",
Pattern: "malware.com",
Reason: "known malware host",
Category: "security",
})
proxy.Filter = rs// Create CSV loader
loader := swg.NewCSVLoader("blocklist.csv")
loader.HasHeader = true
loader.DefaultReason = "blocked by policy"
// Create reloadable filter
filter := swg.NewReloadableFilter(loader)
// Set up callbacks
filter.OnReload = func(count int) {
log.Printf("Loaded %d rules", count)
}
filter.OnError = func(err error) {
log.Printf("Reload error: %v", err)
}
// Initial load
ctx := context.Background()
filter.Load(ctx)
// Start auto-reload every 5 minutes
cancel := filter.StartAutoReload(ctx, 5*time.Minute)
defer cancel()
proxy.Filter = filterCSV format: type,pattern,reason,category
type,pattern,reason,category
domain,ads.example.com,advertising,ads
domain,*.tracking.com,user tracking,analytics
url,https://phishing.com/login,phishing attempt,security
regex,.*\.doubleclick\.net.*,ad tracker,adsSee _examples/postgres/ for a complete example using sqlx:
// Implement RuleLoader interface
type PostgresLoader struct {
DB *sqlx.DB
}
func (l *PostgresLoader) Load(ctx context.Context) ([]swg.Rule, error) {
var rules []swg.Rule
err := l.DB.SelectContext(ctx, &rules,
`SELECT rule_type as type, pattern, reason, category
FROM blocklist WHERE enabled = true`)
return rules, err
}
// Use with ReloadableFilter
loader := &PostgresLoader{DB: db}
filter := swg.NewReloadableFilter(loader)
filter.Load(ctx)// Load from multiple sources
csvLoader := swg.NewCSVLoader("local-rules.csv")
urlLoader := swg.NewURLLoader("https://blocklist.example.com/rules.csv")
staticLoader := swg.NewStaticLoader(
swg.Rule{Type: "domain", Pattern: "always-blocked.com"},
)
multiLoader := swg.NewMultiLoader(csvLoader, urlLoader, staticLoader)
filter := swg.NewReloadableFilter(multiLoader)// Implement the Filter interface
type MyFilter struct{}
func (f *MyFilter) ShouldBlock(req *http.Request) (bool, string) {
// Block requests with specific paths
if strings.Contains(req.URL.Path, "/api/tracking") {
return true, "tracking endpoint blocked"
}
return false, ""
}
proxy.Filter = &MyFilter{}
// Or use FilterFunc for simple cases
proxy.Filter = swg.FilterFunc(func(req *http.Request) (bool, string) {
if req.Host == "blocked.com" {
return true, "domain blocked"
}
return false, ""
})// Use built-in styled block page
proxy.BlockPage = swg.NewBlockPage()
// Or load from file
blockPage, err := swg.NewBlockPageFromFile("block.html")
if err != nil {
log.Fatal(err)
}
proxy.BlockPage = blockPage
// Or from template string
tmpl := `<html><body>Blocked: {{.URL}} - {{.Reason}}</body></html>`
blockPage, err := swg.NewBlockPageFromTemplate(tmpl)
proxy.BlockPage = blockPage| Variable | Description |
|---|---|
{{.URL}} |
Full blocked URL |
{{.Host}} |
Hostname of blocked request |
{{.Path}} |
Path of blocked request |
{{.Reason}} |
Reason for blocking |
{{.Timestamp}} |
Time of block (RFC1123 format) |
certPEM, keyPEM, err := swg.GenerateCA("My Organization", 10) // 10 year validity
if err != nil {
log.Fatal(err)
}
// Save to files
os.WriteFile("ca.crt", certPEM, 0644)
os.WriteFile("ca.key", keyPEM, 0600)
// Or use directly
cm, err := swg.NewCertManagerFromPEM(certPEM, keyPEM)health := swg.NewHealthChecker()
proxy.HealthChecker = health
// Mark alive/ready at appropriate lifecycle points
health.SetAlive(true)
health.SetReady(true)
// Add custom readiness checks
health.ReadinessChecks = append(health.ReadinessChecks, func() error {
if !databaseIsReachable() {
return errors.New("database unavailable")
}
return nil
})Endpoints return JSON:
GET /healthz—{"status":"ok","uptime":"1h30m0s"}GET /readyz—{"status":"ok","uptime":"1h30m0s"}or{"status":"not ready","details":[...]}
// Create a JSON access logger writing to a file
f, _ := os.OpenFile("access.log", os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
alLogger := slog.New(slog.NewJSONHandler(f, nil))
proxy.AccessLog = swg.NewAccessLogger(alLogger)Each request produces a JSON log entry with method, host, path, scheme, status code, duration, bytes written, client address, blocked/reason, and user agent.
reloader := swg.WatchSIGHUP(proxy, func(ctx context.Context) (swg.Filter, error) {
// Rebuild filter from config, database, etc.
cfg, err := swg.LoadConfig("swg.yaml")
if err != nil {
return nil, err
}
loader, _ := cfg.BuildRuleLoader()
filter := swg.NewReloadableFilter(loader)
filter.Load(ctx)
return filter, nil
}, logger)
defer reloader.Cancel()Send SIGHUP to the process to trigger a filter reload without downtime.
The policy engine provides lifecycle hooks for the full request/response pipeline:
policy := swg.NewPolicyEngine()
// Resolve client identity from IP ranges
resolver := swg.NewIPIdentityResolver()
resolver.AddIP("10.0.0.50", "alice", []string{"engineering"})
resolver.AddCIDR("192.168.1.0/24", "guest", []string{"guests"})
policy.IdentityResolver = resolver
// Add request hooks (run before filtering)
policy.RequestHooks = []swg.RequestHook{
swg.RequestHookFunc(func(ctx context.Context, req *http.Request, rc *swg.RequestContext) *http.Response {
log.Printf("request from %s (%s)", rc.Identity, rc.ClientIP)
rc.Tags["inspected"] = "true"
return nil // return non-nil *http.Response to short-circuit
}),
}
proxy.Policy = policygroupFilter := swg.NewGroupPolicyFilter()
// Engineering: minimal blocking
engFilter := swg.NewDomainFilter()
engFilter.AddDomain("malware.example.com")
groupFilter.SetPolicy("engineering", engFilter)
// Guests: allow-list mode (deny everything not explicitly permitted)
guestFilter := swg.NewAllowListFilter()
guestFilter.AddDomains([]string{"docs.google.com", "*.wikipedia.org"})
groupFilter.SetPolicy("guests", guestFilter)
// Fallback for unrecognized users
groupFilter.Default = swg.NewDomainFilter()
proxy.Filter = groupFilter// Deny-by-default: only listed domains are allowed
allow := swg.NewAllowListFilter()
allow.AddDomains([]string{
"docs.google.com",
"*.golang.org",
"pkg.go.dev",
})
allow.Reason = "domain not on approved list"
proxy.Filter = allow// Block social media Mon-Fri 9am-5pm US Eastern
eastern, _ := time.LoadLocation("America/New_York")
socialBlock := swg.NewDomainFilter()
socialBlock.AddDomains([]string{"twitter.com", "facebook.com", "reddit.com"})
proxy.Filter = &swg.TimeRule{
Inner: socialBlock,
StartHour: 9,
EndHour: 17,
Weekdays: []time.Weekday{time.Monday, time.Tuesday, time.Wednesday, time.Thursday, time.Friday},
Location: eastern,
}// Chain multiple filters — first block wins
proxy.Filter = &swg.ChainFilter{
Filters: []swg.Filter{socialTimeRule, afterHoursBlock, malwareFilter},
}// Block executable downloads via ResponseHook
ctFilter := swg.NewContentTypeFilter()
ctFilter.Block("application/x-executable", "executable downloads blocked")
ctFilter.Block("application/x-msdownload", "Windows executables blocked")
policy := swg.NewPolicyEngine()
policy.ResponseHooks = []swg.ResponseHook{ctFilter}
proxy.Policy = policy// Implement ResponseBodyScanner for AV/DLP integration
type AVScanner struct{}
func (s *AVScanner) Scan(ctx context.Context, body []byte, req *http.Request, resp *http.Response) (swg.ScanResult, error) {
if isMalware(body) {
return swg.ScanResult{Verdict: swg.VerdictBlock, Reason: "malware detected"}, nil
}
return swg.ScanResult{Verdict: swg.VerdictAllow}, nil
}
policy := swg.NewPolicyEngine()
policy.BodyScanners = []swg.ResponseBodyScanner{&AVScanner{}}
policy.ScanContentTypes = []string{"text/html", "application/json"} // empty = scan all
policy.MaxScanSize = 10 << 20 // 10 MiB (default)
proxy.Policy = policyScanners return one of three verdicts:
| Verdict | Behavior |
|---|---|
VerdictAllow |
Content passes through unmodified |
VerdictBlock |
Client receives 403 with the reason |
VerdictReplace |
Scanner provides a replacement body (e.g. DLP redaction) |
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := proxy.Shutdown(ctx); err != nil {
log.Printf("shutdown error: %v", err)
}pac := swg.NewPACGenerator("proxy.example.com:8080")
pac.AddBypassDomain("internal.company.com")
pac.AddBypassNetwork("10.0.0.0/8")
// Serve as HTTP handler
http.Handle("/proxy.pac", pac)
// Or generate to file
pac.WriteFile("proxy.pac")The Admin API provides REST endpoints for runtime rule management:
admin := swg.NewAdminAPI(proxy)
admin.Logger = logger
// Optional: configure reload from your source
admin.ReloadFunc = func(ctx context.Context) error {
return filter.Load(ctx)
}
proxy.Admin = adminEndpoints (default prefix /api):
| Method | Path | Description |
|---|---|---|
GET |
/api/status |
Proxy status, rule count, uptime, filter type |
GET |
/api/rules |
List all active rules |
POST |
/api/rules |
Add a rule ({"type":"domain","pattern":"evil.com"}) |
DELETE |
/api/rules |
Remove a rule ({"type":"domain","pattern":"evil.com"}) |
POST |
/api/reload |
Reload rules from source |
# Check status
curl http://localhost:8080/api/status
# List rules
curl http://localhost:8080/api/rules
# Add a rule
curl -X POST http://localhost:8080/api/rules \
-d '{"type":"domain","pattern":"ads.com","reason":"advertising"}'
# Remove a rule
curl -X DELETE http://localhost:8080/api/rules \
-d '{"type":"domain","pattern":"ads.com"}'
# Trigger reload
curl -X POST http://localhost:8080/api/reloadRule mutations require the filter to be a *RuleSet or *ReloadableFilter. Other filter types report status and rules as read-only.
Require client certificates to connect to the proxy, limiting access to managed devices:
// Load CA that signed client certificates
clientAuth, err := swg.NewClientAuthFromFile("client-ca.pem")
if err != nil {
log.Fatal(err)
}
// Optional: allow unauthenticated clients (gradual rollout)
// clientAuth.SetPolicy(tls.VerifyClientCertIfGiven)
proxy.ClientAuth = clientAuthWhen enabled, the proxy listener is wrapped with TLS requiring client certificates. The cert's Subject fields are automatically mapped to identity:
- CommonName →
RequestContext.Identity - Organization →
RequestContext.Groups - Tag
auth=mtlsis set on the request context
Generate client certificates for testing (same package or using the CA PEM directly):
// Parse CA cert from PEM for signing
block, _ := pem.Decode(caCertPEM)
caCert, _ := x509.ParseCertificate(block.Bytes)
certPEM, keyPEM, err := swg.GenerateClientCert(
caCert, caKeyPEM,
"alice", // CommonName (identity)
[]string{"engineering", "ops"}, // Organizations (groups)
1, // Valid for 1 year
)Allow authorized clients to skip content filtering for debugging:
bypass := swg.NewBypass()
bypass.AddToken("debug-token-abc123")
// Or generate a cryptographically random token
tok, _ := bypass.GenerateToken()
fmt.Println("Generated token:", tok)
// Grant bypass by identity (e.g. from mTLS cert CN)
bypass.Identities["admin-user"] = true
proxy.Bypass = bypassClients set the bypass header to skip filtering:
curl -H "X-SWG-Bypass: debug-token-abc123" -x http://proxy:8080 http://blocked-site.comTokens are compared using constant-time comparison. The bypass header is stripped before forwarding to upstream.
metrics := swg.NewMetrics()
http.Handle("/metrics", metrics.Handler())
// Record proxy events
metrics.RecordRequest("GET", "https")
metrics.RecordBlocked("ads")
metrics.RecordRequestDuration("GET", 200, duration)┌─────────┐ ┌───────────────┐ ┌──────────────┐
│ Client │────▶│ SWG Proxy │────▶│ Origin Server│
└─────────┘ └───────────────┘ └──────────────┘
│
┌──────┴──────┐
▼ ▼
┌────────────┐ ┌──────────────┐
│CertManager │ │ PolicyEngine │
│(Dynamic TLS)│ │ (Lifecycle) │
└────────────┘ └──────┬───────┘
│
┌───────────────────┼───────────────────┐
▼ ▼ ▼
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ Identity │ │ Filter │ │ Body Scanner │
│ Resolver │ │ (Block/Allow)│ │ (AV / DLP) │
└──────────────┘ └──────────────┘ └──────────────┘
- Client sends CONNECT request to proxy
- Proxy responds with 200 Connection Established
- Proxy performs TLS handshake with client using dynamically generated certificate
- Policy request hooks run: identity resolution, access control, tagging
- Filter checks: domain, URL, regex, allow-list, time-based, group-based
- If allowed, proxy forwards request to origin server
- Policy response hooks run: content-type filtering
- Body scanners run: AV, DLP, keyword detection
- Response is returned to client through the TLS tunnel
- CA Private Key: The CA private key (
ca.key) should be kept secure. Anyone with access can intercept traffic. - Trust Scope: Only install the CA certificate on systems you control.
- Network Position: The proxy must be in the network path to intercept traffic.
- Certificate Pinning: Applications using certificate pinning will fail through this proxy.
SWG includes Kubernetes manifests and a Helm chart for cluster deployment.
# Create CA secret first
kubectl create namespace swg
kubectl create secret generic swg-ca-cert \
--from-file=ca.crt=ca.crt \
--from-file=ca.key=ca.key \
-n swg
# Apply manifests
kubectl apply -f deploy/kubernetes/See deploy/kubernetes/README.md for details.
helm install swg ./deploy/helm/swg -n swgSee deploy/helm/swg/README.md for configuration options.
MIT License - see LICENSE for details.