diff --git a/.env.sample b/.env.sample
index c945d59..1e07241 100644
--- a/.env.sample
+++ b/.env.sample
@@ -14,4 +14,5 @@ LETSENCRYPT_EMAIL=admin@example.com
# Uncomment next line for prod certs:
LETSENCRYPT_STAGING=1
-# API_KEY=your_api_key
+# Main api key for itsUP
+API_KEY=
diff --git a/.gitignore b/.gitignore
index d7f0155..fc340fd 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,6 +5,7 @@ __pycache__
.venv
certs/*
db.yml
+proxy/docker-compose.yml
proxy/nginx/*.conf
proxy/traefik/*.yml
upstream
diff --git a/.vscode/launch.json b/.vscode/launch.json
index 8fde184..46b337c 100644
--- a/.vscode/launch.json
+++ b/.vscode/launch.json
@@ -16,6 +16,18 @@
"PYTHONPATH": "${workspaceRoot}"
}
},
+ {
+ "name": "Python: write artifacts",
+ "type": "debugpy",
+ "request": "launch",
+ "program": "bin/write-artifacts.py",
+ "console": "integratedTerminal",
+ "justMyCode": false,
+ "envFile": "${workspaceFolder}/.env",
+ "env": {
+ "PYTHONPATH": "${workspaceRoot}"
+ }
+ },
{
"name": "Python Debugger: Current File",
"type": "debugpy",
diff --git a/.vscode/settings.json b/.vscode/settings.json
index 526e7f8..39aa809 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -41,5 +41,8 @@
"files.exclude": {
"**/.history": true
},
- "python.testing.unittestEnabled": true
+ "python.testing.unittestEnabled": true,
+ "[dotenv]": {
+ "editor.defaultFormatter": "foxundermoon.shell-format"
+ }
}
\ No newline at end of file
diff --git a/README.md b/README.md
index 13c4d9d..7bc0bae 100644
--- a/README.md
+++ b/README.md
@@ -4,24 +4,25 @@
## Fully managed docker compose infra
-Single machine multi docker compose architecture with a low cpu/storage footprint and near-zero* downtime.
+Single machine multi docker compose architecture with a low cpu/storage footprint and near-zero\* downtime.
It runs two nginx proxies in series (proxy -> terminate) to be able to:
+
- terminate SSL/TLS
- do SSL/TLS passthrough
- target many upstream endpoints
-
+
**Advantages:**
- shared docker network: encrypted intra-node communication (over a shared network named `proxynet`)
-- near-zero-downtime*
+- near-zero-downtime\*
-**Near-zero-downtime?*
+__Near-zero-downtime?\*
Well, all (stateless) nodes that get rotated do not incur downtime, yet nginx neads a reload signal. During low traffic that will allow for a graceful handling of outstanding http sessions and no downtime, but may be problematic if nginx needs to wait for a magnitude of open sessions. In that case a timeout will force the last open sessions to be terminated.
This approach is a very reliable and cheap approach to achieve zero downtime.
-*But what about stateful services?*
+_But what about stateful services?_
It is surely possible to deploy stateful services but those MUST NOT be targeted with the `entrypoint: xxx` prop, as those services are the entrypoints which MUST be stateless, as those are rolled up with the `docker rollout` by the automation. In order to update those services you are on your own, but it's a breeze compared to local installs, as you can just docker compose commands.
@@ -46,7 +47,7 @@ Install everything and start the proxy and api so that we can receive incoming c
1. `bin/install.sh`: installs all project deps.
2. `bin/start-all.sh`: starts the proxy and the api server.
3. `bin/apply.py`: applies all of `db.yml`.
-4. 4. `bin/api-logs.sh`: tail the output of the api server.
+4. `bin/api-logs.sh`: tail the output of the api server.
### Adding an upstream service
@@ -55,9 +56,28 @@ Install everything and start the proxy and api so that we can receive incoming c
### Adding a passthrough endpoint
-1. Edit `db.yml` and add your service(s), which now need `name`, `domain` and `passthrough: true`.
+1. Add a project without `entrypoint:` and one service, which now need `name`, `domain` and `passthrough: true`.
2. Run `bin/apply.py` to roll out the changes.
+### Adding a local (host) endpoint
+
+1. Add a project without `entrypoint:` and one service, which only need `name` and `domain`.
+2. Run `bin/apply.py` to roll out the changes.
+
+### Plugins
+
+You can enable and configure plugins in `db.yml`. Right now we support the following:
+
+#### CrowdSec
+
+[CrowdSec](https://www.crowdsec.net) can run as a container via plugin [crowdsec-bouncer-traefik-plugin](https://github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin). First set `enable: true`, run `bin/write-artifacts.py`, and bring up the stack (or just the `crowdsec` container:
+
+```
+docker compose exec crowdsec cscli bouncers add crowdsecBouncer
+```
+
+Put the resulting api key in the plugin configuration in `db.yml` and apply with `bin/apply.py`.
+
### Api & OpenApi spec
The API allows openapi compatible clients to do management on this stack (ChatGPT works wonders).
@@ -71,13 +91,15 @@ Exception: Only github webhook endpoints (check for annotation `@app.hooks.regis
#### Webhooks
Webhooks are used for the following:
+
1. to receive updates to this repo, which will result in a `git pull` and `bin/apply.py` to update any changes in the code. The provided project with `name: itsUP` is used for that, so DON'T delete it if you care about automated updates to this repo.
2. to receive incoming github webhooks (or GET requests to `/update-upstream?project=bla&service=dida`) that result in rolling up of a project or specific service only.
One GitHub webhook listening to `workflow_job`s is provided, which needs:
+
- the hook you will register in the github project to end with `/hook?project=bla&service=dida` (`service` optional), and the `github_secret` set to `.env/API_KEY`.
-I mainly use GitHub workflows and created webhooks for my individual projects, so I can just manage all webhooks in one place.
+I mainly use GitHub workflows and created webhooks for my individual projects, so I can just manage all webhooks in one place.
## Dev/ops tools
@@ -95,7 +117,6 @@ I don't want to switch folders/terminals all the time and want to keep history o
### Scripts
- `bin/update-certs.py`: pull certs and reload the proxy if any certs were created or updated. You could run this in a crontab every week if you want to stay up to date.
-- `bin/write-artifacts.py`: after updating `db.yml` yo ucan run this script to check new artifacts.
+- `bin/write-artifacts.py`: after updating `db.yml` you can run this script to generate new artifacts.
- `bin/validate-db.py`: after manually editing `db.yml` please run this (also ran from `bin/write-artifacts.py`)
- `bin/requirements-update.sh`: You may want to update requirements once in a while ;)
-
diff --git a/db.yml.sample b/db.yml.sample
index 4d9fcb8..6a880f7 100644
--- a/db.yml.sample
+++ b/db.yml.sample
@@ -1,3 +1,20 @@
+plugins:
+ crowdsec:
+ enabled: false
+ version: v1.2.0
+# apikey:
+ options:
+ logLevel: INFO
+ updateIntervalSeconds: 60
+ defaultDecisionSeconds: 60
+ httpTimeoutSeconds: 10
+ crowdsecCapiMachineId: login
+ crowdsecCapiPassword: password
+ crowdsecCapiScenarios:
+ - crowdsecurity/http-path-traversal-probing
+ - crowdsecurity/http-xss-probing
+ - crowdsecurity/http-generic-bf
+
projects:
- description: Home Assistant passthrough
domain: home.example.com
diff --git a/lib/data.py b/lib/data.py
index 10df47f..71cf615 100644
--- a/lib/data.py
+++ b/lib/data.py
@@ -1,12 +1,13 @@
+from atexit import register
from logging import debug, info
-from typing import Callable, Dict, List
+from typing import Any, Callable, Dict, List, Union, cast
import yaml
-from lib.models import Env, Project, Service
+from lib.models import Env, Plugin, PluginRegistry, Project, Service
-def get_db() -> Dict[str, List[Dict[str, str]]]:
+def get_db() -> Dict[str, List[Dict[str, Any]] | Dict[str, Any]]:
"""Get the db"""
with open("db.yml", encoding="utf-8") as f:
return yaml.safe_load(f)
@@ -16,18 +17,43 @@ def validate_db() -> None:
"""Validate db.yml contents"""
debug("Validating db.yml")
db = get_db()
+ plugins_raw = cast(Dict[str, Any], db["plugins"])
+ for name, plugin in plugins_raw.items():
+ p = {"name": name, **plugin}
+ Plugin.model_validate(p)
for project in db["projects"]:
Project.model_validate(project)
+def get_plugin_registry() -> PluginRegistry:
+ """Get plugin registry."""
+ debug("Getting plugin registry")
+ db = get_db()
+ plugins_raw = cast(Dict[str, Any], db["plugins"])
+ return PluginRegistry(**plugins_raw)
+
+
+def get_plugins(filter: Callable[[Plugin], bool] = None) -> List[Plugin]:
+ """Get all plugins. Optionally filter the results."""
+ debug("Getting plugins" + (f" with filter {filter}" if filter else ""))
+ registry = get_plugin_registry()
+ plugins = []
+ for name, p in registry:
+ plugin = Plugin(name=name, **p)
+ if not filter or filter(plugin):
+ plugins.append(plugin)
+ return plugins
+
+
def get_projects(filter: Callable[[Project, Service], bool] = None) -> List[Project]:
"""Get all projects. Optionally filter the results."""
debug("Getting projects" + (f" with filter {filter}" if filter else ""))
db = get_db()
+ projects_raw = cast(List[Dict[str, Any]], db["projects"])
ret = []
- for p_json in db["projects"]:
+ for project in projects_raw:
services = []
- p = Project(**p_json)
+ p = Project(**project)
for s in p.services.copy():
if not filter or filter(p, s):
services.append(s)
@@ -83,7 +109,6 @@ def get_service(project: str | Project, service: str, throw: bool = True) -> Ser
"""Get a project's service by name"""
debug(f"Getting service {service} in project {project.name if isinstance(project, Project) else project}")
p = get_project(project, throw) if isinstance(project, str) else project
- assert p is not None
for item in p.services:
if item.name == service:
return item
@@ -98,7 +123,6 @@ def get_env(project: str | Project, service: str) -> Dict[str, str]:
"""Get a project's env by name"""
debug(f"Getting env for service {service} in project {project.name if isinstance(project, Project) else project}")
service = get_service(project, service)
- assert service is not None
return service.env
@@ -106,9 +130,7 @@ def upsert_env(project: str | Project, service: str, env: Env) -> None:
"""Upsert the env of a service"""
p = get_project(project) if isinstance(project, str) else project
debug(f"Upserting env for service {service} in project {p.name}: {env.model_dump_json()}")
- assert p is not None
s = get_service(p, service)
- assert s is not None
s.env = Env(**(s.env.model_dump() | env.model_dump()))
upsert_service(project, s)
@@ -117,9 +139,7 @@ def upsert_service(project: str | Project, service: Service) -> None:
"""Upsert a service"""
p = get_project(project) if isinstance(project, str) else project
debug(f"Upserting service {service.name} in project {p.name}: {service}")
- assert p is not None
for i, s in enumerate(p.services):
- assert s is not None
if s.name == service.name:
p.services[i] = service
break
diff --git a/lib/data_test.py b/lib/data_test.py
index 5f53f4d..a9fe372 100644
--- a/lib/data_test.py
+++ b/lib/data_test.py
@@ -35,7 +35,7 @@ def test_write_projects(self, mock_open: Mock, mock_yaml: Mock) -> None:
mock_open.assert_called_once_with("db.yml", "w", encoding="utf-8")
# Assert that the mock functions were called correctly
- mock_yaml.dump.assert_called_once_with(test_db, mock_open())
+ mock_yaml.dump.assert_called_once_with({"projects": test_db["projects"]}, mock_open())
# Get projects with filter
@mock.patch(
diff --git a/lib/models.py b/lib/models.py
index 2c99070..3629e40 100644
--- a/lib/models.py
+++ b/lib/models.py
@@ -8,6 +8,27 @@ class Env(BaseModel):
model_config = ConfigDict(extra="allow")
+class Plugin(BaseModel):
+ """Plugin model"""
+
+ enabled: bool = False
+ """Wether or not the plugin is enabled"""
+ apikey: str = None
+ """The API key to use for the plugin, if the plugin requires one"""
+ name: str = None
+ """The name of the plugin"""
+ description: str = None
+ """A description of the plugin"""
+ options: Dict[str, Any] = {}
+ """A dictionary of options to pass to the plugin"""
+
+
+class PluginRegistry(BaseModel):
+ """Plugin registry"""
+
+ crowdsec: Plugin
+
+
class Service(BaseModel):
"""Service model"""
diff --git a/lib/proxy.py b/lib/proxy.py
index b494fd6..fcc2a6e 100644
--- a/lib/proxy.py
+++ b/lib/proxy.py
@@ -5,7 +5,7 @@
from dotenv import load_dotenv
from jinja2 import Template
-from lib.data import get_project, get_projects
+from lib.data import get_plugin_registry, get_project, get_projects
from lib.utils import run_command
load_dotenv()
@@ -82,9 +82,12 @@ def write_routers() -> None:
t = f.read()
tpl_routers_web = Template(t)
domain = os.environ.get("TRAEFIK_DOMAIN")
- traefik_admin = os.environ.get("TRAEFIK_ADMIN")
routers_web = tpl_routers_web.render(
- projects=projects, traefik_rule=f"Host(`{domain}`)", traefik_admin=traefik_admin
+ projects=projects,
+ traefik_rule=f"Host(`{domain}`)",
+ traefik_admin=os.environ.get("TRAEFIK_ADMIN"),
+ plugin_registry=get_plugin_registry(),
+ trusted_ips_cidrs=os.environ.get("TRUSTED_IPS_CIDRS").split(","),
)
with open("proxy/traefik/routers-web.yml", "w", encoding="utf-8") as f:
f.write(routers_web)
@@ -94,28 +97,49 @@ def write_routers() -> None:
routers_tcp = tpl_routers_tcp.render(projects=projects, traefik_rule=f"HostSNI(`{domain}`)")
with open("proxy/traefik/routers-tcp.yml", "w", encoding="utf-8") as f:
f.write(routers_tcp)
+
+
+def write_config() -> None:
with open("proxy/tpl/config-tcp.yml.j2", encoding="utf-8") as f:
t = f.read()
tpl_config_tcp = Template(t)
- trusted_ips_cidr = os.environ.get("TRUSTED_IPS_CIDRS").split(",")
- config_tcp = tpl_config_tcp.render(trusted_ips_cidr=trusted_ips_cidr)
+ trusted_ips_cidrs = os.environ.get("TRUSTED_IPS_CIDRS").split(",")
+ config_tcp = tpl_config_tcp.render(trusted_ips_cidrs=trusted_ips_cidrs)
with open("proxy/traefik/config-tcp.yml", "w", encoding="utf-8") as f:
f.write(config_tcp)
with open("proxy/tpl/config-web.yml.j2", encoding="utf-8") as f:
t = f.read()
tpl_config_web = Template(t)
- le_email = os.environ.get("LETSENCRYPT_EMAIL")
- le_staging = os.environ.get("LETSENCRYPT_STAGING")
- config_web = tpl_config_web.render(trusted_ips_cidr=trusted_ips_cidr, le_email=le_email, le_staging=le_staging)
+ plugin_registry = get_plugin_registry()
+ has_plugins = any(plugin.enabled for _, plugin in plugin_registry)
+ config_web = tpl_config_web.render(
+ trusted_ips_cidrs=trusted_ips_cidrs,
+ le_email=os.environ.get("LETSENCRYPT_EMAIL"),
+ le_staging=bool(os.environ.get("LETSENCRYPT_STAGING")),
+ plugin_registry=plugin_registry,
+ has_plugins=has_plugins,
+ )
with open("proxy/traefik/config-web.yml", "w", encoding="utf-8") as f:
f.write(config_web)
+def write_compose() -> None:
+ plugin_registry = get_plugin_registry()
+ with open("proxy/tpl/docker-compose.yml.j2", encoding="utf-8") as f:
+ t = f.read()
+ tpl_compose = Template(t)
+ compose = tpl_compose.render(plugin_registry=plugin_registry)
+ with open("proxy/docker-compose.yml", "w", encoding="utf-8") as f:
+ f.write(compose)
+
+
def write_proxies() -> None:
write_maps()
write_proxy()
write_terminate()
write_routers()
+ write_config()
+ write_compose()
def update_proxy(
diff --git a/lib/proxy_test.py b/lib/proxy_test.py
index 4dc3cbe..3d430af 100644
--- a/lib/proxy_test.py
+++ b/lib/proxy_test.py
@@ -164,10 +164,14 @@ def test_write_terminate(self, _: Mock, mock_get_domains: Mock, mock_open: Mock)
@mock.patch("lib.proxy.write_maps")
@mock.patch("lib.proxy.write_proxy")
@mock.patch("lib.proxy.write_terminate")
+ @mock.patch("lib.proxy.write_compose")
+ @mock.patch("lib.proxy.write_config")
@mock.patch("lib.proxy.write_routers")
def test_write_proxies(
self,
mock_write_routers: Mock,
+ mock_write_config: Mock,
+ mock_write_compose: Mock,
mock_write_terminate: Mock,
mock_write_proxy: Mock,
mock_write_maps: Mock,
@@ -180,6 +184,9 @@ def test_write_proxies(
mock_write_maps.assert_called_once()
mock_write_proxy.assert_called_once()
mock_write_terminate.assert_called_once()
+ mock_write_compose.assert_called_once()
+ mock_write_config.assert_called_once()
+ mock_write_routers.assert_called_once()
@mock.patch("lib.proxy.run_command")
def test_reload_proxy(self, mock_run_command: Mock) -> None:
diff --git a/proxy/docker-compose.yml b/proxy/docker-compose.yml
index 38802c0..31d420a 100755
--- a/proxy/docker-compose.yml
+++ b/proxy/docker-compose.yml
@@ -65,8 +65,6 @@ services:
traefik-web:
image: traefik:v2.11
container_name: traefik-web
- depends_on:
- - dockerproxy
env_file:
- ../.env
networks:
@@ -76,6 +74,30 @@ services:
- '8080'
- '8443'
volumes:
+ - logs:/var/log/traefik
- ./traefik/config-web.yml:/etc/traefik/traefik.yml:ro
- ./traefik/routers-web.yml:/etc/traefik/routers-web.yml:ro
- ./traefik/acme:/etc/acme
+ depends_on:
+ - dockerproxy
+ - traefik-tcp
+ - crowdsec
+ crowdsec:
+ image: crowdsecurity/crowdsec:v1.6.0
+ container_name: crowdsec
+ restart: unless-stopped
+ environment:
+ GID: ${GID-1000}
+ COLLECTIONS: crowdsecurity/linux crowdsecurity/traefik crowdsecurity/http-cve crowdsecurity/sshd crowdsecurity/whitelist-good-actors
+ CUSTOM_HOSTNAME: crowdsec
+ BOUNCER_KEY_TRAEFIK: L9yZ1y2XagDm9915mRg2fg==
+ volumes:
+ - ./traefik/crowdsec/acquis.yml:/etc/crowdsec/acquis.yaml:ro
+ - logs:/var/log/traefik:ro
+ - crowdsec-db:/var/lib/crowdsec/data/
+ labels:
+ - "traefik.enable=false"
+
+volumes:
+ logs:
+ crowdsec-db:
\ No newline at end of file
diff --git a/proxy/tpl/config-tcp.yml.j2 b/proxy/tpl/config-tcp.yml.j2
index b97fd6b..3efa216 100644
--- a/proxy/tpl/config-tcp.yml.j2
+++ b/proxy/tpl/config-tcp.yml.j2
@@ -5,13 +5,13 @@ entryPoints:
forwardedHeaders:
trustedIPs:
- 127.0.0.0/32
-{%- for ip in trusted_ips_cidr %}
+{%- for ip in trusted_ips_cidrs %}
- {{ ip }}
{%- endfor %}
proxyProtocol:
trustedIPs:
- 127.0.0.0/32
-{%- for ip in trusted_ips_cidr %}
+{%- for ip in trusted_ips_cidrs %}
- {{ ip }}
{%- endfor %}
@@ -20,13 +20,13 @@ entryPoints:
forwardedHeaders:
trustedIPs:
- 127.0.0.1/32
-{%- for ip in trusted_ips_cidr %}
+{%- for ip in trusted_ips_cidrs %}
- {{ ip }}
{%- endfor %}
proxyProtocol:
trustedIPs:
- 127.0.0.0/32
-{%- for ip in trusted_ips_cidr %}
+{%- for ip in trusted_ips_cidrs %}
- {{ ip }}
{%- endfor %}
diff --git a/proxy/tpl/config-web.yml.j2 b/proxy/tpl/config-web.yml.j2
index 5d7ba61..ead540c 100644
--- a/proxy/tpl/config-web.yml.j2
+++ b/proxy/tpl/config-web.yml.j2
@@ -1,10 +1,15 @@
-accessLog: {}
+accessLog:
+ filePath: /var/log/traefik/access.log
# format: json
# fields:
# defaultMode: keep
# headers:
# defaultMode: keep
+log:
+ filePath: /var/log/traefik/error.log
+ level: DEBUG
+
entryPoints:
web:
address: ':8080'
@@ -56,10 +61,15 @@ certificatesResolvers:
experimental:
http3: true
+{%- if has_plugins %}
+ plugins:
+ {%- if plugin_registry.crowdsec.enabled %}
+ {%- set p = plugin_registry.crowdsec %}
+ bouncer:
+ modulename: github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin
+ version: {{ p.version }}
+ {%- endif %}
+{%- endif %}
global:
sendAnonymousUsage: true
-
-log:
- level: DEBUG
-
diff --git a/proxy/tpl/docker-compose.yml.j2 b/proxy/tpl/docker-compose.yml.j2
new file mode 100755
index 0000000..94145df
--- /dev/null
+++ b/proxy/tpl/docker-compose.yml.j2
@@ -0,0 +1,107 @@
+---
+version: '3.8'
+
+networks:
+ proxynet:
+ name: proxynet
+ driver: bridge
+ driver_opts:
+ encrypted: 'true'
+
+services:
+ # watchtower:
+ # container_name: watchtower
+ # image: containrrr/watchtower:1.7.1
+ # networks:
+ # - traefik
+ # restart: always
+
+ dockerproxy:
+ image: wollomatic/socket-proxy:1 # see https://github.com/wollomatic/simple-traefik for reference
+ container_name: dockerproxy
+ # this image replaced https://github.com/Tecnativa/docker-socket-proxy as the socket proxy
+ # for an example with the tecnativa proxy refer tags before 2.10
+ # depends_on:
+ # - watchtower
+ command:
+ # with this configuration socket-proxy acts similar to the tecnativa proxy. For additional hardening
+ # please refer to the documentation of the wollomatic/socket-proxy image
+ - '-loglevel=info' # set to debug for far more logging
+ - '-allowfrom=traefik-web'
+ - '-listenip=0.0.0.0'
+ - '-allowGET=/v1\..{1,2}/(version|containers/.*|events.*)' # this regexp allows readonly access only for requests that traefik needs
+ - '-shutdowngracetime=5'
+ - '-watchdoginterval=600'
+ - '-stoponwatchdog'
+ read_only: true
+ mem_limit: 64M
+ cap_drop:
+ - ALL
+ security_opt:
+ - no-new-privileges
+ user: '0:1' # replace with the uid:gid of the host owner of the docker socket
+ networks:
+ - default
+ expose:
+ - '2375'
+ restart: always
+ volumes:
+ - /var/run/docker.sock:/var/run/docker.sock:ro
+
+ traefik-tcp:
+ image: traefik:v2.11
+ container_name: traefik-tcp
+ env_file:
+ - ../.env
+ networks:
+ - default
+ ports:
+ - 8080:8080
+ - 8443:8443
+ volumes:
+ - ./traefik/config-tcp.yml:/etc/traefik/traefik.yml:ro
+ - ./traefik/routers-tcp.yml:/etc/traefik/routers-tcp.yml:ro
+
+ traefik-web:
+ image: traefik:v2.11
+ container_name: traefik-web
+ env_file:
+ - ../.env
+ networks:
+ - default
+ - proxynet
+ expose:
+ - '8080'
+ - '8443'
+ volumes:
+ - logs:/var/log/traefik
+ - ./traefik/config-web.yml:/etc/traefik/traefik.yml:ro
+ - ./traefik/routers-web.yml:/etc/traefik/routers-web.yml:ro
+ - ./traefik/acme:/etc/acme
+ depends_on:
+ - dockerproxy
+ - traefik-tcp
+{%- if plugin_registry.crowdsec.enabled %}
+ - crowdsec
+ crowdsec:
+ image: crowdsecurity/crowdsec:v1.6.0
+ container_name: crowdsec
+ restart: unless-stopped
+ environment:
+ GID: ${GID-1000}
+ COLLECTIONS: crowdsecurity/linux crowdsecurity/traefik crowdsecurity/http-cve crowdsecurity/sshd crowdsecurity/whitelist-good-actors
+ CUSTOM_HOSTNAME: crowdsec
+ {%- if plugin_registry.crowdsec.apikey %}
+ BOUNCER_KEY_TRAEFIK: {{ plugin_registry.crowdsec.apikey }}
+ {%- endif %}
+ volumes:
+ - ./traefik/crowdsec/acquis.yml:/etc/crowdsec/acquis.yaml:ro
+ - logs:/var/log/traefik:ro
+ - crowdsec-db:/var/lib/crowdsec/data/
+ labels:
+ - "traefik.enable=false"
+{%- endif %}
+
+volumes:
+ logs:
+ crowdsec-db:
diff --git a/proxy/tpl/routers-web.yml.j2 b/proxy/tpl/routers-web.yml.j2
index 2026b56..916d5e2 100644
--- a/proxy/tpl/routers-web.yml.j2
+++ b/proxy/tpl/routers-web.yml.j2
@@ -6,12 +6,15 @@ http:
- web
middlewares:
- redirect
+ - crowdsec
rule: Host(`*`)
traefik-secure:
service: api@internal
entryPoints:
- web-secure
middlewares:
+ - default-headers
+ - rate-limit
- traefik-auth
rule: {{ traefik_rule }}
tls:
@@ -31,6 +34,12 @@ http:
tls:
certResolver: letsencrypt
{%- endif %}
+ middlewares:
+ - default-headers
+ - rate-limit
+ {%- if plugin_registry.crowdsec.enabled %}
+ - crowdsec
+ {%- endif %}
{%- endif %}
{%- endfor %}
services:
@@ -59,3 +68,38 @@ http:
traefik-auth:
basicauth:
users: {{ traefik_admin }}
+ default-headers:
+ headers:
+ frameDeny: true
+ sslRedirect: true
+ browserXssFilter: true
+ contentTypeNosniff: true
+ # HSTS - uncomment for HSTS
+ #forceSTSHeader: true
+ #stsIncludeSubdomains: true
+ #stsPreload: true
+ rate-limit:
+ rateLimit:
+ average: 100
+ burst: 50
+{%- if plugin_registry.crowdsec.enabled %}
+ crowdsec:
+ plugin:
+ bouncer:
+{%- for k, v in plugin_registry.crowdsec.options.items() %}
+ {{ k }}: {{ v }}
+{%- endfor %}
+ enabled: true
+ forwardedHeadersTrustedIPs:
+ - 172.0.0.0/8
+ - 192.168.0.0/16
+ clientTrustedIPs: {{ trusted_ips_cidrs }}
+ crowdsecMode: live
+ crowdsecAppsecEnabled: true
+ crowdsecAppsecFailureBlock: true
+ crowdsecAppsecHost: crowdsec:7422
+ crowdsecLapiKey: {{ plugin_registry.crowdsec.apikey }}
+ crowdsecLapiHost: crowdsec:8080
+ crowdsecLapiScheme: http
+ crowdsecLapiTLSInsecureVerify: false
+{%- endif %}
\ No newline at end of file
diff --git a/proxy/traefik/acme/acme.json b/proxy/traefik/acme/acme.json
index befaaaa..ef81140 100644
--- a/proxy/traefik/acme/acme.json
+++ b/proxy/traefik/acme/acme.json
@@ -1,14 +1,17 @@
{
"letsencrypt": {
"Account": {
- "Email": "",
+ "Email": "admin@instrukt.ai",
"Registration": {
"body": {
- "status": "valid"
+ "status": "valid",
+ "contact": [
+ "mailto:admin@instrukt.ai"
+ ]
},
- "uri": "https://acme-staging-v02.api.letsencrypt.org/acme/acct/137175483"
+ "uri": "https://acme-v02.api.letsencrypt.org/acme/acct/1583328607"
},
- "PrivateKey": "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",
+ "PrivateKey": "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",
"KeyType": "4096"
},
"Certificates": [
diff --git a/proxy/traefik/crowdsec/acquis.yml b/proxy/traefik/crowdsec/acquis.yml
new file mode 100644
index 0000000..406d012
--- /dev/null
+++ b/proxy/traefik/crowdsec/acquis.yml
@@ -0,0 +1,12 @@
+---
+filenames:
+ - /var/log/traefik/access.log
+labels:
+ type: traefik
+# ---
+# listen_addr: 0.0.0.0:7422
+# appsec_config: crowdsecurity/virtual-patching
+# name: myAppSecComponent
+# source: appsec
+# labels:
+# type: appsec