Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7e83a43755 | ||
|
|
0d2e715980 | ||
|
|
c899de75ae | ||
|
|
74ff4b483f | ||
|
|
5759e50510 | ||
|
|
62a4a67fad |
@@ -1,4 +0,0 @@
|
||||
netclient:
|
||||
# Token to join default-network
|
||||
# leave empty to ignore
|
||||
join_network_token:
|
||||
@@ -1,3 +0,0 @@
|
||||
---
|
||||
dependencies: []
|
||||
#- role: docker
|
||||
@@ -1,4 +0,0 @@
|
||||
- name: Deploy CA Certificate
|
||||
ansible.builtin.copy:
|
||||
src: secret_files/netmaker_server/ca/ca.crt
|
||||
dest: /etc/ssl/certs/netmaker-ca.pem
|
||||
@@ -1,25 +0,0 @@
|
||||
- name: Install Packages
|
||||
# when: docker_file.stat.exists == False
|
||||
package:
|
||||
name:
|
||||
- gpg
|
||||
- gpg-agent
|
||||
|
||||
- name: Add netmaker-key
|
||||
apt_key:
|
||||
url: https://apt.netmaker.org/gpg.key
|
||||
state: present
|
||||
|
||||
- name: Add netmaker-repository
|
||||
apt_repository:
|
||||
repo: "deb https:apt.netmaker.org stable main"
|
||||
state: present
|
||||
filename: netmaker
|
||||
update_cache: yes
|
||||
|
||||
- name: Install wireguard & netclient
|
||||
package:
|
||||
name:
|
||||
- wireguard
|
||||
- netclient
|
||||
state: latest
|
||||
@@ -1,7 +0,0 @@
|
||||
- name: Join netmaker-network
|
||||
when: "netclient.join_network_token is defined"
|
||||
command: "netclient join -t {{ netclient.join_network_token }}"
|
||||
failed_when: command.rc != 0
|
||||
changed_when: "'starting wireguard' in command.stdout"
|
||||
register: command
|
||||
throttle: 1
|
||||
@@ -1,8 +0,0 @@
|
||||
- import_tasks: ./certs.yml
|
||||
|
||||
- import_tasks: ./install.yml
|
||||
|
||||
- import_tasks: ./join-network.yml
|
||||
|
||||
- name: Gather facts to get changes
|
||||
ansible.builtin.gather_facts:
|
||||
@@ -1,5 +0,0 @@
|
||||
netmaker_creds:
|
||||
rqlite_password:
|
||||
mq_admin_password:
|
||||
|
||||
master_key:
|
||||
@@ -1,28 +0,0 @@
|
||||
# Overwrite for specific nodes to force dynamic-ip (disable setting public-ip and forces external lookup for public-ip)
|
||||
# When false, will check itself for dynamic-ip (based on private-ip)
|
||||
netmaker_dynamicIp: false
|
||||
|
||||
netmaker_nginx:
|
||||
# Listen-port
|
||||
tls_port: 51820
|
||||
# Advertise-Port for services
|
||||
# (must also be reachable by internal services!)
|
||||
advertise_port: 51820
|
||||
|
||||
# This is the base-domain used for generating hostnames for services
|
||||
netmaker_base_domain:
|
||||
|
||||
# host + base_domain
|
||||
netmaker_api:
|
||||
host: netmaker-api
|
||||
netmaker_ui:
|
||||
host: netmaker-ui
|
||||
# MQTT-broker
|
||||
netmaker_broker:
|
||||
tls_host: netmaker-broker
|
||||
|
||||
# host + node_hostname
|
||||
netmaker_rqlite:
|
||||
http_host: netmaker-rqlite-http
|
||||
cluster_host: netmaker-rqlite-cluster
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
@startuml
|
||||
|
||||
interface ng_TLS
|
||||
|
||||
component netmaker_server {
|
||||
component nginx {
|
||||
component ng_stream
|
||||
component ng_http
|
||||
|
||||
ng_stream -up- ng_TLS
|
||||
|
||||
ng_stream -right-> ng_http : tls-termination
|
||||
}
|
||||
|
||||
component nm_ui
|
||||
nm_ui -up- nm_ui_http
|
||||
ng_http -down-( nm_ui_http
|
||||
|
||||
component Mosquitto
|
||||
Mosquitto -up- mq_plain
|
||||
Mosquitto -up- mq_tls
|
||||
ng_stream -down-( mq_tls
|
||||
|
||||
component rqlite
|
||||
rqlite -up- rq_http
|
||||
rqlite -up- rq_cluster
|
||||
ng_stream -down-( rq_cluster
|
||||
ng_http -down-( rq_http
|
||||
|
||||
component nm_api
|
||||
nm_api -down- nm_api_http
|
||||
ng_http --( nm_api_http
|
||||
nm_api .up.( ng_TLS : db-connection to rqlite-master
|
||||
nm_api --( mq_plain
|
||||
}
|
||||
|
||||
@enduml
|
||||
@@ -1,12 +0,0 @@
|
||||
per_listener_settings false
|
||||
|
||||
listener 8883
|
||||
protocol websockets
|
||||
allow_anonymous false
|
||||
|
||||
listener 1883
|
||||
protocol websockets
|
||||
allow_anonymous false
|
||||
|
||||
plugin /usr/lib/mosquitto_dynamic_security.so
|
||||
plugin_opt_config_file /mosquitto/data/dynamic-security.json
|
||||
@@ -1,23 +0,0 @@
|
||||
#!/bin/ash
|
||||
|
||||
wait_for_netmaker() {
|
||||
echo "SERVER: ${NETMAKER_SERVER_HOST}"
|
||||
until curl --output /dev/null --silent --fail --head \
|
||||
--location "${NETMAKER_SERVER_HOST}/api/server/health"; do
|
||||
echo "Waiting for netmaker server to startup"
|
||||
sleep 1
|
||||
done
|
||||
}
|
||||
|
||||
main(){
|
||||
# wait for netmaker to startup
|
||||
apk add curl
|
||||
wait_for_netmaker
|
||||
echo "Starting MQ..."
|
||||
# Run the main container command.
|
||||
/docker-entrypoint.sh
|
||||
/usr/sbin/mosquitto -c /mosquitto/config/mosquitto.conf
|
||||
|
||||
}
|
||||
|
||||
main "${@}"
|
||||
@@ -1,33 +0,0 @@
|
||||
|
||||
user nginx;
|
||||
worker_processes auto;
|
||||
|
||||
error_log /var/log/nginx/error.log notice;
|
||||
pid /var/run/nginx.pid;
|
||||
|
||||
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
|
||||
http {
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
|
||||
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
|
||||
'$status $body_bytes_sent "$http_referer" '
|
||||
'"$http_user_agent" "$http_x_forwarded_for"';
|
||||
|
||||
access_log /var/log/nginx/access.log main;
|
||||
|
||||
sendfile on;
|
||||
#tcp_nopush on;
|
||||
|
||||
keepalive_timeout 65;
|
||||
|
||||
#gzip on;
|
||||
|
||||
include /etc/nginx/conf.d/*.conf;
|
||||
}
|
||||
include /etc/nginx/stream.d/*.conf;
|
||||
@@ -1,3 +0,0 @@
|
||||
---
|
||||
# dependencies:
|
||||
# - role: docker
|
||||
@@ -1,40 +0,0 @@
|
||||
- name: Generate PrivateKey
|
||||
community.crypto.openssl_privatekey:
|
||||
path: /opt/netmaker_server/certs/node.key
|
||||
owner: 1883 # Set owner to mosquitto-user (all other containers seem to run as root)
|
||||
|
||||
- name: Generate Certificate-Signing-Request from privateKey
|
||||
community.crypto.openssl_csr:
|
||||
path: /opt/netmaker_server/certs/node.csr
|
||||
privatekey_path: /opt/netmaker_server/certs/node.key
|
||||
common_name: "{{ ansible_facts.nodename }}"
|
||||
subject_alt_name:
|
||||
"DNS:{{ netmaker_rqlite.http_host }}.{{ ansible_facts.nodename }},\
|
||||
DNS:{{ netmaker_rqlite.cluster_host }}.{{ ansible_facts.nodename }},\
|
||||
DNS:{{ netmaker_broker.tls_host }}.{{ netmaker_base_domain }},\
|
||||
DNS:{{ netmaker_api.host }}.{{ netmaker_base_domain }},\
|
||||
DNS:{{ netmaker_ui.host }}.{{ netmaker_base_domain }}"
|
||||
|
||||
- name: Fetch CSR
|
||||
ansible.builtin.fetch:
|
||||
src: /opt/netmaker_server/certs/node.csr
|
||||
dest: tmp_files/
|
||||
|
||||
- name: Sign CSR locally with CA
|
||||
local_action: community.crypto.x509_certificate
|
||||
args:
|
||||
path: tmp_files/{{ inventory_hostname }}/opt/netmaker_server/certs/node.crt
|
||||
csr_path: tmp_files/{{ inventory_hostname }}/opt/netmaker_server/certs/node.csr
|
||||
ownca_path: secret_files/netmaker_server/ca/ca.crt
|
||||
ownca_privatekey_path: secret_files/netmaker_server/ca/ca.key
|
||||
provider: ownca
|
||||
|
||||
- name: Copy Signed Certificate
|
||||
ansible.builtin.copy:
|
||||
src: tmp_files/{{ inventory_hostname }}/opt/netmaker_server/certs/node.crt
|
||||
dest: /opt/netmaker_server/certs/node.crt
|
||||
|
||||
- name: Copy CA Certificate
|
||||
ansible.builtin.copy:
|
||||
src: secret_files/netmaker_server/ca/ca.crt
|
||||
dest: /opt/netmaker_server/certs/ca.crt
|
||||
@@ -1,20 +0,0 @@
|
||||
- import_tasks: ./prerequisites.yml
|
||||
|
||||
- name: Copy folder-structure
|
||||
ansible.builtin.copy:
|
||||
src: opt/netmaker_server
|
||||
dest: /opt/
|
||||
mode: preserve
|
||||
|
||||
- name: Deploy compose file
|
||||
ansible.builtin.template:
|
||||
src: docker-compose.yml.template
|
||||
dest: /opt/netmaker_server/docker-compose.yml
|
||||
|
||||
- import_tasks: ./certs.yml
|
||||
|
||||
- import_tasks: ./nginx.yml
|
||||
|
||||
- import_tasks: ./rqlite.yml
|
||||
|
||||
- import_tasks: ./netmaker.yml
|
||||
@@ -1,57 +0,0 @@
|
||||
- name: Start rest of netmaker-services
|
||||
command: "docker-compose --project-directory /opt/netmaker_server/ up -d"
|
||||
register: command
|
||||
failed_when: command.rc != 0
|
||||
|
||||
- name: Wait for netmaker-api to become available
|
||||
uri:
|
||||
url: 'https://{{ netmaker_api.host }}.{{ netmaker_base_domain }}:{{ netmaker_nginx.advertise_port }}'
|
||||
return_content: yes
|
||||
validate_certs: no
|
||||
status_code:
|
||||
- 404
|
||||
until: uri_output.status == 404
|
||||
retries: 24 # Retries for 24 * 5 seconds = 120 seconds = 2 minutes
|
||||
delay: 5 # Every 5 seconds
|
||||
register: uri_output
|
||||
|
||||
# todo: check if exists?
|
||||
|
||||
- name: Create default mesh-network 'server'
|
||||
uri:
|
||||
validate_certs: no
|
||||
url: 'https://{{ netmaker_api.host }}.{{ netmaker_base_domain }}:{{ netmaker_nginx.advertise_port }}/api/networks'
|
||||
method: POST
|
||||
body:
|
||||
netid: servnet
|
||||
addressrange: 10.92.0.0/24
|
||||
addressrange6: fd92::/64
|
||||
body_format: json
|
||||
headers:
|
||||
Authorization: 'Bearer {{ netmaker_creds.master_key }}'
|
||||
Content-Type: application/json
|
||||
when: "inventory_hostname == groups['netmaker_server'][0]"
|
||||
register: default_mesh
|
||||
until: "default_mesh is not failed"
|
||||
retries: 2
|
||||
delay: 10
|
||||
|
||||
# todo: check if exists?
|
||||
|
||||
- name: Create token for default-network
|
||||
uri:
|
||||
validate_certs: no
|
||||
url: 'https://{{ netmaker_api.host }}.{{ netmaker_base_domain }}:{{ netmaker_nginx.advertise_port }}/api/networks/servnet/keys' # todo: do implementation
|
||||
method: POST
|
||||
body:
|
||||
name: ""
|
||||
uses: 0
|
||||
body_format: json
|
||||
headers:
|
||||
Authorization: 'Bearer {{ netmaker_creds.master_key }}'
|
||||
Content-Type: application/json
|
||||
when: "inventory_hostname == groups['netmaker_server'][0]"
|
||||
register: default_mesh_key
|
||||
until: "default_mesh_key is not failed"
|
||||
retries: 2
|
||||
delay: 10
|
||||
@@ -1,18 +0,0 @@
|
||||
- name: Deploy nginx configs
|
||||
template:
|
||||
src: "{{item.src}}"
|
||||
dest: "{{item.dst}}"
|
||||
loop:
|
||||
- { src: 'nginx/proxy.conf.template', dst: '/opt/netmaker_server/nginx/conf/conf.d/proxy.conf' }
|
||||
- { src: 'nginx/passthrough.conf.template', dst: '/opt/netmaker_server/nginx/conf/stream.d/passthrough.conf' }
|
||||
|
||||
- name: Start nginx service
|
||||
command: "docker-compose --project-directory /opt/netmaker_server/ up -d nginx"
|
||||
register: command
|
||||
failed_when: command.rc != 0
|
||||
|
||||
- name: Waiting for nginx to accept connections
|
||||
ansible.builtin.wait_for:
|
||||
host: "{{ inventory_hostname }}"
|
||||
port: 51820
|
||||
state: started
|
||||
@@ -1,9 +0,0 @@
|
||||
- name: Install wireguard
|
||||
package:
|
||||
name:
|
||||
- wireguard
|
||||
state: latest
|
||||
|
||||
- name: Check if default-ipv4-address is private
|
||||
set_fact:
|
||||
private_ipv4_address: "{{ ansible_facts.default_ipv4.address | regex_search('^((10)|(192\\.168)|(172\\.((1[6-9])|(2[0-9])|(3[0-1])))|(100))\\.') }}"
|
||||
@@ -1,42 +0,0 @@
|
||||
- name: Deploy rqlite config
|
||||
ansible.builtin.template:
|
||||
src: rqlite-config.json.template
|
||||
dest: /opt/netmaker_server/rqlite/config.json
|
||||
|
||||
- name: Start rqlite service for 1st-node
|
||||
command: "docker-compose --project-directory /opt/netmaker_server/ up -d rqlite"
|
||||
register: command
|
||||
failed_when: command.rc != 0
|
||||
when: "inventory_hostname == groups['netmaker_server'][0]"
|
||||
|
||||
- name: Waiting for rqlite to accept connections on 1st-node
|
||||
uri:
|
||||
url: 'https://{{ netmaker_rqlite.http_host }}.{{ inventory_hostname }}:{{ netmaker_nginx.advertise_port }}/status'
|
||||
return_content: yes
|
||||
validate_certs: no
|
||||
status_code:
|
||||
- 401
|
||||
until: uri_output.status == 401
|
||||
retries: 24 # Retries for 24 * 5 seconds = 120 seconds = 2 minutes
|
||||
delay: 5 # Every 5 seconds
|
||||
register: uri_output
|
||||
when: "inventory_hostname == groups['netmaker_server'][0]"
|
||||
|
||||
- name: Start rqlite service for other nodes
|
||||
command: "docker-compose --project-directory /opt/netmaker_server/ up -d rqlite"
|
||||
register: command
|
||||
failed_when: command.rc != 0
|
||||
when: "inventory_hostname != groups['netmaker_server'][0]"
|
||||
|
||||
- name: Waiting for rqlite to accept connections on other nodes
|
||||
uri:
|
||||
url: 'https://{{ netmaker_rqlite.http_host }}.{{ inventory_hostname }}:{{ netmaker_nginx.advertise_port }}/status'
|
||||
return_content: yes
|
||||
validate_certs: no
|
||||
status_code:
|
||||
- 401
|
||||
until: uri_output.status == 401
|
||||
retries: 24 # Retries for 24 * 5 seconds = 120 seconds = 2 minutes
|
||||
delay: 5 # Every 5 seconds
|
||||
register: uri_output
|
||||
when: "inventory_hostname != groups['netmaker_server'][0]"
|
||||
@@ -1,127 +0,0 @@
|
||||
version: "3.4"
|
||||
|
||||
services:
|
||||
nginx:
|
||||
image: nginx
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro # Override nginx-config to add stream-import
|
||||
- ./nginx/conf/conf.d/:/etc/nginx/conf.d:ro # conf.d
|
||||
- ./nginx/conf/stream.d/:/etc/nginx/stream.d:ro # conf.d
|
||||
- ./certs:/certs:ro # SSL-certificates
|
||||
ports:
|
||||
- {{ netmaker_nginx.tls_port }}:443
|
||||
|
||||
rqlite: # Distributed sqlite-db
|
||||
image: rqlite/rqlite
|
||||
restart: unless-stopped
|
||||
hostname: "{{ ansible_facts.nodename }}"
|
||||
volumes:
|
||||
- "./rqlite/data:/rqlite/file"
|
||||
- "./rqlite/config.json:/config.json:ro"
|
||||
- "./certs:/certs:ro"
|
||||
- ./certs/ca.crt:/etc/ssl/certs/netmaker-ca.pem:ro # Add CA to system-trust-store
|
||||
command: "
|
||||
-http-adv-addr {{ netmaker_rqlite.http_host }}.{{ ansible_facts.nodename }}:{{ netmaker_nginx.advertise_port }}
|
||||
-raft-addr [::]:4002
|
||||
-raft-adv-addr {{ netmaker_rqlite.cluster_host }}.{{ ansible_facts.nodename }}:{{ netmaker_nginx.advertise_port }}
|
||||
|
||||
-node-encrypt
|
||||
-node-cert /certs/node.crt
|
||||
-node-key /certs/node.key
|
||||
-node-no-verify
|
||||
|
||||
-auth /config.json
|
||||
|
||||
{% if inventory_hostname != groups['netmaker_server'][0] %}
|
||||
-join-as netmaker
|
||||
-join https://{{ netmaker_rqlite.http_host }}.{{ groups['netmaker_server'][0] }}:{{ netmaker_nginx.advertise_port }}
|
||||
{% endif %}
|
||||
"
|
||||
# FIXME: /\ \/ Change http -> https
|
||||
|
||||
netmaker: # The Primary Server for running Netmaker
|
||||
image: gravitl/netmaker:v0.17.1
|
||||
depends_on:
|
||||
- rqlite
|
||||
cap_add:
|
||||
- NET_ADMIN
|
||||
- NET_RAW
|
||||
- SYS_MODULE
|
||||
sysctls:
|
||||
- net.ipv4.ip_forward=1
|
||||
- net.ipv4.conf.all.src_valid_mark=1
|
||||
- net.ipv6.conf.all.disable_ipv6=0
|
||||
- net.ipv6.conf.all.forwarding=1
|
||||
restart: unless-stopped
|
||||
volumes: # Volume mounts necessary for sql, coredns, and mqtt
|
||||
- ./dnsconfig/:/root/config/dnsconfig
|
||||
- ./mosquitto/data/:/etc/netmaker/
|
||||
- ./certs/ca.crt:/etc/ssl/certs/netmaker-ca.pem:ro # Add CA to system-trust-store
|
||||
hostname: "{{ ansible_facts.nodename }}"
|
||||
environment: # Necessary capabilities to set iptables when running in container
|
||||
NODE_ID: "{{ ansible_facts.nodename }}"
|
||||
MASTER_KEY: "{{ netmaker_creds.master_key }}" # The admin master key for accessing the API. Change this in any production installation.
|
||||
|
||||
{% if not private_ipv4_address and not netmaker_dynamicIp %}
|
||||
SERVER_HOST: "{{ ansible_facts.default_ipv4.address }}" # Set to public IP of machine.
|
||||
{% endif %}
|
||||
SERVER_NAME: "{{ netmaker_broker.tls_host }}.{{ netmaker_base_domain }}" # The domain/host IP indicating the mq broker address
|
||||
SERVER_HTTP_HOST: "{{ netmaker_api.host }}.{{ netmaker_base_domain }}" # Overrides SERVER_HOST if set. Useful for making HTTP available via different interfaces/networks.
|
||||
SERVER_API_CONN_STRING: "{{ netmaker_api.host }}.{{ netmaker_base_domain }}:{{ netmaker_nginx.advertise_port }}"
|
||||
|
||||
DISABLE_REMOTE_IP_CHECK: "off" # If turned "on", Server will not set Host based on remote IP check. This is already overridden if SERVER_HOST is set. Turned "off" by default.
|
||||
DNS_MODE: "off" # Enables DNS Mode, meaning all nodes will set hosts file for private dns settings.
|
||||
|
||||
API_PORT: "8081" # The HTTP API port for Netmaker. Used for API calls / communication from front end. If changed, need to change port of BACKEND_URL for netmaker-ui.
|
||||
REST_BACKEND: "on" # Enables the REST backend (API running on API_PORT at SERVER_HTTP_HOST). Change to "off" to turn off.
|
||||
RCE: "off" # Enables setting PostUp and PostDown (arbitrary commands) on nodes from the server. Off by default.
|
||||
CORS_ALLOWED_ORIGIN: "*" # The "allowed origin" for API requests. Change to restrict where API requests can come from.
|
||||
DISPLAY_KEYS: "on" # Show keys permanently in UI (until deleted) as opposed to 1-time display.
|
||||
|
||||
DATABASE: "rqlite"
|
||||
SQL_CONN: "https://netmaker:{{ netmaker_creds.rqlite_password }}@{{ netmaker_rqlite.http_host }}.{{ ansible_facts.nodename }}:{{ netmaker_nginx.advertise_port }}/"
|
||||
|
||||
MQ_HOST: "mosquitto" # the address of the mq server. If running from docker compose it will be "mq". Otherwise, need to input address. If using "host networking", it will find and detect the IP of the mq container.
|
||||
MQ_SERVER_PORT: "1883" # the reachable port of MQ by the server - change if internal MQ port changes (or use external port if MQ is not on the same machine)
|
||||
MQ_PORT: "{{ netmaker_nginx.advertise_port }}" # the reachable port of MQ - change if external MQ port changes (port on proxy, not necessarily the one exposed in docker-compose)
|
||||
MQ_ADMIN_PASSWORD: "{{ netmaker_creds.mq_admin_password }}"
|
||||
|
||||
HOST_NETWORK: "off" # whether or not host networking is turned on. Only turn on if configured for host networking (see docker-compose.hostnetwork.yml). Will set host-level settings like iptables.
|
||||
PORT_FORWARD_SERVICES: "" # decide which services to port forward ("dns","ssh", or "mq")
|
||||
|
||||
# this section is for OAuth
|
||||
AUTH_PROVIDER: "" # "<azure-ad|github|google|oidc>"
|
||||
CLIENT_ID: "" # "<client id of your oauth provider>"
|
||||
CLIENT_SECRET: "" # "<client secret of your oauth provider>"
|
||||
FRONTEND_URL: "" # "https://dashboard.<netmaker base domain>"
|
||||
AZURE_TENANT: "" # "<only for azure, you may optionally specify the tenant for the OAuth>"
|
||||
OIDC_ISSUER: "" # https://oidc.yourprovider.com - URL of oidc provider
|
||||
|
||||
VERBOSITY: "1" # logging verbosity level - 1, 2, or 3
|
||||
TELEMETRY: "off" # Whether or not to send telemetry data to help improve Netmaker. Switch to "off" to opt out of sending telemetry.
|
||||
ports:
|
||||
- "51821-51830:51821-51830/udp" # wireguard ports
|
||||
|
||||
netmaker-ui: # The Netmaker UI Component
|
||||
image: gravitl/netmaker-ui:v0.17.1
|
||||
depends_on:
|
||||
- netmaker
|
||||
links:
|
||||
- "netmaker:api"
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
BACKEND_URL: "https://{{ netmaker_api.host }}.{{ netmaker_base_domain }}:{{ netmaker_nginx.advertise_port }}" # URL where UI will send API requests. Change based on SERVER_HOST, SERVER_HTTP_HOST, and API_PORT
|
||||
|
||||
mosquitto: # the MQTT broker for netmaker
|
||||
image: eclipse-mosquitto:2.0.11-openssl
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./mosquitto/config:/mosquitto/config
|
||||
- ./mosquitto/data:/mosquitto/data
|
||||
- ./mosquitto/logs:/mosquitto/log
|
||||
depends_on:
|
||||
- netmaker
|
||||
command: ["/mosquitto/config/wait.sh"]
|
||||
environment:
|
||||
NETMAKER_SERVER_HOST: "http://netmaker:8081"
|
||||
@@ -1,25 +0,0 @@
|
||||
stream{
|
||||
# Map target-hosts based on hostname
|
||||
map $ssl_preread_server_name $target_host {
|
||||
hostnames; # Enable matching including prefix/suffix-mask
|
||||
|
||||
{{ netmaker_ui.host }}.{{ netmaker_base_domain }} 127.0.0.1:8443;
|
||||
{{ netmaker_api.host }}.{{ netmaker_base_domain }} 127.0.0.1:8443;
|
||||
|
||||
{{ netmaker_broker.tls_host }}.{{ netmaker_base_domain }} 127.0.0.1:8443;
|
||||
|
||||
{{ netmaker_rqlite.http_host }}.{{ ansible_facts.nodename }} 127.0.0.1:8443;
|
||||
{{ netmaker_rqlite.cluster_host }}.{{ ansible_facts.nodename }} rqlite:4002;
|
||||
|
||||
default 127.0.0.1:1;
|
||||
}
|
||||
|
||||
server {
|
||||
resolver 127.0.0.11; # Explicitly set docker-resolver
|
||||
|
||||
listen 443;
|
||||
ssl_preread on;
|
||||
|
||||
proxy_pass $target_host;
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
map $host $proxy_name {
|
||||
hostnames;
|
||||
|
||||
{{ netmaker_ui.host }}.{{ netmaker_base_domain }} netmaker-ui:80;
|
||||
{{ netmaker_api.host }}.{{ netmaker_base_domain }} netmaker:8081;
|
||||
|
||||
{{ netmaker_broker.tls_host }}.{{ netmaker_base_domain }} mosquitto:8883;
|
||||
|
||||
{{ netmaker_rqlite.http_host }}.{{ ansible_facts.nodename }} rqlite:4001;
|
||||
|
||||
default 444;
|
||||
}
|
||||
|
||||
server {
|
||||
resolver 127.0.0.11; # Explicitly set docker-resolver
|
||||
|
||||
listen 8443 ssl;
|
||||
|
||||
ssl_certificate /certs/node.crt;
|
||||
ssl_certificate_key /certs/node.key;
|
||||
|
||||
if ($proxy_name = 444){
|
||||
return 444;
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass http://$proxy_name;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
[{
|
||||
"username": "netmaker",
|
||||
"password": "{{ netmaker_creds.rqlite_password }}",
|
||||
"perms": ["all"]
|
||||
}]
|
||||
@@ -1,2 +0,0 @@
|
||||
# Directory the node exporter reads textfile metrics from (the Debian default).
|
||||
apt_updates_textfile_dir: /var/lib/prometheus/node-exporter
|
||||
@@ -1,80 +0,0 @@
|
||||
# The metrics run after apt-daily (update) and apt-daily-upgrade (upgrade), so they show the state apt
|
||||
# just reached without a timer of their own. The "-" lets a failing metrics run leave apt's units green.
|
||||
- name: Create the textfile directory
|
||||
ansible.builtin.file:
|
||||
path: "{{ apt_updates_textfile_dir }}"
|
||||
state: directory
|
||||
mode: "0755"
|
||||
|
||||
- name: Install the apt metrics script
|
||||
ansible.builtin.template:
|
||||
src: apt-updates-metrics.j2
|
||||
dest: /usr/local/sbin/apt-updates-metrics
|
||||
mode: "0755"
|
||||
register: apt_updates_script
|
||||
|
||||
- name: Create the apt timers' drop-in directories
|
||||
ansible.builtin.file:
|
||||
path: /etc/systemd/system/{{ item }}.service.d
|
||||
state: directory
|
||||
mode: "0755"
|
||||
loop:
|
||||
- apt-daily
|
||||
- apt-daily-upgrade
|
||||
|
||||
- name: Run the apt metrics after the apt timers' services
|
||||
ansible.builtin.copy:
|
||||
dest: /etc/systemd/system/{{ item }}.service.d/apt-updates-metrics.conf
|
||||
mode: "0644"
|
||||
content: |
|
||||
[Service]
|
||||
ExecStartPost=-/usr/local/sbin/apt-updates-metrics
|
||||
loop:
|
||||
- apt-daily
|
||||
- apt-daily-upgrade
|
||||
register: apt_updates_dropins
|
||||
|
||||
- name: Reload systemd after the apt metrics drop-ins
|
||||
ansible.builtin.systemd:
|
||||
daemon_reload: true
|
||||
when: apt_updates_dropins.changed
|
||||
# Check mode does not write the drop-ins.
|
||||
ignore_errors: "{{ ansible_check_mode }}"
|
||||
|
||||
# Debian's prometheus-node-exporter-apt collector writes apt.prom with the same metric families as this role under
|
||||
# other HELP texts; node_exporter drops the rows of both files then. This role owns the apt metrics, so the
|
||||
# collector stays off and its file, also a stale leftover of a removed collector, goes away.
|
||||
- name: Look for the Debian apt metrics collector
|
||||
ansible.builtin.stat:
|
||||
path: /lib/systemd/system/prometheus-node-exporter-apt.timer
|
||||
register: apt_updates_debian_collector
|
||||
|
||||
- name: Stop and disable the Debian apt metrics collector timer
|
||||
ansible.builtin.systemd:
|
||||
name: prometheus-node-exporter-apt.timer
|
||||
enabled: false
|
||||
state: stopped
|
||||
when: apt_updates_debian_collector.stat.exists
|
||||
|
||||
- name: Stop the Debian apt metrics collector service
|
||||
ansible.builtin.systemd:
|
||||
name: prometheus-node-exporter-apt.service
|
||||
state: stopped
|
||||
when: apt_updates_debian_collector.stat.exists
|
||||
|
||||
- name: Remove the Debian apt metrics collector output
|
||||
ansible.builtin.file:
|
||||
path: "{{ apt_updates_textfile_dir }}/apt.prom"
|
||||
state: absent
|
||||
|
||||
# The timers' drop-ins write the metrics after the next apt run; this gives a new host and a changed script
|
||||
# current metrics at once.
|
||||
- name: Look for the apt metrics file
|
||||
ansible.builtin.stat:
|
||||
path: "{{ apt_updates_textfile_dir }}/apt_updates.prom"
|
||||
register: apt_updates_prom
|
||||
|
||||
- name: Write the apt metrics
|
||||
ansible.builtin.command: /usr/local/sbin/apt-updates-metrics
|
||||
when: apt_updates_script.changed or not apt_updates_prom.stat.exists
|
||||
changed_when: true
|
||||
@@ -1,89 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Pending upgrades, held packages, reboot flag and age of the package lists as node exporter textfile
|
||||
# metrics. apt-daily and apt-daily-upgrade run it after their update and upgrade (drop-ins).
|
||||
set -euo pipefail
|
||||
|
||||
host={{ inventory_hostname }}
|
||||
textfile_dir={{ apt_updates_textfile_dir }}
|
||||
out=$textfile_dir/apt_updates.prom
|
||||
tmp=$(mktemp "$textfile_dir/.apt_updates.XXXXXX")
|
||||
trap 'rm -f "$tmp"' EXIT
|
||||
|
||||
# Architectures a pending package can have: the native and foreign ones, plus "all" for architecture-independent
|
||||
# packages. Each gets a row per kind even at 0, so a resolved backlog reads as 0 and not as a vanished series.
|
||||
archs="$(dpkg --print-architecture) $(dpkg --print-foreign-architectures | tr '\n' ' ') all"
|
||||
|
||||
# The simulated upgrade lists "Inst <package> [<installed>] (<candidate> <origins> [<arch>])" per package. The candidate
|
||||
# is the newest version, which can come from regular updates while an older, still newer-than-installed version came
|
||||
# from the security archive. So a package counts as security when any version above the installed one in its
|
||||
# "apt-cache policy" table (newest first, installed marked with ***) has a security origin, everything else as regular.
|
||||
sim=$(apt-get -s upgrade)
|
||||
names=$(awk '/^Inst / { print $2 }' <<< "$sim")
|
||||
security=""
|
||||
if [[ -n $names ]]; then
|
||||
# shellcheck disable=SC2086
|
||||
security=$(apt-cache policy $names | awk '
|
||||
/^[^ ]/ { pkg = $1; sub(/:$/, "", pkg); newer = 1; next }
|
||||
/^ \*\*\* / { newer = 0; next }
|
||||
/^ [0-9]+ / { if (newer && tolower($0) ~ /security/) print pkg }
|
||||
')
|
||||
fi
|
||||
|
||||
pending=$(SECURITY="$security" awk -v host="$host" -v archs="$archs" '
|
||||
BEGIN {
|
||||
n = split(ENVIRON["SECURITY"], s, "\n")
|
||||
for (i = 1; i <= n; i++) sec[s[i]] = 1
|
||||
}
|
||||
/^Inst / {
|
||||
line = $0
|
||||
sub(/^[^(]*\(/, "", line)
|
||||
sub(/\).*$/, "", line)
|
||||
n = split(line, f, " ")
|
||||
pkg_arch = f[n]
|
||||
gsub(/\[|\]/, "", pkg_arch)
|
||||
kind = ($2 in sec) ? "security" : "regular"
|
||||
count[kind, pkg_arch]++
|
||||
}
|
||||
END {
|
||||
split(archs, a, " ")
|
||||
for (i in a) {
|
||||
if (!(("security", a[i]) in count)) count["security", a[i]] = 0
|
||||
if (!(("regular", a[i]) in count)) count["regular", a[i]] = 0
|
||||
}
|
||||
for (key in count) {
|
||||
split(key, k, SUBSEP)
|
||||
printf "apt_upgrades_pending{host=\"%s\",origin=\"%s\",arch=\"%s\"} %d\n", host, k[1], k[2], count[key]
|
||||
}
|
||||
}' <<< "$sim")
|
||||
|
||||
held=$(apt-mark showhold | wc -l)
|
||||
|
||||
reboot=0
|
||||
if [[ -e /run/reboot-required ]]; then
|
||||
reboot=1
|
||||
fi
|
||||
|
||||
# Debian writes update-stamp, Ubuntu update-success-stamp; both are touched by a successful update. Both are missing
|
||||
# until apt.systemd.daily has run an update, then the package cache stands in.
|
||||
stamp=/var/lib/apt/periodic/update-success-stamp
|
||||
if [[ ! -e $stamp ]]; then
|
||||
stamp=/var/lib/apt/periodic/update-stamp
|
||||
fi
|
||||
if [[ ! -e $stamp ]]; then
|
||||
stamp=/var/cache/apt/pkgcache.bin
|
||||
fi
|
||||
cache=$(stat -c %Y "$stamp")
|
||||
|
||||
{
|
||||
echo '# TYPE apt_upgrades_pending gauge'
|
||||
echo "$pending"
|
||||
echo '# TYPE apt_upgrades_held gauge'
|
||||
echo "apt_upgrades_held{host=\"$host\"} $held"
|
||||
echo '# TYPE node_reboot_required gauge'
|
||||
echo "node_reboot_required{host=\"$host\"} $reboot"
|
||||
echo '# TYPE apt_package_cache_timestamp_seconds gauge'
|
||||
echo "apt_package_cache_timestamp_seconds{host=\"$host\"} $cache"
|
||||
} > "$tmp"
|
||||
|
||||
chmod 0644 "$tmp"
|
||||
mv -f "$tmp" "$out"
|
||||
@@ -1,22 +0,0 @@
|
||||
ipv6_stable_secret_seed: "@JW7rQ^P3*f^KRkX3744DZQJb3DfMgZJJMtGc!Jmy!ECBqb^QzAMYLHD8dLvf8m%2bRSRFhgY#SXzAgnxWTvHv#ti6b5PngR^k2gHbQW*%dJ9sdeKM$H77Gymh&xamNN"
|
||||
|
||||
# Bridges and other interfaces that appear after sysctl runs at boot and so miss the accept_ra=2 of
|
||||
# "all"; without it a forwarding host ignores the router advertisements on them.
|
||||
ipv6_accept_ra_interfaces: []
|
||||
|
||||
# Times of the heavy disk jobs, for hosts on a shared HDD pool that keep them out of the backup window.
|
||||
disk_maintenance:
|
||||
# systemd OnCalendar for fstrim.timer; unset keeps the distribution schedule.
|
||||
fstrim_on_calendar:
|
||||
# Cron "minute hour" of the monthly ZFS trim and scrub from zfsutils-linux; unset keeps 00:24.
|
||||
zfs_cron_time:
|
||||
|
||||
# journald.conf [Journal] settings of a drop-in, in this order; null leaves journald untouched.
|
||||
journald_size_cap:
|
||||
SystemMaxUse: 512M
|
||||
RuntimeMaxUse: 128M
|
||||
MaxRetentionSec: 2week
|
||||
|
||||
# Remove fail2ban from the host (only while the CrowdSec agent and bouncer run); set with
|
||||
# -e fail2ban_remove=true once CrowdSec covers the jails of the host.
|
||||
fail2ban_remove: false
|
||||
@@ -1,7 +1,2 @@
|
||||
- name: reload_sysctl
|
||||
command: sysctl --system
|
||||
|
||||
- name: restart_journald
|
||||
systemd:
|
||||
name: systemd-journald
|
||||
state: restarted
|
||||
|
||||
+15
-36
@@ -1,41 +1,20 @@
|
||||
- name: Ensure .bashrc exists
|
||||
file:
|
||||
path: "{{ ansible_facts.env.HOME }}/.bashrc"
|
||||
state: touch
|
||||
mode: "0644"
|
||||
|
||||
- name: General aliases
|
||||
blockinfile:
|
||||
path: "{{ ansible_facts.env.HOME }}/.bashrc"
|
||||
marker: "# {mark} ANSIBLE MANAGED BLOCK | General aliases"
|
||||
block: |
|
||||
alias clr="clear"
|
||||
alias hgrep="history | grep"
|
||||
alias syslog="tail -f --lines=100 /var/log/syslog"
|
||||
alias cp="rsync -hlAXEptgoDS --numeric-ids --info=progress2"
|
||||
|
||||
# We can tell bash to check the next word after the alias (i.e sudo) by adding a space to the end of the alias value. https://askubuntu.com/questions/22037/aliases-not-available-when-using-sudo
|
||||
alias sudo='sudo '
|
||||
path: "{{ ansible_facts.env.HOME }}/.bashrc"
|
||||
marker: "# {mark} ANSIBLE MANAGED BLOCK | General aliases"
|
||||
block: |
|
||||
alias clr="clear"
|
||||
alias hgrep="history | grep"
|
||||
alias syslog="tail -f --lines=100 /var/log/syslog"
|
||||
alias cp="rsync -hlAXEptgoDS --numeric-ids --info=progress2"
|
||||
|
||||
- name: ls aliases and colors
|
||||
blockinfile:
|
||||
path: "{{ ansible_facts.env.HOME }}/.bashrc"
|
||||
marker: "# {mark} ANSIBLE MANAGED BLOCK | ls aliases and colors"
|
||||
block: |
|
||||
# enable color support of ls and also add handy aliases
|
||||
if [ -x /usr/bin/dircolors ]; then
|
||||
test -r ~/.dircolors && eval "$(dircolors -b ~/.dircolors)" || eval "$(dircolors -b)"
|
||||
alias ls='ls --color=auto'
|
||||
#alias dir='dir --color=auto'
|
||||
#alias vdir='vdir --color=auto'
|
||||
|
||||
alias grep='grep --color=auto'
|
||||
alias fgrep='fgrep --color=auto'
|
||||
alias egrep='egrep --color=auto'
|
||||
fi
|
||||
|
||||
export LS_OPTIONS='--color=auto'
|
||||
eval "`dircolors`"
|
||||
alias ls='ls $LS_OPTIONS'
|
||||
alias ll='ls $LS_OPTIONS -l'
|
||||
alias l='ls $LS_OPTIONS -la'
|
||||
path: "{{ ansible_facts.env.HOME }}/.bashrc"
|
||||
marker: "# {mark} ANSIBLE MANAGED BLOCK | ls aliases and colors"
|
||||
block: |
|
||||
export LS_OPTIONS='--color=auto'
|
||||
eval "`dircolors`"
|
||||
alias ls='ls $LS_OPTIONS'
|
||||
alias ll='ls $LS_OPTIONS -l'
|
||||
alias l='ls $LS_OPTIONS -la'
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
- name: Pin the fstrim timer
|
||||
when: disk_maintenance.fstrim_on_calendar | default(none) is not none
|
||||
block:
|
||||
- name: Create the fstrim timer drop-in directory
|
||||
file:
|
||||
path: /etc/systemd/system/fstrim.timer.d
|
||||
state: directory
|
||||
|
||||
- name: Set the fstrim timer schedule
|
||||
copy:
|
||||
dest: /etc/systemd/system/fstrim.timer.d/schedule.conf
|
||||
content: |
|
||||
[Timer]
|
||||
OnCalendar=
|
||||
OnCalendar={{ disk_maintenance.fstrim_on_calendar }}
|
||||
RandomizedDelaySec=0
|
||||
# A catch-up run after a boot or a timezone change would land at any time.
|
||||
Persistent=false
|
||||
register: fstrim_timer
|
||||
|
||||
- name: Restart the fstrim timer
|
||||
when: fstrim_timer.changed
|
||||
systemd:
|
||||
name: fstrim.timer
|
||||
daemon_reload: yes
|
||||
state: restarted
|
||||
|
||||
- name: Schedule the monthly ZFS trim and scrub
|
||||
when: disk_maintenance.zfs_cron_time | default(none) is not none
|
||||
lineinfile:
|
||||
path: /etc/cron.d/zfsutils-linux
|
||||
regexp: '^\S+ \S+ (\S+ \* \* root .*/usr/lib/zfs-linux/{{ item }};.*)$'
|
||||
line: '{{ disk_maintenance.zfs_cron_time }} \1'
|
||||
backrefs: yes
|
||||
loop: [trim, scrub]
|
||||
@@ -1,27 +0,0 @@
|
||||
# CrowdSec reads the same logs and bans through its bouncers. fail2ban is
|
||||
# removed only on a host switched over on purpose (fail2ban_remove) where both
|
||||
# are running, so a host never ends up without a ban mechanism.
|
||||
- name: Check the CrowdSec services
|
||||
command: systemctl is-active crowdsec crowdsec-firewall-bouncer
|
||||
register: crowdsec_active
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
check_mode: false
|
||||
tags: fail2ban
|
||||
|
||||
- name: Stop and disable fail2ban
|
||||
systemd:
|
||||
name: fail2ban
|
||||
state: stopped
|
||||
enabled: false
|
||||
when: fail2ban_remove and crowdsec_active.rc == 0
|
||||
failed_when: false
|
||||
tags: fail2ban
|
||||
|
||||
- name: Remove fail2ban
|
||||
apt:
|
||||
name: fail2ban
|
||||
state: absent
|
||||
purge: true
|
||||
when: fail2ban_remove and crowdsec_active.rc == 0
|
||||
tags: fail2ban
|
||||
@@ -1,17 +0,0 @@
|
||||
- name: Cap the journal size
|
||||
when: journald_size_cap | default(none) is not none
|
||||
block:
|
||||
- name: Create the journald drop-in directory
|
||||
file:
|
||||
path: /etc/systemd/journald.conf.d
|
||||
state: directory
|
||||
|
||||
- name: Set the journal size cap
|
||||
copy:
|
||||
dest: /etc/systemd/journald.conf.d/10-size-cap.conf
|
||||
content: |
|
||||
[Journal]
|
||||
{% for key, value in journald_size_cap.items() %}
|
||||
{{ key }}={{ value }}
|
||||
{% endfor %}
|
||||
notify: restart_journald
|
||||
Executable → Regular
-11
@@ -1,18 +1,7 @@
|
||||
- import_tasks: ./packages.yml
|
||||
|
||||
- import_tasks: ./fail2ban.yml
|
||||
|
||||
- import_tasks: ./ssh.yml
|
||||
|
||||
- import_tasks: ./packages.yml
|
||||
|
||||
- import_tasks: ./timesync.yml
|
||||
|
||||
- import_tasks: ./aliases.yml
|
||||
|
||||
- import_tasks: ./networking.yml
|
||||
|
||||
- import_tasks: ./disk_maintenance.yml
|
||||
|
||||
- import_tasks: ./journald.yml
|
||||
tags: journald
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
- name: Set sysctl settings for ip-forwarding
|
||||
copy:
|
||||
dest: "/etc/sysctl.d/ip-forwarding.conf"
|
||||
content: |
|
||||
net.ipv4.ip_forward = 1
|
||||
net.ipv6.conf.all.forwarding = 1
|
||||
notify: reload_sysctl
|
||||
|
||||
- name: Check if the sysctl configuration file exists
|
||||
stat:
|
||||
path: "/etc/sysctl.d/ipv6-slaac-address-generation.conf"
|
||||
register: sysctl_conf
|
||||
|
||||
- block:
|
||||
# - name: Generate IPv6 stable-secret segments
|
||||
# set_fact:
|
||||
# stable_secret_segments: "{{ stable_secret_segments | default() + '%0x' | format(range(0, 2**(16*2)) | random(seed=ipv6_stable_secret_seed+inventory_hostname+(stable_secret_segments | default()))) }}"
|
||||
# loop: "{{ range(4) }}"
|
||||
|
||||
# - name: Format IPv6 stable-secret segments
|
||||
# set_fact:
|
||||
# ipv6_stable_secret: "{{ stable_secret_segments | regex_replace('(.{4})(?=.)', '\\1:') }}"
|
||||
|
||||
- name: Generate random IPv6 stable-secret
|
||||
set_fact:
|
||||
ipv6_stable_secret: "{{ 'fd' + (lookup('password', '/dev/null length=30 chars=hex')).split('.')[0][:7] + '::' + (lookup('password', '/dev/null length=4 chars=hex')) }}"
|
||||
|
||||
- name: Set sysctl settings for ipv6-address-generation
|
||||
copy:
|
||||
dest: "/etc/sysctl.d/ipv6-slaac-address-generation.conf"
|
||||
content: |
|
||||
{% if ansible_facts.default_ipv6.interface is defined %}
|
||||
net.ipv6.conf.{{ ansible_facts.default_ipv6.interface }}.addr_gen_mode = 2
|
||||
net.ipv6.conf.{{ ansible_facts.default_ipv6.interface }}.stable_secret = {{ ipv6_stable_secret }}
|
||||
{% else %}
|
||||
net.ipv6.conf.{{ ansible_facts.default_ipv4.interface }}.addr_gen_mode = 2
|
||||
net.ipv6.conf.{{ ansible_facts.default_ipv4.interface }}.stable_secret = {{ ipv6_stable_secret }}
|
||||
{% endif %}
|
||||
notify: reload_sysctl
|
||||
when:
|
||||
- ansible_facts.default_ipv6 is defined
|
||||
- not sysctl_conf.stat.exists
|
||||
|
||||
- name: Set sysctl settings to override ipv6-slaac with enabled forwarding
|
||||
copy:
|
||||
dest: "/etc/sysctl.d/ipv6-slaac-override.conf"
|
||||
content: |
|
||||
net.ipv6.conf.all.accept_ra = 2
|
||||
{% for iface in ipv6_accept_ra_interfaces %}
|
||||
net.ipv6.conf.{{ iface }}.accept_ra = 2
|
||||
{% endfor %}
|
||||
notify: reload_sysctl
|
||||
tags: ipv6_accept_ra
|
||||
|
||||
- name: Set sysctl settings for TCP BBR
|
||||
copy:
|
||||
dest: "/etc/sysctl.d/tcp-bbr.conf"
|
||||
content: |
|
||||
net.core.default_qdisc = fq
|
||||
net.ipv4.tcp_congestion_control = bbr
|
||||
when:
|
||||
- ansible_kernel is version('4.9', '>=')
|
||||
notify: reload_sysctl
|
||||
+15
-14
@@ -1,19 +1,20 @@
|
||||
- name: Update Packages
|
||||
apt:
|
||||
update_cache: yes
|
||||
upgrade: yes
|
||||
update_cache: yes
|
||||
upgrade: yes
|
||||
when: ansible_facts.distribution == "Debian"
|
||||
|
||||
- name: Install Packages
|
||||
package:
|
||||
name:
|
||||
- gpg
|
||||
- htop
|
||||
- iotop
|
||||
- slurm
|
||||
- nmon
|
||||
- sudo
|
||||
- screen
|
||||
- curl
|
||||
- rsync
|
||||
- zstd
|
||||
state: latest
|
||||
name:
|
||||
- gpg
|
||||
- htop
|
||||
- iotop
|
||||
- slurm
|
||||
- sudo
|
||||
- screen
|
||||
- curl
|
||||
- rsync
|
||||
- zstd
|
||||
state: latest
|
||||
when: ansible_facts.distribution == "Debian"
|
||||
|
||||
+11
-19
@@ -1,20 +1,12 @@
|
||||
- name: Check if SSH daemon is active
|
||||
systemd:
|
||||
name: sshd
|
||||
state: started
|
||||
register: sshd_status
|
||||
- name: Disable SSH password auth
|
||||
lineinfile:
|
||||
dest: /etc/ssh/sshd_config
|
||||
regexp: '^PasswordAuthentication\s*yes'
|
||||
line: "PasswordAuthentication no"
|
||||
register: sshd_config
|
||||
|
||||
- block:
|
||||
- name: Disable SSH password auth
|
||||
lineinfile:
|
||||
dest: /etc/ssh/sshd_config
|
||||
regexp: '^PasswordAuthentication\s*yes'
|
||||
line: "PasswordAuthentication no"
|
||||
register: sshd_config
|
||||
|
||||
- name: Restart SSH daemon
|
||||
service:
|
||||
name: sshd
|
||||
state: restarted
|
||||
when: sshd_config.changed
|
||||
when: sshd_status.status.ActiveState == 'active'
|
||||
- name: Restart SSH daemon
|
||||
service:
|
||||
name: sshd
|
||||
state: restarted
|
||||
when: sshd_config.changed
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
# Containers share the host kernel clock and cannot discipline it themselves.
|
||||
- name: Configure time synchronisation
|
||||
when: ansible_facts['virtualization_role'] != 'guest' or ansible_facts['virtualization_type'] not in ['lxc', 'docker', 'podman', 'container']
|
||||
block:
|
||||
- name: Install systemd-timesyncd
|
||||
package:
|
||||
name: systemd-timesyncd
|
||||
state: present
|
||||
|
||||
- name: Enable systemd-timesyncd
|
||||
systemd:
|
||||
name: systemd-timesyncd
|
||||
enabled: yes
|
||||
state: started
|
||||
|
||||
# Logs and local-time timers line up across hosts.
|
||||
- name: Set the timezone to UTC
|
||||
tags: timezone
|
||||
community.general.timezone:
|
||||
name: Etc/UTC
|
||||
@@ -1,32 +0,0 @@
|
||||
---
|
||||
crowdsec:
|
||||
# Central LAPI; hosts run only the agent and the bouncer.
|
||||
lapi_url: ""
|
||||
registration_token: ""
|
||||
bouncer_api_key: ""
|
||||
# Machine name at the LAPI; empty is the short hostname.
|
||||
machine_name: ""
|
||||
# name -> hub collection, merged across group_vars/host_vars (null drops one).
|
||||
collections: {}
|
||||
# name -> hub scenario outside the collections.
|
||||
scenarios: {}
|
||||
# path below /etc/crowdsec -> content, for local parsers and scenarios.
|
||||
local_files: {}
|
||||
# name -> installed hub scenario removed from the agent (null drops one); needed where a local scenario replaces it.
|
||||
disabled_scenarios: {}
|
||||
# name -> path below /etc/crowdsec of a retired local file, removed when present (null drops one).
|
||||
absent_files: {}
|
||||
# name -> CIDR (single addresses as /32 or /128) never acted upon. Joined by
|
||||
# the public_ips (same format) of every inventory host, then whitelisted by
|
||||
# this agent and, when lapi_allowlist is set, allowlisted on the LAPI.
|
||||
whitelist: {}
|
||||
# LAPI allowlist kept equal to the whitelist; it also drops alerts on ranges
|
||||
# holding a whitelisted address, which a parser whitelist cannot.
|
||||
lapi_allowlist: ""
|
||||
# Host whose k3s kubectl reaches the LAPI pod (crowdsec/crowdsec-lapi).
|
||||
lapi_kubectl_host: ""
|
||||
# name -> acquisition datasource, written to /etc/crowdsec/acquis.d/<name>.yaml.
|
||||
acquisition: {}
|
||||
# packagecloud has no trixie suite yet; the bookworm build is static Go.
|
||||
apt_suite_map:
|
||||
trixie: bookworm
|
||||
@@ -1,10 +0,0 @@
|
||||
---
|
||||
- name: Restart crowdsec
|
||||
ansible.builtin.systemd:
|
||||
name: crowdsec.service
|
||||
state: restarted
|
||||
|
||||
- name: Restart crowdsec-firewall-bouncer
|
||||
ansible.builtin.systemd:
|
||||
name: crowdsec-firewall-bouncer.service
|
||||
state: restarted
|
||||
@@ -1,273 +0,0 @@
|
||||
---
|
||||
- name: Require LAPI settings
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- crowdsec.lapi_url | length > 0
|
||||
- crowdsec.registration_token | length > 0
|
||||
- crowdsec.bouncer_api_key | length > 0
|
||||
fail_msg: crowdsec.lapi_url, crowdsec.registration_token and crowdsec.bouncer_api_key must be set
|
||||
|
||||
- name: Create /etc/apt/keyrings
|
||||
ansible.builtin.file:
|
||||
path: /etc/apt/keyrings
|
||||
state: directory
|
||||
mode: "0755"
|
||||
|
||||
- name: Add the CrowdSec apt key
|
||||
ansible.builtin.get_url:
|
||||
url: https://packagecloud.io/crowdsec/crowdsec/gpgkey
|
||||
dest: /etc/apt/keyrings/crowdsec.asc
|
||||
mode: "0644"
|
||||
|
||||
# A deb822 crowdsec.sources (written by the package or by hand) already
|
||||
# defines the repository; a second crowdsec.list makes apt warn about
|
||||
# duplicate targets.
|
||||
- name: Check for an existing deb822 CrowdSec apt source
|
||||
ansible.builtin.stat:
|
||||
path: /etc/apt/sources.list.d/crowdsec.sources
|
||||
register: crowdsec_sources
|
||||
|
||||
- name: Add the CrowdSec apt repository
|
||||
ansible.builtin.copy:
|
||||
dest: /etc/apt/sources.list.d/crowdsec.list
|
||||
mode: "0644"
|
||||
content: >
|
||||
deb [signed-by=/etc/apt/keyrings/crowdsec.asc]
|
||||
https://packagecloud.io/crowdsec/crowdsec/{{ ansible_distribution | lower }}/
|
||||
{{ crowdsec.apt_suite_map[ansible_distribution_release] | default(ansible_distribution_release) }} main
|
||||
register: crowdsec_repo
|
||||
when: not crowdsec_sources.stat.exists
|
||||
|
||||
# The postinst asks these; without them it registers a local machine for a
|
||||
# local LAPI and enrols in the central CAPI.
|
||||
- name: Preseed the package for a remote LAPI
|
||||
ansible.builtin.debconf:
|
||||
name: crowdsec
|
||||
question: "{{ item.question }}"
|
||||
vtype: "{{ item.vtype }}"
|
||||
value: "{{ item.value }}"
|
||||
loop:
|
||||
- { question: crowdsec/lapi, vtype: boolean, value: "false" }
|
||||
- { question: crowdsec/capi, vtype: boolean, value: "false" }
|
||||
- { question: crowdsec/lapi_host, vtype: string, value: "{{ crowdsec.lapi_url | urlsplit('netloc') }}" }
|
||||
|
||||
- name: Create the CrowdSec config directories
|
||||
ansible.builtin.file:
|
||||
path: "{{ item }}"
|
||||
state: directory
|
||||
mode: "0755"
|
||||
loop:
|
||||
- /etc/crowdsec
|
||||
- /etc/crowdsec/acquis.d
|
||||
- /etc/crowdsec/bouncers
|
||||
- /etc/crowdsec/parsers/s02-enrich
|
||||
- /etc/systemd/system/crowdsec.service.d
|
||||
- /etc/systemd/system/crowdsec-hubupdate.service.d
|
||||
- /etc/systemd/system/crowdsec-firewall-bouncer.service.d
|
||||
- /etc/systemd/system/nftables.service.d
|
||||
|
||||
- name: Disable the local API server
|
||||
ansible.builtin.copy:
|
||||
dest: /etc/crowdsec/config.yaml.local
|
||||
mode: "0644"
|
||||
content: |
|
||||
api:
|
||||
server:
|
||||
enable: false
|
||||
notify: Restart crowdsec
|
||||
|
||||
# The postinst starts the agent before it is registered; an empty credentials
|
||||
# file then makes the start a no-op instead of a failed package install.
|
||||
- name: Start the agent only once registered
|
||||
ansible.builtin.copy:
|
||||
dest: /etc/systemd/system/crowdsec.service.d/registered.conf
|
||||
mode: "0644"
|
||||
content: |
|
||||
[Unit]
|
||||
ConditionFileNotEmpty=/etc/crowdsec/local_api_credentials.yaml
|
||||
register: crowdsec_dropin
|
||||
|
||||
# Start, reload and stop of nftables.service flush the whole ruleset with the
|
||||
# bouncer tables; a restart recreates them with all decisions. Ordering after
|
||||
# nftables.service makes the queued restarts wait for its job to finish.
|
||||
- name: Restart the bouncer after nftables.service flushes the ruleset
|
||||
ansible.builtin.copy:
|
||||
dest: "{{ item.dest }}"
|
||||
mode: "0644"
|
||||
content: "{{ item.content }}"
|
||||
loop:
|
||||
- dest: /etc/systemd/system/nftables.service.d/crowdsec-firewall-bouncer.conf
|
||||
content: |
|
||||
[Service]
|
||||
ExecStartPost=-/bin/systemctl --no-block try-restart crowdsec-firewall-bouncer.service
|
||||
ExecReload=-/bin/systemctl --no-block try-restart crowdsec-firewall-bouncer.service
|
||||
ExecStopPost=-/bin/systemctl --no-block try-restart crowdsec-firewall-bouncer.service
|
||||
- dest: /etc/systemd/system/crowdsec-firewall-bouncer.service.d/nftables.conf
|
||||
content: |
|
||||
[Unit]
|
||||
After=nftables.service
|
||||
loop_control:
|
||||
label: "{{ item.dest }}"
|
||||
register: crowdsec_bouncer_dropins
|
||||
|
||||
# Present before the package install, so the postinst finds an API key and
|
||||
# does not try to create one on a local LAPI.
|
||||
- name: Configure the firewall bouncer
|
||||
ansible.builtin.template:
|
||||
src: crowdsec-firewall-bouncer.yaml.local.j2
|
||||
dest: /etc/crowdsec/bouncers/crowdsec-firewall-bouncer.yaml.local
|
||||
mode: "0600"
|
||||
notify: Restart crowdsec-firewall-bouncer
|
||||
|
||||
- name: Install the parser whitelist
|
||||
ansible.builtin.template:
|
||||
src: whitelist.yaml.j2
|
||||
dest: /etc/crowdsec/parsers/s02-enrich/host-whitelist.yaml
|
||||
mode: "0644"
|
||||
notify: Restart crowdsec
|
||||
|
||||
- name: Install the acquisition files
|
||||
ansible.builtin.copy:
|
||||
dest: "/etc/crowdsec/acquis.d/{{ item.key }}.yaml"
|
||||
mode: "0644"
|
||||
content: "{{ item.value | to_nice_yaml }}"
|
||||
loop: "{{ crowdsec.acquisition | dict2items | rejectattr('value', 'none') }}"
|
||||
notify: Restart crowdsec
|
||||
|
||||
- name: Reload systemd
|
||||
ansible.builtin.systemd:
|
||||
daemon_reload: true
|
||||
when: crowdsec_dropin is changed or crowdsec_bouncer_dropins is changed
|
||||
|
||||
- name: Install the agent and the nftables bouncer
|
||||
ansible.builtin.apt:
|
||||
name: [crowdsec, crowdsec-firewall-bouncer-nftables]
|
||||
state: present
|
||||
update_cache: "{{ crowdsec_repo is changed }}"
|
||||
# The postinst otherwise runs service detection, which needs credentials that only the registration below creates
|
||||
environment:
|
||||
CROWDSEC_SETUP_UNATTENDED_DISABLE: "1"
|
||||
|
||||
- name: Read the agent credentials
|
||||
ansible.builtin.command: grep -q '^login:' /etc/crowdsec/local_api_credentials.yaml
|
||||
register: crowdsec_registered
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
check_mode: false
|
||||
|
||||
- name: Register the agent at the LAPI
|
||||
ansible.builtin.command: >-
|
||||
cscli lapi register --url {{ crowdsec.lapi_url }}
|
||||
--token {{ crowdsec.registration_token }} --machine {{ crowdsec.machine_name | default(ansible_hostname, true) }}
|
||||
when: crowdsec_registered.rc != 0
|
||||
no_log: true
|
||||
notify: Restart crowdsec
|
||||
|
||||
- name: List the installed hub items
|
||||
ansible.builtin.command: cscli hub list -o json
|
||||
register: crowdsec_hub_json
|
||||
changed_when: false
|
||||
# cscli is missing in check mode before the first install.
|
||||
failed_when: crowdsec_hub_json.rc != 0 and not ansible_check_mode
|
||||
check_mode: false
|
||||
|
||||
- name: Install the hub items
|
||||
ansible.builtin.command: "cscli {{ item }} install {{ crowdsec_missing | join(' ') }}"
|
||||
vars:
|
||||
crowdsec_wanted: "{{ crowdsec[item] | dict2items | rejectattr('value', 'none') | map(attribute='value') | unique }}"
|
||||
crowdsec_present: "{{ (crowdsec_hub_json.stdout | default('{}', true) | from_json)[item] | default([], true) | map(attribute='name') }}"
|
||||
crowdsec_missing: "{{ crowdsec_wanted | difference(crowdsec_present) }}"
|
||||
loop: [collections, scenarios]
|
||||
when: crowdsec_missing | length > 0
|
||||
notify: Restart crowdsec
|
||||
|
||||
# The script upgrades every hub item with --force (a scenario removed with --force taints its collection, and a plain upgrade skips
|
||||
# tainted collections) and removes the disabled scenarios again. It runs here once and daily as crowdsec-hubupdate.service.
|
||||
- name: Install the hub update script
|
||||
ansible.builtin.template:
|
||||
src: crowdsec-hubupdate.sh.j2
|
||||
dest: /usr/local/sbin/crowdsec-hubupdate
|
||||
mode: "0755"
|
||||
|
||||
- name: Run the hub update script instead of the package one
|
||||
ansible.builtin.copy:
|
||||
dest: /etc/systemd/system/crowdsec-hubupdate.service.d/override.conf
|
||||
mode: "0644"
|
||||
content: |
|
||||
[Service]
|
||||
ExecStart=
|
||||
ExecStart=/usr/local/sbin/crowdsec-hubupdate
|
||||
register: crowdsec_hubupdate_dropin
|
||||
|
||||
- name: Reload systemd for the hub update unit
|
||||
ansible.builtin.systemd:
|
||||
daemon_reload: true
|
||||
when: crowdsec_hubupdate_dropin is changed
|
||||
|
||||
- name: Upgrade the hub items and remove the disabled scenarios
|
||||
ansible.builtin.command: /usr/local/sbin/crowdsec-hubupdate
|
||||
environment:
|
||||
NO_RELOAD: "1"
|
||||
register: crowdsec_hubupdate
|
||||
changed_when: "'changed' in crowdsec_hubupdate.stdout"
|
||||
notify: Restart crowdsec
|
||||
|
||||
- name: Install the local parsers and scenarios
|
||||
ansible.builtin.copy:
|
||||
dest: "/etc/crowdsec/{{ item.key }}"
|
||||
mode: "0644"
|
||||
content: "{{ item.value }}"
|
||||
loop: "{{ crowdsec.local_files | dict2items | rejectattr('value', 'none') }}"
|
||||
loop_control:
|
||||
label: "{{ item.key }}"
|
||||
notify: Restart crowdsec
|
||||
|
||||
- name: Remove retired local files
|
||||
ansible.builtin.file:
|
||||
path: "/etc/crowdsec/{{ item.value }}"
|
||||
state: absent
|
||||
loop: "{{ crowdsec.absent_files | dict2items | rejectattr('value', 'none') }}"
|
||||
loop_control:
|
||||
label: "{{ item.value }}"
|
||||
notify: Restart crowdsec
|
||||
|
||||
- name: Enable the agent and the bouncer
|
||||
ansible.builtin.systemd:
|
||||
name: "{{ item }}"
|
||||
enabled: true
|
||||
state: started
|
||||
loop: [crowdsec.service, crowdsec-firewall-bouncer.service]
|
||||
|
||||
- name: Sync the LAPI allowlist
|
||||
when: crowdsec.lapi_allowlist | length > 0
|
||||
run_once: true
|
||||
delegate_to: "{{ crowdsec.lapi_kubectl_host }}"
|
||||
vars:
|
||||
crowdsec_lapi_cscli: k3s kubectl -n crowdsec exec deploy/crowdsec-lapi -c crowdsec-lapi -- cscli
|
||||
block:
|
||||
- name: Read the LAPI allowlist
|
||||
ansible.builtin.command: "{{ crowdsec_lapi_cscli }} allowlists inspect {{ crowdsec.lapi_allowlist }} -o json"
|
||||
register: crowdsec_allowlist_json
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
check_mode: false
|
||||
|
||||
- name: Create the LAPI allowlist
|
||||
ansible.builtin.command: >-
|
||||
{{ crowdsec_lapi_cscli }} allowlists create {{ crowdsec.lapi_allowlist }}
|
||||
-d "Internal ranges and the public addresses of all hosts, managed by Ansible"
|
||||
when: crowdsec_allowlist_json.rc != 0
|
||||
|
||||
# cscli stores single addresses without their /32 or /128.
|
||||
- name: Update the LAPI allowlist
|
||||
ansible.builtin.command: "{{ crowdsec_lapi_cscli }} allowlists {{ item.action }} {{ crowdsec.lapi_allowlist }} {{ item.cidrs | join(' ') }}"
|
||||
vars:
|
||||
crowdsec_allowlist_wanted: "{{ crowdsec_whitelist | map('regex_replace', '/(32|128)$', '') | list }}"
|
||||
crowdsec_allowlist_present: >-
|
||||
{{ ((crowdsec_allowlist_json.stdout | default('{}', true) | from_json).get('items') or []) | map(attribute='value') | list }}
|
||||
loop:
|
||||
- { action: add, cidrs: "{{ crowdsec_allowlist_wanted | difference(crowdsec_allowlist_present) }}" }
|
||||
- { action: remove, cidrs: "{{ crowdsec_allowlist_present | difference(crowdsec_allowlist_wanted) }}" }
|
||||
loop_control:
|
||||
label: "{{ item.action }} {{ item.cidrs | join(' ') }}"
|
||||
when: item.cidrs | length > 0
|
||||
@@ -1,8 +0,0 @@
|
||||
# {{ ansible_managed }}
|
||||
api_url: {{ crowdsec.lapi_url }}/
|
||||
api_key: {{ crowdsec.bouncer_api_key }}
|
||||
mode: nftables
|
||||
# forward covers Docker-published ports and Proxmox guests.
|
||||
nftables_hooks:
|
||||
- input
|
||||
- forward
|
||||
@@ -1,49 +0,0 @@
|
||||
#!/bin/sh
|
||||
# Managed by Ansible. crowdsec-hubupdate.service runs this script instead of /usr/libexec/crowdsec/hubupdate.sh.
|
||||
#
|
||||
# "cscli scenarios remove --force" taints the collection that holds the scenario, and a plain "cscli hub upgrade" skips tainted
|
||||
# collections for good. The upgrade therefore runs with --force, which also brings the removed scenarios back, and the removal is
|
||||
# applied again. crowdsec is reloaded only when the installed hub files or the data files differ afterwards; with NO_RELOAD=1
|
||||
# the script prints "changed" instead, for the role, which restarts crowdsec once at the end of its run. A failed step does not skip
|
||||
# the later ones, so the removal of the disabled scenarios always runs; the script then exits non-zero.
|
||||
set -eu
|
||||
|
||||
test -x /usr/bin/cscli || exit 0
|
||||
|
||||
fingerprint() {
|
||||
{
|
||||
find -L /etc/crowdsec -path /etc/crowdsec/hub -prune -o -type f -exec sha256sum {} +
|
||||
find /var/lib/crowdsec/data -maxdepth 1 -type f ! -name 'crowdsec.db*' ! -name trace -exec sha256sum {} +
|
||||
} 2>/dev/null | sort -k2 | sha256sum
|
||||
}
|
||||
|
||||
before="$(fingerprint)"
|
||||
|
||||
failed=0
|
||||
|
||||
# A failed list command does not skip the removal: the scenario is then removed without knowing whether it is installed.
|
||||
remove_scenario() {
|
||||
if list="$(/usr/bin/cscli scenarios list -o raw)"; then
|
||||
printf '%s\n' "$list" | cut -d, -f1 | grep -qxF "$1" || return 0
|
||||
else
|
||||
failed=1
|
||||
fi
|
||||
/usr/bin/cscli --error scenarios remove "$1" --force >/dev/null || failed=1
|
||||
}
|
||||
|
||||
/usr/bin/cscli --error hub update >/dev/null || failed=1
|
||||
/usr/bin/cscli --error hub upgrade --force >/dev/null || failed=1
|
||||
|
||||
{% for scenario in crowdsec.disabled_scenarios.values() | reject('none') | unique | sort %}
|
||||
remove_scenario '{{ scenario }}'
|
||||
{% endfor %}
|
||||
|
||||
if [ "$before" != "$(fingerprint)" ]; then
|
||||
if [ -n "${NO_RELOAD:-}" ]; then
|
||||
echo changed
|
||||
else
|
||||
systemctl reload crowdsec.service
|
||||
fi
|
||||
fi
|
||||
|
||||
exit "$failed"
|
||||
@@ -1,9 +0,0 @@
|
||||
# {{ ansible_managed }}
|
||||
name: local/host-whitelist
|
||||
description: Internal ranges and the public addresses of all hosts
|
||||
whitelist:
|
||||
reason: internal or admin source
|
||||
cidr:
|
||||
{% for cidr in crowdsec_whitelist %}
|
||||
- "{{ cidr }}"
|
||||
{% endfor %}
|
||||
@@ -1,4 +0,0 @@
|
||||
---
|
||||
crowdsec_whitelist: >-
|
||||
{{ (crowdsec.whitelist | dict2items | rejectattr('value', 'none') | map(attribute='value') | list
|
||||
+ groups['all'] | map('extract', hostvars, 'public_ips') | select('defined') | flatten) | unique }}
|
||||
@@ -1,15 +0,0 @@
|
||||
[Unit]
|
||||
Description=Mirror the CrowdSec range ban decisions into the nftables table inet crowdsec_ranges
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/local/sbin/crowdsec-range-bouncer
|
||||
TimeoutStartSec=60
|
||||
StateDirectory=crowdsec-range-bouncer
|
||||
StateDirectoryMode=0700
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
PrivateTmp=true
|
||||
NoNewPrivileges=true
|
||||
@@ -1,10 +0,0 @@
|
||||
[Unit]
|
||||
Description=Mirror the CrowdSec range ban decisions into nftables every minute
|
||||
|
||||
[Timer]
|
||||
OnBootSec=30s
|
||||
OnUnitActiveSec=60s
|
||||
AccuracySec=5s
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
@@ -1,7 +0,0 @@
|
||||
---
|
||||
- name: Run the range bouncer
|
||||
ansible.builtin.systemd:
|
||||
name: crowdsec-range-bouncer.service
|
||||
state: started
|
||||
# Check mode does not install the unit.
|
||||
ignore_errors: "{{ ansible_check_mode }}"
|
||||
@@ -1,53 +0,0 @@
|
||||
---
|
||||
# crowdsec-firewall-bouncer in nftables mode stores only the network address of
|
||||
# a range decision (CIDR scope "Range") in a plain address set. This role keeps
|
||||
# the ranges in interval sets of its own table, next to the firewall bouncer,
|
||||
# which keeps handling the single addresses.
|
||||
- name: Require LAPI settings
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- crowdsec.lapi_url | length > 0
|
||||
- crowdsec.range_bouncer_api_key | default('') | length > 0
|
||||
fail_msg: crowdsec.lapi_url and crowdsec.range_bouncer_api_key must be set
|
||||
|
||||
- name: Install python3 and nftables
|
||||
ansible.builtin.apt:
|
||||
name: [python3, nftables]
|
||||
state: present
|
||||
|
||||
- name: Configure the range bouncer
|
||||
ansible.builtin.template:
|
||||
src: crowdsec-range-bouncer.json.j2
|
||||
dest: /etc/crowdsec-range-bouncer.json
|
||||
mode: "0600"
|
||||
no_log: true
|
||||
notify: Run the range bouncer
|
||||
|
||||
- name: Install the range bouncer
|
||||
ansible.builtin.template:
|
||||
src: crowdsec-range-bouncer.py.j2
|
||||
dest: /usr/local/sbin/crowdsec-range-bouncer
|
||||
mode: "0755"
|
||||
notify: Run the range bouncer
|
||||
|
||||
- name: Install the systemd units
|
||||
ansible.builtin.copy:
|
||||
src: "{{ item }}"
|
||||
dest: "/etc/systemd/system/{{ item }}"
|
||||
mode: "0644"
|
||||
loop: [crowdsec-range-bouncer.service, crowdsec-range-bouncer.timer]
|
||||
register: crowdsec_range_bouncer_units
|
||||
notify: Run the range bouncer
|
||||
|
||||
- name: Enable the range bouncer timer
|
||||
ansible.builtin.systemd:
|
||||
name: crowdsec-range-bouncer.timer
|
||||
enabled: true
|
||||
state: started
|
||||
daemon_reload: "{{ crowdsec_range_bouncer_units is changed }}"
|
||||
# Check mode does not install the unit.
|
||||
ignore_errors: "{{ ansible_check_mode }}"
|
||||
|
||||
# The first run has to succeed before the play moves on to the next host.
|
||||
- name: Apply the changes now
|
||||
ansible.builtin.meta: flush_handlers
|
||||
@@ -1 +0,0 @@
|
||||
{{ {'lapi_url': crowdsec.lapi_url, 'api_key': crowdsec.range_bouncer_api_key, 'allow': crowdsec_range_bouncer_allow} | to_nice_json }}
|
||||
@@ -1,131 +0,0 @@
|
||||
#!/usr/bin/python3
|
||||
# {{ ansible_managed }}
|
||||
"""Mirror the CrowdSec range ban decisions into the nftables table inet crowdsec_ranges.
|
||||
|
||||
Stateless: every run fetches all range decisions from the LAPI, builds the
|
||||
whole table and loads it in one transaction. The table is only touched when
|
||||
its text changed or the table is gone (nftables.service flushes the whole
|
||||
ruleset on reload). Any error leaves the current table in place.
|
||||
"""
|
||||
import hashlib
|
||||
import ipaddress
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import urllib.request
|
||||
|
||||
CONFIG = "/etc/crowdsec-range-bouncer.json"
|
||||
STATE_DIR = os.environ.get("STATE_DIRECTORY", "/var/lib/crowdsec-range-bouncer")
|
||||
HASH_FILE = os.path.join(STATE_DIR, "last.sha256")
|
||||
NFT = "/usr/sbin/nft"
|
||||
TABLE = "crowdsec_ranges"
|
||||
# A prefix this wide is a poisoned decision, not a bad network.
|
||||
MIN_DECISION_PREFIXLEN = {4: 8, 6: 16}
|
||||
|
||||
|
||||
def fetch_ranges(lapi_url, api_key):
|
||||
"""Return the networks of all ban decisions with scope Range."""
|
||||
request = urllib.request.Request(
|
||||
lapi_url.rstrip("/") + "/v1/decisions?scopes=range",
|
||||
headers={"X-Api-Key": api_key, "User-Agent": "crowdsec-range-bouncer"},
|
||||
)
|
||||
with urllib.request.urlopen(request, timeout=20) as response:
|
||||
decisions = json.load(response)
|
||||
# The LAPI answers a literal null when nothing matches.
|
||||
if decisions is None:
|
||||
return []
|
||||
if not isinstance(decisions, list):
|
||||
raise ValueError("unexpected LAPI answer: %s" % type(decisions).__name__)
|
||||
networks = []
|
||||
for d in decisions:
|
||||
if d["type"] != "ban" or d["scope"].lower() != "range":
|
||||
continue
|
||||
network = ipaddress.ip_network(d["value"], strict=False)
|
||||
if network.prefixlen < MIN_DECISION_PREFIXLEN[network.version]:
|
||||
print("skipping suspiciously wide range decision %s" % network)
|
||||
continue
|
||||
networks.append(network)
|
||||
return networks
|
||||
|
||||
|
||||
def collapse(networks, version):
|
||||
"""Merge overlapping and adjacent networks: interval set elements must not overlap."""
|
||||
return [str(n) for n in ipaddress.collapse_addresses(n for n in networks if n.version == version)]
|
||||
|
||||
|
||||
def set_text(name, address_type, elements):
|
||||
text = "\tset %s {\n\t\ttype %s\n\t\tflags interval\n" % (name, address_type)
|
||||
if elements:
|
||||
text += "\t\telements = {\n\t\t\t%s\n\t\t}\n" % ",\n\t\t\t".join(elements)
|
||||
return text + "\t}\n"
|
||||
|
||||
|
||||
def table_text(allow, block):
|
||||
# The accept of an allowed address only ends the chain of this table:
|
||||
# host_firewall, Docker, Cilium and the CrowdSec firewall bouncer still decide.
|
||||
# Same hooks and priority as the firewall bouncer: input, and forward for
|
||||
# published Docker ports and guests.
|
||||
rules = (
|
||||
"\t\tip saddr @allow4 accept\n"
|
||||
"\t\tip6 saddr @allow6 accept\n"
|
||||
"\t\tip saddr @block4 counter drop\n"
|
||||
"\t\tip6 saddr @block6 counter drop\n"
|
||||
)
|
||||
return (
|
||||
"table inet %(t)s {}\n"
|
||||
"delete table inet %(t)s\n"
|
||||
"table inet %(t)s {\n"
|
||||
% {"t": TABLE}
|
||||
+ set_text("allow4", "ipv4_addr", collapse(allow, 4))
|
||||
+ set_text("allow6", "ipv6_addr", collapse(allow, 6))
|
||||
+ set_text("block4", "ipv4_addr", collapse(block, 4))
|
||||
+ set_text("block6", "ipv6_addr", collapse(block, 6))
|
||||
+ "\tchain input {\n\t\ttype filter hook input priority -10; policy accept;\n"
|
||||
+ "\t\tiifname \"lo\" accept\n"
|
||||
+ rules
|
||||
+ "\t}\n"
|
||||
+ "\tchain forward {\n\t\ttype filter hook forward priority -10; policy accept;\n"
|
||||
+ rules
|
||||
+ "\t}\n}\n"
|
||||
)
|
||||
|
||||
|
||||
def table_exists():
|
||||
result = subprocess.run(
|
||||
[NFT, "list", "table", "inet", TABLE], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
|
||||
)
|
||||
return result.returncode == 0
|
||||
|
||||
|
||||
def main():
|
||||
with open(CONFIG) as f:
|
||||
config = json.load(f)
|
||||
allow = [ipaddress.ip_network(v, strict=False) for v in config["allow"]]
|
||||
block = fetch_ranges(config["lapi_url"], config["api_key"])
|
||||
text = table_text(allow, block)
|
||||
digest = hashlib.sha256(text.encode()).hexdigest()
|
||||
|
||||
try:
|
||||
with open(HASH_FILE) as f:
|
||||
last = f.read().strip()
|
||||
except FileNotFoundError:
|
||||
last = ""
|
||||
if digest == last and table_exists():
|
||||
return
|
||||
|
||||
subprocess.run([NFT, "-f", "-"], input=text.encode(), check=True)
|
||||
tmp = HASH_FILE + ".tmp"
|
||||
with open(tmp, "w") as f:
|
||||
f.write(digest + "\n")
|
||||
os.replace(tmp, HASH_FILE)
|
||||
print("loaded %d IPv4 and %d IPv6 ranges from %d decisions" % (
|
||||
len(collapse(block, 4)), len(collapse(block, 6)), len(block)))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except Exception as e:
|
||||
print("error, keeping the current table: %s: %s" % (type(e).__name__, e), file=sys.stderr)
|
||||
sys.exit(1)
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
# Same sources as the crowdsec role (roles/crowdsec/vars/main.yml), plus loopback.
|
||||
crowdsec_range_bouncer_allow: >-
|
||||
{{ (crowdsec.whitelist | default({}) | dict2items | rejectattr('value', 'none') | map(attribute='value') | list
|
||||
+ groups['all'] | map('extract', hostvars, 'public_ips') | select('defined') | flatten
|
||||
+ ['127.0.0.0/8', '::1/128']) | unique }}
|
||||
@@ -1,12 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Silent on success: cron mails all output to root, so mail arrives only when the prune fails.
|
||||
set -u
|
||||
|
||||
output=$(docker system prune -af --filter "until=168h" 2>&1)
|
||||
rc=$?
|
||||
|
||||
if [ "$rc" -ne 0 ]; then
|
||||
printf '%s\n' "$output"
|
||||
echo "docker system prune exited with rc=$rc"
|
||||
exit "$rc"
|
||||
fi
|
||||
+7
-60
@@ -9,45 +9,18 @@
|
||||
name:
|
||||
- gpg
|
||||
- gpg-agent
|
||||
- python3-debian
|
||||
tags: docker_repo
|
||||
|
||||
- name: Create apt keyrings directory
|
||||
file:
|
||||
path: /etc/apt/keyrings
|
||||
state: directory
|
||||
mode: "0755"
|
||||
tags: docker_repo
|
||||
|
||||
- name: Add docker-key
|
||||
get_url:
|
||||
url: "https://download.docker.com/linux/{{ ansible_facts.distribution | lower }}/gpg"
|
||||
dest: /etc/apt/keyrings/docker.asc
|
||||
mode: "0644"
|
||||
tags: docker_repo
|
||||
|
||||
- name: Remove one-line docker-repository
|
||||
file:
|
||||
path: /etc/apt/sources.list.d/docker.list
|
||||
state: absent
|
||||
tags: docker_repo
|
||||
apt_key:
|
||||
url: https://download.docker.com/linux/debian/gpg
|
||||
state: present
|
||||
|
||||
- name: Add docker-repository
|
||||
deb822_repository:
|
||||
name: docker
|
||||
types: deb
|
||||
uris: "https://download.docker.com/linux/{{ ansible_facts.distribution | lower }}"
|
||||
suites: "{{ ansible_facts.distribution_release }}"
|
||||
components: stable
|
||||
signed_by: /etc/apt/keyrings/docker.asc
|
||||
register: docker_repo
|
||||
tags: docker_repo
|
||||
|
||||
- name: Update apt cache after docker-repository change
|
||||
apt:
|
||||
apt_repository:
|
||||
repo: "deb https://download.docker.com/linux/{{ ansible_facts.distribution | lower }} {{ ansible_facts.distribution_release }} stable"
|
||||
state: present
|
||||
filename: docker
|
||||
update_cache: yes
|
||||
when: docker_repo.changed
|
||||
tags: docker_repo
|
||||
|
||||
- name: Install docker
|
||||
package:
|
||||
@@ -65,29 +38,3 @@
|
||||
dest: "/usr/local/bin/docker-compose"
|
||||
mode: preserve
|
||||
|
||||
- name: Check for docker binary
|
||||
ansible.builtin.stat:
|
||||
path: /usr/bin/docker
|
||||
register: docker_binary
|
||||
tags: docker_prune
|
||||
|
||||
- name: Deploy docker-prune script
|
||||
ansible.builtin.copy:
|
||||
src: docker-prune
|
||||
dest: /usr/local/sbin/docker-prune
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0755"
|
||||
when: docker_binary.stat.exists
|
||||
tags: docker_prune
|
||||
|
||||
- name: Schedule hourly docker-prune
|
||||
ansible.builtin.cron:
|
||||
name: docker prune
|
||||
cron_file: docker-prune
|
||||
user: root
|
||||
minute: "0"
|
||||
job: /usr/local/sbin/docker-prune
|
||||
when: docker_binary.stat.exists
|
||||
tags: docker_prune
|
||||
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
---
|
||||
# Address the node exporter binds to; empty = all addresses. For hosts without host_firewall: the NetBird
|
||||
# address keeps the port off the LAN, and the mesh ACL decides who reaches it.
|
||||
host_exporters_listen_address: ""
|
||||
|
||||
# Adds prometheus-smartctl-exporter (trixie-backports) next to the node exporter
|
||||
# on hosts with physical disks.
|
||||
host_exporters_smart: false
|
||||
|
||||
# Adds ipmitool and prometheus-ipmi-exporter (port 9290) on hosts with a BMC reachable through /dev/ipmi0.
|
||||
host_exporters_ipmi: false
|
||||
host_exporters_ipmi_collectors:
|
||||
- ipmi
|
||||
- bmc
|
||||
- sel
|
||||
- chassis
|
||||
# The tools run as root and parse the FreeIPMI configuration file named by --config-file. The exporter
|
||||
# writes that file itself (a named pipe in its /tmp, empty for in-band local mode), so a compromised
|
||||
# exporter could set options such as sdr-cache-directory and make root write anywhere. The wrappers
|
||||
# therefore replace the path with this static root-owned file, and sudoers admits only that path.
|
||||
host_exporters_ipmi_freeipmi_conf: /etc/ipmi-exporter/freeipmi.conf
|
||||
|
||||
# FreeIPMI tool behind each collector and the fixed arguments the exporter (1.10) passes before
|
||||
# --config-file. sudo admits exactly these, so a compromised exporter cannot run chassis control or
|
||||
# clear the SEL. A different exporter version needs matching arguments (ipmi_up stays 0 until then).
|
||||
# dcmi is not listed: the S1200RP BMC has no power monitoring.
|
||||
host_exporters_ipmi_tools:
|
||||
ipmi:
|
||||
tool: ipmimonitoring
|
||||
args: --quiet-cache --ignore-unrecognized-events --comma-separated-output --no-header-output --sdr-cache-recreate --output-event-bitmask --output-sensor-state
|
||||
bmc:
|
||||
tool: bmc-info
|
||||
args: ""
|
||||
sel:
|
||||
tool: ipmi-sel
|
||||
args: --info
|
||||
chassis:
|
||||
tool: ipmi-chassis
|
||||
args: --get-chassis-status
|
||||
@@ -1,224 +0,0 @@
|
||||
---
|
||||
# Both exporters listen on all addresses (the node exporter on host_exporters_listen_address if set); host_firewall
|
||||
# limits who reaches them.
|
||||
# Its recommends (smartmontools, nvme-cli, ipmitool) start units that fail on hosts without such hardware.
|
||||
- name: Install the node exporter
|
||||
ansible.builtin.apt:
|
||||
name: prometheus-node-exporter
|
||||
install_recommends: false
|
||||
state: present
|
||||
|
||||
- name: Bind the node exporter to one address
|
||||
ansible.builtin.lineinfile:
|
||||
path: /etc/default/prometheus-node-exporter
|
||||
regexp: '^ARGS='
|
||||
line: 'ARGS="--web.listen-address={{ host_exporters_listen_address }}:9100"'
|
||||
when: host_exporters_listen_address | length > 0
|
||||
register: host_exporters_listen
|
||||
# Check mode does not install the package.
|
||||
ignore_errors: "{{ ansible_check_mode }}"
|
||||
|
||||
- name: Create the node exporter unit drop-in directory
|
||||
ansible.builtin.file:
|
||||
path: /etc/systemd/system/prometheus-node-exporter.service.d
|
||||
state: directory
|
||||
mode: "0755"
|
||||
when: host_exporters_listen_address | length > 0
|
||||
|
||||
# The NetBird address exists only after the daemon is up, and a failed bind must not exhaust the start limit.
|
||||
- name: Start the bound node exporter after NetBird and retry
|
||||
ansible.builtin.copy:
|
||||
dest: /etc/systemd/system/prometheus-node-exporter.service.d/listen-address.conf
|
||||
mode: "0644"
|
||||
content: |
|
||||
[Unit]
|
||||
After=netbird.service
|
||||
StartLimitIntervalSec=0
|
||||
|
||||
[Service]
|
||||
RestartSec=5s
|
||||
when: host_exporters_listen_address | length > 0
|
||||
register: host_exporters_listen_unit
|
||||
|
||||
- name: Enable the node exporter
|
||||
ansible.builtin.systemd:
|
||||
name: prometheus-node-exporter
|
||||
enabled: true
|
||||
state: "{{ 'restarted' if (host_exporters_listen.changed or host_exporters_listen_unit.changed) else 'started' }}"
|
||||
daemon_reload: "{{ host_exporters_listen_unit.changed }}"
|
||||
# Check mode does not install the package.
|
||||
ignore_errors: "{{ ansible_check_mode }}"
|
||||
|
||||
- name: Install the smartctl exporter
|
||||
when: host_exporters_smart
|
||||
block:
|
||||
- name: Look for an existing backports source
|
||||
ansible.builtin.shell: >-
|
||||
grep -rEqs '^[^#]*{{ ansible_distribution_release }}-backports'
|
||||
--include='*.list' --include='*.sources' --exclude=host-exporters-backports.sources /etc/apt
|
||||
register: host_exporters_backports
|
||||
changed_when: false
|
||||
failed_when: host_exporters_backports.rc > 1
|
||||
check_mode: false
|
||||
|
||||
# Backports stay at priority 100, so only packages named with default_release come from there.
|
||||
- name: Add the backports source
|
||||
ansible.builtin.copy:
|
||||
dest: /etc/apt/sources.list.d/host-exporters-backports.sources
|
||||
mode: "0644"
|
||||
content: |
|
||||
Types: deb
|
||||
URIs: http://deb.debian.org/debian
|
||||
Suites: {{ ansible_distribution_release }}-backports
|
||||
Components: main
|
||||
Signed-By: /usr/share/keyrings/debian-archive-keyring.gpg
|
||||
register: host_exporters_source
|
||||
when: host_exporters_backports.rc == 1
|
||||
|
||||
# The module rejects default_release while the release is missing from its package cache.
|
||||
- name: Look for the backports release in the package lists
|
||||
ansible.builtin.shell: apt-cache policy | grep -q 'n={{ ansible_distribution_release }}-backports'
|
||||
register: host_exporters_lists
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
check_mode: false
|
||||
|
||||
- name: Refresh the package lists for the backports source
|
||||
ansible.builtin.apt:
|
||||
update_cache: true
|
||||
when: host_exporters_lists.rc != 0
|
||||
|
||||
- name: Install the smartctl exporter from backports
|
||||
ansible.builtin.apt:
|
||||
name: prometheus-smartctl-exporter
|
||||
default_release: "{{ ansible_distribution_release }}-backports"
|
||||
state: present
|
||||
# Check mode does not write the backports source.
|
||||
ignore_errors: "{{ ansible_check_mode }}"
|
||||
|
||||
# The packaged unit runs as root, which smartctl needs for the disks.
|
||||
- name: Enable the smartctl exporter
|
||||
ansible.builtin.systemd:
|
||||
name: smartctl_exporter
|
||||
enabled: true
|
||||
state: started
|
||||
ignore_errors: "{{ ansible_check_mode }}"
|
||||
|
||||
# Reads the BMC through /dev/ipmi0, read-only: no raw commands, no configuration.
|
||||
# FreeIPMI refuses in-band access for any uid but 0, so the exporter runs as the packaged
|
||||
# unprivileged user prometheus and starts the FreeIPMI tools it needs through sudo.
|
||||
- name: Install the IPMI exporter
|
||||
when: host_exporters_ipmi
|
||||
block:
|
||||
- name: Install ipmitool and the IPMI exporter
|
||||
ansible.builtin.apt:
|
||||
name:
|
||||
- ipmitool
|
||||
- sudo
|
||||
- prometheus-ipmi-exporter
|
||||
install_recommends: false
|
||||
state: present
|
||||
|
||||
- name: Allow the exporter user to run the FreeIPMI tools
|
||||
ansible.builtin.copy:
|
||||
dest: /etc/sudoers.d/prometheus-ipmi-exporter
|
||||
mode: "0440"
|
||||
validate: visudo -cf %s
|
||||
content: |
|
||||
Defaults:prometheus !pam_session
|
||||
{% for collector in host_exporters_ipmi_collectors %}
|
||||
prometheus ALL = (root) NOPASSWD: /usr/sbin/{{ host_exporters_ipmi_tools[collector].tool }} {{ host_exporters_ipmi_tools[collector].args }} --config-file {{ host_exporters_ipmi_freeipmi_conf }}
|
||||
{% endfor %}
|
||||
register: host_exporters_ipmi_sudo
|
||||
|
||||
- name: Create the FreeIPMI configuration directory
|
||||
ansible.builtin.file:
|
||||
path: "{{ host_exporters_ipmi_freeipmi_conf | dirname }}"
|
||||
state: directory
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0755"
|
||||
|
||||
# See the defaults for why the file is static.
|
||||
- name: Write the FreeIPMI configuration
|
||||
ansible.builtin.copy:
|
||||
dest: "{{ host_exporters_ipmi_freeipmi_conf }}"
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0644"
|
||||
content: |
|
||||
# In-band access needs no settings. Not even sdr-cache-directory: the tools without an SDR cache
|
||||
# reject it, and the cache of the ipmi tool stays in the home of root.
|
||||
|
||||
# collector_cmd replaces the tool itself, so each collector gets a wrapper that adds sudo. It swaps
|
||||
# the exporter's --config-file for the static one. The exporter fills a named pipe at the path it
|
||||
# passes and waits for a reader; the wrapper drains it so that writer does not hang.
|
||||
- name: Create the sudo wrappers directory
|
||||
ansible.builtin.file:
|
||||
path: /usr/local/libexec/ipmi-exporter
|
||||
state: directory
|
||||
mode: "0755"
|
||||
|
||||
- name: Write the sudo wrappers
|
||||
ansible.builtin.copy:
|
||||
dest: /usr/local/libexec/ipmi-exporter/{{ item.key }}
|
||||
mode: "0755"
|
||||
content: |
|
||||
#!/bin/sh
|
||||
n=$#
|
||||
while [ "$n" -gt 0 ]; do
|
||||
a=$1; shift; n=$((n - 1))
|
||||
if [ "$a" = --config-file ] && [ "$n" -gt 0 ]; then
|
||||
timeout 5 cat "$1" > /dev/null 2>&1
|
||||
shift; n=$((n - 1))
|
||||
else
|
||||
set -- "$@" "$a"
|
||||
fi
|
||||
done
|
||||
exec sudo -n /usr/sbin/{{ item.value.tool }} "$@" --config-file {{ host_exporters_ipmi_freeipmi_conf }}
|
||||
loop: "{{ host_exporters_ipmi_tools | dict2items | selectattr('key', 'in', host_exporters_ipmi_collectors) | list }}"
|
||||
loop_control:
|
||||
label: "{{ item.key }}"
|
||||
register: host_exporters_ipmi_wrappers
|
||||
|
||||
- name: Write the IPMI exporter configuration
|
||||
ansible.builtin.copy:
|
||||
dest: /etc/prometheus/ipmi-local.yml
|
||||
mode: "0644"
|
||||
content: |
|
||||
modules:
|
||||
default:
|
||||
collectors:
|
||||
{% for collector in host_exporters_ipmi_collectors %}
|
||||
- {{ collector }}
|
||||
{% endfor %}
|
||||
collector_cmd:
|
||||
{% for collector in host_exporters_ipmi_collectors %}
|
||||
{{ collector }}: /usr/local/libexec/ipmi-exporter/{{ collector }}
|
||||
{% endfor %}
|
||||
register: host_exporters_ipmi_config
|
||||
|
||||
- name: Create the IPMI exporter unit drop-in directory
|
||||
ansible.builtin.file:
|
||||
path: /etc/systemd/system/prometheus-ipmi-exporter.service.d
|
||||
state: directory
|
||||
mode: "0755"
|
||||
|
||||
# sudo needs setuid, so NoNewPrivileges stays off.
|
||||
- name: Run the IPMI exporter with that configuration
|
||||
ansible.builtin.copy:
|
||||
dest: /etc/systemd/system/prometheus-ipmi-exporter.service.d/local.conf
|
||||
mode: "0644"
|
||||
content: |
|
||||
[Service]
|
||||
ExecStart=
|
||||
ExecStart=/usr/bin/prometheus-ipmi-exporter --config.file=/etc/prometheus/ipmi-local.yml
|
||||
register: host_exporters_ipmi_unit
|
||||
|
||||
- name: Enable the IPMI exporter
|
||||
ansible.builtin.systemd:
|
||||
name: prometheus-ipmi-exporter
|
||||
enabled: true
|
||||
state: "{{ 'restarted' if (host_exporters_ipmi_config.changed or host_exporters_ipmi_unit.changed or host_exporters_ipmi_wrappers.changed) else 'started' }}"
|
||||
daemon_reload: true
|
||||
ignore_errors: "{{ ansible_check_mode }}"
|
||||
@@ -1,115 +0,0 @@
|
||||
---
|
||||
# Port and address maps are name -> value so group_vars and host_vars levels
|
||||
# merge into one map (hash_behaviour = merge); a null value drops an entry.
|
||||
# Ports are numbers or "first-last" ranges.
|
||||
firewall:
|
||||
public:
|
||||
tcp: {}
|
||||
udp: {}
|
||||
# Accepted only from the geo sets or the admin set.
|
||||
geo:
|
||||
tcp: {}
|
||||
# Accepted on wt0 before the public zone is evaluated.
|
||||
vpn:
|
||||
tcp: {}
|
||||
udp: {}
|
||||
# tcp: ports accepted from the admin set alone, never from the geo sets.
|
||||
admin:
|
||||
tcp: {}
|
||||
v4: {}
|
||||
v6: {}
|
||||
# Metrics scrapers: on wt0 only these source addresses reach the listed tcp
|
||||
# ports (the vpn zone applies to every peer). Rendered only when both are set.
|
||||
scrapers:
|
||||
tcp: {}
|
||||
v4: {}
|
||||
v6: {}
|
||||
# Interface names (nft wildcards allowed) whose traffic is trusted.
|
||||
internal_ifaces: {}
|
||||
# Guest bridges (Proxmox vmbr*): DHCP, router solicitations and the public
|
||||
# zone only, never the geo ports.
|
||||
guest_ifaces: {}
|
||||
# Kubernetes ring: pod interfaces, and node NetBird IPs / pod CIDRs on wt0.
|
||||
cluster:
|
||||
ifaces: {}
|
||||
v4: {}
|
||||
v6: {}
|
||||
# IPv4 port forwards: name -> {daddr, to, tcp: ports, udp: ports}, ports as in
|
||||
# the maps above. Forwarded traffic passes the forward hook, not input.
|
||||
dnat: {}
|
||||
# IPv4 source NAT: name -> nft match, e.g. 'oifname "vmbr0"'.
|
||||
masquerade: {}
|
||||
# Forwarded traffic to guarded guests: name -> {ifaces, v4, v6}, where v4 and
|
||||
# v6 are {daddr, tcp: ports, icmp: icmp or icmpv6 types}. Replies and traffic
|
||||
# in on ifaces pass, the listed ports and types pass, the rest to daddr drops;
|
||||
# traffic to other addresses is not filtered.
|
||||
forward_guard: {}
|
||||
# Limits on new inbound TCP connections per service, applied to the public and geo ports the host serves (input) and to
|
||||
# DNATed traffic (forward: port forwards and Docker-published ports), after the admin, cluster, VPN, guest and internal
|
||||
# sources and the public_ips of every inventory host (IPv4 per address, IPv6 per /64) are exempted. The rate and burst
|
||||
# below are the service limit, enforced by the handshake meter per source: it counts the packet that completes a TCP
|
||||
# handshake, which a spoofed source cannot send, and logs the excess with fw-rl-hs: and resets the connection. Each service
|
||||
# also has a SYN meter at twice the rate and burst, as a flood cap above the service limit so that a real client reaches the
|
||||
# handshake meter first: a tier per source (IPv4 /32, IPv6 /64) and a tier per range (IPv4 /24, IPv6 /48) at
|
||||
# rate * range_factor. Its excess drops with the log prefix fw-rl-syn-addr: (source tier) or fw-rl-syn-range: (range tier);
|
||||
# a spoofed source can trigger both, so they are evidence only. Log limit: log.ratelimit_rate.
|
||||
# name -> {ports: port or list, rate: connections per `per`, per: second|minute (minute), burst: connections (2 * rate),
|
||||
# range_factor_v4, range_factor_v6: override the factors below}; null drops a service, a service is idle unless a port is served.
|
||||
# Values are 3x the highest legitimate rate seen, with a floor of 12 per minute (3x four connections per minute) where
|
||||
# there is no or too little history; the measurement is noted at each service.
|
||||
ratelimit:
|
||||
range_factor:
|
||||
v4: 6
|
||||
v6: 3
|
||||
services:
|
||||
# 14 days of sshd Accepted lines in VictoriaLogs (all hosts, per host and source, /64 and /24 grouped): at most 8 per
|
||||
# minute and 4 per 10 seconds from one address or /64 (Ansible runs), 22 per 10 minutes.
|
||||
ssh:
|
||||
ports: 22
|
||||
rate: 24
|
||||
# 14 days of Traefik access logs of the cluster: distinct client ports of one source within a minute, which bounds the
|
||||
# new connections, at most 51 (IPv4) and 58 (IPv6 /64); 16 per second at most. Applies to the host ports 80 and 443.
|
||||
web:
|
||||
ports: [80, 443]
|
||||
rate: 180
|
||||
# Kernel log of dropped inbound packets (fw-in-drop: ), rate-limited connections (fw-rl-syn-addr: , fw-rl-syn-range: , fw-rl-hs: ) and of rejected
|
||||
# outbound mail (fw-out-reject: ), read from the journal by CrowdSec. Each
|
||||
# rate is one limit shared by all matching packets.
|
||||
log:
|
||||
in_rate: "5/second burst 30 packets"
|
||||
# One limit each for fw-rl-syn-addr: , fw-rl-syn-range: and fw-rl-hs: , shared by all services.
|
||||
ratelimit_rate: "5/second burst 30 packets"
|
||||
out_rate: "6/minute burst 5 packets"
|
||||
# Lets the kernel log the nft log statements of container network
|
||||
# namespaces (LXC guests) into the host kernel log.
|
||||
all_netns: false
|
||||
# Shared by the Spamhaus (fw-spamhaus-in: , fw-spamhaus-out: ), FireHOL (fw-firehol-in: , fw-firehol-out: ) and scanner (fw-scanners-in: ) chains, one limit each.
|
||||
spamhaus_rate: "6/minute burst 10 packets"
|
||||
# Spamhaus DROP, DROPv6 and ASN-DROP: traffic from the listed networks drops
|
||||
# (input, forward), traffic to them is rejected (output, forward). The sets
|
||||
# are refreshed daily by nft-host-spamhaus.
|
||||
spamhaus: true
|
||||
# FireHOL level1 (IPv4): treated like the Spamhaus lists, with the prefixes
|
||||
# fw-firehol-in: and fw-firehol-out: and the same log limit. Entries in
|
||||
# private or reserved space and ones holding an address of the host are left
|
||||
# out. Refreshed daily by nft-host-firehol.
|
||||
firehol: true
|
||||
# Internet-wide scanners (Censys, Shodan, ONYPHE, Shadowserver, ...), IPv4
|
||||
# and IPv6: the cidr files of github.com/OpenFilters/internet-scanners
|
||||
# (Unlicense). Inbound traffic from the listed networks drops (input,
|
||||
# forward) once the established rule has passed; outbound traffic is never
|
||||
# filtered. Log prefix fw-scanners-in: with the Spamhaus log limit, which
|
||||
# CrowdSec whitelists. Entries in private or reserved space, overlapping the
|
||||
# admin, scraper and cluster sets, the CrowdSec whitelist or the public_ips
|
||||
# of any host, and ones holding an address of the host are left out
|
||||
# (/etc/nft-host-scanners.allowlist). Refreshed daily by nft-host-scanners.
|
||||
scanners: true
|
||||
# Reject outbound TCP 25; the mail group turns it off.
|
||||
block_smtp_out: true
|
||||
# EU-27 plus CH, NO, GB, IS, LI (ipdeny country codes). Empty leaves the geo
|
||||
# ports to the admin set; a load peaks at about 60 MB of kernel memory, too much for a 1 GB host.
|
||||
geo_countries: [at, be, bg, hr, cy, cz, dk, ee, fi, fr, de, gr, hu, ie, it, lv, lt, lu, mt, nl, pl, pt, ro, sk, si, es, se, ch, "no", gb, is, li]
|
||||
rollback_seconds: 120
|
||||
# name -> shell command that has to exit 0 through the new rule set before the
|
||||
# rollback is disarmed; retried for a quarter of the rollback window.
|
||||
confirm: {}
|
||||
@@ -1,127 +0,0 @@
|
||||
#!/usr/bin/python3
|
||||
# Managed by Ansible (role host_firewall), do not edit.
|
||||
"""Rebuilds the FireHOL level1 set firehol_v4 of table inet host.
|
||||
|
||||
A failed download or sanity check exits non-zero before anything changes, so
|
||||
the previous set stays in place.
|
||||
"""
|
||||
import ipaddress
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.request
|
||||
|
||||
PERSISTED = "/etc/nftables.d/host-firehol.nft"
|
||||
URL = "https://raw.githubusercontent.com/firehol/blocklist-ipsets/master/firehol_level1.netset"
|
||||
USER_AGENT = "ruekov-host-firewall (nft-host-firehol)"
|
||||
|
||||
# Lower bound that catches an empty or truncated answer.
|
||||
MIN_ENTRIES = 1000
|
||||
# A prefix this wide is a parse error, not a bad network.
|
||||
MIN_PREFIXLEN = 8
|
||||
# Private, CGNAT (NetBird), loopback, link-local and multicast/reserved space:
|
||||
# an entry overlapping one of these never lands in the set, so the own
|
||||
# infrastructure cannot block itself.
|
||||
EXCLUDED = [ipaddress.ip_network(n) for n in (
|
||||
"0.0.0.0/8", "10.0.0.0/8", "100.64.0.0/10", "127.0.0.0/8",
|
||||
"169.254.0.0/16", "172.16.0.0/12", "192.168.0.0/16", "224.0.0.0/3")]
|
||||
|
||||
|
||||
def fail(message):
|
||||
print(f"nft-host-firehol: {message}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def fetch():
|
||||
error = None
|
||||
for attempt in range(3):
|
||||
try:
|
||||
request = urllib.request.Request(URL, headers={"User-Agent": USER_AGENT})
|
||||
with urllib.request.urlopen(request, timeout=120) as response:
|
||||
return response.read().decode("utf-8")
|
||||
except OSError as e:
|
||||
error = e
|
||||
time.sleep(5 * (attempt + 1))
|
||||
fail(f"download of {URL} failed: {error}")
|
||||
|
||||
|
||||
def own_addresses():
|
||||
result = subprocess.run(["ip", "-j", "-4", "address", "show"], capture_output=True, text=True)
|
||||
if result.returncode != 0:
|
||||
fail(f"ip address show failed: {result.stderr.strip()}")
|
||||
return [ipaddress.ip_address(a["local"]) for link in json.loads(result.stdout)
|
||||
for a in link.get("addr_info", []) if a.get("family") == "inet"]
|
||||
|
||||
|
||||
def main():
|
||||
networks = []
|
||||
for line in fetch().splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
try:
|
||||
networks.append(ipaddress.IPv4Network(line))
|
||||
except ValueError:
|
||||
fail(f"unparsable line: {line!r}")
|
||||
if len(networks) < MIN_ENTRIES:
|
||||
fail(f"only {len(networks)} entries")
|
||||
|
||||
own = own_addresses()
|
||||
kept, excluded, own_skipped = [], 0, 0
|
||||
for network in networks:
|
||||
if any(network.overlaps(e) for e in EXCLUDED):
|
||||
excluded += 1
|
||||
elif any(address in network for address in own):
|
||||
own_skipped += 1
|
||||
else:
|
||||
if network.prefixlen < MIN_PREFIXLEN:
|
||||
fail(f"suspiciously wide prefix {network}")
|
||||
kept.append(network)
|
||||
merged = list(ipaddress.collapse_addresses(kept))
|
||||
print(f"firehol_v4: {len(networks)} entries, {excluded} in private or reserved space and "
|
||||
f"{own_skipped} holding an own address skipped -> {len(merged)} merged")
|
||||
|
||||
elements = ",\n\t\t".join(str(n) for n in merged) + ","
|
||||
persisted = ("# Generated by nft-host-firehol, do not edit.\n"
|
||||
"table inet host {\n"
|
||||
"\tset firehol_v4 { type ipv4_addr; flags interval; auto-merge; elements = {\n"
|
||||
f"\t\t{elements}\n\t}} }}\n}}\n")
|
||||
|
||||
# Checked under another table name: a dry run against the live table
|
||||
# name changes the live sets for its duration and unlisted addresses match.
|
||||
check_rules = persisted.replace("table inet host {", "table inet host_firehol_check {")
|
||||
checked = subprocess.run(["nft", "-c", "-f", "-"], input=check_rules, capture_output=True, text=True)
|
||||
if checked.returncode != 0:
|
||||
fail(f"nft rejects the set: {checked.stderr.strip()}")
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
previous = None
|
||||
if os.path.exists(PERSISTED):
|
||||
previous = os.path.join(tmp, "previous.nft")
|
||||
shutil.copyfile(PERSISTED, previous)
|
||||
with open(PERSISTED + ".new", "w", encoding="utf-8") as f:
|
||||
f.write(persisted)
|
||||
os.chmod(PERSISTED + ".new", 0o644)
|
||||
os.replace(PERSISTED + ".new", PERSISTED)
|
||||
|
||||
# A reload replaces the whole table, sets included, in one transaction.
|
||||
active = subprocess.run(["systemctl", "-q", "is-active", "nft-host.service"]).returncode == 0
|
||||
if active:
|
||||
reloaded = subprocess.run(["systemctl", "reload", "nft-host.service"], capture_output=True, text=True)
|
||||
if reloaded.returncode != 0:
|
||||
if previous:
|
||||
shutil.copyfile(previous, PERSISTED)
|
||||
fail(f"reloading the rule set failed, kept the previous set: {reloaded.stderr.strip()}")
|
||||
print("reloaded the rule set" if active else "persisted only")
|
||||
|
||||
|
||||
try:
|
||||
main()
|
||||
except SystemExit:
|
||||
raise
|
||||
except Exception as e: # keep the previous set on any surprise
|
||||
fail(f"{type(e).__name__}: {e}")
|
||||
@@ -1,8 +0,0 @@
|
||||
[Unit]
|
||||
Description=Refresh the FireHOL set of the host firewall
|
||||
Wants=network-online.target
|
||||
After=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/local/sbin/nft-host-firehol
|
||||
@@ -1,10 +0,0 @@
|
||||
[Unit]
|
||||
Description=Daily refresh of the host firewall FireHOL set
|
||||
|
||||
[Timer]
|
||||
OnCalendar=daily
|
||||
RandomizedDelaySec=6h
|
||||
Persistent=true
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
@@ -1,8 +0,0 @@
|
||||
[Unit]
|
||||
Description=Refresh the geo sets of the host firewall
|
||||
Wants=network-online.target
|
||||
After=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/local/sbin/nft-host-geo
|
||||
@@ -1,10 +0,0 @@
|
||||
[Unit]
|
||||
Description=Daily refresh of the host firewall geo sets
|
||||
|
||||
[Timer]
|
||||
OnCalendar=daily
|
||||
RandomizedDelaySec=6h
|
||||
Persistent=true
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
@@ -1,9 +0,0 @@
|
||||
#!/bin/sh
|
||||
# Started by a transient timer before a rule set load; Ansible stops the timer
|
||||
# once the new rule set is confirmed.
|
||||
cd /etc/nftables.d || exit 1
|
||||
mv -f host.nft host.nft.rolled-back
|
||||
if [ -s host.nft.good ]; then
|
||||
cp host.nft.good host.nft && nft -f host.nft && exit 0
|
||||
fi
|
||||
nft delete table inet host
|
||||
@@ -1,182 +0,0 @@
|
||||
#!/usr/bin/python3
|
||||
# Managed by Ansible (role host_firewall), do not edit.
|
||||
"""Rebuilds the internet scanner sets scanners_v4 and scanners_v6 of table inet host.
|
||||
|
||||
Source: the cidr directory of OpenFilters/internet-scanners (Unlicense), read
|
||||
from the tarball of the default branch. A failed download or sanity check
|
||||
exits non-zero before anything changes, so the previous sets stay in place.
|
||||
"""
|
||||
import io
|
||||
import ipaddress
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.request
|
||||
|
||||
PERSISTED = "/etc/nftables.d/host-scanners.nft"
|
||||
# One CIDR or address per line: the public addresses of the hosts, the
|
||||
# CrowdSec whitelist and the admin, scraper and cluster sets of the firewall.
|
||||
ALLOWLIST = "/etc/nft-host-scanners.allowlist"
|
||||
URL = "https://codeload.github.com/OpenFilters/internet-scanners/tar.gz/refs/heads/main"
|
||||
USER_AGENT = "ruekov-host-firewall (nft-host-scanners)"
|
||||
MEMBER = re.compile(r"^[^/]+/cidr/[a-z0-9]+_v([46])\.txt$")
|
||||
MAX_BYTES = 50 * 1024 * 1024
|
||||
|
||||
# Lower bounds that catch an empty or truncated answer (the list holds about
|
||||
# 21000 IPv4 and 3000 IPv6 entries).
|
||||
MIN_ENTRIES = {4: 5000, 6: 500}
|
||||
# A prefix this wide is a parse error, not a scanner network.
|
||||
MIN_PREFIXLEN = {4: 12, 6: 24}
|
||||
# Private, CGNAT (NetBird), loopback, link-local, ULA and multicast/reserved
|
||||
# space: an entry overlapping one of these never lands in a set, so the own
|
||||
# infrastructure cannot block itself.
|
||||
EXCLUDED = [ipaddress.ip_network(n) for n in (
|
||||
"0.0.0.0/8", "10.0.0.0/8", "100.64.0.0/10", "127.0.0.0/8",
|
||||
"169.254.0.0/16", "172.16.0.0/12", "192.168.0.0/16", "224.0.0.0/3",
|
||||
"::/128", "::1/128", "::ffff:0:0/96", "fc00::/7", "fe80::/10", "ff00::/8")]
|
||||
|
||||
|
||||
def fail(message):
|
||||
print(f"nft-host-scanners: {message}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def fetch():
|
||||
error = None
|
||||
for attempt in range(3):
|
||||
try:
|
||||
request = urllib.request.Request(URL, headers={"User-Agent": USER_AGENT})
|
||||
with urllib.request.urlopen(request, timeout=120) as response:
|
||||
data = response.read(MAX_BYTES + 1)
|
||||
if len(data) > MAX_BYTES:
|
||||
fail(f"answer from {URL} exceeds {MAX_BYTES} bytes")
|
||||
return data
|
||||
except OSError as e:
|
||||
error = e
|
||||
time.sleep(5 * (attempt + 1))
|
||||
fail(f"download of {URL} failed: {error}")
|
||||
|
||||
|
||||
def parse(data):
|
||||
"""Returns {4: [networks], 6: [networks]} from the cidr/*_v4.txt and *_v6.txt files."""
|
||||
networks = {4: [], 6: []}
|
||||
try:
|
||||
archive = tarfile.open(fileobj=io.BytesIO(data), mode="r:gz")
|
||||
except (tarfile.TarError, OSError) as e:
|
||||
fail(f"unreadable archive: {e}")
|
||||
with archive:
|
||||
for member in archive:
|
||||
match = MEMBER.match(member.name)
|
||||
if not match or not member.isfile():
|
||||
continue
|
||||
version = int(match.group(1))
|
||||
for line in archive.extractfile(member).read().decode("utf-8").splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
try:
|
||||
# Host bits set (34.19.127.176/27) are masked, not rejected.
|
||||
network = ipaddress.ip_network(line, strict=False)
|
||||
except ValueError:
|
||||
fail(f"unparsable line in {member.name}: {line!r}")
|
||||
if network.version != version:
|
||||
fail(f"IPv{network.version} entry in {member.name}: {line!r}")
|
||||
networks[version].append(network)
|
||||
return networks
|
||||
|
||||
|
||||
def read_allowlist():
|
||||
entries = []
|
||||
with open(ALLOWLIST, encoding="utf-8") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
try:
|
||||
entries.append(ipaddress.ip_network(line, strict=False))
|
||||
except ValueError:
|
||||
fail(f"unparsable line in {ALLOWLIST}: {line!r}")
|
||||
return entries
|
||||
|
||||
|
||||
def own_addresses():
|
||||
result = subprocess.run(["ip", "-j", "address", "show"], capture_output=True, text=True)
|
||||
if result.returncode != 0:
|
||||
fail(f"ip address show failed: {result.stderr.strip()}")
|
||||
return [ipaddress.ip_address(a["local"]) for link in json.loads(result.stdout)
|
||||
for a in link.get("addr_info", []) if a.get("family") in ("inet", "inet6")]
|
||||
|
||||
|
||||
def main():
|
||||
listed = parse(fetch())
|
||||
for version, minimum in MIN_ENTRIES.items():
|
||||
if len(listed[version]) < minimum:
|
||||
fail(f"only {len(listed[version])} IPv{version} entries")
|
||||
|
||||
protected = EXCLUDED + read_allowlist()
|
||||
own = own_addresses()
|
||||
merged = {}
|
||||
for version in (4, 6):
|
||||
kept, protected_skipped, own_skipped = [], 0, 0
|
||||
for network in listed[version]:
|
||||
if network.prefixlen < MIN_PREFIXLEN[version]:
|
||||
fail(f"suspiciously wide prefix {network}")
|
||||
if any(p.version == version and network.overlaps(p) for p in protected):
|
||||
protected_skipped += 1
|
||||
elif any(a.version == version and a in network for a in own):
|
||||
own_skipped += 1
|
||||
else:
|
||||
kept.append(network)
|
||||
merged[version] = list(ipaddress.collapse_addresses(kept))
|
||||
print(f"scanners_v{version}: {len(listed[version])} entries, {protected_skipped} in private, reserved or "
|
||||
f"allowlisted space and {own_skipped} holding an own address skipped -> {len(merged[version])} merged")
|
||||
|
||||
persisted = "# Generated by nft-host-scanners, do not edit.\ntable inet host {\n"
|
||||
for version in (4, 6):
|
||||
elements = ",\n\t\t".join(str(n) for n in merged[version]) + ","
|
||||
persisted += (f"\tset scanners_v{version} {{ type ipv{version}_addr; flags interval; auto-merge; elements = {{\n"
|
||||
f"\t\t{elements}\n\t}} }}\n")
|
||||
persisted += "}\n"
|
||||
|
||||
# Checked under another table name: a dry run against the live table
|
||||
# name changes the live sets for its duration and unlisted addresses match.
|
||||
check_rules = persisted.replace("table inet host {", "table inet host_scanners_check {")
|
||||
checked = subprocess.run(["nft", "-c", "-f", "-"], input=check_rules, capture_output=True, text=True)
|
||||
if checked.returncode != 0:
|
||||
fail(f"nft rejects the sets: {checked.stderr.strip()}")
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
previous = None
|
||||
if os.path.exists(PERSISTED):
|
||||
previous = os.path.join(tmp, "previous.nft")
|
||||
shutil.copyfile(PERSISTED, previous)
|
||||
with open(PERSISTED + ".new", "w", encoding="utf-8") as f:
|
||||
f.write(persisted)
|
||||
os.chmod(PERSISTED + ".new", 0o644)
|
||||
os.replace(PERSISTED + ".new", PERSISTED)
|
||||
|
||||
# A reload replaces the whole table, sets included, in one transaction.
|
||||
active = subprocess.run(["systemctl", "-q", "is-active", "nft-host.service"]).returncode == 0
|
||||
if active:
|
||||
reloaded = subprocess.run(["systemctl", "reload", "nft-host.service"], capture_output=True, text=True)
|
||||
if reloaded.returncode != 0:
|
||||
if previous:
|
||||
shutil.copyfile(previous, PERSISTED)
|
||||
else:
|
||||
os.remove(PERSISTED)
|
||||
fail(f"reloading the rule set failed, kept the previous sets: {reloaded.stderr.strip()}")
|
||||
print("reloaded the rule set" if active else "persisted only")
|
||||
|
||||
|
||||
try:
|
||||
main()
|
||||
except SystemExit:
|
||||
raise
|
||||
except Exception as e: # keep the previous sets on any surprise
|
||||
fail(f"{type(e).__name__}: {e}")
|
||||
@@ -1,8 +0,0 @@
|
||||
[Unit]
|
||||
Description=Refresh the internet scanner sets of the host firewall
|
||||
Wants=network-online.target
|
||||
After=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/local/sbin/nft-host-scanners
|
||||
@@ -1,10 +0,0 @@
|
||||
[Unit]
|
||||
Description=Daily refresh of the host firewall internet scanner sets
|
||||
|
||||
[Timer]
|
||||
OnCalendar=daily
|
||||
RandomizedDelaySec=6h
|
||||
Persistent=true
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
@@ -1,167 +0,0 @@
|
||||
#!/usr/bin/python3
|
||||
# Managed by Ansible (role host_firewall), do not edit.
|
||||
"""Rebuilds the Spamhaus sets spamhaus_v4 and spamhaus_v6 of table inet host.
|
||||
|
||||
Sources are the Spamhaus DROP and DROPv6 prefix lists and the ASN-DROP list,
|
||||
whose ASNs are expanded to prefixes with the iptoasn bulk files (one download
|
||||
per address family). Any failed download or sanity check exits non-zero before
|
||||
anything changes, so the previous sets stay in place.
|
||||
"""
|
||||
import gzip
|
||||
import http.client
|
||||
import ipaddress
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.request
|
||||
|
||||
PERSISTED = "/etc/nftables.d/host-spamhaus.nft"
|
||||
SPAMHAUS = "https://www.spamhaus.org/drop/"
|
||||
IPTOASN = "https://iptoasn.com/data/"
|
||||
USER_AGENT = "ruekov-host-firewall (nft-host-spamhaus)"
|
||||
|
||||
# Lower bounds that catch an empty or truncated answer.
|
||||
MIN_RECORDS = {"drop_v4": 800, "drop_v6": 30, "asndrop": 200}
|
||||
MIN_TSV_LINES = {4: 200000, 6: 20000}
|
||||
MIN_ASN_PREFIXES_V4 = 100
|
||||
# A prefix this wide is a parse error, not a bad network.
|
||||
MIN_PREFIXLEN = {4: 8, 6: 16}
|
||||
|
||||
|
||||
def fail(message):
|
||||
print(f"nft-host-spamhaus: {message}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def fetch(url, dest):
|
||||
error = None
|
||||
for attempt in range(3):
|
||||
try:
|
||||
request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
|
||||
with urllib.request.urlopen(request, timeout=120) as response, open(dest, "wb") as out:
|
||||
shutil.copyfileobj(response, out)
|
||||
return
|
||||
except (OSError, http.client.HTTPException) as e:
|
||||
error = e
|
||||
time.sleep(5 * (attempt + 1))
|
||||
fail(f"download of {url} failed: {error}")
|
||||
|
||||
|
||||
def spamhaus_records(name, tmp):
|
||||
path = os.path.join(tmp, name + ".json")
|
||||
fetch(f"{SPAMHAUS}{name}.json", path)
|
||||
records, meta = [], None
|
||||
with open(path, encoding="utf-8") as f:
|
||||
for line in f:
|
||||
if not line.strip():
|
||||
continue
|
||||
record = json.loads(line)
|
||||
if record.get("type") == "metadata":
|
||||
meta = record
|
||||
else:
|
||||
records.append(record)
|
||||
if meta is None or meta.get("records") != len(records):
|
||||
fail(f"{name}: record count does not match the metadata record")
|
||||
if len(records) < MIN_RECORDS[name]:
|
||||
fail(f"{name}: only {len(records)} records")
|
||||
return records
|
||||
|
||||
|
||||
def asn_prefixes(asns, family, tmp):
|
||||
path = os.path.join(tmp, f"ip2asn-v{family}.tsv.gz")
|
||||
fetch(f"{IPTOASN}ip2asn-v{family}.tsv.gz", path)
|
||||
networks, lines = [], 0
|
||||
with gzip.open(path, "rt", encoding="utf-8") as f:
|
||||
for line in f:
|
||||
lines += 1
|
||||
fields = line.split("\t", 3)
|
||||
if len(fields) < 3 or fields[2] not in asns:
|
||||
continue
|
||||
networks.extend(ipaddress.summarize_address_range(
|
||||
ipaddress.ip_address(fields[0]), ipaddress.ip_address(fields[1])))
|
||||
if lines < MIN_TSV_LINES[family]:
|
||||
fail(f"ip2asn-v{family}: only {lines} lines")
|
||||
return networks
|
||||
|
||||
|
||||
def merge(family, *groups):
|
||||
networks, skipped = [], 0
|
||||
for group in groups:
|
||||
for network in group:
|
||||
network = ipaddress.ip_network(network)
|
||||
if network.prefixlen < MIN_PREFIXLEN[family]:
|
||||
fail(f"suspiciously wide prefix {network}")
|
||||
if not network.is_global:
|
||||
skipped += 1
|
||||
continue
|
||||
networks.append(network)
|
||||
return list(ipaddress.collapse_addresses(networks)), skipped
|
||||
|
||||
|
||||
def main():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
drop4 = [r["cidr"] for r in spamhaus_records("drop_v4", tmp)]
|
||||
drop6 = [r["cidr"] for r in spamhaus_records("drop_v6", tmp)]
|
||||
asns = {str(r["asn"]) for r in spamhaus_records("asndrop", tmp)}
|
||||
asn4 = asn_prefixes(asns, 4, tmp)
|
||||
asn6 = asn_prefixes(asns, 6, tmp)
|
||||
if len(asn4) < MIN_ASN_PREFIXES_V4:
|
||||
fail(f"only {len(asn4)} IPv4 prefixes for {len(asns)} ASNs")
|
||||
sets = {}
|
||||
for family, name, drop, asn in ((4, "spamhaus_v4", drop4, asn4), (6, "spamhaus_v6", drop6, asn6)):
|
||||
sets[name], skipped = merge(family, drop, asn)
|
||||
print(f"{name}: {len(drop)} DROP + {len(asn)} ASN-DROP prefixes -> "
|
||||
f"{len(sets[name])} merged, {skipped} non-global skipped")
|
||||
print(f"ASN-DROP: {len(asns)} ASNs")
|
||||
|
||||
def elements(networks):
|
||||
return ",\n\t\t".join(str(n) for n in networks) + ","
|
||||
|
||||
persisted = ["# Generated by nft-host-spamhaus, do not edit.", "table inet host {"]
|
||||
for name, kind in (("spamhaus_v4", "ipv4_addr"), ("spamhaus_v6", "ipv6_addr")):
|
||||
persisted.append(f"\tset {name} {{ type {kind}; flags interval; auto-merge; elements = {{\n"
|
||||
f"\t\t{elements(sets[name])}\n\t}} }}")
|
||||
persisted.append("}\n")
|
||||
|
||||
candidate = os.path.join(tmp, "host-spamhaus.nft")
|
||||
with open(candidate, "w", encoding="utf-8") as f:
|
||||
f.write("\n".join(persisted))
|
||||
# Checked under another table name: a dry run against the live table
|
||||
# name changes the live sets for its duration and unlisted addresses match.
|
||||
with open(candidate, encoding="utf-8") as f:
|
||||
check_rules = f.read().replace("table inet host {", "table inet host_spamhaus_check {")
|
||||
checked = subprocess.run(["nft", "-c", "-f", "-"], input=check_rules, capture_output=True, text=True)
|
||||
if checked.returncode != 0:
|
||||
fail(f"nft rejects the sets: {checked.stderr.strip()}")
|
||||
|
||||
previous = None
|
||||
if os.path.exists(PERSISTED):
|
||||
previous = os.path.join(tmp, "previous.nft")
|
||||
shutil.copyfile(PERSISTED, previous)
|
||||
shutil.copyfile(candidate, PERSISTED + ".new")
|
||||
os.chmod(PERSISTED + ".new", 0o644)
|
||||
os.replace(PERSISTED + ".new", PERSISTED)
|
||||
|
||||
# A reload replaces the whole table, sets included, in one transaction.
|
||||
# Flushing and refilling a running interval set instead lets packets of
|
||||
# unlisted addresses match while the transaction commits.
|
||||
active = subprocess.run(["systemctl", "-q", "is-active", "nft-host.service"]).returncode == 0
|
||||
if active:
|
||||
reloaded = subprocess.run(["systemctl", "reload", "nft-host.service"], capture_output=True, text=True)
|
||||
if reloaded.returncode != 0:
|
||||
if previous:
|
||||
shutil.copyfile(previous, PERSISTED)
|
||||
fail(f"reloading the rule set failed, kept the previous sets: {reloaded.stderr.strip()}")
|
||||
print("reloaded the rule set" if active else "persisted only")
|
||||
|
||||
|
||||
try:
|
||||
main()
|
||||
except SystemExit:
|
||||
raise
|
||||
except Exception as e: # keep the previous sets on any surprise
|
||||
fail(f"{type(e).__name__}: {e}")
|
||||
@@ -1,8 +0,0 @@
|
||||
[Unit]
|
||||
Description=Refresh the Spamhaus sets of the host firewall
|
||||
Wants=network-online.target
|
||||
After=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/local/sbin/nft-host-spamhaus
|
||||
@@ -1,10 +0,0 @@
|
||||
[Unit]
|
||||
Description=Daily refresh of the host firewall Spamhaus sets
|
||||
|
||||
[Timer]
|
||||
OnCalendar=daily
|
||||
RandomizedDelaySec=6h
|
||||
Persistent=true
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
@@ -1,22 +0,0 @@
|
||||
[Unit]
|
||||
Description=Host input firewall and NAT (nftables tables inet host and ip host_nat)
|
||||
# Same early-boot placement as nftables.service. Its "flush ruleset" on start,
|
||||
# reload and stop would wipe this table, so load after it and follow all three.
|
||||
DefaultDependencies=no
|
||||
Wants=network-pre.target
|
||||
Before=network-pre.target shutdown.target
|
||||
After=nftables.service
|
||||
PartOf=nftables.service
|
||||
ReloadPropagatedFrom=nftables.service
|
||||
Conflicts=shutdown.target
|
||||
ConditionPathExists=/etc/nftables.d/host.nft
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
RemainAfterExit=yes
|
||||
ExecStart=/usr/sbin/nft -f /etc/nftables.d/host.nft
|
||||
ExecReload=/usr/sbin/nft -f /etc/nftables.d/host.nft
|
||||
ExecStop=-/usr/sbin/nft delete table inet host
|
||||
|
||||
[Install]
|
||||
WantedBy=sysinit.target
|
||||
@@ -1,363 +0,0 @@
|
||||
---
|
||||
- name: Install nftables and curl
|
||||
ansible.builtin.apt:
|
||||
name: [nftables, curl]
|
||||
state: present
|
||||
|
||||
- name: Check that this node is part of its own cluster ring
|
||||
ansible.builtin.assert:
|
||||
that: ansible_wt0.ipv4.address in (firewall.cluster.v4 | dict2items | map(attribute='value') | list)
|
||||
fail_msg: "wt0 address {{ ansible_wt0.ipv4.address }} is missing from firewall.cluster.v4"
|
||||
when: firewall.cluster.v4 | default({}, true) | length > 0 and ansible_wt0.ipv4.address is defined
|
||||
|
||||
- name: Remove the include block of the old nftables role
|
||||
ansible.builtin.blockinfile:
|
||||
path: /etc/nftables.conf
|
||||
marker: "# {mark} ANSIBLE MANAGED BLOCK - nftables"
|
||||
state: absent
|
||||
|
||||
- name: Remove the rule files of the old nftables role
|
||||
ansible.builtin.file:
|
||||
path: /etc/nftables/ansible-managed
|
||||
state: absent
|
||||
|
||||
- name: Create /etc/nftables.d
|
||||
ansible.builtin.file:
|
||||
path: /etc/nftables.d
|
||||
state: directory
|
||||
mode: "0755"
|
||||
|
||||
# The kernel logs nft log statements of other network namespaces (LXC guests)
|
||||
# only with this sysctl; the lines land in the host kernel log.
|
||||
- name: Log the firewalls of container network namespaces
|
||||
ansible.posix.sysctl:
|
||||
name: net.netfilter.nf_log_all_netns
|
||||
value: "1"
|
||||
sysctl_file: /etc/sysctl.d/90-nf-log-all-netns.conf
|
||||
sysctl_set: true
|
||||
reload: false
|
||||
when: firewall.log.all_netns
|
||||
|
||||
# The handshake meters match `ct original packets 2`, which counts only with conntrack accounting (off by default).
|
||||
# The module is loaded at boot so that systemd-sysctl finds the key.
|
||||
- name: Load nf_conntrack at boot
|
||||
ansible.builtin.copy:
|
||||
dest: /etc/modules-load.d/nf_conntrack.conf
|
||||
content: "nf_conntrack\n"
|
||||
mode: "0644"
|
||||
|
||||
- name: Count packets per connection in conntrack
|
||||
ansible.posix.sysctl:
|
||||
name: net.netfilter.nf_conntrack_acct
|
||||
value: "1"
|
||||
sysctl_file: /etc/sysctl.d/90-nf-conntrack-acct.conf
|
||||
sysctl_set: true
|
||||
reload: false
|
||||
|
||||
- name: Install the systemd units
|
||||
ansible.builtin.copy:
|
||||
src: "{{ item }}"
|
||||
dest: "/etc/systemd/system/{{ item }}"
|
||||
mode: "0644"
|
||||
loop: [nft-host.service, nft-host-geo.service, nft-host-geo.timer, nft-host-spamhaus.service, nft-host-spamhaus.timer, nft-host-firehol.service, nft-host-firehol.timer, nft-host-scanners.service, nft-host-scanners.timer]
|
||||
register: firewall_units
|
||||
|
||||
- name: Install the rollback script
|
||||
ansible.builtin.copy:
|
||||
src: nft-host-rollback
|
||||
dest: /usr/local/sbin/nft-host-rollback
|
||||
mode: "0755"
|
||||
|
||||
- name: Geo sets
|
||||
when: firewall.geo_countries | length > 0
|
||||
block:
|
||||
- name: Install the geo set updater
|
||||
ansible.builtin.template:
|
||||
src: nft-host-geo.j2
|
||||
dest: /usr/local/sbin/nft-host-geo
|
||||
mode: "0755"
|
||||
|
||||
- name: Build the geo sets on first run
|
||||
ansible.builtin.command: /usr/local/sbin/nft-host-geo
|
||||
args:
|
||||
creates: /etc/nftables.d/host-geo.nft
|
||||
|
||||
- name: Read the geo sets file
|
||||
ansible.builtin.stat:
|
||||
path: /etc/nftables.d/host-geo.nft
|
||||
register: firewall_geo
|
||||
|
||||
# Without geo sets the public SSH port would only answer NetBird and the admin set.
|
||||
- name: Require non-empty geo sets
|
||||
ansible.builtin.assert:
|
||||
that: firewall_geo.stat.exists and firewall_geo.stat.size > 0
|
||||
fail_msg: /etc/nftables.d/host-geo.nft is missing or empty, refusing to apply the firewall
|
||||
# Check mode neither installs the updater nor builds the sets.
|
||||
when: not ansible_check_mode
|
||||
|
||||
- name: Enable the geo set timer
|
||||
ansible.builtin.systemd:
|
||||
name: nft-host-geo.timer
|
||||
enabled: true
|
||||
state: started
|
||||
daemon_reload: "{{ firewall_units is changed }}"
|
||||
# Check mode does not install the unit.
|
||||
ignore_errors: "{{ ansible_check_mode }}"
|
||||
|
||||
- name: Stop the geo set refresh
|
||||
when: firewall.geo_countries | length == 0
|
||||
block:
|
||||
- name: Disable the geo set timer
|
||||
ansible.builtin.systemd:
|
||||
name: nft-host-geo.timer
|
||||
enabled: false
|
||||
state: stopped
|
||||
daemon_reload: "{{ firewall_units is changed }}"
|
||||
# Check mode does not install the unit.
|
||||
ignore_errors: "{{ ansible_check_mode }}"
|
||||
|
||||
- name: Remove the geo sets file
|
||||
ansible.builtin.file:
|
||||
path: /etc/nftables.d/host-geo.nft
|
||||
state: absent
|
||||
|
||||
- name: Spamhaus sets
|
||||
when: firewall.spamhaus
|
||||
block:
|
||||
- name: Install the Spamhaus set updater
|
||||
ansible.builtin.copy:
|
||||
src: nft-host-spamhaus
|
||||
dest: /usr/local/sbin/nft-host-spamhaus
|
||||
mode: "0755"
|
||||
|
||||
# The rule set includes this file, so a missing one would fail the load at boot.
|
||||
- name: Build the Spamhaus sets on first run
|
||||
ansible.builtin.command: /usr/local/sbin/nft-host-spamhaus
|
||||
args:
|
||||
creates: /etc/nftables.d/host-spamhaus.nft
|
||||
|
||||
- name: Read the Spamhaus sets file
|
||||
ansible.builtin.stat:
|
||||
path: /etc/nftables.d/host-spamhaus.nft
|
||||
register: firewall_spamhaus
|
||||
|
||||
- name: Require non-empty Spamhaus sets
|
||||
ansible.builtin.assert:
|
||||
that: firewall_spamhaus.stat.exists and firewall_spamhaus.stat.size > 0
|
||||
fail_msg: /etc/nftables.d/host-spamhaus.nft is missing or empty, refusing to apply the firewall
|
||||
# Check mode neither installs the updater nor builds the sets.
|
||||
when: not ansible_check_mode
|
||||
|
||||
- name: Enable the Spamhaus set timer
|
||||
ansible.builtin.systemd:
|
||||
name: nft-host-spamhaus.timer
|
||||
enabled: true
|
||||
state: started
|
||||
daemon_reload: "{{ firewall_units is changed }}"
|
||||
# Check mode does not install the unit.
|
||||
ignore_errors: "{{ ansible_check_mode }}"
|
||||
|
||||
- name: Stop the Spamhaus set refresh
|
||||
when: not firewall.spamhaus
|
||||
block:
|
||||
- name: Disable the Spamhaus set timer
|
||||
ansible.builtin.systemd:
|
||||
name: nft-host-spamhaus.timer
|
||||
enabled: false
|
||||
state: stopped
|
||||
daemon_reload: "{{ firewall_units is changed }}"
|
||||
# Check mode does not install the unit.
|
||||
ignore_errors: "{{ ansible_check_mode }}"
|
||||
|
||||
- name: Remove the Spamhaus sets file
|
||||
ansible.builtin.file:
|
||||
path: /etc/nftables.d/host-spamhaus.nft
|
||||
state: absent
|
||||
|
||||
- name: FireHOL set
|
||||
when: firewall.firehol
|
||||
block:
|
||||
- name: Install the FireHOL set updater
|
||||
ansible.builtin.copy:
|
||||
src: nft-host-firehol
|
||||
dest: /usr/local/sbin/nft-host-firehol
|
||||
mode: "0755"
|
||||
|
||||
# The rule set includes this file, so a missing one would fail the load at boot.
|
||||
- name: Build the FireHOL set on first run
|
||||
ansible.builtin.command: /usr/local/sbin/nft-host-firehol
|
||||
args:
|
||||
creates: /etc/nftables.d/host-firehol.nft
|
||||
|
||||
- name: Read the FireHOL set file
|
||||
ansible.builtin.stat:
|
||||
path: /etc/nftables.d/host-firehol.nft
|
||||
register: firewall_firehol
|
||||
|
||||
- name: Require non-empty FireHOL set
|
||||
ansible.builtin.assert:
|
||||
that: firewall_firehol.stat.exists and firewall_firehol.stat.size > 0
|
||||
fail_msg: /etc/nftables.d/host-firehol.nft is missing or empty, refusing to apply the firewall
|
||||
# Check mode neither installs the updater nor builds the sets.
|
||||
when: not ansible_check_mode
|
||||
|
||||
- name: Enable the FireHOL set timer
|
||||
ansible.builtin.systemd:
|
||||
name: nft-host-firehol.timer
|
||||
enabled: true
|
||||
state: started
|
||||
daemon_reload: "{{ firewall_units is changed }}"
|
||||
# Check mode does not install the unit.
|
||||
ignore_errors: "{{ ansible_check_mode }}"
|
||||
|
||||
- name: Stop the FireHOL set refresh
|
||||
when: not firewall.firehol
|
||||
block:
|
||||
- name: Disable the FireHOL set timer
|
||||
ansible.builtin.systemd:
|
||||
name: nft-host-firehol.timer
|
||||
enabled: false
|
||||
state: stopped
|
||||
daemon_reload: "{{ firewall_units is changed }}"
|
||||
# Check mode does not install the unit.
|
||||
ignore_errors: "{{ ansible_check_mode }}"
|
||||
|
||||
- name: Remove the FireHOL set file
|
||||
ansible.builtin.file:
|
||||
path: /etc/nftables.d/host-firehol.nft
|
||||
state: absent
|
||||
|
||||
- name: Internet scanner sets
|
||||
when: firewall.scanners
|
||||
block:
|
||||
- name: Install the internet scanner set updater
|
||||
ansible.builtin.copy:
|
||||
src: nft-host-scanners
|
||||
dest: /usr/local/sbin/nft-host-scanners
|
||||
mode: "0755"
|
||||
|
||||
# The updater leaves out entries overlapping these addresses.
|
||||
- name: Install the internet scanner allowlist
|
||||
ansible.builtin.template:
|
||||
src: nft-host-scanners.allowlist.j2
|
||||
dest: /etc/nft-host-scanners.allowlist
|
||||
mode: "0644"
|
||||
|
||||
# The rule set includes this file, so a missing one would fail the load at boot.
|
||||
- name: Build the internet scanner sets on first run
|
||||
ansible.builtin.command: /usr/local/sbin/nft-host-scanners
|
||||
args:
|
||||
creates: /etc/nftables.d/host-scanners.nft
|
||||
|
||||
- name: Read the internet scanner sets file
|
||||
ansible.builtin.stat:
|
||||
path: /etc/nftables.d/host-scanners.nft
|
||||
register: firewall_scanners
|
||||
|
||||
- name: Require non-empty internet scanner sets
|
||||
ansible.builtin.assert:
|
||||
that: firewall_scanners.stat.exists and firewall_scanners.stat.size > 0
|
||||
fail_msg: /etc/nftables.d/host-scanners.nft is missing or empty, refusing to apply the firewall
|
||||
# Check mode neither installs the updater nor builds the sets.
|
||||
when: not ansible_check_mode
|
||||
|
||||
- name: Enable the internet scanner set timer
|
||||
ansible.builtin.systemd:
|
||||
name: nft-host-scanners.timer
|
||||
enabled: true
|
||||
state: started
|
||||
daemon_reload: "{{ firewall_units is changed }}"
|
||||
# Check mode does not install the unit.
|
||||
ignore_errors: "{{ ansible_check_mode }}"
|
||||
|
||||
- name: Stop the internet scanner set refresh
|
||||
when: not firewall.scanners
|
||||
block:
|
||||
- name: Disable the internet scanner set timer
|
||||
ansible.builtin.systemd:
|
||||
name: nft-host-scanners.timer
|
||||
enabled: false
|
||||
state: stopped
|
||||
daemon_reload: "{{ firewall_units is changed }}"
|
||||
# Check mode does not install the unit.
|
||||
ignore_errors: "{{ ansible_check_mode }}"
|
||||
|
||||
- name: Remove the internet scanner sets file
|
||||
ansible.builtin.file:
|
||||
path: /etc/nftables.d/host-scanners.nft
|
||||
state: absent
|
||||
|
||||
- name: Render the rule set
|
||||
ansible.builtin.template:
|
||||
src: host.nft.j2
|
||||
dest: /etc/nftables.d/host.nft
|
||||
mode: "0644"
|
||||
validate: nft -c -f %s
|
||||
register: firewall_rules
|
||||
|
||||
- name: Read the firewall unit state
|
||||
ansible.builtin.command: systemctl is-active nft-host.service
|
||||
register: firewall_active
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
check_mode: false
|
||||
|
||||
- name: Apply the rule set with a rollback timer
|
||||
when:
|
||||
- firewall_rules is changed or firewall_units is changed or firewall_active.stdout != 'active'
|
||||
- not ansible_check_mode
|
||||
block:
|
||||
# A failed run of the transient unit keeps its name taken for systemd-run.
|
||||
- name: Clear a leftover rollback timer
|
||||
ansible.builtin.shell: >-
|
||||
systemctl stop nft-host-rollback.timer;
|
||||
systemctl reset-failed nft-host-rollback.timer nft-host-rollback.service;
|
||||
true
|
||||
changed_when: false
|
||||
|
||||
# Unless disarmed below, this moves the rejected rule file aside so a reboot
|
||||
# does not load it again, and reloads the last confirmed one; without one
|
||||
# it drops the table.
|
||||
- name: Arm the rollback
|
||||
ansible.builtin.command: systemd-run --on-active={{ firewall.rollback_seconds }} --unit=nft-host-rollback /usr/local/sbin/nft-host-rollback
|
||||
changed_when: false
|
||||
|
||||
- name: Load the rule set
|
||||
ansible.builtin.systemd:
|
||||
name: nft-host.service
|
||||
enabled: true
|
||||
state: reloaded
|
||||
daemon_reload: true
|
||||
|
||||
# A reused SSH control socket would hide a lockout, so test a fresh connection.
|
||||
- name: Drop the SSH connection
|
||||
ansible.builtin.meta: reset_connection
|
||||
|
||||
- name: Reconnect through the new rule set
|
||||
ansible.builtin.wait_for_connection:
|
||||
timeout: "{{ (firewall.rollback_seconds / 2) | int }}"
|
||||
|
||||
- name: Check the services behind the new rule set
|
||||
ansible.builtin.shell: "{{ item.value }}"
|
||||
loop: "{{ firewall.confirm | default({}, true) | dict2items | rejectattr('value', 'none') }}"
|
||||
loop_control:
|
||||
label: "{{ item.key }}"
|
||||
register: firewall_confirm
|
||||
until: firewall_confirm.rc == 0
|
||||
retries: "{{ (firewall.rollback_seconds / 20) | int }}"
|
||||
delay: 5
|
||||
changed_when: false
|
||||
|
||||
# Fails when the timer already fired, so a slow check cannot pass for a rolled-back host.
|
||||
- name: Disarm the rollback
|
||||
ansible.builtin.shell: >-
|
||||
test "$(systemctl show --property=SubState --value nft-host-rollback.timer)" = waiting &&
|
||||
systemctl stop nft-host-rollback.timer
|
||||
changed_when: true
|
||||
|
||||
- name: Keep the rule set as the last confirmed one
|
||||
ansible.builtin.copy:
|
||||
src: /etc/nftables.d/host.nft
|
||||
dest: /etc/nftables.d/host.nft.good
|
||||
remote_src: true
|
||||
mode: "0644"
|
||||
@@ -1,468 +0,0 @@
|
||||
#!/usr/sbin/nft -f
|
||||
# {{ ansible_managed }}
|
||||
{% macro list(map) -%}
|
||||
{{ map | default({}, true) | dict2items | rejectattr('value', 'none') | map(attribute='value') | map('string') | unique | join(', ') }}
|
||||
{%- endmacro %}
|
||||
{% macro addr_set(name, type, map) -%}
|
||||
set {{ name }} {
|
||||
type {{ type }}; flags interval; auto-merge;
|
||||
{% if list(map) %}
|
||||
elements = { {{ list(map) }} }
|
||||
{% endif %}
|
||||
}
|
||||
{%- endmacro %}
|
||||
{% macro addr_list_set(name, type, items) -%}
|
||||
set {{ name }} {
|
||||
type {{ type }}; flags interval; auto-merge;
|
||||
{% if items %}
|
||||
elements = { {{ items | join(', ') }} }
|
||||
{% endif %}
|
||||
}
|
||||
{%- endmacro %}
|
||||
{% set cluster = firewall.cluster | default({}, true) %}
|
||||
{# public_ips of every inventory host: IPv4 per address, IPv6 per /64 (a longer prefix is widened, nft masks the host bits) #}
|
||||
{% set public_ips = groups['all'] | map('extract', hostvars, 'public_ips') | select('defined') | flatten | map('string') | unique | list %}
|
||||
{% set exempt_v4 = public_ips | reject('search', ':') | map('regex_replace', '^([^/]+)$', '\\1/32') | list %}
|
||||
{% set exempt_v6 = public_ips | select('search', ':') | map('regex_replace', '^([^/]+)$', '\\1/128') | map('regex_replace', '/(6[5-9]|[7-9][0-9]|1[01][0-9]|12[0-8])$', '/64') | list %}
|
||||
{% set cluster_ifaces = cluster.ifaces | default({}, true) | dict2items | rejectattr('value', 'none') | map(attribute='value') | unique | list %}
|
||||
{% set internal_ifaces = firewall.internal_ifaces | default({}, true) | dict2items | rejectattr('value', 'none') | map(attribute='value') | unique | list %}
|
||||
{% set guest_ifaces = firewall.guest_ifaces | default({}, true) | dict2items | rejectattr('value', 'none') | map(attribute='value') | unique | list %}
|
||||
{% set guest_match = 'iifname { "' ~ (guest_ifaces | join('", "')) ~ '" }' %}
|
||||
{% set dnat = firewall.dnat | default({}, true) | dict2items | rejectattr('value', 'none') | map(attribute='value') | list %}
|
||||
{% set masquerade = firewall.masquerade | default({}, true) | dict2items | rejectattr('value', 'none') | map(attribute='value') | unique | list %}
|
||||
{% set scrapers = firewall.scrapers | default({}, true) %}
|
||||
{% set forward_guard = firewall.forward_guard | default({}, true) | dict2items | rejectattr('value', 'none') | list %}
|
||||
{% set blocklists = firewall.spamhaus or firewall.firehol %}
|
||||
{% macro port_values(map) -%}
|
||||
{{ map | default({}, true) | dict2items | rejectattr('value', 'none') | map(attribute='value') | map('string') | unique | join(' ') }}
|
||||
{%- endmacro %}
|
||||
{# Per-service limits apply only to ports the host serves: input for the public and geo ports, forward for DNATed traffic (port forwards of the host and Docker-published ports). #}
|
||||
{% set served_input = (port_values(firewall.public.tcp) ~ ' ' ~ port_values(firewall.geo.tcp)).split() %}
|
||||
{% set served_forward = (port_values(firewall.public.tcp) ~ ' ' ~ port_values(firewall.geo.tcp) ~ ' ' ~ (dnat | map(attribute='tcp') | map('default', {}, true) | map('dict2items') | sum(start=[]) | rejectattr('value', 'none') | map(attribute='value') | map('string') | join(' '))).split() %}
|
||||
{% set rl = firewall.ratelimit | default({}, true) %}
|
||||
{% set rl_factor = rl.range_factor | default({}, true) %}
|
||||
{% set rl_services = [] %}
|
||||
{% for svc in rl.services | default({}, true) | dict2items | rejectattr('value', 'none') %}
|
||||
{% set ports = [svc.value.ports] | flatten | map('string') | list %}
|
||||
{% set per = svc.value.per | default('minute', true) %}
|
||||
{% set burst = svc.value.burst | default(svc.value.rate * 2, true) %}
|
||||
{% set _ = rl_services.append({
|
||||
'name': svc.key | replace('-', '_'),
|
||||
'ports': ports | join(', '),
|
||||
'rate': svc.value.rate,
|
||||
'burst': burst,
|
||||
'syn_rate': svc.value.rate * 2,
|
||||
'syn_burst': burst * 2,
|
||||
'per': per,
|
||||
'f4': svc.value.range_factor_v4 | default(rl_factor.v4 | default(6), true),
|
||||
'f6': svc.value.range_factor_v6 | default(rl_factor.v6 | default(3), true),
|
||||
'input': (ports | select('in', served_input) | list | length) > 0,
|
||||
'forward': (ports | select('in', served_forward) | list | length) > 0,
|
||||
}) %}
|
||||
{% endfor %}
|
||||
{% set rl_input = rl_services | selectattr('input') | list %}
|
||||
{% set rl_forward = rl_services | selectattr('forward') | list %}
|
||||
|
||||
# Declare-then-delete makes every load one atomic transaction that replaces
|
||||
# only these tables; Cilium, Docker, NetBird and CrowdSec tables stay untouched.
|
||||
table inet host {}
|
||||
delete table inet host
|
||||
table ip host_nat {}
|
||||
delete table ip host_nat
|
||||
|
||||
{% if firewall.geo_countries %}
|
||||
include "/etc/nftables.d/host-geo.nft"
|
||||
{% endif %}
|
||||
{% if firewall.spamhaus %}
|
||||
include "/etc/nftables.d/host-spamhaus.nft"
|
||||
{% endif %}
|
||||
{% if firewall.firehol %}
|
||||
include "/etc/nftables.d/host-firehol.nft"
|
||||
{% endif %}
|
||||
{% if firewall.scanners %}
|
||||
include "/etc/nftables.d/host-scanners.nft"
|
||||
{% endif %}
|
||||
|
||||
table inet host {
|
||||
{{ addr_set('admin_v4', 'ipv4_addr', firewall.admin.v4) }}
|
||||
{{ addr_set('admin_v6', 'ipv6_addr', firewall.admin.v6) }}
|
||||
{{ addr_set('cluster_v4', 'ipv4_addr', cluster.v4) }}
|
||||
{{ addr_set('cluster_v6', 'ipv6_addr', cluster.v6) }}
|
||||
{{ addr_set('scrapers_v4', 'ipv4_addr', scrapers.v4) }}
|
||||
{{ addr_set('scrapers_v6', 'ipv6_addr', scrapers.v6) }}
|
||||
{{ addr_list_set('rl_exempt_v4', 'ipv4_addr', exempt_v4) }}
|
||||
{{ addr_list_set('rl_exempt_v6', 'ipv6_addr', exempt_v6) }}
|
||||
{% for svc in rl_services if svc.input or svc.forward %}
|
||||
set rl_{{ svc.name }}_v4 {
|
||||
type ipv4_addr; flags dynamic; timeout 10m; size 65535;
|
||||
}
|
||||
set rl_{{ svc.name }}_v6 {
|
||||
type ipv6_addr; flags dynamic; timeout 10m; size 65535;
|
||||
}
|
||||
set rl_{{ svc.name }}_v4r {
|
||||
type ipv4_addr; flags dynamic; timeout 10m; size 16384;
|
||||
}
|
||||
set rl_{{ svc.name }}_v6r {
|
||||
type ipv6_addr; flags dynamic; timeout 10m; size 16384;
|
||||
}
|
||||
set rl_hs_{{ svc.name }}_v4 {
|
||||
type ipv4_addr; flags dynamic; timeout 10m; size 65535;
|
||||
}
|
||||
set rl_hs_{{ svc.name }}_v6 {
|
||||
type ipv6_addr; flags dynamic; timeout 10m; size 65535;
|
||||
}
|
||||
{% endfor %}
|
||||
{% if rl_input or rl_forward %}
|
||||
# Flows the handshake meter rejected: the client sends its first data segment right behind the ACK, which reaches the
|
||||
# host before the reset reaches the client and would complete the handshake there.
|
||||
set rl_hs_rejected_v4 {
|
||||
type ipv4_addr . inet_service . ipv4_addr . inet_service; flags dynamic,timeout; timeout 30s; size 65535;
|
||||
}
|
||||
set rl_hs_rejected_v6 {
|
||||
type ipv6_addr . inet_service . ipv6_addr . inet_service; flags dynamic,timeout; timeout 30s; size 65535;
|
||||
}
|
||||
{% endif %}
|
||||
|
||||
chain input {
|
||||
type filter hook input priority filter; policy drop;
|
||||
{% if blocklists %}
|
||||
|
||||
# Before the established rule: outbound is blocked too, so listed
|
||||
# addresses have no legitimate replies.
|
||||
{% if firewall.spamhaus %}
|
||||
ip saddr @spamhaus_v4 jump spamhaus_in
|
||||
ip6 saddr @spamhaus_v6 jump spamhaus_in
|
||||
{% endif %}
|
||||
{% if firewall.firehol %}
|
||||
ip saddr @firehol_v4 jump firehol_in
|
||||
{% endif %}
|
||||
|
||||
{% endif %}
|
||||
{% if rl_input %}
|
||||
# Before the established rule. Packets of rejected flows get a reset (not on RST, so that the half-open socket of the
|
||||
# client side reset is cleaned up; a reset per packet is cheap, the client sends only a few before it sees the first).
|
||||
# The packet that completes a handshake is a bare ACK and the second original-direction packet (needs
|
||||
# net.netfilter.nf_conntrack_acct); an RST or FIN as second packet is no handshake, and a blind spoofer can send it.
|
||||
meta l4proto tcp tcp flags & rst == 0 ip saddr . tcp sport . ip daddr . tcp dport @rl_hs_rejected_v4 counter reject with tcp reset
|
||||
meta l4proto tcp tcp flags & rst == 0 ip6 saddr . tcp sport . ip6 daddr . tcp dport @rl_hs_rejected_v6 counter reject with tcp reset
|
||||
meta l4proto tcp tcp flags & (fin | syn | rst | ack) == ack ct state established ct direction original ct original packets 2 jump ratelimit_hs_in
|
||||
{% endif %}
|
||||
ct state established,related accept
|
||||
{% if firewall.scanners %}
|
||||
|
||||
# After the established rule: only inbound connections of scanners drop.
|
||||
ip saddr @scanners_v4 jump scanners_in
|
||||
ip6 saddr @scanners_v6 jump scanners_in
|
||||
|
||||
{% endif %}
|
||||
ct state invalid jump drop_log
|
||||
iif lo accept
|
||||
|
||||
icmp type { echo-request, destination-unreachable, time-exceeded, parameter-problem } accept
|
||||
icmpv6 type { echo-request, destination-unreachable, packet-too-big, time-exceeded, parameter-problem, nd-router-advert, nd-neighbor-solicit, nd-neighbor-advert, mld-listener-query } accept
|
||||
udp sport 67 udp dport 68 accept
|
||||
meta nfproto ipv6 udp sport 547 udp dport 546 accept
|
||||
{% if cluster_ifaces or list(cluster.v4) or list(cluster.v6) %}
|
||||
|
||||
# ponytail: the cluster ring trusts every port between nodes and pods;
|
||||
# per-port lists (6443, 10250, 2379-2380, 4240, 8472, ...) are the upgrade path.
|
||||
{% for iface in cluster_ifaces %}
|
||||
iifname "{{ iface }}" accept
|
||||
{% endfor %}
|
||||
iifname "wt0" ip saddr @cluster_v4 accept
|
||||
iifname "wt0" ip6 saddr @cluster_v6 accept
|
||||
{% endif %}
|
||||
{% if internal_ifaces %}
|
||||
|
||||
# ponytail: internal bridges are trusted on every port; per-port lists are the upgrade path.
|
||||
{% for iface in internal_ifaces %}
|
||||
iifname "{{ iface }}" accept
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% if guest_ifaces %}
|
||||
|
||||
# Pods on k3s guests egress through the node IP, so guest bridges get
|
||||
# only DHCP and router solicitations beyond the public zone.
|
||||
{{ guest_match }} udp dport 67 accept
|
||||
{{ guest_match }} icmpv6 type nd-router-solicit accept
|
||||
{% endif %}
|
||||
|
||||
# VPN peers get the vpn ports, then everything public.
|
||||
iifname "wt0" jump vpn
|
||||
jump public
|
||||
|
||||
# Broadcasts and multicasts are never scans; they stay out of the log.
|
||||
meta pkttype { broadcast, multicast } drop
|
||||
jump drop_log
|
||||
}
|
||||
|
||||
# A single rule makes the limit global: a flood logs at the limit and the
|
||||
# rest drops silently. The kernel log line has the iptables LOG fields that
|
||||
# the CrowdSec iptables-logs parser reads.
|
||||
chain drop_log {
|
||||
limit rate {{ firewall.log.in_rate }} log prefix "fw-in-drop: "
|
||||
drop
|
||||
}
|
||||
{% if rl_services | selectattr('input') | list or rl_forward %}
|
||||
|
||||
# Per-service limits on new inbound TCP connections (firewall.ratelimit): per source address (IPv4 /32, IPv6 /64) and per
|
||||
# range (IPv4 /24, IPv6 /48; the per-source rate times range_factor). Admin, cluster, VPN, guest and internal sources
|
||||
# and the public addresses of all inventory hosts (rl_exempt_v4, rl_exempt_v6) are exempt: cluster nodes reach other hosts
|
||||
# with their public address (ArgoCD git fetches, mail submission). The public set is checked only here, no other chain
|
||||
# trusts it. The handshake meters carry the service limit (rate and burst of firewall.ratelimit): they count the packet that
|
||||
# completes a handshake (the second original-direction packet of an established connection), which a spoofed source cannot
|
||||
# send, so a real client above the limit is logged with fw-rl-hs: and reset. Only the address tier exists there; ranges are
|
||||
# left to CrowdSec. The SYN meters are a looser flood cap at twice the rate and burst: they drop the excess with the log
|
||||
# prefixes fw-rl-syn-addr: (per address) and fw-rl-syn-range: (per range), which a spoofed source can trigger, so they are
|
||||
# evidence only. Their cap sits above the service limit so that a real client reaches the handshake meter first.
|
||||
{% macro rl_exempt() %}
|
||||
iifname "wt0" return
|
||||
{% for iface in cluster_ifaces + internal_ifaces + guest_ifaces %}
|
||||
iifname "{{ iface }}" return
|
||||
{% endfor %}
|
||||
ip saddr @admin_v4 return
|
||||
ip6 saddr @admin_v6 return
|
||||
ip saddr @cluster_v4 return
|
||||
ip6 saddr @cluster_v6 return
|
||||
ip saddr @rl_exempt_v4 return
|
||||
ip6 saddr @rl_exempt_v6 return
|
||||
{% endmacro %}
|
||||
{% for hook, svcs in [('in', rl_input), ('fwd', rl_forward)] if svcs %}
|
||||
chain ratelimit_{{ hook }} {
|
||||
{{ rl_exempt() }}
|
||||
{% for svc in svcs %}
|
||||
meta l4proto tcp ct original proto-dst { {{ svc.ports }} } counter jump rl_{{ svc.name }}
|
||||
{% endfor %}
|
||||
}
|
||||
|
||||
{% endfor %}
|
||||
{% for hook, svcs in [('in', rl_input), ('fwd', rl_forward)] if svcs %}
|
||||
chain ratelimit_hs_{{ hook }} {
|
||||
{{ rl_exempt() }}
|
||||
{% for svc in svcs %}
|
||||
meta l4proto tcp ct original proto-dst { {{ svc.ports }} } counter jump rl_hs_{{ svc.name }}
|
||||
{% endfor %}
|
||||
}
|
||||
|
||||
{% endfor %}
|
||||
{% for svc in rl_services if svc.input or svc.forward %}
|
||||
chain rl_{{ svc.name }} {
|
||||
update @rl_{{ svc.name }}_v4 { ip saddr limit rate over {{ svc.syn_rate }}/{{ svc.per }} burst {{ svc.syn_burst }} packets } counter jump rl_drop_addr
|
||||
update @rl_{{ svc.name }}_v6 { ip6 saddr & ffff:ffff:ffff:ffff:: limit rate over {{ svc.syn_rate }}/{{ svc.per }} burst {{ svc.syn_burst }} packets } counter jump rl_drop_addr
|
||||
update @rl_{{ svc.name }}_v4r { ip saddr & 255.255.255.0 limit rate over {{ svc.syn_rate * svc.f4 }}/{{ svc.per }} burst {{ svc.syn_burst * svc.f4 }} packets } counter jump rl_drop_range
|
||||
update @rl_{{ svc.name }}_v6r { ip6 saddr & ffff:ffff:ffff:: limit rate over {{ svc.syn_rate * svc.f6 }}/{{ svc.per }} burst {{ svc.syn_burst * svc.f6 }} packets } counter jump rl_drop_range
|
||||
}
|
||||
|
||||
{% endfor %}
|
||||
{% for svc in rl_services if svc.input or svc.forward %}
|
||||
chain rl_hs_{{ svc.name }} {
|
||||
update @rl_hs_{{ svc.name }}_v4 { ip saddr limit rate over {{ svc.rate }}/{{ svc.per }} burst {{ svc.burst }} packets } counter jump rl_hs_reject
|
||||
update @rl_hs_{{ svc.name }}_v6 { ip6 saddr & ffff:ffff:ffff:ffff:: limit rate over {{ svc.rate }}/{{ svc.per }} burst {{ svc.burst }} packets } counter jump rl_hs_reject
|
||||
}
|
||||
|
||||
{% endfor %}
|
||||
# One log limit per prefix, so a flood of spoofed SYNs cannot use up the budget of the handshake evidence.
|
||||
chain rl_drop_addr {
|
||||
limit rate {{ firewall.log.ratelimit_rate }} log prefix "fw-rl-syn-addr: "
|
||||
drop
|
||||
}
|
||||
|
||||
chain rl_drop_range {
|
||||
limit rate {{ firewall.log.ratelimit_rate }} log prefix "fw-rl-syn-range: "
|
||||
drop
|
||||
}
|
||||
|
||||
# The handshake is complete, so the connection has to be ended actively; the flow is remembered so that its later
|
||||
# packets are reset without a log line (one fw-rl-hs: line per rejected connection).
|
||||
chain rl_hs_reject {
|
||||
meta nfproto ipv4 add @rl_hs_rejected_v4 { ip saddr . tcp sport . ip daddr . tcp dport }
|
||||
meta nfproto ipv6 add @rl_hs_rejected_v6 { ip6 saddr . tcp sport . ip6 daddr . tcp dport }
|
||||
limit rate {{ firewall.log.ratelimit_rate }} log prefix "fw-rl-hs: "
|
||||
reject with tcp reset
|
||||
}
|
||||
{% endif %}
|
||||
{% if firewall.spamhaus %}
|
||||
|
||||
# Own limits, so a listed scanner cannot use up the budget of fw-in-drop.
|
||||
# CrowdSec whitelists both prefixes: the lists already ban these networks.
|
||||
chain spamhaus_in {
|
||||
limit rate {{ firewall.log.spamhaus_rate }} log prefix "fw-spamhaus-in: "
|
||||
drop
|
||||
}
|
||||
|
||||
chain spamhaus_out {
|
||||
limit rate {{ firewall.log.spamhaus_rate }} log prefix "fw-spamhaus-out: "
|
||||
reject
|
||||
}
|
||||
{% endif %}
|
||||
{% if firewall.firehol %}
|
||||
|
||||
chain firehol_in {
|
||||
limit rate {{ firewall.log.spamhaus_rate }} log prefix "fw-firehol-in: "
|
||||
drop
|
||||
}
|
||||
|
||||
chain firehol_out {
|
||||
limit rate {{ firewall.log.spamhaus_rate }} log prefix "fw-firehol-out: "
|
||||
reject
|
||||
}
|
||||
{% endif %}
|
||||
{% if firewall.scanners %}
|
||||
|
||||
# Own limit and prefix, so scanners use up neither the fw-in-drop budget
|
||||
# nor the one of the blocklists. CrowdSec whitelists the prefix: scans of
|
||||
# these networks are known and need no decision.
|
||||
chain scanners_in {
|
||||
limit rate {{ firewall.log.spamhaus_rate }} log prefix "fw-scanners-in: "
|
||||
drop
|
||||
}
|
||||
{% endif %}
|
||||
{% if firewall.block_smtp_out or blocklists %}
|
||||
|
||||
# Outbound mail goes through the mail host only. Loopback stays open for a
|
||||
# local MTA; the log lines carry OUT=, which CrowdSec does not count as a scan.
|
||||
chain output {
|
||||
type filter hook output priority filter; policy accept;
|
||||
{% if firewall.spamhaus %}
|
||||
|
||||
ip daddr @spamhaus_v4 jump spamhaus_out
|
||||
ip6 daddr @spamhaus_v6 jump spamhaus_out
|
||||
{% endif %}
|
||||
{% if firewall.firehol %}
|
||||
|
||||
ip daddr @firehol_v4 jump firehol_out
|
||||
{% endif %}
|
||||
{% if firewall.block_smtp_out %}
|
||||
|
||||
oifname != "lo" tcp dport 25 limit rate {{ firewall.log.out_rate }} log prefix "fw-out-reject: "
|
||||
oifname != "lo" tcp dport 25 reject with tcp reset
|
||||
{% endif %}
|
||||
}
|
||||
{% endif %}
|
||||
|
||||
chain vpn {
|
||||
{% if list(scrapers.tcp) %}
|
||||
{% if list(scrapers.v4) %}
|
||||
ip saddr @scrapers_v4 tcp dport { {{ list(scrapers.tcp) }} } accept
|
||||
{% endif %}
|
||||
{% if list(scrapers.v6) %}
|
||||
ip6 saddr @scrapers_v6 tcp dport { {{ list(scrapers.tcp) }} } accept
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if list(firewall.vpn.tcp) %}
|
||||
tcp dport { {{ list(firewall.vpn.tcp) }} } accept
|
||||
{% endif %}
|
||||
{% if list(firewall.vpn.udp) %}
|
||||
udp dport { {{ list(firewall.vpn.udp) }} } accept
|
||||
{% endif %}
|
||||
}
|
||||
|
||||
chain public {
|
||||
{% if rl_input %}
|
||||
ct state new jump ratelimit_in
|
||||
{% endif %}
|
||||
{% if list(firewall.public.tcp) %}
|
||||
tcp dport { {{ list(firewall.public.tcp) }} } accept
|
||||
{% endif %}
|
||||
{% if list(firewall.public.udp) %}
|
||||
udp dport { {{ list(firewall.public.udp) }} } accept
|
||||
{% endif %}
|
||||
{% if list(firewall.geo.tcp) %}
|
||||
tcp dport { {{ list(firewall.geo.tcp) }} } jump geo
|
||||
{% endif %}
|
||||
{% if list(firewall.admin.tcp) %}
|
||||
tcp dport { {{ list(firewall.admin.tcp) }} } jump admin
|
||||
{% endif %}
|
||||
}
|
||||
{% if list(firewall.admin.tcp) %}
|
||||
|
||||
chain admin {
|
||||
ip saddr @admin_v4 accept
|
||||
ip6 saddr @admin_v6 accept
|
||||
}
|
||||
{% endif %}
|
||||
|
||||
chain geo {
|
||||
{% if guest_ifaces %}
|
||||
# Guest IPv6 addresses sit inside the geo sets.
|
||||
{{ guest_match }} return
|
||||
{% endif %}
|
||||
ip saddr @admin_v4 accept
|
||||
ip6 saddr @admin_v6 accept
|
||||
{% if firewall.geo_countries %}
|
||||
ip saddr @geo_v4 accept
|
||||
ip6 saddr @geo_v6 accept
|
||||
{% endif %}
|
||||
}
|
||||
{% if forward_guard or blocklists or firewall.scanners or rl_forward %}
|
||||
|
||||
chain forward {
|
||||
type filter hook forward priority filter; policy accept;
|
||||
{% if rl_forward %}
|
||||
|
||||
# Docker-published ports and port forwards arrive here after DNAT; pod and guest egress is not DNATed.
|
||||
ct status dnat ct state new jump ratelimit_fwd
|
||||
meta l4proto tcp ct status dnat tcp flags & rst == 0 ip saddr . tcp sport . ip daddr . tcp dport @rl_hs_rejected_v4 counter reject with tcp reset
|
||||
meta l4proto tcp ct status dnat tcp flags & rst == 0 ip6 saddr . tcp sport . ip6 daddr . tcp dport @rl_hs_rejected_v6 counter reject with tcp reset
|
||||
meta l4proto tcp tcp flags & (fin | syn | rst | ack) == ack ct status dnat ct state established ct direction original ct original packets 2 jump ratelimit_hs_fwd
|
||||
{% endif %}
|
||||
{% if firewall.spamhaus %}
|
||||
|
||||
ip saddr @spamhaus_v4 jump spamhaus_in
|
||||
ip6 saddr @spamhaus_v6 jump spamhaus_in
|
||||
ip daddr @spamhaus_v4 jump spamhaus_out
|
||||
ip6 daddr @spamhaus_v6 jump spamhaus_out
|
||||
{% endif %}
|
||||
{% if firewall.firehol %}
|
||||
|
||||
ip saddr @firehol_v4 jump firehol_in
|
||||
ip daddr @firehol_v4 jump firehol_out
|
||||
{% endif %}
|
||||
{% if firewall.scanners %}
|
||||
|
||||
ct state new ip saddr @scanners_v4 jump scanners_in
|
||||
ct state new ip6 saddr @scanners_v6 jump scanners_in
|
||||
{% endif %}
|
||||
{% for guard in forward_guard %}
|
||||
|
||||
# {{ guard.key }}
|
||||
{% set ifaces = guard.value.ifaces | default({}, true) | dict2items | rejectattr('value', 'none') | map(attribute='value') | unique | list %}
|
||||
{% for fam, l3, icmp in [('v4', 'ip', 'icmp'), ('v6', 'ip6', 'icmpv6')] if list((guard.value[fam] | default({}, true)).daddr) %}
|
||||
{% set zone = guard.value[fam] %}
|
||||
{% set match = l3 ~ ' daddr { ' ~ list(zone.daddr) ~ ' }' %}
|
||||
{{ match }} ct state established,related accept
|
||||
{% if ifaces %}
|
||||
{{ match }} iifname { "{{ ifaces | join('", "') }}" } accept
|
||||
{% endif %}
|
||||
{% if list(zone.tcp) %}
|
||||
{{ match }} tcp dport { {{ list(zone.tcp) }} } accept
|
||||
{% endif %}
|
||||
{% if list(zone.icmp) %}
|
||||
{{ match }} {{ icmp }} type { {{ list(zone.icmp) }} } accept
|
||||
{% endif %}
|
||||
{{ match }} jump drop_log
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
}
|
||||
{% endif %}
|
||||
}
|
||||
{% if dnat or masquerade %}
|
||||
|
||||
table ip host_nat {
|
||||
chain prerouting {
|
||||
type nat hook prerouting priority dstnat; policy accept;
|
||||
{% for fwd in dnat %}
|
||||
{% for proto in ['tcp', 'udp'] if list(fwd[proto]) %}
|
||||
ip daddr {{ fwd.daddr }} {{ proto }} dport { {{ list(fwd[proto]) }} } dnat to {{ fwd.to }}
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
}
|
||||
|
||||
chain postrouting {
|
||||
type nat hook postrouting priority srcnat; policy accept;
|
||||
{% for match in masquerade %}
|
||||
{{ match }} masquerade
|
||||
{% endfor %}
|
||||
}
|
||||
}
|
||||
{% endif %}
|
||||
@@ -1,48 +0,0 @@
|
||||
#!/bin/sh
|
||||
# {{ ansible_managed }}
|
||||
# Rebuilds the geo sets of table inet host from ipdeny. Any failed download or
|
||||
# check exits before the swap, so the previous file stays in place.
|
||||
set -eu
|
||||
|
||||
out=/etc/nftables.d/host-geo.nft
|
||||
countries="{{ firewall.geo_countries | join(' ') }}"
|
||||
prefix='^[0-9a-f.:]+/[0-9]+$'
|
||||
|
||||
tmp=$(mktemp -d)
|
||||
trap 'rm -rf "$tmp"' EXIT
|
||||
|
||||
fetch() {
|
||||
curl -fsS --retry 3 --max-time 120 -o "$2" "$1"
|
||||
grep -Eq "$prefix" "$2" || { echo "no prefixes in $1" >&2; exit 1; }
|
||||
}
|
||||
|
||||
for cc in $countries; do
|
||||
fetch "https://www.ipdeny.com/ipblocks/data/aggregated/$cc-aggregated.zone" "$tmp/$cc.v4"
|
||||
fetch "https://www.ipdeny.com/ipv6/ipaddresses/aggregated/$cc-aggregated.zone" "$tmp/$cc.v6"
|
||||
done
|
||||
|
||||
elements() {
|
||||
cat "$@" | grep -E "$prefix" | sed 's/$/,/'
|
||||
}
|
||||
|
||||
{
|
||||
echo "# Generated by $0, do not edit."
|
||||
echo 'table inet host {'
|
||||
echo ' set geo_v4 { type ipv4_addr; flags interval; auto-merge; elements = {'
|
||||
elements "$tmp"/*.v4
|
||||
echo ' } }'
|
||||
echo ' set geo_v6 { type ipv6_addr; flags interval; auto-merge; elements = {'
|
||||
elements "$tmp"/*.v6
|
||||
echo ' } }'
|
||||
echo '}'
|
||||
} >"$tmp/host-geo.nft"
|
||||
|
||||
# Checked under another table name: a dry run against the live table name
|
||||
# changes the live sets for its duration and unlisted addresses match.
|
||||
sed 's/^table inet host {/table inet host_geo_check {/' "$tmp/host-geo.nft" | nft -c -f -
|
||||
install -m 0644 "$tmp/host-geo.nft" "$out.new"
|
||||
mv -f "$out.new" "$out"
|
||||
|
||||
if systemctl -q is-active nft-host.service; then
|
||||
systemctl reload nft-host.service
|
||||
fi
|
||||
@@ -1,11 +0,0 @@
|
||||
# {{ ansible_managed }}
|
||||
{% set maps = [
|
||||
(crowdsec | default({}, true)).whitelist | default({}, true),
|
||||
firewall.admin.v4 | default({}, true), firewall.admin.v6 | default({}, true),
|
||||
firewall.scrapers.v4 | default({}, true), firewall.scrapers.v6 | default({}, true),
|
||||
firewall.cluster.v4 | default({}, true), firewall.cluster.v6 | default({}, true)] %}
|
||||
{% set entries = (maps | map('dict2items') | sum(start=[]) | rejectattr('value', 'none') | map(attribute='value') | map('string') | list
|
||||
+ groups['all'] | map('extract', hostvars, 'public_ips') | select('defined') | flatten) | unique %}
|
||||
{% for entry in entries %}
|
||||
{{ entry }}
|
||||
{% endfor %}
|
||||
@@ -1,12 +0,0 @@
|
||||
---
|
||||
journal_upload:
|
||||
# Ingest route of VictoriaLogs (insert path only, NetBird sources, basic auth per host).
|
||||
# The public DNS answers with the cluster's public address, where the route rejects
|
||||
# every source outside NetBird; /etc/hosts pins the name to the NetBird addresses below.
|
||||
host: ""
|
||||
path: /insert/journald
|
||||
# NetBird addresses of the Traefik nodes. The client tries them in this order when one is down.
|
||||
addresses: []
|
||||
# Basic auth credential; the user is the host's entry in the Secret vl-ingest-users.
|
||||
user: "{{ inventory_hostname }}"
|
||||
password: ""
|
||||
@@ -1,10 +0,0 @@
|
||||
---
|
||||
- name: Reload systemd manager
|
||||
ansible.builtin.systemd:
|
||||
daemon_reload: true
|
||||
|
||||
- name: Restart systemd-journal-upload
|
||||
ansible.builtin.systemd:
|
||||
name: systemd-journal-upload
|
||||
state: restarted
|
||||
ignore_errors: "{{ ansible_check_mode }}"
|
||||
@@ -1,83 +0,0 @@
|
||||
---
|
||||
# Ships the whole journal to VictoriaLogs. The uploader keeps the cursor of the last uploaded
|
||||
# entry in its state directory, so a restart resumes where it stopped.
|
||||
- name: Require the endpoint and the credential
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- journal_upload.host | length > 0
|
||||
- journal_upload.addresses | length > 0
|
||||
- journal_upload.user | length > 0
|
||||
- journal_upload.password | length > 0
|
||||
fail_msg: journal_upload.host, addresses, user and password must be set
|
||||
|
||||
- name: Install the journal uploader
|
||||
ansible.builtin.apt:
|
||||
name: systemd-journal-remote
|
||||
install_recommends: false
|
||||
state: present
|
||||
|
||||
# A node reaching its own address arrives at Traefik from its cilium_host address, which the
|
||||
# NetBird allowlist rejects; it takes the other nodes.
|
||||
- name: Pin the ingest name to the NetBird addresses
|
||||
ansible.builtin.blockinfile:
|
||||
path: /etc/hosts
|
||||
marker: "# {mark} journal_upload"
|
||||
block: |
|
||||
{% for address in journal_upload.addresses | reject('in', ansible_facts.all_ipv4_addresses) %}
|
||||
{{ address }} {{ journal_upload.host }}
|
||||
{% endfor %}
|
||||
notify: Restart systemd-journal-upload
|
||||
|
||||
- name: Create the uploader config directory
|
||||
ansible.builtin.file:
|
||||
path: /etc/systemd/journal-upload.conf.d
|
||||
state: directory
|
||||
mode: "0755"
|
||||
|
||||
# The service runs as a dynamic user, which reads the file after the manager dropped privileges.
|
||||
- name: Configure the upload URL
|
||||
ansible.builtin.copy:
|
||||
dest: /etc/systemd/journal-upload.conf.d/10-victorialogs.conf
|
||||
mode: "0644"
|
||||
content: |
|
||||
[Upload]
|
||||
URL=https://{{ journal_upload.user }}:{{ journal_upload.password }}@{{ journal_upload.host }}{{ journal_upload.path }}
|
||||
# No client certificate; the server certificate is checked against the system CAs.
|
||||
ServerKeyFile=-
|
||||
ServerCertificateFile=-
|
||||
TrustedCertificateFile=/etc/ssl/certs/ca-certificates.crt
|
||||
notify: Restart systemd-journal-upload
|
||||
no_log: true
|
||||
|
||||
# The default start limit (5 starts within 10 seconds) gives up during an ingest outage. The
|
||||
# uploader resumes from its cursor, so it keeps retrying.
|
||||
- name: Create the uploader unit drop-in directory
|
||||
ansible.builtin.file:
|
||||
path: /etc/systemd/system/systemd-journal-upload.service.d
|
||||
state: directory
|
||||
mode: "0755"
|
||||
|
||||
- name: Keep the uploader retrying through ingest outages
|
||||
ansible.builtin.copy:
|
||||
dest: /etc/systemd/system/systemd-journal-upload.service.d/restart.conf
|
||||
mode: "0644"
|
||||
content: |
|
||||
[Unit]
|
||||
StartLimitIntervalSec=0
|
||||
|
||||
[Service]
|
||||
Restart=always
|
||||
RestartSec=30s
|
||||
notify: Reload systemd manager
|
||||
|
||||
# The reload has to precede the start below, which otherwise hits the default start limit.
|
||||
- name: Load the uploader drop-in
|
||||
ansible.builtin.meta: flush_handlers
|
||||
|
||||
- name: Enable the journal uploader
|
||||
ansible.builtin.systemd:
|
||||
name: systemd-journal-upload
|
||||
enabled: true
|
||||
state: started
|
||||
# Check mode does not install the package.
|
||||
ignore_errors: "{{ ansible_check_mode }}"
|
||||
@@ -1,33 +0,0 @@
|
||||
---
|
||||
kopia_backup:
|
||||
# S3 repository; it is created, and its policies and maintenance are set, outside this role.
|
||||
endpoint: tier1.backups.ruekov.eu
|
||||
bucket: ""
|
||||
access_key: ""
|
||||
secret_key: ""
|
||||
password: ""
|
||||
# Snapshot owner is root@<hostname>; several hosts can share one repository.
|
||||
hostname: "{{ inventory_hostname_short }}"
|
||||
# Kopia defaults when unset.
|
||||
content_cache_size_mb:
|
||||
metadata_cache_size_mb:
|
||||
# Paths to snapshot; missing ones are skipped at run time.
|
||||
sources: []
|
||||
# source path -> ignore rules, replacing the policy rules on every run (null drops one).
|
||||
ignore: {}
|
||||
# Command run first, e.g. a host script writing dumps into a source; a failure fails the run, the snapshot still runs.
|
||||
pre_command: ""
|
||||
# Database dumps written to /var/backups/dumps before the snapshot, which then includes that directory.
|
||||
# Each item: file, container, command (run with sh -c inside the container, output is the dump).
|
||||
dumps: []
|
||||
# Files copied to the host as they are, each { src: local path, dest, mode }; owned by root.
|
||||
files: []
|
||||
# Snapshot the newest k3s etcd snapshot, staged in /var/backups/etcd.
|
||||
etcd_snapshot: false
|
||||
# Explicit UTC: the hosts run in different timezones.
|
||||
on_calendar: "*-*-* 03:30 UTC"
|
||||
randomized_delay: ""
|
||||
# Extra units the service starts after, e.g. docker.service for dumps.
|
||||
after: []
|
||||
# Nice=10 and idle IO scheduling.
|
||||
idle_priority: true
|
||||
@@ -1,104 +0,0 @@
|
||||
---
|
||||
- name: Require the repository settings
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- kopia_backup.bucket | length > 0
|
||||
- kopia_backup.access_key | length > 0
|
||||
- kopia_backup.secret_key | length > 0
|
||||
- kopia_backup.password | length > 0
|
||||
- kopia_backup.sources | length > 0
|
||||
fail_msg: kopia_backup.bucket, access_key, secret_key, password and sources must be set
|
||||
|
||||
- name: Create /etc/apt/keyrings
|
||||
ansible.builtin.file:
|
||||
path: /etc/apt/keyrings
|
||||
state: directory
|
||||
mode: "0755"
|
||||
|
||||
- name: Install gpg for the apt key
|
||||
ansible.builtin.apt:
|
||||
name: gpg
|
||||
state: present
|
||||
|
||||
- name: Add the Kopia apt key
|
||||
ansible.builtin.shell: curl -fsSL https://kopia.io/signing-key | gpg --dearmor -o /etc/apt/keyrings/kopia-keyring.gpg
|
||||
args:
|
||||
creates: /etc/apt/keyrings/kopia-keyring.gpg
|
||||
|
||||
- name: Add the Kopia apt repository
|
||||
ansible.builtin.copy:
|
||||
dest: /etc/apt/sources.list.d/kopia.list
|
||||
mode: "0644"
|
||||
content: |
|
||||
deb [signed-by=/etc/apt/keyrings/kopia-keyring.gpg] http://packages.kopia.io/apt/ stable main
|
||||
register: kopia_backup_repo
|
||||
|
||||
- name: Install Kopia
|
||||
ansible.builtin.apt:
|
||||
name: kopia
|
||||
state: present
|
||||
update_cache: "{{ kopia_backup_repo is changed }}"
|
||||
|
||||
- name: Read the repository connection
|
||||
ansible.builtin.command: kopia repository status
|
||||
environment:
|
||||
KOPIA_CHECK_FOR_UPDATES: "false"
|
||||
register: kopia_backup_status
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
check_mode: false
|
||||
|
||||
- name: Connect the repository
|
||||
ansible.builtin.command:
|
||||
argv: "{{ argv + (cache if kopia_backup.content_cache_size_mb else []) }}"
|
||||
vars:
|
||||
argv:
|
||||
- kopia
|
||||
- repository
|
||||
- connect
|
||||
- s3
|
||||
- --endpoint={{ kopia_backup.endpoint }}
|
||||
- --bucket={{ kopia_backup.bucket }}
|
||||
- --override-hostname={{ kopia_backup.hostname }}
|
||||
- --override-username=root
|
||||
cache:
|
||||
- --content-cache-size-mb={{ kopia_backup.content_cache_size_mb }}
|
||||
- --metadata-cache-size-mb={{ kopia_backup.metadata_cache_size_mb }}
|
||||
# Secrets through the environment, flags would show them in the process list
|
||||
environment:
|
||||
AWS_ACCESS_KEY_ID: "{{ kopia_backup.access_key }}"
|
||||
AWS_SECRET_ACCESS_KEY: "{{ kopia_backup.secret_key }}"
|
||||
KOPIA_PASSWORD: "{{ kopia_backup.password }}"
|
||||
KOPIA_CHECK_FOR_UPDATES: "false"
|
||||
when: kopia_backup_status.rc != 0
|
||||
no_log: true
|
||||
|
||||
- name: Install the files of the host
|
||||
ansible.builtin.copy:
|
||||
src: "{{ item.src }}"
|
||||
dest: "{{ item.dest }}"
|
||||
mode: "{{ item.mode }}"
|
||||
owner: root
|
||||
group: root
|
||||
loop: "{{ kopia_backup.files }}"
|
||||
|
||||
- name: Install the backup script
|
||||
ansible.builtin.template:
|
||||
src: kopia-backup.j2
|
||||
dest: /usr/local/sbin/kopia-backup
|
||||
mode: "0755"
|
||||
|
||||
- name: Install the systemd units
|
||||
ansible.builtin.template:
|
||||
src: "{{ item }}.j2"
|
||||
dest: "/etc/systemd/system/{{ item }}"
|
||||
mode: "0644"
|
||||
loop: [kopia-backup.service, kopia-backup.timer]
|
||||
register: kopia_backup_units
|
||||
|
||||
- name: Enable the timer
|
||||
ansible.builtin.systemd:
|
||||
name: kopia-backup.timer
|
||||
enabled: true
|
||||
state: started
|
||||
daemon_reload: "{{ kopia_backup_units is changed }}"
|
||||
@@ -1,58 +0,0 @@
|
||||
#!/bin/bash
|
||||
# {{ ansible_managed }}
|
||||
# Daily Kopia backup of {{ kopia_backup.hostname }} into the bucket {{ kopia_backup.bucket }} on {{ kopia_backup.endpoint }}.
|
||||
set -uo pipefail
|
||||
export KOPIA_CHECK_FOR_UPDATES=false
|
||||
failed=0
|
||||
sources=()
|
||||
{% if kopia_backup.pre_command %}
|
||||
|
||||
{{ kopia_backup.pre_command }} || { echo "pre_command failed" >&2; failed=1; }
|
||||
{% endif %}
|
||||
{% if kopia_backup.etcd_snapshot %}
|
||||
|
||||
# Only the latest etcd snapshot; the live etcd dir is not consistent on disk and older snapshots only add churn
|
||||
etcd=/var/backups/etcd
|
||||
latest=$(ls -t /var/lib/rancher/k3s/server/db/snapshots/etcd-snapshot-* 2>/dev/null | head -1)
|
||||
if [ -n "$latest" ]; then
|
||||
install -d -m700 "$etcd"
|
||||
rm -f "$etcd/etcd-snapshot"
|
||||
ln "$latest" "$etcd/etcd-snapshot" 2>/dev/null || cp -p "$latest" "$etcd/etcd-snapshot" || failed=1
|
||||
fi
|
||||
{% endif %}
|
||||
{% if kopia_backup.dumps %}
|
||||
|
||||
dumps=/var/backups/dumps
|
||||
install -d -m700 "$dumps"
|
||||
dump() { # file, container, command run inside the container
|
||||
if docker exec "$2" sh -c "$3" > "$dumps/$1.tmp"; then
|
||||
mv "$dumps/$1.tmp" "$dumps/$1"
|
||||
else
|
||||
echo "dump of $1 failed" >&2; rm -f "$dumps/$1.tmp"; failed=1
|
||||
fi
|
||||
}
|
||||
{% for d in kopia_backup.dumps %}
|
||||
dump {{ d.file | quote }} {{ d.container | quote }} {{ d.command | quote }}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% set ignores = kopia_backup.ignore | dict2items | rejectattr('value', 'none') | list %}
|
||||
{% if ignores %}
|
||||
|
||||
# Ignore rules of a source, replaced as a whole (--clear-ignore runs after --add-ignore within one call).
|
||||
# Kopia sorts the rules, so negations (!) cannot be relied on to come after the rule they override.
|
||||
ignore() {
|
||||
local path=$1; shift
|
||||
kopia policy set "$path" --clear-ignore >/dev/null 2>&1 &&
|
||||
kopia policy set "$path" "${@/#/--add-ignore=}" >/dev/null 2>&1 ||
|
||||
{ echo "setting the ignore rules of $path failed" >&2; failed=1; }
|
||||
}
|
||||
{% for i in ignores %}
|
||||
ignore {{ i.key | quote }} {{ i.value | map('quote') | join(' ') }}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
for p in {{ kopia_backup.sources | map('quote') | join(' ') }}{{ ' "$etcd"' if kopia_backup.etcd_snapshot }}{{ ' "$dumps"' if kopia_backup.dumps }}; do
|
||||
[ -d "$p" ] && sources+=("$p")
|
||||
done
|
||||
kopia snapshot create "${sources[@]}" || failed=1
|
||||
exit $failed
|
||||
@@ -1,14 +0,0 @@
|
||||
# {{ ansible_managed }}
|
||||
[Unit]
|
||||
Description=Kopia backup to {{ kopia_backup.endpoint }}
|
||||
After={{ (['network-online.target'] + kopia_backup.after) | join(' ') }}
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
Environment=HOME=/root
|
||||
{% if kopia_backup.idle_priority %}
|
||||
Nice=10
|
||||
IOSchedulingClass=idle
|
||||
{% endif %}
|
||||
ExecStart=/usr/local/sbin/kopia-backup
|
||||
@@ -1,13 +0,0 @@
|
||||
# {{ ansible_managed }}
|
||||
[Unit]
|
||||
Description=Daily Kopia backup to {{ kopia_backup.endpoint }}
|
||||
|
||||
[Timer]
|
||||
OnCalendar={{ kopia_backup.on_calendar }}
|
||||
{% if kopia_backup.randomized_delay %}
|
||||
RandomizedDelaySec={{ kopia_backup.randomized_delay }}
|
||||
{% endif %}
|
||||
Persistent=true
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
@@ -1,83 +0,0 @@
|
||||
---
|
||||
kubernetes:
|
||||
ipPool:
|
||||
ipv4:
|
||||
# Minimum: /24
|
||||
cluster_cidr: 10.42.0.0/16
|
||||
service_cidr: 10.43.0.0/16
|
||||
ipv6:
|
||||
# Minimum: /120
|
||||
cluster_cidr: fd42::/112
|
||||
service_cidr: fd43::/112
|
||||
|
||||
# Interface to grab node-IPv4/v6 from
|
||||
nodeIp_interface: <interface to grab nodeIp from>
|
||||
|
||||
control_plane:
|
||||
dns_name: <control-plane dns-reachable-name>
|
||||
|
||||
token: <shared token for nodes to join>
|
||||
|
||||
network:
|
||||
# Writes /etc/resolv-k3s.conf (k3s resolv-conf, CoreDNS upstreams) from
|
||||
# resolv_conf_nameservers
|
||||
clean_resolv_conf: false
|
||||
|
||||
# Fixed upstreams, not copied from the node: a copied snapshot goes stale
|
||||
# when the node's DNS changes and can leave CoreDNS with a single upstream
|
||||
resolv_conf_nameservers:
|
||||
- 1.1.1.1
|
||||
- 9.9.9.9
|
||||
|
||||
# One of [none, flannel, calico, cilium]
|
||||
plugin: calico
|
||||
|
||||
# Helper for networking
|
||||
helper:
|
||||
# https://github.com/Ruakij/RoutingTableToWg
|
||||
# Translates received-routes from e.g. BGP to wireguard-allowedips
|
||||
# Helpful, when nodeIp_interface is a wireguard-interface
|
||||
routingtabletowg: false
|
||||
|
||||
# One of [none, traefik-ingress, nginx-ingress]
|
||||
ingress_controller: traefik-ingress
|
||||
|
||||
# k3s version to install, e.g. v1.37.1+k3s1. An installed k3s is replaced only
|
||||
# when this is set and differs; empty installs the latest when k3s is missing.
|
||||
version: ""
|
||||
|
||||
# KubeletConfiguration fields (evictionHard, evictionSoft, ...) written to
|
||||
# /etc/rancher/k3s/kubelet.conf.d/20-memory.conf; empty writes no file
|
||||
kubelet_memory: {}
|
||||
|
||||
# KubeletConfiguration fields systemReserved and kubeReserved written to
|
||||
# /etc/rancher/k3s/kubelet.conf.d/30-reserved.conf; empty writes no file.
|
||||
# Both only shrink the allocatable of the node (enforceNodeAllocatable stays
|
||||
# pods). Sized in group_vars per node type from the measured memory and CPU
|
||||
# of k3s.service (kubeReserved) and of everything else outside kubepods.slice
|
||||
# (systemReserved).
|
||||
kubelet_reserved: {}
|
||||
|
||||
# Extra config of the nodes running etcd, left out with disable-etcd
|
||||
etcd_config_extra:
|
||||
# Without it etcd serves metrics on 127.0.0.1 only, out of reach of vmagent
|
||||
etcd-expose-metrics: true
|
||||
|
||||
config_extra:
|
||||
# etcd-tuning
|
||||
# heartbeat: 0.5-1.5x of rtt
|
||||
# election: 10x- of heartbeat
|
||||
etcd-arg:
|
||||
heartbeat-interval: 500
|
||||
election-timeout: 5000
|
||||
|
||||
registries_config:
|
||||
# Defines the names and endpoints of registries
|
||||
# When embedded-registry is enabled, every registry needs to be defined here to allow this node to distribute those images.
|
||||
#mirrors:
|
||||
# Enable distribution for all registries
|
||||
#"*": {}
|
||||
|
||||
# Defines authentication and extra settings for the registries
|
||||
#config:
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
@startuml
|
||||
|
||||
rectangle "Control-Plane" as control_plane {
|
||||
rectangle "Node" as sn1 {
|
||||
component "netclient" as sn1_netclient
|
||||
|
||||
component etcd as sn1_etcd
|
||||
component "k3s-server" as sn1_k3s_server
|
||||
sn1_k3s_server - sn1_etcd
|
||||
}
|
||||
|
||||
rectangle "Node" as sn2 {
|
||||
component "netclient" as sn2_netclient
|
||||
|
||||
component etcd as sn2_etcd
|
||||
component "k3s-server" as sn2_k3s_server
|
||||
sn2_k3s_server - sn2_etcd
|
||||
}
|
||||
|
||||
sn1_netclient -- sn2_netclient
|
||||
sn1_etcd -- sn2_etcd
|
||||
}
|
||||
|
||||
rectangle "Workers" {
|
||||
rectangle "Node" as an1 {
|
||||
component "netclient" as an1_netclient
|
||||
|
||||
component "k3s-agent" as sn1_k3s_agent
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@enduml
|
||||
@@ -31,5 +31,6 @@ disabled_plugins = []
|
||||
# level = "info"
|
||||
|
||||
[plugins."io.containerd.grpc.v1.cri".containerd.runtimes.runc]
|
||||
|
||||
[plugins."io.containerd.grpc.v1.cri".containerd.runtimes.runc.options]
|
||||
SystemdCgroup = true
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
FROM golang:1-buster
|
||||
|
||||
# Add PPA
|
||||
RUN echo "deb http://ppa.launchpad.net/dqlite/dev/ubuntu bionic main" > /etc/apt/sources.list.d/ppa_dqlite_dev_bionic.list
|
||||
RUN apt-key adv --keyserver keyserver.ubuntu.com --recv-keys 50FB3D04
|
||||
# Install dependencies
|
||||
RUN apt update -y && apt install -y build-essential git libraft-dev libsqlite3-dev libdqlite-dev
|
||||
|
||||
# Clone
|
||||
RUN git clone https://github.com/canonical/k8s-dqlite --branch v1.0.4 /k8s-dqlite
|
||||
WORKDIR /k8s-dqlite
|
||||
|
||||
# Compile
|
||||
ENV CGO_LDFLAGS_ALLOW="-Wl,-z,now"
|
||||
RUN go build -o k8s-dqlite -tags libsqlite3,dqlite k8s-dqlite.go
|
||||
Executable
BIN
Binary file not shown.
@@ -1,39 +1,3 @@
|
||||
- name: reload_sysctl
|
||||
command: sysctl --system
|
||||
|
||||
- name: restart_containerd
|
||||
ansible.builtin.service:
|
||||
name: containerd
|
||||
state: restarted
|
||||
|
||||
- name: reload_networking
|
||||
service:
|
||||
name: networking
|
||||
state: restarted
|
||||
async: 5
|
||||
poll: 0
|
||||
notify: wait_for_connection
|
||||
|
||||
- name: wait_for_connection
|
||||
wait_for_connection:
|
||||
delay: 5
|
||||
|
||||
# Restarts the kubelet to read changed drop-ins. Runs only with
|
||||
# -e k3s_restart_allowed=true; node.yml refuses it for more than one host.
|
||||
- name: restart_k3s
|
||||
ansible.builtin.service:
|
||||
name: k3s
|
||||
state: restarted
|
||||
when:
|
||||
- k3s_restart_allowed | default(false) | bool
|
||||
- not ansible_check_mode
|
||||
notify: wait_for_k3s
|
||||
|
||||
- name: wait_for_k3s
|
||||
command: k3s kubectl get --raw /readyz
|
||||
register: k3s_readyz
|
||||
until: k3s_readyz.rc == 0 and k3s_readyz.stdout == 'ok'
|
||||
retries: 30
|
||||
delay: 10
|
||||
changed_when: false
|
||||
when: not ansible_check_mode
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
---
|
||||
#dependencies:
|
||||
# - role: docker
|
||||
dependencies:
|
||||
- { role: docker }
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
# kubelet gives kubepods.slice a cpu.weight from its allocatable CPUs (313 on
|
||||
# 8 cores), while system.slice and k3s.service keep the default 100. Under load
|
||||
# the pods then outweigh etcd, apiserver and kubelet. Weight 1000 lets the
|
||||
# control plane win that contention.
|
||||
- name: Set the CPUWeight of system.slice and the k3s unit
|
||||
vars:
|
||||
k3s_unit: "{{ 'k3s' if type == 'server' else 'k3s-agent' }}.service"
|
||||
block:
|
||||
- name: Create the drop-in directories
|
||||
ansible.builtin.file:
|
||||
path: "/etc/systemd/system/{{ item }}.d"
|
||||
state: directory
|
||||
mode: "0755"
|
||||
loop:
|
||||
- system.slice
|
||||
- "{{ k3s_unit }}"
|
||||
|
||||
- name: Write the CPUWeight drop-ins
|
||||
ansible.builtin.copy:
|
||||
dest: "/etc/systemd/system/{{ item.unit }}.d/50-cpu-weight.conf"
|
||||
mode: "0644"
|
||||
content: |
|
||||
[{{ item.section }}]
|
||||
CPUWeight=1000
|
||||
loop:
|
||||
- unit: system.slice
|
||||
section: Slice
|
||||
- unit: "{{ k3s_unit }}"
|
||||
section: Service
|
||||
register: cpu_weight
|
||||
|
||||
# Only daemon-reload: the weight applies to the running k3s unit without a restart
|
||||
- name: Reload systemd for the CPUWeight drop-ins
|
||||
ansible.builtin.systemd_service:
|
||||
daemon_reload: true
|
||||
when: cpu_weight is changed
|
||||
@@ -0,0 +1,12 @@
|
||||
- name: Deploy Cilium-CLI
|
||||
ansible.builtin.unarchive:
|
||||
src: https://github.com/cilium/cilium-cli/releases/latest/download/cilium-linux-amd64.tar.gz
|
||||
dest: /usr/local/bin
|
||||
remote_src: yes
|
||||
mode: u=rwx,g=rx,o=rx
|
||||
|
||||
- name: Install Cilium
|
||||
when: "inventory_hostname == groups['kubernetes'][0]"
|
||||
command: -cilium install
|
||||
environment:
|
||||
KUBECONFIG: /etc/kubernetes/admin.conf
|
||||
@@ -1,68 +0,0 @@
|
||||
- name: Create k3s-folder
|
||||
ansible.builtin.file:
|
||||
path: /etc/rancher/k3s/
|
||||
state: directory
|
||||
mode: "0755"
|
||||
|
||||
- name: Read the live k3s config
|
||||
slurp:
|
||||
src: /etc/rancher/k3s/config.yaml
|
||||
register: live_config
|
||||
failed_when: false
|
||||
|
||||
# A rewrite restarts k3s, so the file is written only when k3s would read other
|
||||
# settings: the live files differ from the rendering in comments, order and
|
||||
# whitespace.
|
||||
- name: Deploy k3s config
|
||||
ansible.builtin.template:
|
||||
src: k3s/{{ type }}/config.yaml.jinja2
|
||||
dest: /etc/rancher/k3s/config.yaml
|
||||
register: config
|
||||
when: >-
|
||||
live_config.content is not defined or
|
||||
(live_config.content | b64decode | from_yaml) != (lookup('template', 'k3s/' ~ type ~ '/config.yaml.jinja2') | from_yaml)
|
||||
|
||||
- name: Read the installed k3s version
|
||||
command: k3s --version
|
||||
register: k3s_installed
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
check_mode: false
|
||||
|
||||
- name: Download install-script
|
||||
get_url:
|
||||
url: https://get.k3s.io
|
||||
dest: /root/k3s_install.sh
|
||||
mode: "744"
|
||||
# todo: update when file changed?
|
||||
|
||||
- name: Read the live registries.yaml
|
||||
slurp:
|
||||
src: /etc/rancher/k3s/registries.yaml
|
||||
register: live_registries
|
||||
failed_when: false
|
||||
when: "type == 'server'"
|
||||
|
||||
# Written only on a change k3s would see, like config.yaml
|
||||
- name: Deploy registries.yaml
|
||||
ansible.builtin.template:
|
||||
src: k3s/server/registries.yaml.j2
|
||||
dest: /etc/rancher/k3s/registries.yaml
|
||||
register: registries_config
|
||||
when:
|
||||
- "type == 'server'"
|
||||
- >-
|
||||
live_registries.content is not defined or
|
||||
(live_registries.content | b64decode | from_yaml) != (lookup('template', 'k3s/server/registries.yaml.j2') | from_yaml)
|
||||
|
||||
- import_tasks: ./install/server/setup_network.yml
|
||||
when: "type == 'server'"
|
||||
|
||||
#- import_tasks: ./install/server/install_helm.yml
|
||||
# when: "type == 'server'"
|
||||
|
||||
- import_tasks: ./install/server/install_k3s.yml
|
||||
when: "type == 'server'"
|
||||
|
||||
- import_tasks: ./install/agent/install_k3s.yml
|
||||
when: "type == 'agent'"
|
||||
@@ -1,15 +0,0 @@
|
||||
- name: Install K3s agent
|
||||
command: /root/k3s_install.sh {{ type }}
|
||||
environment:
|
||||
INSTALL_K3S_VERSION: "{{ kubernetes.version }}"
|
||||
when: k3s_installed.rc != 0 or (kubernetes.version != '' and ('k3s version ' ~ kubernetes.version ~ ' ') not in k3s_installed.stdout)
|
||||
register: command
|
||||
changed_when: "'No change detected' not in command.stdout"
|
||||
until: "command is not failed"
|
||||
retries: 2
|
||||
delay: 10
|
||||
|
||||
- name: Make sure service is started / restarted on config change
|
||||
service:
|
||||
name: k3s-agent
|
||||
state: "{{ 'restarted' if not command.changed and config.changed else 'started' }}"
|
||||
@@ -1,17 +0,0 @@
|
||||
- name: Add Balto key
|
||||
apt_key:
|
||||
url: https://baltocdn.com/helm/signing.asc
|
||||
state: present
|
||||
|
||||
- name: Add Balto Repository
|
||||
apt_repository:
|
||||
repo: "deb https://baltocdn.com/helm/stable/debian/ all main"
|
||||
state: present
|
||||
filename: kubernetes
|
||||
update_cache: yes
|
||||
|
||||
- name: Install helm
|
||||
package:
|
||||
name:
|
||||
- helm
|
||||
state: latest
|
||||
@@ -1,50 +0,0 @@
|
||||
- block:
|
||||
- name: Install K3s-server
|
||||
command: /root/k3s_install.sh {{ type }}
|
||||
environment:
|
||||
INSTALL_K3S_CHANNEL: latest
|
||||
INSTALL_K3S_VERSION: "{{ kubernetes.version }}"
|
||||
when: k3s_installed.rc != 0 or (kubernetes.version != '' and ('k3s version ' ~ kubernetes.version ~ ' ') not in k3s_installed.stdout)
|
||||
register: command
|
||||
changed_when: "'No change detected' not in command.stdout"
|
||||
until: "command is not failed"
|
||||
retries: 2
|
||||
delay: 10
|
||||
|
||||
- name: Make sure service is started / restarted on config change
|
||||
service:
|
||||
name: k3s
|
||||
enabled: true
|
||||
state: "{{ 'restarted' if not command.changed and (config.changed or registries_config.changed) else 'started' }}"
|
||||
|
||||
- name: Waiting for K3s-server to accept connections
|
||||
ansible.builtin.wait_for:
|
||||
host: "{{ inventory_hostname }}"
|
||||
port: 6443
|
||||
state: started
|
||||
throttle: 1
|
||||
|
||||
- name: Ensure the .kube directory exists
|
||||
file:
|
||||
path: "~/.kube"
|
||||
state: directory
|
||||
mode: "0755"
|
||||
|
||||
- name: Create symbolic link for k3s.yaml
|
||||
file:
|
||||
src: "/etc/rancher/k3s/k3s.yaml"
|
||||
dest: "~/.kube/config"
|
||||
state: link
|
||||
|
||||
- name: Deploy calico
|
||||
import_tasks: ./roles/kubernetes/tasks/install/server/network-plugin/deploy_calico.yml
|
||||
when:
|
||||
- "kubernetes.network.plugin == 'calico'"
|
||||
|
||||
- name: Deploy cilium
|
||||
import_tasks: ./roles/kubernetes/tasks/install/server/network-plugin/deploy_cilium.yml
|
||||
when:
|
||||
- "kubernetes.network.plugin == 'cilium'"
|
||||
|
||||
- name: Deploy network-helpers
|
||||
import_tasks: ./roles/kubernetes/tasks/install/server/network-plugin/deploy_network_helper.yml
|
||||
@@ -1,19 +0,0 @@
|
||||
- name: Deploy calico operator
|
||||
command: kubectl create -f https://raw.githubusercontent.com/projectcalico/calico/v3.28.0/manifests/tigera-operator.yaml
|
||||
register: command
|
||||
changed_when: "'created' in command.stdout"
|
||||
run_once: true
|
||||
failed_when:
|
||||
- "command.rc == 1 and 'AlreadyExists' not in command.stderr"
|
||||
|
||||
- name: Deploy calico ressource template
|
||||
ansible.builtin.template:
|
||||
src: ./k3s/server/network-plugin/calico/custom-ressource.yml.jinja2
|
||||
dest: /root/calico-ressource.yml
|
||||
run_once: true
|
||||
|
||||
- name: Deploy calico ressource
|
||||
command: kubectl apply -f /root/calico-ressource.yml
|
||||
register: command
|
||||
changed_when: "'created' in command.stdout"
|
||||
run_once: true
|
||||
@@ -1,71 +0,0 @@
|
||||
- name: Check if already installed in cluster
|
||||
command: "cilium status"
|
||||
register: cilium_status
|
||||
ignore_errors: true
|
||||
changed_when: false
|
||||
check_mode: false
|
||||
run_once: true
|
||||
|
||||
- name: Check if the Cilium CLI is installed
|
||||
stat:
|
||||
path: /usr/local/bin/cilium
|
||||
register: cilium_cli
|
||||
|
||||
- name: Get Cilium CLI version
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- curl
|
||||
- -s
|
||||
- https://raw.githubusercontent.com/cilium/cilium-cli/main/stable.txt
|
||||
register: cilium_cli_version
|
||||
check_mode: no
|
||||
changed_when: false
|
||||
run_once: true
|
||||
|
||||
- name: Determine CLI architecture
|
||||
set_fact:
|
||||
cli_arch: "{{ 'arm64' if ansible_architecture == 'aarch64' else 'amd64' }}"
|
||||
run_once: true
|
||||
|
||||
- name: Download and verify Cilium CLI
|
||||
block:
|
||||
- name: Download Cilium CLI tar.gz file
|
||||
get_url:
|
||||
url: "https://github.com/cilium/cilium-cli/releases/download/{{ cilium_cli_version.stdout }}/cilium-linux-{{ cli_arch }}.tar.gz"
|
||||
dest: "/tmp/cilium-linux-{{ cli_arch }}.tar.gz"
|
||||
checksum: "sha256:{{ lookup('url', 'https://github.com/cilium/cilium-cli/releases/download/' + cilium_cli_version.stdout + '/cilium-linux-' + cli_arch + '.tar.gz.sha256sum', wantlist=True)[0].split(' ')[0] }}"
|
||||
mode: '0755'
|
||||
|
||||
- name: Extract Cilium CLI tar.gz to /usr/local/bin
|
||||
unarchive:
|
||||
src: "/tmp/cilium-linux-{{ cli_arch }}.tar.gz"
|
||||
dest: "/usr/local/bin"
|
||||
remote_src: yes
|
||||
#extra_opts: ["--strip-components=1"]
|
||||
mode: 777
|
||||
# An installed CLI is left alone, not upgraded to the latest release
|
||||
when: cilium_cli_version.stdout is defined and not cilium_cli.stat.exists
|
||||
become: yes
|
||||
|
||||
- name: Clean up temporary files
|
||||
file:
|
||||
path: "/tmp/cilium-linux-{{ cli_arch }}.tar.gz"
|
||||
state: absent
|
||||
|
||||
# Install cilium via cli
|
||||
- block:
|
||||
- name: Install cilium
|
||||
command: >
|
||||
cilium install
|
||||
--set
|
||||
imap.mode=kubernetes,
|
||||
ipv6.enabled=true,
|
||||
#ipam.operator.clusterPoolIPv4PodCIDRList={{ kubernetes.ipPool.ipv4.cluster_cidr }},
|
||||
#ipam.operator.clusterPoolIPv6PodCIDRList={{ kubernetes.ipPool.ipv6.cluster_cidr }}
|
||||
|
||||
- name: Wait for cilium
|
||||
command: cilium status --wait
|
||||
changed_when: false
|
||||
run_once: true
|
||||
when:
|
||||
- cilium_status.rc != 0
|
||||
@@ -1,7 +0,0 @@
|
||||
- name: Deploy service-file for routing-table to wireguard-translation
|
||||
ansible.builtin.template:
|
||||
src: ./k3s/server/network-plugin/calico/routingtabletowg.yml.jinja2
|
||||
dest: /var/lib/rancher/k3s/server/manifests/routingtabletowg.yml
|
||||
mode: u=rw,g=r,o=r
|
||||
run_once: true
|
||||
when: "kubernetes.network.helper.routingtabletowg"
|
||||
@@ -1,6 +0,0 @@
|
||||
- name: Set control-plane-dns-endpoint towards local-ip
|
||||
blockinfile:
|
||||
path: /etc/hosts
|
||||
marker: "# {mark} ANSIBLE MANAGED BLOCK | k3s"
|
||||
block: |
|
||||
{{ nodeip_ipv4 }} {{ kubernetes.control_plane.dns_name }}
|
||||
@@ -0,0 +1,28 @@
|
||||
- name: Add Google-Cloud key
|
||||
apt_key:
|
||||
url: https://packages.cloud.google.com/apt/doc/apt-key.gpg
|
||||
state: present
|
||||
|
||||
- name: Add Kubernetes Repository
|
||||
apt_repository:
|
||||
repo: "deb https://apt.kubernetes.io/ kubernetes-xenial main"
|
||||
state: present
|
||||
filename: kubernetes
|
||||
update_cache: yes
|
||||
|
||||
- name: Install kubernetes-tools
|
||||
package:
|
||||
name:
|
||||
- kubeadm
|
||||
- kubelet
|
||||
- kubectl
|
||||
state: latest
|
||||
|
||||
- name: Hold upgrades for kubernetes-tools
|
||||
dpkg_selections:
|
||||
name: "{{ item }}"
|
||||
selection: hold
|
||||
loop:
|
||||
- kubeadm
|
||||
- kubelet
|
||||
- kubectl
|
||||
@@ -0,0 +1,4 @@
|
||||
- name: Join other nodes to cluster
|
||||
when: "inventory_hostname != groups['kubernetes'][0]"
|
||||
command:
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user