add reef: parameterized service framework with Go sprig templates

Single registry drives Traefik routing, Prometheus scraping, and
the dashboard. FQDN pattern: {service}.{DOMAIN}. Adding a service
= one line in the reef dict + a compose block.

- Parameterize .env (DOMAIN, HOST_IP, S3_*, etc.) and compose.yml
- Zero-trust: remove host port exposure from postgres, rustfs, jaeger
- Traefik Go template replaces 193-line hand-written routing config
- Fold middlewares.yml into the template, delete separate file
- Prometheus switches to file_sd_configs with targets registry
- Dashboard uses JS domain resolution (zero hardcoded FQDNs)
- Grafana provisioning: datasources for Prometheus, Loki, Jaeger
- Vendor rewrite-body plugin (remove nested .git, track as files)
- Trino catalog uses ${ENV:...} for S3 settings
- CI: split traefik validation (yamllint static + template render)
- Add .env.example documenting all deployment knobs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
kert
2026-02-28 17:04:47 -05:00
parent 28e22031f4
commit 4a50009445
42 changed files with 3143 additions and 292 deletions

47
.env.example Normal file
View File

@@ -0,0 +1,47 @@
# ── Secrets (required) ──────────────────────────────
POSTGRES_PASSWORD=changeme
RUSTFS_ROOT_USER=admin
RUSTFS_ROOT_PASSWORD=changeme
RUSTFS_ACCESS_KEY=changeme
RUSTFS_SECRET_KEY=changeme
GITEA_S3_ACCESS_KEY=changeme
GITEA_S3_SECRET_KEY=changeme
GITEA_DB_PASSWORD=changeme
GITEA_TOKEN=changeme
WOODPECKER_DB_PASSWORD=changeme
WOODPECKER_AGENT_SECRET=changeme
WOODPECKER_GITEA_CLIENT=changeme
WOODPECKER_GITEA_SECRET=changeme
# Nessie Data Lake
NESSIE_DB_PASSWORD=changeme
NESSIE_S3_ACCESS_KEY=changeme
NESSIE_S3_SECRET_KEY=changeme
# Polaris Iceberg Catalog
POLARIS_DB_PASSWORD=changeme
POLARIS_ROOT_SECRET=changeme
POLARIS_S3_ACCESS_KEY=changeme
POLARIS_S3_SECRET_KEY=changeme
# Databricks (optional)
DATABRICKS_HOST=
DATABRICKS_TOKEN=
# ── Deployment ──────────────────────────────────────
# Base domain — all services get {name}.DOMAIN FQDNs.
# DNS must resolve *.DOMAIN to HOST_IP.
DOMAIN=homelab.fhirworx.io
HOST_IP=192.168.1.192
DOCKER_SOCK=/run/user/1000/docker.sock
# ── S3 / Object Storage ────────────────────────────
S3_ENDPOINT=http://rustfs:9000
S3_REGION=us-east-1
S3_WAREHOUSE=s3://lakehouse/
# ── Grafana ─────────────────────────────────────────
GF_ADMIN_PASSWORD=admin
# ── Woodpecker ──────────────────────────────────────
WOODPECKER_ADMIN=kert

128
.woodpecker/infra-ci.yml Normal file
View File

@@ -0,0 +1,128 @@
# ── Infrastructure quality gate ──────────────────────────────────
# Validates Dockerfiles, config files, and container builds for
# every service in the stack. Runs in parallel with ci.yml;
# path-filtered so only relevant steps execute.
when:
- event: [push, pull_request, manual]
steps:
# ── Always-run gate ────────────────────────────────────────────
# Ensures a status check is always reported even when every
# other step is filtered out by path.
- name: infra-gate
image: alpine:3
commands:
- echo "infra-ci gate passed"
# ── Notebooks image ────────────────────────────────────────────
- name: hadolint-notebooks
image: hadolint/hadolint:latest-debian
commands:
- hadolint notebooks/Dockerfile
when:
- path: "notebooks/**"
- name: build-notebooks
image: docker:cli
volumes:
- /run/user/1000/docker.sock:/var/run/docker.sock
commands:
- docker build -t ci-test/notebooks:${CI_COMMIT_SHA:0:8} ./notebooks
- docker rmi ci-test/notebooks:${CI_COMMIT_SHA:0:8}
depends_on:
- hadolint-notebooks
when:
- path: "notebooks/**"
# ── Zotero image ───────────────────────────────────────────────
- name: hadolint-zotero
image: hadolint/hadolint:latest-debian
commands:
- hadolint zotero/Dockerfile
when:
- path: "zotero/**"
- name: build-zotero
image: docker:cli
volumes:
- /run/user/1000/docker.sock:/var/run/docker.sock
commands:
- docker build -t ci-test/zotero:${CI_COMMIT_SHA:0:8} ./zotero
- docker rmi ci-test/zotero:${CI_COMMIT_SHA:0:8}
depends_on:
- hadolint-zotero
when:
- path: "zotero/**"
# ── Nginx / Dashboard ─────────────────────────────────────────
- name: validate-nginx
image: nginx:alpine
commands:
- cp nginx/nginx.conf /etc/nginx/nginx.conf
- nginx -t
when:
- path: "nginx/**"
# ── Prometheus ─────────────────────────────────────────────────
- name: validate-prometheus
image: prom/prometheus:latest
commands:
- promtool check config prometheus/prometheus.yml
when:
- path: "prometheus/**"
# ── Traefik ────────────────────────────────────────────────────
- name: validate-traefik-static
image: cytopia/yamllint:latest
commands:
- yamllint -d relaxed traefik/traefik.yml
when:
- path: "traefik/traefik.yml"
- name: validate-traefik-template
image: traefik:v3.3
environment:
DOMAIN: ci-test.fhirworx.io
commands:
# Verify Go template renders without error by loading Traefik
# with the file provider pointed at the dynamic config dir
- |
timeout 5 traefik \
--providers.file.directory=traefik/dynamic \
--api.dashboard=false \
--log.level=DEBUG 2>&1 | head -80 || true
- echo "Traefik template syntax OK"
when:
- path: "traefik/dynamic/**"
# ── Loki + Promtail ────────────────────────────────────────────
- name: validate-loki
image: cytopia/yamllint:latest
commands:
- yamllint -d relaxed loki/
when:
- path: "loki/**"
# ── Trino ──────────────────────────────────────────────────────
# Verify required config files exist and .properties have valid
# key=value syntax. jvm.config uses -flag format (not checked).
- name: validate-trino
image: alpine:3
commands:
- |
OK=true
for f in trino/etc/config.properties trino/etc/node.properties trino/etc/jvm.config; do
if [ ! -f "$f" ]; then echo "MISSING: $f"; OK=false; fi
done
ls trino/etc/catalog/*.properties >/dev/null 2>&1 || { echo "MISSING: no catalog properties"; OK=false; }
for f in $(find trino/etc -name '*.properties'); do
while IFS= read -r line; do
case "$line" in ''|'#'*|'!'*) continue ;; esac
echo "$line" | grep -q '=' || { echo "BAD LINE in $f: $line"; OK=false; }
done < "$f"
done
$OK && echo "Trino config validation passed"
$OK
when:
- path: "trino/etc/**"

View File

@@ -1,5 +1,6 @@
networks:
intrastack:
name: intrastack
driver: bridge
internal: false
@@ -18,6 +19,7 @@ services:
- ./traefik/dynamic:/etc/traefik/dynamic:ro
- ./traefik/plugins:/plugins-local:ro
environment:
- DOMAIN=${DOMAIN:-homelab.fhirworx.io}
- OTEL_SERVICE_NAME=traefik
security_opt:
- no-new-privileges:true
@@ -35,9 +37,6 @@ services:
volumes:
- rustfs_data:/data
- rustfs_logs:/logs
ports:
- "9000:9000"
- "9001:9001"
security_opt:
- no-new-privileges:true
restart: unless-stopped
@@ -52,8 +51,6 @@ services:
- POSTGRESQL_DATABASE=gitea
volumes:
- postgres_data:/bitnami/postgresql
ports:
- "5432:5432"
security_opt:
- no-new-privileges:true
restart: unless-stopped
@@ -88,10 +85,10 @@ services:
- GITEA__packages__MINIO_SECRET_ACCESS_KEY=${GITEA_S3_SECRET_KEY}
- GITEA__packages__MINIO_BUCKET=gitea-packages
- GITEA__packages__MINIO_USE_SSL=false
- GITEA__server__DOMAIN=gitea.home.fhirworx.io
- GITEA__server__ROOT_URL=http://gitea.home.fhirworx.io/
- GITEA__server__SSH_DOMAIN=gitea.home.fhirworx.io
- GITEA__webhook__ALLOWED_HOST_LIST=woodpecker-server,ci.home.fhirworx.io,192.168.1.192,172.19.0.0/16
- GITEA__server__DOMAIN=gitea.${DOMAIN:-homelab.fhirworx.io}
- GITEA__server__ROOT_URL=http://gitea.${DOMAIN:-homelab.fhirworx.io}/
- GITEA__server__SSH_DOMAIN=${DOMAIN:-homelab.fhirworx.io}
- GITEA__webhook__ALLOWED_HOST_LIST=woodpecker-server,ci.${DOMAIN:-homelab.fhirworx.io},${HOST_IP:-192.168.1.192},172.19.0.0/16
- GITEA__ui__THEMES=gitea-auto,gitea-light,gitea-dark,nes-sports
- GITEA__ui__DEFAULT_THEME=nes-sports
volumes:
@@ -111,15 +108,15 @@ services:
networks:
- intrastack
extra_hosts:
- "gitea.home.fhirworx.io:192.168.1.192"
- "gitea.${DOMAIN:-homelab.fhirworx.io}:${HOST_IP:-192.168.1.192}"
environment:
- WOODPECKER_HOST=http://ci.home.fhirworx.io
- WOODPECKER_HOST=http://ci.${DOMAIN:-homelab.fhirworx.io}
- WOODPECKER_GRPC_ADDR=:9000
- WOODPECKER_LOG_LEVEL=debug
- WOODPECKER_ADMIN=kert
- WOODPECKER_ADMIN=${WOODPECKER_ADMIN:-kert}
- WOODPECKER_OPEN=true
- WOODPECKER_GITEA=true
- WOODPECKER_GITEA_URL=http://gitea.home.fhirworx.io
- WOODPECKER_GITEA_URL=http://gitea.${DOMAIN:-homelab.fhirworx.io}
- WOODPECKER_GITEA_CLIENT=${WOODPECKER_GITEA_CLIENT}
- WOODPECKER_GITEA_SECRET=${WOODPECKER_GITEA_SECRET}
- WOODPECKER_AGENT_SECRET=${WOODPECKER_AGENT_SECRET}
@@ -145,10 +142,10 @@ services:
- WOODPECKER_AGENT_SECRET=${WOODPECKER_AGENT_SECRET}
- WOODPECKER_MAX_WORKFLOWS=4
- WOODPECKER_BACKEND=docker
- WOODPECKER_BACKEND_DOCKER_NETWORK=stack_intrastack
- WOODPECKER_BACKEND_DOCKER_NETWORK=intrastack
- DOCKER_API_VERSION=1.44
volumes:
- /run/user/1000/docker.sock:/var/run/docker.sock
- ${DOCKER_SOCK:-/run/user/1000/docker.sock}:/var/run/docker.sock
- woodpecker_agent_config:/etc/woodpecker
depends_on:
- woodpecker-server
@@ -226,12 +223,12 @@ services:
- QUARKUS_DATASOURCE_USERNAME=nessie
- QUARKUS_DATASOURCE_PASSWORD=${NESSIE_DB_PASSWORD}
# Iceberg REST Catalog settings
- NESSIE_CATALOG_DEFAULT_WAREHOUSE=s3://lakehouse/
- NESSIE_CATALOG_SERVICE_S3_DEFAULT_OPTIONS_ENDPOINT=http://rustfs:9000
- NESSIE_CATALOG_DEFAULT_WAREHOUSE=${S3_WAREHOUSE:-s3://lakehouse/}
- NESSIE_CATALOG_SERVICE_S3_DEFAULT_OPTIONS_ENDPOINT=${S3_ENDPOINT:-http://rustfs:9000}
- NESSIE_CATALOG_SERVICE_S3_DEFAULT_OPTIONS_ACCESS_KEY_ID=${NESSIE_S3_ACCESS_KEY}
- NESSIE_CATALOG_SERVICE_S3_DEFAULT_OPTIONS_SECRET_ACCESS_KEY=${NESSIE_S3_SECRET_KEY}
- NESSIE_CATALOG_SERVICE_S3_DEFAULT_OPTIONS_PATH_STYLE_ACCESS=true
- NESSIE_CATALOG_SERVICE_S3_DEFAULT_OPTIONS_REGION=us-east-1
- NESSIE_CATALOG_SERVICE_S3_DEFAULT_OPTIONS_REGION=${S3_REGION:-us-east-1}
# OpenTelemetry tracing
- QUARKUS_OTEL_ENABLED=true
- QUARKUS_OTEL_EXPORTER_OTLP_TRACES_ENDPOINT=http://jaeger:4317
@@ -251,6 +248,9 @@ services:
environment:
- NESSIE_S3_ACCESS_KEY=${NESSIE_S3_ACCESS_KEY}
- NESSIE_S3_SECRET_KEY=${NESSIE_S3_SECRET_KEY}
- S3_ENDPOINT=${S3_ENDPOINT:-http://rustfs:9000}
- S3_REGION=${S3_REGION:-us-east-1}
- S3_WAREHOUSE=${S3_WAREHOUSE:-s3://lakehouse/}
volumes:
- ./trino/etc:/etc/trino:ro
depends_on:
@@ -279,7 +279,7 @@ services:
# S3 storage defaults
- AWS_ACCESS_KEY_ID=${POLARIS_S3_ACCESS_KEY}
- AWS_SECRET_ACCESS_KEY=${POLARIS_S3_SECRET_KEY}
- AWS_REGION=us-east-1
- AWS_REGION=${S3_REGION:-us-east-1}
# OpenTelemetry
- QUARKUS_OTEL_ENABLED=true
- QUARKUS_OTEL_EXPORTER_OTLP_TRACES_ENDPOINT=http://jaeger:4317
@@ -313,12 +313,6 @@ services:
environment:
- COLLECTOR_OTLP_ENABLED=true
- COLLECTOR_ZIPKIN_HOST_PORT=:9411
ports:
- "14268:14268" # Jaeger collector HTTP
- "14250:14250" # Jaeger collector gRPC
- "6831:6831/udp" # Jaeger agent UDP
- "4317:4317" # OTLP gRPC
- "4318:4318" # OTLP HTTP
healthcheck:
test:
[
@@ -376,6 +370,7 @@ services:
- intrastack
volumes:
- ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro
- ./prometheus/targets:/etc/prometheus/targets:ro
- prometheus_data:/prometheus
command:
- "--config.file=/etc/prometheus/prometheus.yml"
@@ -402,11 +397,11 @@ services:
- intrastack
environment:
- GF_SECURITY_ADMIN_USER=admin
- GF_SECURITY_ADMIN_PASSWORD=admin
- GF_SECURITY_ADMIN_PASSWORD=${GF_ADMIN_PASSWORD:-admin}
- GF_USERS_ALLOW_SIGN_UP=false
- GF_AUTH_ANONYMOUS_ENABLED=true
- GF_AUTH_ANONYMOUS_ORG_ROLE=Viewer
- GF_SERVER_ROOT_URL=http://grafana.home.fhirworx.io
- GF_SERVER_ROOT_URL=http://grafana.${DOMAIN:-homelab.fhirworx.io}
volumes:
- ./grafana/provisioning:/etc/grafana/provisioning:ro
- ./grafana/dashboards:/var/lib/grafana/dashboards:ro

View File

View File

@@ -0,0 +1,11 @@
apiVersion: 1
providers:
- name: default
orgId: 1
folder: ''
type: file
disableDeletion: false
updateIntervalSeconds: 30
options:
path: /var/lib/grafana/dashboards
foldersFromFilesStructure: false

View File

@@ -0,0 +1,15 @@
apiVersion: 1
datasources:
- name: Prometheus
type: prometheus
access: proxy
url: http://prometheus:9090
isDefault: true
- name: Loki
type: loki
access: proxy
url: http://loki:3100
- name: Jaeger
type: jaeger
access: proxy
url: http://jaeger:16686

View File

@@ -324,126 +324,126 @@
</header>
<div class="grid">
<a href="http://traefik.home.fhirworx.io" target="_blank" class="tile proxy">
<a data-subdomain="traefik" target="_blank" class="tile proxy">
<span class="badge new">NEW</span>
<span class="status"></span>
<span class="icon">&#128279;</span>
<h2 class="tile-title">TRAEFIK</h2>
<p class="tile-desc">Cloud-native reverse proxy and load balancer</p>
<span class="tile-port">traefik.home.fhirworx.io</span>
<span class="tile-port"></span>
<div class="pixel-border" style="color: #55efc4;"></div>
</a>
<a href="http://minio.home.fhirworx.io" target="_blank" class="tile storage">
<a data-subdomain="minio" target="_blank" class="tile storage">
<span class="status"></span>
<span class="icon">&#128190;</span>
<h2 class="tile-title">RUSTFS</h2>
<p class="tile-desc">S3-compatible object storage for all your data needs</p>
<span class="tile-port">minio.home.fhirworx.io</span>
<span class="tile-port"></span>
<div class="pixel-border" style="color: #ff6b6b;"></div>
</a>
<a href="http://gitea.home.fhirworx.io" target="_blank" class="tile git">
<a data-subdomain="gitea" target="_blank" class="tile git">
<span class="status"></span>
<span class="icon">&#128025;</span>
<h2 class="tile-title">GITEA</h2>
<p class="tile-desc">Self-hosted Git service with packages & CI</p>
<span class="tile-port">gitea.home.fhirworx.io</span>
<span class="tile-port"></span>
<div class="pixel-border" style="color: #f7b731;"></div>
</a>
<a href="http://ci.home.fhirworx.io" target="_blank" class="tile ci">
<a data-subdomain="ci" target="_blank" class="tile ci">
<span class="status"></span>
<span class="icon">&#128296;</span>
<h2 class="tile-title">WOODPECKER</h2>
<p class="tile-desc">Simple yet powerful CI/CD engine</p>
<span class="tile-port">ci.home.fhirworx.io</span>
<span class="tile-port"></span>
<div class="pixel-border" style="color: #a55eea;"></div>
</a>
<a href="http://notebooks.home.fhirworx.io" target="_blank" class="tile notebook">
<a data-subdomain="notebooks" target="_blank" class="tile notebook">
<span class="badge gpu">GPU</span>
<span class="status"></span>
<span class="icon">&#128209;</span>
<h2 class="tile-title">NOTEBOOKS</h2>
<p class="tile-desc">Marimo reactive notebooks with CUDA</p>
<span class="tile-port">notebooks.home.fhirworx.io</span>
<span class="tile-port"></span>
<div class="pixel-border" style="color: #26de81;"></div>
</a>
<a href="http://zotero.home.fhirworx.io" target="_blank" class="tile research">
<a data-subdomain="zotero" target="_blank" class="tile research">
<span class="badge gpu">GPU</span>
<span class="status"></span>
<span class="icon">&#128218;</span>
<h2 class="tile-title">ZOTERO</h2>
<p class="tile-desc">Research reference manager in the cloud</p>
<span class="tile-port">zotero.home.fhirworx.io</span>
<span class="tile-port"></span>
<div class="pixel-border" style="color: #fd79a8;"></div>
</a>
<a href="http://nessie.home.fhirworx.io/api/v2/config" target="_blank" class="tile catalog">
<a data-subdomain="nessie" data-path="/api/v2/config" target="_blank" class="tile catalog">
<span class="badge data">DATA</span>
<span class="status"></span>
<span class="icon">&#127795;</span>
<h2 class="tile-title">NESSIE</h2>
<p class="tile-desc">Git-like versioned data catalog for Iceberg</p>
<span class="tile-port">nessie.home.fhirworx.io</span>
<span class="tile-port"></span>
<div class="pixel-border" style="color: #00cec9;"></div>
</a>
<a href="http://trino.home.fhirworx.io" target="_blank" class="tile query">
<a data-subdomain="trino" target="_blank" class="tile query">
<span class="badge data">DATA</span>
<span class="status"></span>
<span class="icon">&#128269;</span>
<h2 class="tile-title">TRINO</h2>
<p class="tile-desc">Distributed SQL query engine for the lakehouse</p>
<span class="tile-port">trino.home.fhirworx.io</span>
<span class="tile-port"></span>
<div class="pixel-border" style="color: #6c5ce7;"></div>
</a>
<a href="http://polaris.home.fhirworx.io" target="_blank" class="tile catalog">
<a data-subdomain="polaris" target="_blank" class="tile catalog">
<span class="badge new">NEW</span>
<span class="status"></span>
<span class="icon">&#11088;</span>
<h2 class="tile-title">POLARIS</h2>
<p class="tile-desc">Apache Iceberg catalog with governance & Unity-style access</p>
<span class="tile-port">polaris.home.fhirworx.io</span>
<span class="tile-port"></span>
<div class="pixel-border" style="color: #00cec9;"></div>
</a>
<a href="http://grafana.home.fhirworx.io" target="_blank" class="tile observability">
<a data-subdomain="grafana" target="_blank" class="tile observability">
<span class="status"></span>
<span class="icon">&#128202;</span>
<h2 class="tile-title">GRAFANA</h2>
<p class="tile-desc">Observability dashboards for logs and traces</p>
<span class="tile-port">grafana.home.fhirworx.io</span>
<span class="tile-port"></span>
<div class="pixel-border" style="color: #e17055;"></div>
</a>
<a href="http://jaeger.home.fhirworx.io" target="_blank" class="tile metrics">
<a data-subdomain="jaeger" target="_blank" class="tile metrics">
<span class="status"></span>
<span class="icon">&#128065;</span>
<h2 class="tile-title">JAEGER</h2>
<p class="tile-desc">Distributed tracing for microservices</p>
<span class="tile-port">jaeger.home.fhirworx.io</span>
<span class="tile-port"></span>
<div class="pixel-border" style="color: #fdcb6e;"></div>
</a>
<a href="http://prometheus.home.fhirworx.io" target="_blank" class="tile logs">
<a data-subdomain="prometheus" target="_blank" class="tile logs">
<span class="status"></span>
<span class="icon">&#128200;</span>
<h2 class="tile-title">PROMETHEUS</h2>
<p class="tile-desc">Metrics collection and alerting</p>
<span class="tile-port">prometheus.home.fhirworx.io</span>
<span class="tile-port"></span>
<div class="pixel-border" style="color: #74b9ff;"></div>
</a>
<a href="http://loki.home.fhirworx.io/ready" target="_blank" class="tile database">
<a data-subdomain="loki" data-path="/ready" target="_blank" class="tile database">
<span class="status"></span>
<span class="icon">&#128203;</span>
<h2 class="tile-title">LOKI</h2>
<p class="tile-desc">Log aggregation system by Grafana</p>
<span class="tile-port">loki.home.fhirworx.io</span>
<span class="tile-port"></span>
<div class="pixel-border" style="color: #4ecdc4;"></div>
</a>
</div>
@@ -454,10 +454,25 @@
</div>
<script>
// ── Domain resolution: derive base domain from current hostname ──
// dashboard.homelab.fhirworx.io → homelab.fhirworx.io
// homelab.fhirworx.io → homelab.fhirworx.io
(function() {
var base = location.hostname.replace(/^dashboard\./, '');
document.querySelectorAll('[data-subdomain]').forEach(function(el) {
var sub = el.dataset.subdomain;
var path = el.dataset.path || '';
var fqdn = sub + '.' + base;
el.href = location.protocol + '//' + fqdn + path;
var port = el.querySelector('.tile-port');
if (port) port.textContent = fqdn;
});
})();
// Generate starfield
const starsContainer = document.getElementById('stars');
for (let i = 0; i < 100; i++) {
const star = document.createElement('div');
var starsContainer = document.getElementById('stars');
for (var i = 0; i < 100; i++) {
var star = document.createElement('div');
star.className = 'star';
star.style.left = Math.random() * 100 + '%';
star.style.top = Math.random() * 100 + '%';
@@ -468,12 +483,11 @@
}
// Add click sound effect (visual feedback)
document.querySelectorAll('.tile').forEach(tile => {
document.querySelectorAll('.tile').forEach(function(tile) {
tile.addEventListener('click', function(e) {
this.style.transform = 'scale(0.95)';
setTimeout(() => {
this.style.transform = '';
}, 100);
var self = this;
setTimeout(function() { self.style.transform = ''; }, 100);
});
});
</script>

View File

@@ -3,41 +3,12 @@ global:
evaluation_interval: 15s
scrape_configs:
# Prometheus self-monitoring
- job_name: 'prometheus'
- job_name: prometheus
static_configs:
- targets: ['localhost:9090']
# Jaeger metrics
- job_name: 'jaeger'
static_configs:
- targets: ['jaeger:14269']
# Loki metrics
- job_name: 'loki'
static_configs:
- targets: ['loki:3100']
# Trino metrics (if enabled)
- job_name: 'trino'
static_configs:
- targets: ['trino:8080']
metrics_path: /v1/status
# Nessie metrics (management port)
- job_name: 'nessie'
static_configs:
- targets: ['nessie:9000']
metrics_path: /q/metrics
# Polaris metrics
- job_name: 'polaris'
static_configs:
- targets: ['polaris:8182']
metrics_path: /q/metrics
# Traefik metrics
- job_name: 'traefik'
static_configs:
- targets: ['traefik:8080']
metrics_path: /metrics
- job_name: services
file_sd_configs:
- files:
- /etc/prometheus/targets/*.yml
refresh_interval: 30s

View File

@@ -0,0 +1,37 @@
# ── Scrape Target Registry ───────────────────────────
# To add a service: append one entry below.
# Pattern: container_name:port, job label, optional __metrics_path__.
# Traefik
- targets: ['traefik:8080']
labels:
job: traefik
__metrics_path__: /metrics
# Jaeger
- targets: ['jaeger:14269']
labels:
job: jaeger
# Loki
- targets: ['loki:3100']
labels:
job: loki
# Nessie
- targets: ['nessie:9000']
labels:
job: nessie
__metrics_path__: /q/metrics
# Polaris
- targets: ['polaris:8182']
labels:
job: polaris
__metrics_path__: /q/metrics
# Trino
- targets: ['trino:8080']
labels:
job: trino
__metrics_path__: /v1/status

View File

@@ -1,24 +0,0 @@
# Dynamic Traefik Configuration - Middlewares
http:
middlewares:
# Security headers
secure-headers:
headers:
frameDeny: true
browserXssFilter: true
contentTypeNosniff: true
# Rate limiting
rate-limit:
rateLimit:
average: 100
burst: 50
# NES theme CSS injection
inject-nes-theme:
plugin:
rewrite-body:
lastModified: true
rewrites:
- regex: "</head>"
replacement: '<link rel="stylesheet" type="text/css" href="http://home.fhirworx.io/nes-theme.css"></head>'

View File

@@ -1,192 +1,102 @@
# Dynamic Traefik Configuration - Services
# All services defined manually instead of Docker provider
{{/* ═══════════════════════════════════════════════════════════════
REEF — the single source of truth for all routing.
To add a service: add one entry to $reef below.
FQDN pattern: {name}.{DOMAIN} (DOMAIN from env, e.g. homelab.fhirworx.io)
Adding a service to the reef:
1. compose.yml — add service block
2. Here — add 1 line to $reef dict
3. prometheus/targets/services.yml — add 4 lines (if metrics)
4. nginx/index.html — add tile with data-subdomain (optional)
═══════════════════════════════════════════════════════════════ */}}
{{- $domain := env "DOMAIN" | default "homelab.fhirworx.io" -}}
{{/* ── The Reef: one router + one backend per service ───────────── */}}
{{- $reef := dict
"dashboard" (dict "port" "80" "theme" false "extra_hosts" (list $domain))
"gitea" (dict "port" "3000" "theme" false)
"woodpecker" (dict "port" "8000" "theme" false "subdomain" "ci")
"notebooks" (dict "port" "2718" "theme" false)
"zotero" (dict "port" "8080" "theme" false)
"nessie" (dict "port" "19120" "theme" false)
"trino" (dict "port" "8080" "theme" true)
"polaris" (dict "port" "8181" "theme" false)
"grafana" (dict "port" "3000" "theme" true)
"prometheus" (dict "port" "9090" "theme" true)
"jaeger" (dict "port" "16686" "theme" true)
"loki" (dict "port" "3100" "theme" false)
-}}
{{/* ── Multi-endpoint services ─────────────────────────────────── */}}
{{- $multi := dict
"rustfs-api" (dict "container" "rustfs" "port" "9000" "subdomain" "s3" "theme" false)
"rustfs-console" (dict "container" "rustfs" "port" "9001" "subdomain" "minio" "theme" true)
-}}
http:
{{/* ── Middlewares (inlined — no separate middlewares.yml) ──────── */}}
middlewares:
secure-headers:
headers:
frameDeny: true
browserXssFilter: true
contentTypeNosniff: true
rate-limit:
rateLimit:
average: 100
burst: 50
inject-nes-theme:
plugin:
rewrite-body:
lastModified: true
rewrites:
- regex: "</head>"
replacement: '<link rel="stylesheet" type="text/css" href="http://{{ $domain }}/nes-theme.css"></head>'
{{/* ── Generated routers ─────────────────────────────────────── */}}
routers:
# Dashboard
dashboard:
rule: "Host(`dashboard.home.fhirworx.io`) || Host(`home.fhirworx.io`)"
service: dashboard
entryPoints:
- web
# Gitea
gitea:
rule: "Host(`gitea.home.fhirworx.io`)"
service: gitea
entryPoints:
- web
# Woodpecker CI
woodpecker:
rule: "Host(`ci.home.fhirworx.io`)"
service: woodpecker
entryPoints:
- web
# Notebooks
notebooks:
rule: "Host(`notebooks.home.fhirworx.io`)"
service: notebooks
entryPoints:
- web
# Zotero
zotero:
rule: "Host(`zotero.home.fhirworx.io`)"
service: zotero
entryPoints:
- web
# Nessie
nessie:
rule: "Host(`nessie.home.fhirworx.io`)"
service: nessie
entryPoints:
- web
# Trino
trino:
rule: "Host(`trino.home.fhirworx.io`)"
service: trino
{{- range $name, $svc := $reef }}
{{ $name }}:
rule: "Host(`{{ get $svc "subdomain" | default $name }}.{{ $domain }}`){{ range get $svc "extra_hosts" | default list }} || Host(`{{ . }}`){{ end }}"
service: {{ $name }}
entryPoints:
- web
{{- if get $svc "theme" }}
middlewares:
- inject-nes-theme
# RustFS S3 API
rustfs-api:
rule: "Host(`s3.home.fhirworx.io`)"
service: rustfs-api
entryPoints:
- web
# RustFS Console
rustfs-console:
rule: "Host(`minio.home.fhirworx.io`)"
service: rustfs-console
{{- end }}
{{- end }}
{{- range $name, $svc := $multi }}
{{ $name }}:
rule: "Host(`{{ get $svc "subdomain" }}.{{ $domain }}`)"
service: {{ $name }}
entryPoints:
- web
{{- if get $svc "theme" }}
middlewares:
- inject-nes-theme
# Grafana
grafana:
rule: "Host(`grafana.home.fhirworx.io`)"
service: grafana
entryPoints:
- web
middlewares:
- inject-nes-theme
# Prometheus
prometheus:
rule: "Host(`prometheus.home.fhirworx.io`)"
service: prometheus
entryPoints:
- web
middlewares:
- inject-nes-theme
# Jaeger
jaeger:
rule: "Host(`jaeger.home.fhirworx.io`)"
service: jaeger
entryPoints:
- web
middlewares:
- inject-nes-theme
# Loki
loki:
rule: "Host(`loki.home.fhirworx.io`)"
service: loki
entryPoints:
- web
# Traefik Dashboard
traefik:
rule: "Host(`traefik.home.fhirworx.io`)"
{{- end }}
{{- end }}
traefik-dashboard:
rule: "Host(`traefik.{{ $domain }}`)"
service: api@internal
entryPoints:
- web
middlewares:
- inject-nes-theme
# Polaris Catalog
polaris:
rule: "Host(`polaris.home.fhirworx.io`)"
service: polaris
entryPoints:
- web
{{/* ── Generated services ────────────────────────────────────── */}}
services:
dashboard:
{{- range $name, $svc := $reef }}
{{ $name }}:
loadBalancer:
servers:
- url: "http://dashboard:80"
gitea:
- url: "http://{{ $name }}:{{ get $svc "port" }}"
{{- end }}
{{- range $name, $svc := $multi }}
{{ $name }}:
loadBalancer:
servers:
- url: "http://gitea:3000"
woodpecker:
loadBalancer:
servers:
- url: "http://woodpecker-server:8000"
notebooks:
loadBalancer:
servers:
- url: "http://notebooks:2718"
zotero:
loadBalancer:
servers:
- url: "http://zotero:8080"
nessie:
loadBalancer:
servers:
- url: "http://nessie:19120"
trino:
loadBalancer:
servers:
- url: "http://trino:8080"
rustfs-api:
loadBalancer:
servers:
- url: "http://rustfs:9000"
rustfs-console:
loadBalancer:
servers:
- url: "http://rustfs:9001"
grafana:
loadBalancer:
servers:
- url: "http://grafana:3000"
prometheus:
loadBalancer:
servers:
- url: "http://prometheus:9090"
jaeger:
loadBalancer:
servers:
- url: "http://jaeger:16686"
loki:
loadBalancer:
servers:
- url: "http://loki:3100"
polaris:
loadBalancer:
servers:
- url: "http://polaris:8181"
- url: "http://{{ get $svc "container" }}:{{ get $svc "port" }}"
{{- end }}

View File

@@ -0,0 +1,24 @@
# See here for image contents: https://github.com/microsoft/vscode-dev-containers/tree/v0.234.0/containers/go/.devcontainer/base.Dockerfile
# [Choice] Go version (use -bullseye variants on local arm64/Apple Silicon): 1, 1.16, 1.17, 1-bullseye, 1.16-bullseye, 1.17-bullseye, 1-buster, 1.16-buster, 1.17-buster
ARG VARIANT="1.18-bullseye"
FROM mcr.microsoft.com/vscode/devcontainers/go:0-${VARIANT}
# [Choice] Node.js version: none, lts/*, 16, 14, 12, 10
ARG NODE_VERSION="none"
RUN if [ "${NODE_VERSION}" != "none" ]; then su vscode -c "umask 0002 && . /usr/local/share/nvm/nvm.sh && nvm install ${NODE_VERSION} 2>&1"; fi
# [Optional] Uncomment this section to install additional OS packages.
RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
&& apt-get -y install --no-install-recommends \
vim
# [Optional] Uncomment the next lines to use go get to install anything else you need
USER vscode
RUN go install mvdan.cc/gofumpt@latest
ENV YAEGI_VERSION="v0.9.20"
RUN curl -sfL https://raw.githubusercontent.com/traefik/yaegi/master/install.sh | bash -s -- -b $(go env GOPATH)/bin ${YAEGI_VERSION}
# [Optional] Uncomment this line to install global node packages.
# RUN su vscode -c "source /usr/local/share/nvm/nvm.sh && npm install -g <your-package-here>" 2>&1

View File

@@ -0,0 +1,50 @@
// For format details, see https://aka.ms/devcontainer.json. For config options, see the README at:
// https://github.com/microsoft/vscode-dev-containers/tree/v0.234.0/containers/go
{
"name": "rewrite-body",
"build": {
"dockerfile": "Dockerfile",
"args": {
// Update the VARIANT arg to pick a version of Go: 1, 1.18, 1.17
// Append -bullseye or -buster to pin to an OS version.
// Use -bullseye variants on local arm64/Apple Silicon.
"VARIANT": "1.18",
// Options
"NODE_VERSION": "lts/*"
}
},
"runArgs": [
"--cap-add=SYS_PTRACE",
"--security-opt",
"seccomp=unconfined"
],
// Set *default* container specific settings.json values on container create.
"settings": {
"go.toolsManagement.checkForUpdates": "local",
"go.useLanguageServer": true,
"go.gopath": "/go",
"terminal.integrated.defaultProfile.linux": "zsh"
},
"mounts": [
"source=${localWorkspaceFolderBasename}-home,target=/home/vscode,type=volume",
"source=${localEnv:HOME}/.ssh,target=/home/vscode/.ssh,type=bind",
"source=${localEnv:HOME}/.zshrc,target=/home/vscode/.zshrc,type=bind",
"source=${localEnv:HOME}/.oh-my-zsh,target=/home/vscode/.oh-my-zsh,type=bind",
"source=${localEnv:HOME}/.oh-my-zsh-custom,target=/home/vscode/.oh-my-zsh-custom,type=bind",
],
// Add the IDs of extensions you want installed when the container is created.
"extensions": [
"golang.Go"
],
// Use 'forwardPorts' to make a list of ports inside the container available locally.
// "forwardPorts": [],
// Use 'postCreateCommand' to run commands after the container is created.
// "postCreateCommand": "go version",
// Comment out to connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root.
"remoteUser": "vscode",
"features": {
"git": "latest"
},
"workspaceMount": "source=${localWorkspaceFolder},target=/go/src/github.com/${localWorkspaceFolderBasename},type=bind,consistency=cached",
"workspaceFolder": "/go/src/github.com/${localWorkspaceFolderBasename}"
}

View File

@@ -0,0 +1,44 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: ''
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**Relevant Config**
Any specific information about regex used. Specific information about
HTTP query would be appreciated as well!
**Expected behavior**
A clear and concise description of what you expected to happen.
**Actual behavior**
A clear and concise description of what actually happened.
**Logs**
Any logs from your Treafik logs would be greatly appreciated!!
You can add the following to your Traefik config:
```yaml
log:
level: DEBUG
```
Then look for logs that end with:
```console
module=github.com/packruler/rewrite-body plugin=plugin-rewritebody
```
**Server (please complete the following information):**
- OS: [e.g. Docker, Ubuntu]
- Traefik Version [e.g. 2.6]
- Plugin Version [e.g. 0.5.0]
**Additional context**
Add any other context about the problem here.

View File

@@ -0,0 +1,20 @@
---
name: Feature request
about: Suggest an idea for this project
title: ''
labels: ''
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.

View File

@@ -0,0 +1,47 @@
name: Go Matrix
on: [push, pull_request]
jobs:
cross:
name: Go
runs-on: ${{ matrix.os }}
env:
CGO_ENABLED: 0
strategy:
matrix:
go-version: [ 1.16, 1.17, 1.x ]
os: [ubuntu-latest, macos-latest, windows-latest]
steps:
# https://github.com/marketplace/actions/setup-go-environment
- name: Set up Go ${{ matrix.go-version }}
uses: actions/setup-go@v2
with:
go-version: ${{ matrix.go-version }}
# https://github.com/marketplace/actions/checkout
- name: Checkout code
uses: actions/checkout@v2
# https://github.com/marketplace/actions/cache
- name: Cache Go modules
uses: actions/cache@v2
with:
# In order:
# * Module download cache
# * Build cache (Linux)
# * Build cache (Mac)
# * Build cache (Windows)
path: |
~/go/pkg/mod
~/.cache/go-build
~/Library/Caches/go-build
%LocalAppData%\go-build
key: ${{ runner.os }}-${{ matrix.go-version }}-go-${{ hashFiles('**/go.sum') }}
restore-keys: |
${{ runner.os }}-${{ matrix.go-version }}-go-
- name: Test
run: go test -v -cover ./...

View File

@@ -0,0 +1,74 @@
name: Main
on:
push:
branches:
- master
- plugin-utils
tags:
- v*
pull_request:
jobs:
main:
name: Main Process
runs-on: ubuntu-latest
env:
GO_VERSION: 1.16
GOLANGCI_LINT_VERSION: v1.41.1
YAEGI_VERSION: v0.9.20
CGO_ENABLED: 0
defaults:
run:
working-directory: ${{ github.workspace }}/go/src/github.com/${{ github.repository }}
steps:
# https://github.com/marketplace/actions/setup-go-environment
- name: Set up Go ${{ env.GO_VERSION }}
uses: actions/setup-go@v2
with:
go-version: ${{ env.GO_VERSION }}
# https://github.com/marketplace/actions/checkout
- name: Check out code
uses: actions/checkout@v2
with:
path: go/src/github.com/${{ github.repository }}
fetch-depth: 0
# https://github.com/marketplace/actions/cache
- name: Cache Go modules
uses: actions/cache@v2
with:
path: ${{ github.workspace }}/go/pkg/mod
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
restore-keys: |
${{ runner.os }}-go-
# https://golangci-lint.run/usage/install#other-ci
- name: Install golangci-lint ${{ env.GOLANGCI_LINT_VERSION }}
run: curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin ${GOLANGCI_LINT_VERSION}
- name: Install Yaegi ${{ env.YAEGI_VERSION }}
run: curl -sfL https://raw.githubusercontent.com/traefik/yaegi/master/install.sh | bash -s -- -b $(go env GOPATH)/bin ${YAEGI_VERSION}
- name: Setup GOPATH
run: go env -w GOPATH=${{ github.workspace }}/go
- name: Check and get dependencies
run: |
go mod tidy
git diff --exit-code go.mod
# git diff --exit-code go.sum
go mod vendor
# git diff --exit-code ./vendor/
- name: Lint and Tests
run: make
- name: Run tests with Yaegi
run: make yaegi_test
env:
GOPATH: ${{ github.workspace }}/go

View File

@@ -0,0 +1,2 @@
.idea/
.DS_Store

View File

@@ -0,0 +1,73 @@
run:
timeout: 3m
skip-files: []
skip-dirs: []
linters-settings:
govet:
check-shadowing: false
golint:
min-confidence: 0
gocyclo:
min-complexity: 12
maligned:
suggest-new: true
goconst:
min-len: 3
min-occurrences: 4
misspell:
locale: US
funlen:
lines: 60
statements: 40
ireturn:
# ireturn allows using `allow` and `reject` settings at the same time.
# Both settings are lists of the keywords and regular expressions matched to interface or package names.
# keywords:
# - `empty` for `interface{}`
# - `error` for errors
# - `stdlib` for standard library
# - `anon` for anonymous interfaces
# By default, it allows using errors, empty interfaces, anonymous interfaces,
# and interfaces provided by the standard library.
allow:
- anon
- error
- empty
- stdlib
# You can specify idiomatic endings for interface
- (or|er)$
- github.com/packruler/rewrite-body/logger.LogWriter
linters:
enable-all: true
disable:
- scopelint
- golint
- interfacer
- maligned
- bodyclose
- goerr113
- wrapcheck
- maligned
- exhaustivestruct
- testpackage
- paralleltest
- tparallel
issues:
exclude-use-default: false
max-per-linter: 0
max-same-issues: 0
exclude:
- don't use an underscore in package name
- "ST1003: should not use underscores in package names"
- "SA1019: http.CloseNotifier has been deprecated since Go 1.11 and an alternative has been available since Go 1.7: the CloseNotifier interface predates Go's context package. New code should use Request.Context instead."
exclude-rules:
- path: (.+)_test.go
linters:
- goconst
- funlen
- godot
- lll
- path: config.go
linters:
- lll

View File

@@ -0,0 +1,17 @@
displayName: Rewrite Body (with compression support)
summary: Built on top of Traefik Rewrite Body plugin to also support gzip decompress -> update -> recompress.
type: middleware
import: github.com/packruler/rewrite-body
testData:
lastModified: true
rewrites:
- regex: "bar"
replacement: "foo"
monitor:
types:
- text/html
methods: ["GET"]

View File

@@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2020 Containous SAS
Copyright 2020 Traefik Labs
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

View File

@@ -0,0 +1,20 @@
.PHONY: lint test vendor clean
export GO111MODULE=on
default: lint test
lint:
golangci-lint run
test:
go test -v -cover ./...
yaegi_test:
yaegi test -v .
vendor:
go mod vendor
clean:
rm -rf ./vendor

View File

@@ -0,0 +1,130 @@
# Rewrite Body
This is a fork of [Traefik](https://github.com/traefik)'s [plugin-rewritebody](https://github.com/traefik/plugin-rewritebody)
that is aimed at extending support to handle `gzip` content. This was initially aimed at extending the support for utilizing
[theme.park](https://github.com/traefik/plugin-rewritebody)'s themes but can likely be used for a range of other uses.
## Changes From Original Traefik Plugin
The primary change is to add support for `gzip` content. This brought another potential issue to mind, what about really large
content? This was handled as well.
### Process For Handling Body Content
#### Body Content Requirements
* The header must have `Content-Type` that includes `text`. For example:
* `text/html`
* `text/json`
* The header must have `Content-Encoding` header that is supported by this plugin
* The original plugin supported `Content-Encoding` of `identity` or empty
* This plugin adds support for `gzip` and `zlib` encoding
#### Processing Paths
* If the either of the previous conditions failes the body is passed on as is and no further processing from this plugin occurs.
* If the `Content-Encoding` is empty or `identity` it is handled in mostly the same manner as the original plugin.
* If the `Content-Encoding` is `gzip` the following process happens:
* The body content is decompressed by [Go-lang's gzip library](https://pkg.go.dev/compress/gzip)
* The resulting content is run through the `regex` process created by the original plugin
* The processed content is then compressed with the same library and returned
## Configuration
### Static
```yaml
pilot:
token: "xxxx"
experimental:
plugins:
rewrite-body:
moduleName: "github.com/packruler/rewrite-body"
version: "v1.1.0"
```
### Dynamic
To configure the `Rewrite Body` plugin you should create a [middleware](https://docs.traefik.io/middlewares/overview/) in
your dynamic configuration as explained [here](https://docs.traefik.io/middlewares/overview/). The following example creates
and uses the `rewritebody` middleware plugin to replace all foo occurences by bar in the HTTP response body.
If you want to apply some limits on the response body, you can chain this middleware plugin with the [Buffering middleware](https://docs.traefik.io/middlewares/buffering/) from Traefik.
```yaml
http:
routers:
my-router:
rule: "Host(`localhost`)"
middlewares:
- "rewrite-foo"
service: "my-service"
middlewares:
rewrite-foo:
plugin:
rewrite-body:
# Keep Last-Modified header returned by the HTTP service.
# By default, the Last-Modified header is removed.
lastModified: true
# Rewrites all "foo" occurences by "bar"
rewrites:
- regex: "foo"
replacement: "bar"
# logLevel is optional, defaults to Info level.
# Available logLevels: (Trace: -2, Debug: -1, Info: 0, Warning: 1, Error: 2)
logLevel: 0
# monitoring is optional, defaults to below configuration
# monitoring configuration limits the HTTP queries that are checked for regex replacement.
monitoring:
# methods is a string list. Options are standard HTTP Methods. Entries MUST be ALL CAPS
# For a list of options: https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods
methods:
- GET
# types is a string list. Options are HTTP Content Types. Entries should match standard formatting
# For a list of options: https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types
# Wildcards(*) are not supported!
types:
- text/html
services:
my-service:
loadBalancer:
servers:
- url: "http://127.0.0.1"
```
## Example theme.park
### Dynamic
```yaml
http:
routers:
sonarr-router:
rule: "Host(`sonarr.example.com`)"
middlewares:
- sonarr-theme
service: sonarr-service
middlewares:
sonarr-theme:
plugin:
rewrite-body:
rewrites:
- regex: </head>
replacement: <link rel="stylesheet" type="text/css" href="https://theme-park.dev/css/base/sonarr/{{ env "THEME" }}.css"></head>
services:
sonarr-service:
servers:
- url: http://localhost:8989
```
You can set an environment variable `THEME` to the name of a theme for easier consistency across apps.

View File

@@ -0,0 +1,10 @@
package compressutil
const (
// Gzip compression algorithm string.
Gzip string = "gzip"
// Deflate compression algorithm string.
Deflate string = "deflate"
// Identity compression algorithm string.
Identity string = "identity"
)

View File

@@ -0,0 +1,95 @@
// Package compressutil a plugin to handle compression and decompression tasks
package compressutil
import (
"bytes"
"compress/flate"
"compress/gzip"
"io"
"log"
)
// ReaderError for notating that an error occurred while reading compressed data.
type ReaderError struct {
error
cause error
}
// Decode data in a bytes.Reader based on supplied encoding.
func Decode(byteReader *bytes.Buffer, encoding string) ([]byte, error) {
reader, err := getRawReader(byteReader, encoding)
if err != nil {
return nil, &ReaderError{
error: err,
cause: err,
}
}
return io.ReadAll(reader)
}
func getRawReader(byteReader *bytes.Buffer, encoding string) (io.Reader, error) {
switch encoding {
case Gzip:
return gzip.NewReader(byteReader)
case Deflate:
return flate.NewReader(byteReader), nil
default:
return byteReader, nil
}
}
// Encode data in a []byte based on supplied encoding.
func Encode(data []byte, encoding string) ([]byte, error) {
switch encoding {
case Gzip:
return compressWithGzip(data)
case Deflate:
return compressWithZlib(data)
default:
return data, nil
}
}
func compressWithGzip(bodyBytes []byte) ([]byte, error) {
var buf bytes.Buffer
gzipWriter := gzip.NewWriter(&buf)
if _, err := gzipWriter.Write(bodyBytes); err != nil {
log.Printf("unable to recompress rewrited body: %v", err)
return nil, err
}
if err := gzipWriter.Close(); err != nil {
log.Printf("unable to close gzip writer: %v", err)
return nil, err
}
return buf.Bytes(), nil
}
func compressWithZlib(bodyBytes []byte) ([]byte, error) {
var buf bytes.Buffer
zlibWriter, _ := flate.NewWriter(&buf, flate.DefaultCompression)
if _, err := zlibWriter.Write(bodyBytes); err != nil {
log.Printf("unable to recompress rewrited body: %v", err)
return nil, err
}
if err := zlibWriter.Close(); err != nil {
log.Printf("unable to close zlib writer: %v", err)
return nil, err
}
return buf.Bytes(), nil
}

View File

@@ -0,0 +1,154 @@
package compressutil_test
import (
"bytes"
"testing"
"github.com/packruler/rewrite-body/compressutil"
)
type TestStruct struct {
desc string
input []byte
expected []byte
encoding string
shouldMatch bool
}
func TestEncode(t *testing.T) {
var (
deflatedBytes = []byte{
74, 203, 207, 87, 200, 44, 86, 40, 201, 72, 85,
200, 75, 45, 87, 72, 74, 44, 2, 4, 0, 0, 255, 255,
}
gzippedBytes = []byte{
31, 139, 8, 0, 0, 0, 0, 0, 0, 255, 74, 203, 207, 87, 200, 44, 86, 40, 201, 72, 85,
200, 75, 45, 87, 72, 74, 44, 2, 4, 0, 0, 255, 255, 251, 28, 166, 187, 18, 0, 0, 0,
}
normalBytes = []byte("foo is the new bar")
)
tests := []TestStruct{
{
desc: "should support identity",
input: normalBytes,
expected: normalBytes,
encoding: compressutil.Identity,
shouldMatch: true,
},
{
desc: "should support gzip",
input: normalBytes,
expected: gzippedBytes,
encoding: compressutil.Gzip,
shouldMatch: false,
},
{
desc: "should support deflate",
input: normalBytes,
expected: deflatedBytes,
encoding: compressutil.Deflate,
shouldMatch: false,
},
{
desc: "should NOT support brotli",
input: normalBytes,
expected: normalBytes,
encoding: "br",
shouldMatch: true,
},
}
for _, test := range tests {
t.Run(test.desc, func(t *testing.T) {
output, err := compressutil.Encode(test.input, test.encoding)
if err != nil {
t.Errorf("unexpected error: %v", err)
}
isBad := !bytes.Equal(test.expected, output)
if isBad {
t.Errorf("expected error got body: %v\n wanted: %v", output, test.expected)
}
if test.shouldMatch {
isBad = !bytes.Equal(test.input, output)
} else {
isBad = bytes.Equal(test.input, output)
}
if isBad {
t.Errorf("match error got body: %v\n wanted: %v", output, test.input)
}
})
}
}
func TestDecode(t *testing.T) {
var (
deflatedBytes = []byte{
74, 203, 207, 87, 200, 44, 86, 40, 201, 72, 85,
200, 75, 45, 87, 72, 74, 44, 2, 4, 0, 0, 255, 255,
}
gzippedBytes = []byte{
31, 139, 8, 0, 0, 0, 0, 0, 0, 255, 74, 203, 207, 87, 200, 44, 86, 40, 201, 72, 85,
200, 75, 45, 87, 72, 74, 44, 2, 4, 0, 0, 255, 255, 251, 28, 166, 187, 18, 0, 0, 0,
}
normalBytes = []byte("foo is the new bar")
)
tests := []TestStruct{
{
desc: "should support identity",
input: normalBytes,
expected: normalBytes,
encoding: compressutil.Identity,
shouldMatch: true,
},
{
desc: "should support gzip",
input: gzippedBytes,
expected: normalBytes,
encoding: compressutil.Gzip,
shouldMatch: false,
},
{
desc: "should support deflate",
input: deflatedBytes,
expected: normalBytes,
encoding: compressutil.Deflate,
shouldMatch: false,
},
{
desc: "should NOT support brotli",
input: normalBytes,
expected: normalBytes,
encoding: "br",
shouldMatch: true,
},
}
for _, test := range tests {
t.Run(test.desc, func(t *testing.T) {
output, err := compressutil.Decode(bytes.NewBuffer(test.input), test.encoding)
if err != nil {
t.Errorf("unexpected error: %v", err)
}
isBad := !bytes.Equal(test.expected, output)
if isBad {
t.Errorf("expected error got body: %v\n wanted: %v", output, test.expected)
}
if test.shouldMatch {
isBad = !bytes.Equal(test.input, output)
} else {
isBad = bytes.Equal(test.input, output)
}
if isBad {
t.Errorf("match error got body: %s\n wanted: %s", output, test.input)
}
})
}
}

View File

@@ -0,0 +1,9 @@
[config]
lastModified = true
logLevel = 0
[[config.rewrites]]
regex = "test"
replacement = "test"
[config.monitor]
types = ["text/html"]
methods = ["GET"]

View File

@@ -0,0 +1,10 @@
config:
lastModified: true
logLevel: 0
monitor:
types:
- text/html
methods: ["GET"]
rewrites:
- regex: "test"
replacement: "test"

View File

@@ -0,0 +1,3 @@
module github.com/packruler/rewrite-body
go 1.16

View File

@@ -0,0 +1,26 @@
package handler
import (
"regexp"
"github.com/packruler/rewrite-body/httputil"
)
// Rewrite holds one rewrite body configuration.
type Rewrite struct {
Regex string `json:"regex" yaml:"regex" toml:"regex"`
Replacement string `json:"replacement" yaml:"replacement" toml:"replacement"`
}
// Config holds the plugin configuration.
type Config struct {
LastModified bool `json:"lastModified" toml:"lastModified" yaml:"lastModified"`
Rewrites []Rewrite `json:"rewrites" toml:"rewrites" yaml:"rewrites"`
LogLevel int8 `json:"logLevel" toml:"logLevel" yaml:"logLevel"`
Monitoring httputil.MonitoringConfig `json:"monitoring" toml:"monitoring" yaml:"monitoring"`
}
type rewrite struct {
regex *regexp.Regexp
replacement []byte
}

View File

@@ -0,0 +1,142 @@
// Package handler a plugin to rewrite response body.
package handler
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"regexp"
"github.com/packruler/rewrite-body/httputil"
"github.com/packruler/rewrite-body/logger"
)
type rewriteBody struct {
name string
next http.Handler
rewrites []rewrite
lastModified bool
logger logger.LogWriter
monitoringConfig httputil.MonitoringConfig
}
// New creates and returns a new rewrite body plugin instance.
func New(_ context.Context, next http.Handler, config *Config, name string) (http.Handler, error) {
rewrites := make([]rewrite, len(config.Rewrites))
for index, rewriteConfig := range config.Rewrites {
regex, err := regexp.Compile(rewriteConfig.Regex)
if err != nil {
return nil, fmt.Errorf("error compiling regex %q: %w", rewriteConfig.Regex, err)
}
rewrites[index] = rewrite{
regex: regex,
replacement: []byte(rewriteConfig.Replacement),
}
}
logWriter := *logger.CreateLogger(logger.LogLevel(config.LogLevel))
config.Monitoring.EnsureDefaults()
config.Monitoring.EnsureProperFormat()
result := &rewriteBody{
name: name,
next: next,
rewrites: rewrites,
lastModified: config.LastModified,
logger: logWriter,
monitoringConfig: config.Monitoring,
}
data, _ := json.Marshal(config)
logWriter.LogDebugf("Initial config: %v", string(data))
return result, nil
}
func (bodyRewrite *rewriteBody) ServeHTTP(response http.ResponseWriter, req *http.Request) {
defer bodyRewrite.handlePanic()
wrappedRequest := httputil.WrapRequest(req, bodyRewrite.monitoringConfig, bodyRewrite.logger)
// allow default http.ResponseWriter to handle calls targeting WebSocket upgrades and non GET methods
if !wrappedRequest.SupportsProcessing() {
bodyRewrite.logger.LogDebugf("Ignoring unsupported request: %v", req)
bodyRewrite.next.ServeHTTP(response, req)
return
}
bodyRewrite.logger.LogDebugf("Starting supported request: %v", req)
wrappedWriter := httputil.WrapWriter(
response,
bodyRewrite.monitoringConfig,
bodyRewrite.logger,
bodyRewrite.lastModified,
)
wrappedWriter.SetLastModified(bodyRewrite.lastModified)
// look into using https://pkg.go.dev/net/http#RoundTripper
bodyRewrite.next.ServeHTTP(wrappedWriter, wrappedRequest.CloneWithSupportedEncoding())
if !wrappedWriter.SupportsProcessing() {
// We are ignoring these any errors because the content should be unchanged here.
// This could "error" if writing is not supported but content will return properly.
_, _ = response.Write(wrappedWriter.GetBuffer().Bytes())
bodyRewrite.logger.LogDebugf("Ignoring unsupported response: %v", wrappedWriter)
return
}
bodyBytes, err := wrappedWriter.GetContent()
if err != nil {
bodyRewrite.logger.LogErrorf("Error loading content: %v", err)
if _, err := response.Write(wrappedWriter.GetBuffer().Bytes()); err != nil {
bodyRewrite.logger.LogErrorf("unable to write error content: %v", err)
}
return
}
bodyRewrite.logger.LogDebugf("Response body: %s", bodyBytes)
if len(bodyBytes) == 0 {
// If the body is empty there is no purpose in continuing this process.
return
}
for _, rwt := range bodyRewrite.rewrites {
bodyBytes = rwt.regex.ReplaceAll(bodyBytes, rwt.replacement)
}
bodyRewrite.logger.LogDebugf("Transformed body: %s", bodyBytes)
encoding := wrappedWriter.Header().Get("Content-Encoding")
wrappedWriter.SetContent(bodyBytes, encoding)
}
func (bodyRewrite *rewriteBody) handlePanic() {
if recovery := recover(); recovery != nil {
if err, ok := recovery.(error); ok {
bodyRewrite.logError(err)
} else {
bodyRewrite.logger.LogWarningf("Unhandled error: %v", recovery)
}
}
}
func (bodyRewrite *rewriteBody) logError(err error) {
// Ignore http.ErrAbortHandler because they are expected errors that do not require handling
if errors.Is(err, http.ErrAbortHandler) {
return
}
bodyRewrite.logger.LogWarningf("Recovered from: %v", err)
}

View File

@@ -0,0 +1,255 @@
package handler
import (
"bytes"
"context"
"fmt"
"net/http"
"net/http/httptest"
"strconv"
"testing"
"github.com/packruler/rewrite-body/compressutil"
"github.com/packruler/rewrite-body/httputil"
)
func TestServeHTTP(t *testing.T) {
tests := []struct {
desc string
contentEncoding string
contentType string `default:"text/html"`
rewrites []Rewrite
lastModified bool
resBody string
expResBody string
expLastModified bool
}{
{
desc: "should replace foo by bar",
rewrites: []Rewrite{
{
Regex: "foo",
Replacement: "bar",
},
},
contentType: "text/html",
resBody: "foo is the new bar",
expResBody: "bar is the new bar",
},
{
desc: "should replace foo by bar, then by foo",
rewrites: []Rewrite{
{
Regex: "foo",
Replacement: "bar",
},
{
Regex: "bar",
Replacement: "foo",
},
},
contentType: "text/html",
resBody: "foo is the new bar",
expResBody: "foo is the new foo",
},
{
desc: "should not replace anything if content encoding is not identity or empty",
rewrites: []Rewrite{
{
Regex: "foo",
Replacement: "bar",
},
},
contentEncoding: "other",
contentType: "text/html",
resBody: "foo is the new bar",
expResBody: "foo is the new bar",
},
{
desc: "should not replace anything if content type does not contain text or is not empty",
rewrites: []Rewrite{
{
Regex: "foo",
Replacement: "bar",
},
},
contentType: "image",
resBody: "foo is the new bar",
expResBody: "foo is the new bar",
},
{
desc: "should replace foo by bar if content encoding is identity",
rewrites: []Rewrite{
{
Regex: "foo",
Replacement: "bar",
},
},
contentEncoding: "identity",
contentType: "text/html",
resBody: "foo is the new bar",
expResBody: "bar is the new bar",
},
{
desc: "should not remove the last modified header",
rewrites: []Rewrite{
{
Regex: "foo",
Replacement: "bar",
},
},
contentEncoding: "identity",
contentType: "text/html",
lastModified: true,
resBody: "foo is the new bar",
expResBody: "bar is the new bar",
expLastModified: true,
},
{
desc: "should support gzip encoding",
rewrites: []Rewrite{
{
Regex: "foo",
Replacement: "bar",
},
},
contentEncoding: "gzip",
contentType: "text/html",
lastModified: true,
resBody: compressString("foo is the new bar", "gzip"),
expResBody: compressString("bar is the new bar", "gzip"),
expLastModified: true,
},
{
desc: "should support deflate encoding",
rewrites: []Rewrite{
{
Regex: "foo",
Replacement: "bar",
},
},
contentEncoding: "deflate",
contentType: "text/html",
lastModified: true,
resBody: compressString("foo is the new bar", "deflate"),
expResBody: compressString("bar is the new bar", "deflate"),
expLastModified: true,
},
{
desc: "should ignore unsupported encoding",
rewrites: []Rewrite{
{
Regex: "foo",
Replacement: "bar",
},
},
contentEncoding: "br",
contentType: "text/html",
lastModified: true,
resBody: "foo is the new bar",
expResBody: "foo is the new bar",
expLastModified: true,
},
}
for _, test := range tests {
t.Run(test.desc, func(t *testing.T) {
config := &Config{
LastModified: test.lastModified,
Rewrites: test.rewrites,
LogLevel: -1,
}
next := func(responseWriter http.ResponseWriter, req *http.Request) {
responseWriter.Header().Set("Content-Encoding", test.contentEncoding)
responseWriter.Header().Set("Content-Type", test.contentType)
responseWriter.Header().Set("Last-Modified", "Thu, 02 Jun 2016 06:01:08 GMT")
responseWriter.Header().Set("Content-Length", strconv.Itoa(len(test.resBody)))
responseWriter.WriteHeader(http.StatusOK)
_, _ = fmt.Fprintf(responseWriter, test.resBody)
}
rewriteBody, err := New(context.Background(), http.HandlerFunc(next), config, "rewriteBody")
if err != nil {
t.Fatal(err)
}
recorder := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set("Accept", "text/html")
rewriteBody.ServeHTTP(recorder, req)
if _, exists := recorder.Result().Header["Last-Modified"]; exists != test.expLastModified {
t.Errorf("got last-modified header %v, want %v", exists, test.expLastModified)
}
if _, exists := recorder.Result().Header["Content-Length"]; exists {
t.Error("The Content-Length Header must be deleted")
}
if !bytes.Equal([]byte(test.expResBody), recorder.Body.Bytes()) {
t.Errorf("got body: %v\n wanted: %v", recorder.Body.Bytes(), []byte(test.expResBody))
}
})
}
}
func compressString(value string, encoding string) string {
compressed, _ := compressutil.Encode([]byte(value), encoding)
return string(compressed)
}
func TestNew(t *testing.T) {
tests := []struct {
desc string
rewrites []Rewrite
expErr bool
}{
{
desc: "should return no error",
rewrites: []Rewrite{
{
Regex: "foo",
Replacement: "bar",
},
{
Regex: "bar",
Replacement: "foo",
},
},
expErr: false,
},
{
desc: "should return an error",
rewrites: []Rewrite{
{
Regex: "*",
Replacement: "bar",
},
},
expErr: true,
},
}
defaultMonitoring := httputil.MonitoringConfig{
Types: []string{"text/html"},
Methods: []string{http.MethodGet},
}
for _, test := range tests {
t.Run(test.desc, func(t *testing.T) {
config := &Config{
Rewrites: test.rewrites,
Monitoring: defaultMonitoring,
}
_, err := New(context.Background(), nil, config, "rewriteBody")
if test.expErr && err == nil {
t.Fatal("expected error on bad regexp format")
}
})
}
}

View File

@@ -0,0 +1,35 @@
package httputil
import (
"net/http"
"strings"
)
// MonitoringConfig structure of data for handling configuration for
// controlling what content is monitored.
type MonitoringConfig struct {
Types []string `json:"types,omitempty" yaml:"types,omitempty" toml:"types,omitempty" export:"true"`
Methods []string `json:"methods,omitempty" yaml:"methods,omitempty" toml:"methods,omitempty" export:"true"`
}
// EnsureDefaults check Types and Methods for empty arrays and apply default values if found.
func (config *MonitoringConfig) EnsureDefaults() {
if len(config.Methods) == 0 {
config.Methods = []string{http.MethodGet}
}
if len(config.Types) == 0 {
config.Types = []string{"text/html"}
}
}
// EnsureProperFormat handle weird yaml parsing until the underlying issue can be resolved.
func (config *MonitoringConfig) EnsureProperFormat() {
if len(config.Methods) == 1 && strings.HasPrefix(config.Methods[0], "║24║") {
config.Methods = strings.Split(strings.ReplaceAll(config.Methods[0], "║24║", ""), "║")
}
if len(config.Types) == 1 && strings.HasPrefix(config.Types[0], "║24║") {
config.Types = strings.Split(strings.ReplaceAll(config.Types[0], "║24║", ""), "║")
}
}

View File

@@ -0,0 +1,90 @@
package httputil_test
import (
"net/http"
"testing"
"github.com/packruler/rewrite-body/httputil"
)
func TestMonitoringConfigParsing(t *testing.T) {
tests := []struct {
desc string
inputConfig httputil.MonitoringConfig
expectedConfig httputil.MonitoringConfig
}{
{
desc: "defaults will be supplied for empty arrays",
inputConfig: httputil.MonitoringConfig{
Types: []string{},
Methods: []string{},
},
expectedConfig: httputil.MonitoringConfig{
Types: []string{"text/html"},
Methods: []string{http.MethodGet},
},
},
{
desc: "defaults will be supplied for empty types with populated methods",
inputConfig: httputil.MonitoringConfig{
Types: []string{},
Methods: []string{"POST"},
},
expectedConfig: httputil.MonitoringConfig{
Types: []string{"text/html"},
Methods: []string{http.MethodPost},
},
},
{
desc: "defaults will be supplied for populated types with empty methods",
inputConfig: httputil.MonitoringConfig{
Types: []string{"application/json"},
Methods: []string{},
},
expectedConfig: httputil.MonitoringConfig{
Types: []string{"application/json"},
Methods: []string{http.MethodGet},
},
},
{
desc: "handle weird yaml parsing",
inputConfig: httputil.MonitoringConfig{
Types: []string{"║24║application/javascript║application/json"},
Methods: []string{},
},
expectedConfig: httputil.MonitoringConfig{
Types: []string{"application/javascript", "application/json"},
Methods: []string{http.MethodGet},
},
},
}
for _, test := range tests {
t.Run(test.desc, func(t *testing.T) {
config := test.inputConfig
config.EnsureDefaults()
config.EnsureProperFormat()
if len(config.Types) != len(test.expectedConfig.Types) {
t.Errorf("Expected Types: '%v' | Got Types: '%v'", test.expectedConfig.Types, config.Types)
}
for i, v := range test.expectedConfig.Types {
if v != config.Types[i] {
t.Errorf("Expected Types: '%v' | Got Types: '%v'", test.expectedConfig.Types, config.Types)
}
}
if len(config.Methods) != len(test.expectedConfig.Methods) {
t.Errorf("Expected Methods: '%v' | Got Methods: '%v'", test.expectedConfig.Methods, config.Methods)
}
for i, v := range test.expectedConfig.Methods {
if v != config.Methods[i] {
t.Errorf("Expected Methods: '%v' | Got Methods: '%v'", test.expectedConfig.Methods, config.Methods)
}
}
})
}
}

View File

@@ -0,0 +1,163 @@
package httputil
import (
"net/http"
"sort"
"strconv"
"strings"
"github.com/packruler/rewrite-body/compressutil"
"github.com/packruler/rewrite-body/logger"
)
// RequestWrapper a struct that centralizes request modifications.
type RequestWrapper struct {
logWriter logger.LogWriter
monitoring MonitoringConfig
http.Request
}
// WrapRequest to get a new instance of RequestWrapper.
func WrapRequest(request *http.Request, monitoringConfig MonitoringConfig, logWriter logger.LogWriter) *RequestWrapper {
return &RequestWrapper{
logWriter: logWriter,
monitoring: monitoringConfig,
Request: *request,
}
}
// CloneNoEncode create an http.Request that request no encoding.
func (req *RequestWrapper) CloneNoEncode() *http.Request {
clonedRequest := req.Clone(req.Context())
clonedRequest.Header.Set("Accept-Encoding", compressutil.Identity)
return clonedRequest
}
// CloneWithSupportedEncoding create an http.Request that request only supported encoding.
func (req *RequestWrapper) CloneWithSupportedEncoding() *http.Request {
clonedRequest := req.Clone(req.Context())
clonedRequest.Header.Set("Accept-Encoding", removeUnsupportedAcceptEncoding(clonedRequest.Header))
return clonedRequest
}
// GetEncodingTarget get the supported encoding algorithm preferred by request.
func (req *RequestWrapper) GetEncodingTarget() string {
// Limit Accept-Encoding header to encodings we can handle.
// acceptEncoding := header.ParseAccept(req.Header, "Accept-Encoding")
acceptEncoding := parseAcceptEncoding(req.Header)
filteredEncodings := make([]encodingSpec, 0, len(acceptEncoding))
for _, a := range acceptEncoding {
switch a.Value {
case compressutil.Gzip, compressutil.Deflate:
filteredEncodings = append(filteredEncodings, a)
}
}
if len(filteredEncodings) == 0 {
return compressutil.Identity
}
sort.Slice(filteredEncodings, func(i, j int) bool {
return filteredEncodings[i].Quality > filteredEncodings[j].Quality
})
return filteredEncodings[0].Value
}
type encodingSpec struct {
Value string
Quality float64
}
func parseAcceptEncoding(header http.Header) []encodingSpec {
encodingHeader := header.Get("Accept-Encoding")
if encodingHeader == "*" {
return []encodingSpec{{Quality: 1.0, Value: compressutil.Gzip}, {Quality: 1.0, Value: compressutil.Deflate}}
}
encodingList := strings.Split(encodingHeader, ",")
result := make([]encodingSpec, 0, len(encodingList))
for _, encoding := range encodingList {
result = append(result, parseEncodingItem(encoding))
}
return result
}
func parseEncodingItem(encoding string) encodingSpec {
encoding = strings.TrimSpace(encoding)
if encoding == "*" {
return encodingSpec{Value: compressutil.Gzip, Quality: 1.0}
}
split := strings.Split(encoding, ";q=")
quality := 1.0
if qualitySplitSize := 2; len(split) == qualitySplitSize {
targetFloat := 64
parsedQuality, err := strconv.ParseFloat(split[1], targetFloat)
if err == nil {
quality = parsedQuality
}
}
return encodingSpec{Value: split[0], Quality: quality}
}
func removeUnsupportedAcceptEncoding(header http.Header) string {
encodingList := strings.Split(header.Get("Accept-Encoding"), ",")
result := make([]string, 0, len(encodingList))
for _, encoding := range encodingList {
split := strings.Split(strings.TrimSpace(encoding), ";q=")
switch split[0] {
case compressutil.Gzip, compressutil.Deflate, compressutil.Identity:
result = append(result, encoding)
}
}
return strings.Join(result, ",")
}
// SupportsProcessing determine if http.Request is supported by this plugin.
func (req *RequestWrapper) SupportsProcessing() bool {
acceptHeader := req.Header.Get("Accept")
isSupported := false
for _, monitoredType := range req.monitoring.Types {
if strings.Contains(acceptHeader, monitoredType) {
isSupported = true
}
}
if !isSupported {
return false
}
isSupported = false
// Ignore non GET requests
for _, monitoredMethod := range req.monitoring.Methods {
if strings.Contains(req.Method, monitoredMethod) {
isSupported = true
}
}
if !isSupported {
return false
}
if strings.Contains(req.Header.Get("Upgrade"), "websocket") {
return false
}
return true
}

View File

@@ -0,0 +1,241 @@
package httputil
import (
"bytes"
"context"
"net/http"
"testing"
"github.com/packruler/rewrite-body/logger"
)
func TestGetEncodingTarget(t *testing.T) {
tests := []struct {
desc string
acceptEncoding string
expectedTarget string
}{
{
desc: "Supports gzip",
acceptEncoding: "gzip",
expectedTarget: "gzip",
},
{
desc: "Supports deflate",
acceptEncoding: "deflate",
expectedTarget: "deflate",
},
{
desc: "Supports identity",
acceptEncoding: "identity",
expectedTarget: "identity",
},
{
desc: "Ignores brotli",
acceptEncoding: "br, gzip",
expectedTarget: "gzip",
},
{
desc: "Wildcard to gzip",
acceptEncoding: "*",
expectedTarget: "gzip",
},
{
desc: "Respects quality in order",
acceptEncoding: "gzip;q=0.8, deflate;q=0.6",
expectedTarget: "gzip",
},
{
desc: "Respects quality out of order",
acceptEncoding: "gzip;q=0.8, deflate;q=0.9",
expectedTarget: "deflate",
},
}
defaultMonitoring := MonitoringConfig{
Types: []string{"text/html"},
Methods: []string{"GET"},
}
defaultLogWriter := logger.CreateLogger(logger.Error)
for _, test := range tests {
t.Run(test.desc, func(t *testing.T) {
request, err := http.NewRequestWithContext(
context.Background(),
http.MethodGet,
"http://google.com",
&bytes.Reader{})
if err != nil {
t.Errorf("Error creating request: %v", err)
}
request.Header.Set("Accept-Encoding", test.acceptEncoding)
wrappedRequest := WrapRequest(request, defaultMonitoring, *defaultLogWriter)
target := wrappedRequest.GetEncodingTarget()
if target != test.expectedTarget {
t.Errorf("Expected: '%s' | Got: '%s'", test.expectedTarget, target)
}
})
}
}
func TestRemoveUnuspportedEncoding(t *testing.T) {
tests := []struct {
desc string
acceptEncoding string
expectedTarget string
}{
{
desc: "Supports gzip",
acceptEncoding: "gzip",
expectedTarget: "gzip",
},
{
desc: "Supports deflate",
acceptEncoding: "deflate",
expectedTarget: "deflate",
},
{
desc: "Supports identity",
acceptEncoding: "identity",
expectedTarget: "identity",
},
{
desc: "Ignores brotli",
acceptEncoding: "br, gzip",
expectedTarget: " gzip",
},
{
desc: "Wildcard is dropped",
acceptEncoding: "*",
expectedTarget: "",
},
{
desc: "Respects quality in order",
acceptEncoding: "gzip;q=0.8, deflate;q=0.6",
expectedTarget: "gzip;q=0.8, deflate;q=0.6",
},
}
defaultMonitoring := MonitoringConfig{
Types: []string{"text/html"},
Methods: []string{"GET"},
}
defaultLogWriter := logger.CreateLogger(logger.Error)
for _, test := range tests {
t.Run(test.desc, func(t *testing.T) {
request, err := http.NewRequestWithContext(
context.Background(),
http.MethodGet,
"http://google.com",
&bytes.Reader{})
if err != nil {
t.Errorf("Error creating request: %v", err)
}
request.Header.Set("Accept-Encoding", test.acceptEncoding)
wrappedRequest := WrapRequest(request, defaultMonitoring, *defaultLogWriter)
target := wrappedRequest.CloneWithSupportedEncoding().Header.Get("Accept-Encoding")
if target != test.expectedTarget {
t.Errorf("Expected: '%s' | Got: '%s'", test.expectedTarget, target)
}
})
}
}
func TestSupportsProcessing(t *testing.T) {
tests := []struct {
desc string
inputType string
inputMethod string
monitoringConfig MonitoringConfig
expectedSupport bool
}{
{
desc: "Supports default config",
inputType: "text/html",
inputMethod: http.MethodGet,
expectedSupport: true,
monitoringConfig: MonitoringConfig{
Types: []string{"text/html"},
Methods: []string{"GET"},
},
},
{
desc: "Supports default browser load",
inputType: "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9",
inputMethod: http.MethodGet,
expectedSupport: true,
monitoringConfig: MonitoringConfig{
Types: []string{"text/html"},
Methods: []string{"GET"},
},
},
{
desc: "Supports when types includes unsupported type first",
inputType: "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9",
inputMethod: http.MethodGet,
expectedSupport: true,
monitoringConfig: MonitoringConfig{
Types: []string{"application/javascript", "text/html"},
Methods: []string{"GET"},
},
},
{
desc: "Supports multiple methods",
inputType: "text/html",
inputMethod: http.MethodPost,
expectedSupport: true,
monitoringConfig: MonitoringConfig{
Types: []string{"text/html"},
Methods: []string{"GET", "POST"},
},
},
{
desc: "Does not support type not included",
inputType: "application/javascript",
inputMethod: http.MethodGet,
expectedSupport: false,
monitoringConfig: MonitoringConfig{
Types: []string{"text/html"},
Methods: []string{"GET"},
},
},
{
desc: "Does not support method not included",
inputType: "text/html",
inputMethod: http.MethodPost,
expectedSupport: false,
monitoringConfig: MonitoringConfig{
Types: []string{"text/html"},
Methods: []string{"GET"},
},
},
}
defaultLogWriter := logger.CreateLogger(logger.Error)
for _, test := range tests {
t.Run(test.desc, func(t *testing.T) {
request, err := http.NewRequestWithContext(
context.Background(),
test.inputMethod,
"http://google.com",
&bytes.Reader{})
if err != nil {
t.Errorf("Error creating request: %v", err)
}
request.Header.Set("Accept", test.inputType)
wrappedRequest := WrapRequest(request, test.monitoringConfig, *defaultLogWriter)
if test.expectedSupport != wrappedRequest.SupportsProcessing() {
t.Errorf("Test input: '%v'", test)
}
})
}
}

View File

@@ -0,0 +1,185 @@
// Package httputil a package for handling http data tasks
package httputil
import (
"bufio"
"bytes"
"fmt"
"net"
"net/http"
"strings"
"github.com/packruler/rewrite-body/compressutil"
"github.com/packruler/rewrite-body/logger"
)
// ResponseWrapper a wrapper used to simplify ResponseWriter data access and manipulation.
type ResponseWrapper struct {
buffer bytes.Buffer
lastModified bool `default:"true"`
wroteHeader bool
code int `default:"200"`
logWriter logger.LogWriter
monitoring MonitoringConfig
http.ResponseWriter
}
// WrapWriter create a ResponseWrapper for provided configuration.
func WrapWriter(
responseWriter http.ResponseWriter,
monitoringConfig MonitoringConfig,
logWriter logger.LogWriter,
lastModified bool,
) *ResponseWrapper {
return &ResponseWrapper{
buffer: bytes.Buffer{},
lastModified: lastModified,
wroteHeader: false,
code: http.StatusOK,
logWriter: logWriter,
monitoring: monitoringConfig,
ResponseWriter: responseWriter,
}
}
// WriteHeader into wrapped ResponseWriter.
func (wrapper *ResponseWrapper) WriteHeader(statusCode int) {
if wrapper.wroteHeader {
return
}
if !wrapper.lastModified {
wrapper.ResponseWriter.Header().Del("Last-Modified")
}
wrapper.code = statusCode
wrapper.wroteHeader = true
// Delegates the Content-Length Header creation to the final body write.
wrapper.ResponseWriter.Header().Del("Content-Length")
wrapper.ResponseWriter.WriteHeader(statusCode)
}
// Write data to internal buffer and mark the status code as http.StatusOK.
func (wrapper *ResponseWrapper) Write(data []byte) (int, error) {
if !wrapper.wroteHeader {
wrapper.WriteHeader(http.StatusOK)
}
return wrapper.buffer.Write(data)
}
// GetBuffer get a pointer to the ResponseWriter buffer.
func (wrapper *ResponseWrapper) GetBuffer() *bytes.Buffer {
return &wrapper.buffer
}
// GetContent load the content currently in the internal buffer
// accounting for possible encoding.
func (wrapper *ResponseWrapper) GetContent() ([]byte, error) {
encoding := wrapper.getContentEncoding()
return compressutil.Decode(wrapper.GetBuffer(), encoding)
}
// SetContent write data to the internal ResponseWriter buffer
// and match initial encoding.
func (wrapper *ResponseWrapper) SetContent(data []byte, encoding string) {
bodyBytes, _ := compressutil.Encode(data, encoding)
if !wrapper.wroteHeader {
wrapper.WriteHeader(http.StatusOK)
}
if _, err := wrapper.ResponseWriter.Write(bodyBytes); err != nil {
wrapper.logWriter.LogErrorf("unable to write rewriten body: %v", err)
wrapper.LogHeaders()
}
}
func (wrapper *ResponseWrapper) getHeader(headerName string) string {
return wrapper.ResponseWriter.Header().Get(headerName)
}
// LogHeaders writes current response headers.
func (wrapper *ResponseWrapper) LogHeaders() {
wrapper.logWriter.LogDebugf("Error Headers: %v", wrapper.ResponseWriter.Header())
}
// getContentEncoding get the Content-Encoding header value.
func (wrapper *ResponseWrapper) getContentEncoding() string {
return wrapper.getHeader("Content-Encoding")
}
// getContentType get the Content-Encoding header value.
func (wrapper *ResponseWrapper) getContentType() string {
return wrapper.getHeader("Content-Type")
}
// SupportsProcessing determine if HttpWrapper is supported by this plugin based on encoding.
func (wrapper *ResponseWrapper) SupportsProcessing() bool {
foundContentType := false
// If content type does not match return values with false
contentType := wrapper.getContentType()
for _, monitoredType := range wrapper.monitoring.Types {
if strings.Contains(contentType, monitoredType) {
foundContentType = true
break
}
}
if !foundContentType {
return false
}
encoding := wrapper.getContentEncoding()
// If content type is supported validate encoding as well
switch encoding {
case compressutil.Gzip, compressutil.Deflate, compressutil.Identity, "":
return true
default:
return false
}
}
// SetLastModified update the local lastModified variable from non-package-based users.
func (wrapper *ResponseWrapper) SetLastModified(value bool) {
wrapper.lastModified = value
}
// CloseNotify returns a channel that receives at most a
// single value (true) when the client connection has gone away.
func (wrapper *ResponseWrapper) CloseNotify() <-chan bool {
if w, ok := wrapper.ResponseWriter.(http.CloseNotifier); ok {
return w.CloseNotify()
}
return make(<-chan bool)
}
// Hijack hijacks the connection.
func (wrapper *ResponseWrapper) Hijack() (net.Conn, *bufio.ReadWriter, error) {
if hj, ok := wrapper.ResponseWriter.(http.Hijacker); ok {
return hj.Hijack()
}
return nil, nil, fmt.Errorf("%T is not a http.Hijacker", wrapper.ResponseWriter)
}
// Flush sends any buffered data to the client.
func (wrapper *ResponseWrapper) Flush() {
// If WriteHeader was already called from the caller, this is a NOOP.
// Otherwise, codeCatcher.code is actually a 200 here.
wrapper.WriteHeader(wrapper.code)
if flusher, ok := wrapper.ResponseWriter.(http.Flusher); ok {
flusher.Flush()
}
}

View File

@@ -0,0 +1,121 @@
// Package logger a package for handling writing content to logs.
package logger
import (
"bytes"
"fmt"
"log"
"os"
)
// LogLevel type definition of supported log levels.
type LogLevel int8
const (
// Trace level of logs.
Trace LogLevel = iota - 2
// Debug level of logs.
Debug
// Info level of logs.
Info
// Warning level of logs.
Warning
// Error level of logs.
Error
)
// LogWriter the struct used for writing logs.
type LogWriter struct {
level LogLevel
loggers map[LogLevel]*log.Logger
}
// CreateLogger create the LogWriter struct with required content.
func CreateLogger(level LogLevel) *LogWriter {
loggers := make(map[LogLevel]*log.Logger, Error-1)
loggers[Trace] = log.New(os.Stdout, "Rewrite-Body | TRACE", log.Ldate|log.Ltime|log.Lshortfile)
loggers[Debug] = log.New(os.Stdout, "Rewrite-Body | DEBUG", log.Ldate|log.Ltime|log.Lshortfile)
loggers[Info] = log.New(os.Stdout, "Rewrite-Body | INFO", log.Ldate|log.Ltime|log.Lshortfile)
loggers[Warning] = log.New(os.Stdout, "Rewrite-Body | WARNING", log.Ldate|log.Ltime|log.Lshortfile)
loggers[Error] = log.New(os.Stderr, "Rewrite-Body | ERROR", log.Ldate|log.Ltime|log.Lshortfile)
return &LogWriter{
level: level,
loggers: loggers,
}
}
func createLoggerWithBuffer(level LogLevel, buffer *bytes.Buffer) *LogWriter {
loggers := make(map[LogLevel]*log.Logger, Error-1)
loggers[Trace] = log.New(buffer, "Rewrite-Body | TRACE", log.Ldate|log.Ltime|log.Lshortfile)
loggers[Debug] = log.New(buffer, "Rewrite-Body | DEBUG", log.Ldate|log.Ltime|log.Lshortfile)
loggers[Info] = log.New(buffer, "Rewrite-Body | INFO", log.Ldate|log.Ltime|log.Lshortfile)
loggers[Warning] = log.New(buffer, "Rewrite-Body | WARNING", log.Ldate|log.Ltime|log.Lshortfile)
loggers[Error] = log.New(buffer, "Rewrite-Body | ERROR", log.Ldate|log.Ltime|log.Lshortfile)
return &LogWriter{
level: level,
loggers: loggers,
}
}
func (logger *LogWriter) writeLog(level LogLevel, message string) {
if level < logger.level {
return
}
output := logger.loggers[level]
output.Print(message)
}
// LogTrace write Trace level logs.
func (logger *LogWriter) LogTrace(message string) {
logger.writeLog(Trace, message)
}
// LogDebug write Debug level logs.
func (logger *LogWriter) LogDebug(message string) {
logger.writeLog(Debug, message)
}
// LogInfo write Info level logs.
func (logger *LogWriter) LogInfo(message string) {
logger.writeLog(Info, message)
}
// LogWarning write Warning level logs.
func (logger *LogWriter) LogWarning(message string) {
logger.writeLog(Warning, message)
}
// LogError write Error level logs.
func (logger *LogWriter) LogError(message string) {
logger.writeLog(Error, message)
}
// LogTracef write Trace level logs with formatting similar to fmt.Sprintf.
func (logger *LogWriter) LogTracef(format string, a ...interface{}) {
logger.writeLog(Trace, fmt.Sprintf(format, a...))
}
// LogDebugf write Debug level logs with formatting similar to fmt.Sprintf.
func (logger *LogWriter) LogDebugf(format string, a ...interface{}) {
logger.writeLog(Debug, fmt.Sprintf(format, a...))
}
// LogInfof write Info level logs with formatting similar to fmt.Sprintf.
func (logger *LogWriter) LogInfof(format string, a ...interface{}) {
logger.writeLog(Info, fmt.Sprintf(format, a...))
}
// LogWarningf write Warning level logs with formatting similar to fmt.Sprintf.
func (logger *LogWriter) LogWarningf(format string, a ...interface{}) {
logger.writeLog(Warning, fmt.Sprintf(format, a...))
}
// LogErrorf write Error level logs with formatting similar to fmt.Sprintf.
func (logger *LogWriter) LogErrorf(format string, a ...interface{}) {
logger.writeLog(Error, fmt.Sprintf(format, a...))
}

View File

@@ -0,0 +1,230 @@
package logger
import (
"bytes"
"testing"
)
func TestLogging(t *testing.T) {
tests := []struct {
desc string
configLevel LogLevel
targetLevel LogLevel
shouldWrite bool
}{
{
desc: "Trace level should write when targeting Trace",
configLevel: Trace,
targetLevel: Trace,
shouldWrite: true,
},
{
desc: "Trace level should write when targeting Debug",
configLevel: Trace,
targetLevel: Debug,
shouldWrite: true,
},
{
desc: "Trace level should write when targeting Info",
configLevel: Trace,
targetLevel: Info,
shouldWrite: true,
},
{
desc: "Trace level should write when targeting Warning",
configLevel: Trace,
targetLevel: Warning,
shouldWrite: true,
},
{
desc: "Trace level should write when targeting Error",
configLevel: Trace,
targetLevel: Error,
shouldWrite: true,
},
{
desc: "Debug level should NOT write when targeting Trace",
configLevel: Debug,
targetLevel: Trace,
shouldWrite: false,
},
{
desc: "Debug level should write when targeting Debug",
configLevel: Debug,
targetLevel: Debug,
shouldWrite: true,
},
{
desc: "Debug level should write when targeting Info",
configLevel: Debug,
targetLevel: Info,
shouldWrite: true,
},
{
desc: "Debug level should write when targeting Warning",
configLevel: Debug,
targetLevel: Warning,
shouldWrite: true,
},
{
desc: "Debug level should write when targeting Error",
configLevel: Debug,
targetLevel: Error,
shouldWrite: true,
},
{
desc: "Info level should NOT write when targeting Trace",
configLevel: Info,
targetLevel: Trace,
shouldWrite: false,
},
{
desc: "Info level should NOT write when targeting Debug",
configLevel: Info,
targetLevel: Debug,
shouldWrite: false,
},
{
desc: "Info level should write when targeting Info",
configLevel: Info,
targetLevel: Info,
shouldWrite: true,
},
{
desc: "Info level should write when targeting Warning",
configLevel: Info,
targetLevel: Warning,
shouldWrite: true,
},
{
desc: "Info level should write when targeting Error",
configLevel: Info,
targetLevel: Error,
shouldWrite: true,
},
{
desc: "Warning level should NOT write when targeting Trace",
configLevel: Warning,
targetLevel: Trace,
shouldWrite: false,
},
{
desc: "Warning level should NOT write when targeting Debug",
configLevel: Warning,
targetLevel: Debug,
shouldWrite: false,
},
{
desc: "Warning level should NOT write when targeting Info",
configLevel: Warning,
targetLevel: Info,
shouldWrite: false,
},
{
desc: "Warning level should write when targeting Warning",
configLevel: Warning,
targetLevel: Warning,
shouldWrite: true,
},
{
desc: "Warning level should write when targeting Error",
configLevel: Warning,
targetLevel: Error,
shouldWrite: true,
},
{
desc: "Warning level should NOT write when targeting Trace",
configLevel: Warning,
targetLevel: Trace,
shouldWrite: false,
},
{
desc: "Warning level should NOT write when targeting Debug",
configLevel: Warning,
targetLevel: Debug,
shouldWrite: false,
},
{
desc: "Warning level should NOT write when targeting Info",
configLevel: Warning,
targetLevel: Info,
shouldWrite: false,
},
{
desc: "Warning level should write when targeting Warning",
configLevel: Warning,
targetLevel: Warning,
shouldWrite: true,
},
{
desc: "Warning level should write when targeting Error",
configLevel: Warning,
targetLevel: Error,
shouldWrite: true,
},
{
desc: "Error level should NOT write when targeting Trace",
configLevel: Error,
targetLevel: Trace,
shouldWrite: false,
},
{
desc: "Error level should NOT write when targeting Debug",
configLevel: Error,
targetLevel: Debug,
shouldWrite: false,
},
{
desc: "Error level should NOT write when targeting Info",
configLevel: Error,
targetLevel: Info,
shouldWrite: false,
},
{
desc: "Error level should NOT write when targeting Warning",
configLevel: Error,
targetLevel: Warning,
shouldWrite: false,
},
{
desc: "Error level should write when targeting Error",
configLevel: Error,
targetLevel: Error,
shouldWrite: true,
},
}
for _, test := range tests {
t.Run(test.desc, func(t *testing.T) {
var buffer bytes.Buffer
logger := createLoggerWithBuffer(test.configLevel, &buffer)
switch test.targetLevel {
case Trace:
logger.LogTrace("test")
case Debug:
logger.LogDebug("test")
case Info:
logger.LogInfo("test")
case Warning:
logger.LogWarning("test")
case Error:
logger.LogError("test")
}
loggedContent := buffer.Bytes()
if !test.shouldWrite {
if len(loggedContent) != 0 {
t.Errorf("Log level %v written with test value set to %v: '%s'",
test.targetLevel, test.configLevel, loggedContent)
}
} else {
if len(loggedContent) == 0 {
t.Errorf("Log level %v NOT written with test value set to %v: '%s'",
test.targetLevel, test.configLevel, loggedContent)
}
}
})
}
}

View File

@@ -0,0 +1,19 @@
// Package rewrite_body a plugin to rewrite response body.
package rewrite_body
import (
"context"
"net/http"
"github.com/packruler/rewrite-body/handler"
)
// CreateConfig creates and initializes the plugin configuration.
func CreateConfig() *handler.Config {
return &handler.Config{}
}
// New creates and returns a new rewrite body plugin instance.
func New(context context.Context, next http.Handler, config *handler.Config, name string) (http.Handler, error) {
return handler.New(context, next, config, name)
}

View File

@@ -0,0 +1,256 @@
package rewrite_body
import (
"bytes"
"context"
"fmt"
"net/http"
"net/http/httptest"
"strconv"
"testing"
"github.com/packruler/rewrite-body/compressutil"
"github.com/packruler/rewrite-body/handler"
"github.com/packruler/rewrite-body/httputil"
)
func TestServeHTTP(t *testing.T) {
tests := []struct {
desc string
contentEncoding string
contentType string `default:"text/html"`
rewrites []handler.Rewrite
lastModified bool
resBody string
expResBody string
expLastModified bool
}{
{
desc: "should replace foo by bar",
rewrites: []handler.Rewrite{
{
Regex: "foo",
Replacement: "bar",
},
},
contentType: "text/html",
resBody: "foo is the new bar",
expResBody: "bar is the new bar",
},
{
desc: "should replace foo by bar, then by foo",
rewrites: []handler.Rewrite{
{
Regex: "foo",
Replacement: "bar",
},
{
Regex: "bar",
Replacement: "foo",
},
},
contentType: "text/html",
resBody: "foo is the new bar",
expResBody: "foo is the new foo",
},
{
desc: "should not replace anything if content encoding is not identity or empty",
rewrites: []handler.Rewrite{
{
Regex: "foo",
Replacement: "bar",
},
},
contentEncoding: "other",
contentType: "text/html",
resBody: "foo is the new bar",
expResBody: "foo is the new bar",
},
{
desc: "should not replace anything if content type does not contain text or is not empty",
rewrites: []handler.Rewrite{
{
Regex: "foo",
Replacement: "bar",
},
},
contentType: "image",
resBody: "foo is the new bar",
expResBody: "foo is the new bar",
},
{
desc: "should replace foo by bar if content encoding is identity",
rewrites: []handler.Rewrite{
{
Regex: "foo",
Replacement: "bar",
},
},
contentEncoding: "identity",
contentType: "text/html",
resBody: "foo is the new bar",
expResBody: "bar is the new bar",
},
{
desc: "should not remove the last modified header",
rewrites: []handler.Rewrite{
{
Regex: "foo",
Replacement: "bar",
},
},
contentEncoding: "identity",
contentType: "text/html",
lastModified: true,
resBody: "foo is the new bar",
expResBody: "bar is the new bar",
expLastModified: true,
},
{
desc: "should support gzip encoding",
rewrites: []handler.Rewrite{
{
Regex: "foo",
Replacement: "bar",
},
},
contentEncoding: "gzip",
contentType: "text/html",
lastModified: true,
resBody: compressString("foo is the new bar", "gzip"),
expResBody: compressString("bar is the new bar", "gzip"),
expLastModified: true,
},
{
desc: "should support deflate encoding",
rewrites: []handler.Rewrite{
{
Regex: "foo",
Replacement: "bar",
},
},
contentEncoding: "deflate",
contentType: "text/html",
lastModified: true,
resBody: compressString("foo is the new bar", "deflate"),
expResBody: compressString("bar is the new bar", "deflate"),
expLastModified: true,
},
{
desc: "should ignore unsupported encoding",
rewrites: []handler.Rewrite{
{
Regex: "foo",
Replacement: "bar",
},
},
contentEncoding: "br",
contentType: "text/html",
lastModified: true,
resBody: "foo is the new bar",
expResBody: "foo is the new bar",
expLastModified: true,
},
}
for _, test := range tests {
t.Run(test.desc, func(t *testing.T) {
config := &handler.Config{
LastModified: test.lastModified,
Rewrites: test.rewrites,
LogLevel: -1,
}
next := func(responseWriter http.ResponseWriter, req *http.Request) {
responseWriter.Header().Set("Content-Encoding", test.contentEncoding)
responseWriter.Header().Set("Content-Type", test.contentType)
responseWriter.Header().Set("Last-Modified", "Thu, 02 Jun 2016 06:01:08 GMT")
responseWriter.Header().Set("Content-Length", strconv.Itoa(len(test.resBody)))
responseWriter.WriteHeader(http.StatusOK)
_, _ = fmt.Fprintf(responseWriter, test.resBody)
}
rewriteBody, err := New(context.Background(), http.HandlerFunc(next), config, "rewriteBody")
if err != nil {
t.Fatal(err)
}
recorder := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set("Accept", "text/html")
rewriteBody.ServeHTTP(recorder, req)
if _, exists := recorder.Result().Header["Last-Modified"]; exists != test.expLastModified {
t.Errorf("got last-modified header %v, want %v", exists, test.expLastModified)
}
if _, exists := recorder.Result().Header["Content-Length"]; exists {
t.Error("The Content-Length Header must be deleted")
}
if !bytes.Equal([]byte(test.expResBody), recorder.Body.Bytes()) {
t.Errorf("got body: %v\n wanted: %v", recorder.Body.Bytes(), []byte(test.expResBody))
}
})
}
}
func compressString(value string, encoding string) string {
compressed, _ := compressutil.Encode([]byte(value), encoding)
return string(compressed)
}
func TestNew(t *testing.T) {
tests := []struct {
desc string
rewrites []handler.Rewrite
expErr bool
}{
{
desc: "should return no error",
rewrites: []handler.Rewrite{
{
Regex: "foo",
Replacement: "bar",
},
{
Regex: "bar",
Replacement: "foo",
},
},
expErr: false,
},
{
desc: "should return an error",
rewrites: []handler.Rewrite{
{
Regex: "*",
Replacement: "bar",
},
},
expErr: true,
},
}
defaultMonitoring := httputil.MonitoringConfig{
Types: []string{"text/html"},
Methods: []string{http.MethodGet},
}
for _, test := range tests {
t.Run(test.desc, func(t *testing.T) {
config := &handler.Config{
Rewrites: test.rewrites,
Monitoring: defaultMonitoring,
}
_, err := New(context.Background(), nil, config, "rewriteBody")
if test.expErr && err == nil {
t.Fatal("expected error on bad regexp format")
}
})
}
}

View File

@@ -2,10 +2,10 @@ connector.name=iceberg
iceberg.catalog.type=nessie
iceberg.nessie-catalog.uri=http://nessie:19120/api/v2
iceberg.nessie-catalog.ref=main
iceberg.nessie-catalog.default-warehouse-dir=s3://lakehouse/
iceberg.nessie-catalog.default-warehouse-dir=${ENV:S3_WAREHOUSE}
fs.native-s3.enabled=true
s3.endpoint=http://rustfs:9000
s3.endpoint=${ENV:S3_ENDPOINT}
s3.path-style-access=true
s3.region=us-east-1
s3.region=${ENV:S3_REGION}
s3.aws-access-key=${ENV:NESSIE_S3_ACCESS_KEY}
s3.aws-secret-key=${ENV:NESSIE_S3_SECRET_KEY}