Compare commits
65 Commits
6268884c00
...
master
Author | SHA1 | Date | |
---|---|---|---|
c7649f966c | |||
c63351da59 | |||
62f44939d8 | |||
262e11ba7c | |||
00c43503a9 | |||
4479dd486d | |||
6b5d54ecdf | |||
f4e31ceebe | |||
db5b91b469 | |||
c3179fb681 | |||
357db8f1e0 | |||
4986b970d8 | |||
7f7cd29cb7 | |||
569e97d6d6 | |||
5064a66c3e | |||
dd9abb5672 | |||
f35baa2c63 | |||
6273b6c99e | |||
f0516806da | |||
c4781aa615 | |||
1bbf75d3dd | |||
7b6103be72 | |||
af2ac3e38d | |||
6f5687a98b | |||
f9d781c8f7 | |||
3beb55caae | |||
0406077aa5 | |||
e24ec0a602 | |||
69992c23da | |||
564ab297a9 | |||
114034c0cd | |||
99711a1bb7 | |||
ef86808214 | |||
7f5f8e16b8 | |||
ce44e728e0 | |||
98f6eb0ae0 | |||
caf7ad64d1 | |||
719ee22276 | |||
74a6f42171 | |||
35e6f80243 | |||
723dac7a6f | |||
905f97ef55 | |||
ea344c8940 | |||
a4ff9aff4b | |||
824e6c67d0 | |||
afdc8aa7af | |||
2c10e3766d | |||
f4339dae00 | |||
c092cbdcf3 | |||
1e796771cc | |||
be3b65f3a4 | |||
296dd39d2e | |||
81036f5e99 | |||
1e53adb529 | |||
40290fdc59 | |||
a92e83a7c6 | |||
69bdac4aa6 | |||
7b3ebde367 | |||
b35aa03e70 | |||
d51a1f9638 | |||
c5ae3c0c89 | |||
f4379bbfc2 | |||
9dcb91d9dd | |||
19372524d5 | |||
f061fdc8cc |
241
.gitignore
vendored
Normal file
241
.gitignore
vendored
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
# ---> Python
|
||||||
|
# Byte-compiled / optimized / DLL files
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
|
||||||
|
# C extensions
|
||||||
|
*.so
|
||||||
|
|
||||||
|
# Distribution / packaging
|
||||||
|
.Python
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
share/python-wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
MANIFEST
|
||||||
|
|
||||||
|
# PyInstaller
|
||||||
|
# Usually these files are written by a python script from a template
|
||||||
|
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||||
|
*.manifest
|
||||||
|
*.spec
|
||||||
|
|
||||||
|
# Installer logs
|
||||||
|
pip-log.txt
|
||||||
|
pip-delete-this-directory.txt
|
||||||
|
|
||||||
|
# Unit test / coverage reports
|
||||||
|
htmlcov/
|
||||||
|
.tox/
|
||||||
|
.nox/
|
||||||
|
.coverage
|
||||||
|
.coverage.*
|
||||||
|
.cache
|
||||||
|
nosetests.xml
|
||||||
|
coverage.xml
|
||||||
|
*.cover
|
||||||
|
*.py,cover
|
||||||
|
.hypothesis/
|
||||||
|
.pytest_cache/
|
||||||
|
cover/
|
||||||
|
|
||||||
|
# Translations
|
||||||
|
*.mo
|
||||||
|
*.pot
|
||||||
|
|
||||||
|
# Django stuff:
|
||||||
|
*.log
|
||||||
|
local_settings.py
|
||||||
|
db.sqlite3
|
||||||
|
db.sqlite3-journal
|
||||||
|
|
||||||
|
# Flask stuff:
|
||||||
|
instance/
|
||||||
|
.webassets-cache
|
||||||
|
|
||||||
|
# Scrapy stuff:
|
||||||
|
.scrapy
|
||||||
|
|
||||||
|
# Sphinx documentation
|
||||||
|
docs/_build/
|
||||||
|
|
||||||
|
# PyBuilder
|
||||||
|
.pybuilder/
|
||||||
|
target/
|
||||||
|
|
||||||
|
# Jupyter Notebook
|
||||||
|
.ipynb_checkpoints
|
||||||
|
|
||||||
|
# IPython
|
||||||
|
profile_default/
|
||||||
|
ipython_config.py
|
||||||
|
|
||||||
|
# pyenv
|
||||||
|
# For a library or package, you might want to ignore these files since the code is
|
||||||
|
# intended to run in multiple environments; otherwise, check them in:
|
||||||
|
# .python-version
|
||||||
|
|
||||||
|
# pipenv
|
||||||
|
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||||
|
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||||
|
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||||
|
# install all needed dependencies.
|
||||||
|
#Pipfile.lock
|
||||||
|
|
||||||
|
# poetry
|
||||||
|
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
||||||
|
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||||
|
# commonly ignored for libraries.
|
||||||
|
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
||||||
|
#poetry.lock
|
||||||
|
|
||||||
|
# PEP 582; used by e.g. github.com/David-OConnor/pyflow
|
||||||
|
__pypackages__/
|
||||||
|
|
||||||
|
# Celery stuff
|
||||||
|
celerybeat-schedule
|
||||||
|
celerybeat.pid
|
||||||
|
|
||||||
|
# SageMath parsed files
|
||||||
|
*.sage.py
|
||||||
|
|
||||||
|
# Environments
|
||||||
|
.env
|
||||||
|
.venv
|
||||||
|
env/
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
env.bak/
|
||||||
|
venv.bak/
|
||||||
|
|
||||||
|
# Spyder project settings
|
||||||
|
.spyderproject
|
||||||
|
.spyproject
|
||||||
|
|
||||||
|
# Rope project settings
|
||||||
|
.ropeproject
|
||||||
|
|
||||||
|
# mkdocs documentation
|
||||||
|
/site
|
||||||
|
|
||||||
|
# mypy
|
||||||
|
.mypy_cache/
|
||||||
|
.dmypy.json
|
||||||
|
dmypy.json
|
||||||
|
|
||||||
|
# Pyre type checker
|
||||||
|
.pyre/
|
||||||
|
|
||||||
|
# pytype static type analyzer
|
||||||
|
.pytype/
|
||||||
|
|
||||||
|
# Cython debug symbols
|
||||||
|
cython_debug/
|
||||||
|
|
||||||
|
# PyCharm
|
||||||
|
# JetBrains specific template is maintainted in a separate JetBrains.gitignore that can
|
||||||
|
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||||
|
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||||
|
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||||
|
#.idea/
|
||||||
|
|
||||||
|
# ---> JetBrains
|
||||||
|
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
|
||||||
|
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
|
||||||
|
|
||||||
|
# User-specific stuff
|
||||||
|
.idea/**/workspace.xml
|
||||||
|
.idea/**/tasks.xml
|
||||||
|
.idea/**/usage.statistics.xml
|
||||||
|
.idea/**/dictionaries
|
||||||
|
.idea/**/shelf
|
||||||
|
|
||||||
|
# AWS User-specific
|
||||||
|
.idea/**/aws.xml
|
||||||
|
|
||||||
|
# Generated files
|
||||||
|
.idea/**/contentModel.xml
|
||||||
|
|
||||||
|
# Sensitive or high-churn files
|
||||||
|
.idea/**/dataSources/
|
||||||
|
.idea/**/dataSources.ids
|
||||||
|
.idea/**/dataSources.local.xml
|
||||||
|
.idea/**/sqlDataSources.xml
|
||||||
|
.idea/**/dynamic.xml
|
||||||
|
.idea/**/uiDesigner.xml
|
||||||
|
.idea/**/dbnavigator.xml
|
||||||
|
|
||||||
|
# Gradle
|
||||||
|
.idea/**/gradle.xml
|
||||||
|
.idea/**/libraries
|
||||||
|
|
||||||
|
# Gradle and Maven with auto-import
|
||||||
|
# When using Gradle or Maven with auto-import, you should exclude module files,
|
||||||
|
# since they will be recreated, and may cause churn. Uncomment if using
|
||||||
|
# auto-import.
|
||||||
|
# .idea/artifacts
|
||||||
|
# .idea/compiler.xml
|
||||||
|
# .idea/jarRepositories.xml
|
||||||
|
# .idea/modules.xml
|
||||||
|
# .idea/*.iml
|
||||||
|
# .idea/modules
|
||||||
|
# *.iml
|
||||||
|
# *.ipr
|
||||||
|
|
||||||
|
# CMake
|
||||||
|
cmake-build-*/
|
||||||
|
|
||||||
|
# Mongo Explorer plugin
|
||||||
|
.idea/**/mongoSettings.xml
|
||||||
|
|
||||||
|
# File-based project format
|
||||||
|
*.iws
|
||||||
|
|
||||||
|
# IntelliJ
|
||||||
|
out/
|
||||||
|
|
||||||
|
# mpeltonen/sbt-idea plugin
|
||||||
|
.idea_modules/
|
||||||
|
|
||||||
|
# JIRA plugin
|
||||||
|
atlassian-ide-plugin.xml
|
||||||
|
|
||||||
|
# Cursive Clojure plugin
|
||||||
|
.idea/replstate.xml
|
||||||
|
|
||||||
|
# SonarLint plugin
|
||||||
|
.idea/sonarlint/
|
||||||
|
|
||||||
|
# Crashlytics plugin (for Android Studio and IntelliJ)
|
||||||
|
com_crashlytics_export_strings.xml
|
||||||
|
crashlytics.properties
|
||||||
|
crashlytics-build.properties
|
||||||
|
fabric.properties
|
||||||
|
|
||||||
|
# Editor-based Rest Client
|
||||||
|
.idea/httpRequests
|
||||||
|
|
||||||
|
# Android studio 3.1+ serialized cache file
|
||||||
|
.idea/caches/build_file_checksums.ser
|
||||||
|
|
||||||
|
# ---> Custom
|
||||||
|
.idea/deployment.xml
|
||||||
|
.idea/misc.xml
|
||||||
|
.idea/remote-mappings.xml
|
||||||
|
.idea/*.iml
|
||||||
|
|
||||||
|
# Project-specific
|
||||||
|
config.ini
|
8
.idea/.gitignore
generated
vendored
Normal file
8
.idea/.gitignore
generated
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
# Default ignored files
|
||||||
|
/shelf/
|
||||||
|
/workspace.xml
|
||||||
|
# Editor-based HTTP Client requests
|
||||||
|
/httpRequests/
|
||||||
|
# Datasource local storage ignored files
|
||||||
|
/dataSources/
|
||||||
|
/dataSources.local.xml
|
35
.idea/inspectionProfiles/Project_Default.xml
generated
Normal file
35
.idea/inspectionProfiles/Project_Default.xml
generated
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<component name="InspectionProjectProfileManager">
|
||||||
|
<profile version="1.0">
|
||||||
|
<option name="myName" value="Project Default" />
|
||||||
|
<inspection_tool class="PyCompatibilityInspection" enabled="true" level="WARNING" enabled_by_default="true">
|
||||||
|
<option name="ourVersions">
|
||||||
|
<value>
|
||||||
|
<list size="1">
|
||||||
|
<item index="0" class="java.lang.String" itemvalue="3.11" />
|
||||||
|
</list>
|
||||||
|
</value>
|
||||||
|
</option>
|
||||||
|
</inspection_tool>
|
||||||
|
<inspection_tool class="PyPackageRequirementsInspection" enabled="true" level="WARNING" enabled_by_default="true">
|
||||||
|
<option name="ignoredPackages">
|
||||||
|
<value>
|
||||||
|
<list size="1">
|
||||||
|
<item index="0" class="java.lang.String" itemvalue="google" />
|
||||||
|
</list>
|
||||||
|
</value>
|
||||||
|
</option>
|
||||||
|
</inspection_tool>
|
||||||
|
<inspection_tool class="PyUnresolvedReferencesInspection" enabled="true" level="WARNING" enabled_by_default="true">
|
||||||
|
<option name="ignoredIdentifiers">
|
||||||
|
<list>
|
||||||
|
<option value="str.decode" />
|
||||||
|
</list>
|
||||||
|
</option>
|
||||||
|
</inspection_tool>
|
||||||
|
<inspection_tool class="SpellCheckingInspection" enabled="false" level="TYPO" enabled_by_default="false">
|
||||||
|
<option name="processCode" value="true" />
|
||||||
|
<option name="processLiterals" value="true" />
|
||||||
|
<option name="processComments" value="true" />
|
||||||
|
</inspection_tool>
|
||||||
|
</profile>
|
||||||
|
</component>
|
6
.idea/inspectionProfiles/profiles_settings.xml
generated
Normal file
6
.idea/inspectionProfiles/profiles_settings.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<component name="InspectionProjectProfileManager">
|
||||||
|
<settings>
|
||||||
|
<option name="USE_PROJECT_PROFILE" value="false" />
|
||||||
|
<version value="1.0" />
|
||||||
|
</settings>
|
||||||
|
</component>
|
7
.idea/markdown.xml
generated
Normal file
7
.idea/markdown.xml
generated
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="MarkdownSettings">
|
||||||
|
<option name="customStylesheetText" value="body { font-size: 95% !important; } " />
|
||||||
|
<option name="useCustomStylesheetText" value="true" />
|
||||||
|
</component>
|
||||||
|
</project>
|
8
.idea/modules.xml
generated
Normal file
8
.idea/modules.xml
generated
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="ProjectModuleManager">
|
||||||
|
<modules>
|
||||||
|
<module fileurl="file://$PROJECT_DIR$/.idea/update-firewall-source.iml" filepath="$PROJECT_DIR$/.idea/update-firewall-source.iml" />
|
||||||
|
</modules>
|
||||||
|
</component>
|
||||||
|
</project>
|
3
.idea/scopes/ini_example.xml
generated
Normal file
3
.idea/scopes/ini_example.xml
generated
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<component name="DependencyValidationManager">
|
||||||
|
<scope name="ini_example" pattern="file:examples/config.ini.example||file:README.md" />
|
||||||
|
</component>
|
12
.idea/vcs.xml
generated
Normal file
12
.idea/vcs.xml
generated
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="CommitMessageInspectionProfile">
|
||||||
|
<profile version="1.0">
|
||||||
|
<inspection_tool class="CommitFormat" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||||
|
<inspection_tool class="CommitNamingConvention" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||||
|
</profile>
|
||||||
|
</component>
|
||||||
|
<component name="VcsDirectoryMappings">
|
||||||
|
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
||||||
|
</component>
|
||||||
|
</project>
|
25
.idea/watcherTasks.xml
generated
Normal file
25
.idea/watcherTasks.xml
generated
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="ProjectTasksOptions">
|
||||||
|
<TaskOptions isEnabled="true">
|
||||||
|
<option name="arguments" value="-Ur "$ContentRoot$\README.md"" />
|
||||||
|
<option name="checkSyntaxErrors" value="true" />
|
||||||
|
<option name="description" />
|
||||||
|
<option name="exitCodeBehavior" value="ERROR" />
|
||||||
|
<option name="fileExtension" value="*" />
|
||||||
|
<option name="immediateSync" value="true" />
|
||||||
|
<option name="name" value="Run cog on Markdown files" />
|
||||||
|
<option name="output" value="" />
|
||||||
|
<option name="outputFilters">
|
||||||
|
<array />
|
||||||
|
</option>
|
||||||
|
<option name="outputFromStdout" value="false" />
|
||||||
|
<option name="program" value="$USER_HOME$/AppData/Local/Miniconda3/envs/cog/Scripts/cog" />
|
||||||
|
<option name="runOnExternalChanges" value="true" />
|
||||||
|
<option name="scopeName" value="ini_example" />
|
||||||
|
<option name="trackOnlyRoot" value="false" />
|
||||||
|
<option name="workingDir" value="" />
|
||||||
|
<envs />
|
||||||
|
</TaskOptions>
|
||||||
|
</component>
|
||||||
|
</project>
|
428
README.md
428
README.md
@@ -1,3 +1,429 @@
|
|||||||
# update-firewall-source
|
# update-firewall-source
|
||||||
|
|
||||||
Update a firewall rule that relies on dynamic DNS names
|
A `firewalld` direct rules generator
|
||||||
|
|
||||||
|
## What
|
||||||
|
|
||||||
|
Script `update-firewall-source.py`, UFS for short, assists `firewalld` in writing `iptables`-compatible rules, the so-called _direct_ rules.
|
||||||
|
|
||||||
|
UFS focuses on environments where the following is true:
|
||||||
|
|
||||||
|
1. You're on a Red Hat Enterprise Linux or a derivative operating system
|
||||||
|
2. You want to keep using its default firewall management tool `firewalld`
|
||||||
|
3. You want to use Docker
|
||||||
|
4. You want published Docker ports to not be accessible from everywhere
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
By installing a moderately modern version of Docker Engine it will very kindly take control of some aspects of firewall rules. If you don't do anything with what Docker gives you the end result is that all ports you publish via Docker (and by extension `docker compose`) are quite literally published to the entire Internet. All source addresses can access published ports on your machine which may not necessarily be desired.
|
||||||
|
|
||||||
|
On the one hand Docker expects you to add custom rules for container access to an `iptables` chain called `DOCKER-USER`. On the other hand Docker does not care for how to limit access to host ports.
|
||||||
|
|
||||||
|
UFS handles both container ports and host ports. It largely follows suggestions outlined by [John Michael Carr's August 2017 unrouted.io blog post "Docker meet firewall - finally an answer"](https://unrouted.io/2017/08/15/docker-firewall/).
|
||||||
|
|
||||||
|
## How
|
||||||
|
|
||||||
|
`update-firewall-source.py` uses a `config.ini` file that may in its simplest form look somewhat like this:
|
||||||
|
|
||||||
|
```
|
||||||
|
[My home]
|
||||||
|
addr = some.dyndns.host.net
|
||||||
|
ports = 22, 80, 443
|
||||||
|
|
||||||
|
[deny-all]
|
||||||
|
target = DROP
|
||||||
|
addr =
|
||||||
|
ports =
|
||||||
|
proto =
|
||||||
|
state =
|
||||||
|
```
|
||||||
|
|
||||||
|
Over in the ['examples' directory](examples) you will find systemd `.service` and `.timer` example files to regularly execute UFS.
|
||||||
|
|
||||||
|
Its systemd journal output will look somewhat like this:
|
||||||
|
|
||||||
|
```
|
||||||
|
systemd[1]: Starting firewalld direct rules generator...
|
||||||
|
python[961809]: Generating rules from section '[My home]' ...
|
||||||
|
python[961809]: Verifying address ['some.dyndns.host.net'] ...
|
||||||
|
python[961809]: For 'some.dyndns.host.net' found records: ['1.2.3.4', '2606:4700:20::681a:804']
|
||||||
|
python[961809]: Adding IPv4 address '1.2.3.4' ...
|
||||||
|
python[961809]: For section '[My home]' option 'do_ipv6' equals false. Skipping IPv6 handling of
|
||||||
|
python[961809]: 2606:4700:20::681a:804' ...
|
||||||
|
python[961809]: Writing new firewalld direct config ...
|
||||||
|
python[961809]: Restarting systemd firewalld.service unit ...
|
||||||
|
python[961809]: Done
|
||||||
|
systemd[1]: update-firewall-source.service: Succeeded.
|
||||||
|
systemd[1]: Started firewalld direct rules generator.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tying it together
|
||||||
|
|
||||||
|
A Docker Engine installation nowadays adds the `iptables` chain `DOCKER-USER` which is all well and good. Adding rules to it makes sure that Docker's published ports can only be accessed from where you want.
|
||||||
|
|
||||||
|
If you want to cover both Docker containers and the host OS, however, that doesn't fly. UFS adds a chain named `FILTERS`. This chain is called from both `DOCKER-USER` (anything accessing a Docker published port goes this route) **_and_** from the `INPUT` chain (anything headed for the host operating system goes that way).
|
||||||
|
|
||||||
|
You only maintain the `FILTERS` chain and don't have to worry about whether an application is unknowingly accessible via public Internet - no matter if that app is a container or a `dnf` package. Even better: UFS does management for you, you just give it a `config.ini` file.
|
||||||
|
|
||||||
|
Find more in-depth info on how `ip(6)tables` evolves with UFS down in the ["iptables behind the scenes" section](#iptables-behind-the-scenes).
|
||||||
|
|
||||||
|
# Prep
|
||||||
|
|
||||||
|
Aside from Python dependencies make sure that your OS has headers and static libraries for D-Bus GLib bindings installed as well as generic D-Bus development files. On a Rocky Linux 8 installation for example these come via:
|
||||||
|
```
|
||||||
|
dnf -y install dbus-glib-devel dbus-devel
|
||||||
|
```
|
||||||
|
|
||||||
|
This script assumes write access to `firewalld` direct rules file `/etc/firewalld/direct.xml` or whereever else you've configured this file to live. Typically that means you're going to want to run UFS as `root`.
|
||||||
|
|
||||||
|
UFS understands the environment variable `UFS_LOGLEVEL` to set its log verbosity. `UFS_LOGLEVEL` defaults to `INFO`, for more verbosity change it to `DEBUG`, for less change it to either `WARNING` or even just `ERROR`. The example systemd `.service` file in ['examples' directory](examples) makes use of the following decleration:
|
||||||
|
|
||||||
|
```
|
||||||
|
Environment='...' 'UFS_LOGLEVEL=INFO'
|
||||||
|
```
|
||||||
|
|
||||||
|
Since `UFS_LOGLEVEL=INFO` is default anyway this particular example is redundant and serves as starting point for you.
|
||||||
|
|
||||||
|
# Config structure
|
||||||
|
|
||||||
|
Package configuration happens via a `config.ini` file that follows INI-style syntax. Copy [examples/config.ini.example](examples/config.ini.example) to `config.ini` to get started:
|
||||||
|
|
||||||
|
<!-- [[[cog
|
||||||
|
import os
|
||||||
|
examples_dir = "examples"
|
||||||
|
config_ini_example = "config.ini.example"
|
||||||
|
config_ini_example_abs = os.path.join(examples_dir, config_ini_example)
|
||||||
|
try:
|
||||||
|
with open(config_ini_example_abs) as config_ini_example_handle:
|
||||||
|
config_ini_example_content = config_ini_example_handle.read()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
cog.out(f"```\n"
|
||||||
|
f"{config_ini_example_content.rstrip()}\n"
|
||||||
|
f"```")
|
||||||
|
]]] -->
|
||||||
|
```
|
||||||
|
[DEFAULT]
|
||||||
|
target = ACCEPT
|
||||||
|
addr =
|
||||||
|
ports = 80, 443
|
||||||
|
proto = tcp
|
||||||
|
state = NEW
|
||||||
|
hitcount =
|
||||||
|
do_ipv6 = false
|
||||||
|
firewalld_direct_file_abs = /etc/firewalld/direct.xml
|
||||||
|
restart_firewalld_after_change = true
|
||||||
|
|
||||||
|
[anyone-may-icmp-with-limit]
|
||||||
|
addr =
|
||||||
|
ports =
|
||||||
|
proto = icmp
|
||||||
|
state = NEW,UNTRACKED
|
||||||
|
hitcount = 120/60
|
||||||
|
|
||||||
|
[anyone-can-access-website]
|
||||||
|
|
||||||
|
# Unsetting 'proto' while having a 'ports' value results in an invalid section
|
||||||
|
# [these-guys-can-dns]
|
||||||
|
# addr = google.li, 142.251.36.195, lowendbox.com, 2606:4700:20::ac43:4775
|
||||||
|
# ports = 53
|
||||||
|
# proto =
|
||||||
|
# do_ipv6 = true
|
||||||
|
|
||||||
|
[maybe-a-webserver]
|
||||||
|
addr = 2606:4700:20::681a:804, lowendtalk.com
|
||||||
|
ports = 80, 443
|
||||||
|
do_ipv6 = true
|
||||||
|
|
||||||
|
[anyone-may-access-mail-services]
|
||||||
|
ports = 143, 993, 110, 995, 25, 465, 587
|
||||||
|
hitcount = 120/60
|
||||||
|
|
||||||
|
[deny-all]
|
||||||
|
target = DROP
|
||||||
|
addr =
|
||||||
|
ports =
|
||||||
|
proto =
|
||||||
|
state =
|
||||||
|
do_ipv6 = true
|
||||||
|
```
|
||||||
|
<!-- [[[end]]] -->
|
||||||
|
|
||||||
|
# Layout
|
||||||
|
|
||||||
|
A config file can have an optional `[DEFAULT]` section and must have at least one `[section]` other than `[DEFAULT]`. Any `[DEFAULT]` option that's undefined retains its default value. Feel free to delete the entire `[DEFAULT]` section from your file. A setting changed in `[DEFAULT]` section affects all sections. A setting changed only in a custom `[section]` overwrites it for only the section.
|
||||||
|
|
||||||
|
Custom sections such as `[maybe-a-webserver]` in above example file are treated as organizational helper constructs. You can but don't have to group IP address rules by sections. Technically nothing's stopping you from adding all IP allow list entries into a single section.
|
||||||
|
|
||||||
|
# Example explanation
|
||||||
|
|
||||||
|
Setting `restart_firewalld_after_change` controls if you want the `firewalld` systemd unit to be restarted
|
||||||
|
|
||||||
|
In above example file note that `[anyone-can-access-website]` makes `[maybe-a-webserver]` irrelevant, the latter one could easily be deleted. `[anyone-can-access-website]` does not overwrite defaults, it's an empty section. With it `firewalld` will create a rule that - following all default settings - allows access from any source address on TCP ports 80 and 443. In section `[maybe-a-webserver]` we do the same and additionally limit source addresses. Rules are added in `config.ini` order so the first rule permitting access to TCP ports 80 and 443 from anywhere makes the second one irrelevant.
|
||||||
|
|
||||||
|
We strongly recommend you do keep the very last example section:
|
||||||
|
```
|
||||||
|
[deny-all]
|
||||||
|
target = DROP
|
||||||
|
addr =
|
||||||
|
ports =
|
||||||
|
proto =
|
||||||
|
state =
|
||||||
|
do_ipv6 = true
|
||||||
|
```
|
||||||
|
If a packet has traversed rules this far without being accepted it will be dropped. Note that if any of your custom `[sections]` use `do_ipv6 = true` your final `DROP` rule should do the same. Otherwise you'll just get a `DROP` rule in `iptables` but not in `ip6tables`.
|
||||||
|
|
||||||
|
# Options
|
||||||
|
|
||||||
|
## Globals
|
||||||
|
|
||||||
|
In `[DEFAULT]` section the following settings are called globals. They're only valid in `[DEFAULT]` context. Adding them to a custom `[section]` (see [Locals](#locals) below) won't do anything, in a custom `[section]` the following settings are ignored.
|
||||||
|
|
||||||
|
* `firewalld_direct_file_abs`, __*optional*__, defaults to `/etc/firewalld/direct.xml`: Location of `firewalld`'s direct rules file. This is where new XML rule content is written.
|
||||||
|
|
||||||
|
* `restart_firewalld_after_change`, __*optional*__, defaults to `true`: After putting a new `/etc/firewalld/direct.xml` file in place restart the `firewalld` systemd service unit.
|
||||||
|
|
||||||
|
## Locals
|
||||||
|
|
||||||
|
A custom `[section]` has the following options. We're calling them locals most of which are optional.
|
||||||
|
|
||||||
|
* `target`, __*mandatory*__, defaults to `ACCEPT`, can be any valid `iptables` target. Must not be empty nor unset. A string specifying the fate of a packet that matched this rule. See "TARGETS" section in [iptables man page](https://ipset.netfilter.org/iptables.man.html). You're most likely going to want to stick to either `ACCEPT` or `DROP`. By default matching packets are accepted. We do not do our own validation of what you write here. `firewalld` will try its best to get your files loaded into `ip(6)tables`. It will complain via its systemd journal if that fails for example because of a bogus target.
|
||||||
|
|
||||||
|
```
|
||||||
|
# Valid example:
|
||||||
|
target = DROP
|
||||||
|
```
|
||||||
|
|
||||||
|
* `addr`, __*optional*__, defaults to an empty string: A comma-separated list of any combination of IPv4 addresses, IPv6 addresses and domain names. When `update-firewall-source.py` constructs `firewalld` rules these addresses are allowed to access the server. If left undefined `addr` defaults to an empty list meaning rules apply to any and all source addresses.
|
||||||
|
|
||||||
|
Subnets are unsupported, both as subnet masks (`142.251.36.195/255.255.255.248`) and in [CIDR](https://en.wikipedia.org/wiki/Classless_Inter-Domain_Routing) notation (`142.251.36.195/29`). Do not single- nor double-quote list entries. Do feel free to separate entries with comma-space instead of just a comma.
|
||||||
|
|
||||||
|
```
|
||||||
|
# Valid example:
|
||||||
|
addr = 2606:4700:20::681a:804, lowendtalk.com
|
||||||
|
|
||||||
|
# Also valid (this is the default):
|
||||||
|
addr =
|
||||||
|
```
|
||||||
|
|
||||||
|
* `ports`, __*optional*__, defaults to `80, 443`: A comma-separated list of ports that should be accessible from `addr`. If empty `addr` may access all ports. See [iptables-extensions man page, section "multiport"](https://ipset.netfilter.org/iptables-extensions.man.html#lbBM) for syntax reference. All port-based rules use `iptables ... --match multiport` even if you're only allowing access to a single port. In essence construct your ports list with any combination of single ports (`80, 443, 8080`) and port ranges (`6660:7000, 61000:65535`).
|
||||||
|
|
||||||
|
```
|
||||||
|
# Valid example:
|
||||||
|
ports = 80, 443, 6660:7000, 8080
|
||||||
|
```
|
||||||
|
|
||||||
|
* `proto`, __*optional*__, defaults to `tcp`: A singular protocol that should be allowed for `addr` on `ports`. Can be set to an empty value in which case all protocols are allowed. Since `firewalld` direct rules use `iptables` syntax the list of possible protocol names is largely identical to what the [iptables man page](https://ipset.netfilter.org/iptables.man.html) says about its `--protocol` argument:
|
||||||
|
> The specified protocol can be one of `tcp`, `udp`, `udplite`, `icmp`, `icmpv6`, `esp`, `ah`, `sctp`, `mh` or the special keyword `all`, or it can be a numeric value, representing one of these protocols or a different one. A protocol name from `/etc/protocols` is also allowed. A `!` argument before the protocol inverts the test. The number zero is equivalent to `all`.
|
||||||
|
|
||||||
|
Your mileage may vary depending on which specific OS flavor and version you're running.
|
||||||
|
|
||||||
|
**Implementation details:**
|
||||||
|
* `proto` is treated as a string, not a list. To for example allow access via both TCP and UDP create two `[sections]` like so:
|
||||||
|
```
|
||||||
|
[tcp-rule]
|
||||||
|
addr = 1.1.1.1
|
||||||
|
ports =
|
||||||
|
proto = tcp
|
||||||
|
|
||||||
|
[udp-rule]
|
||||||
|
addr = 1.1.1.1
|
||||||
|
ports =
|
||||||
|
proto = udp
|
||||||
|
```
|
||||||
|
Since `proto = tcp` is default you can leave it out of the top section. Side note, in this specific example you would want to set the `[DEFAULT]` value `ports =` instead of repeating it in each `[section]`. Alternatively set `proto =` to allow all protocols in which case a single `[section]` is enough to cover that use case:
|
||||||
|
```
|
||||||
|
[permit-port-53-via-all-protocols]
|
||||||
|
addr = 1.1.1.1
|
||||||
|
ports =
|
||||||
|
proto =
|
||||||
|
```
|
||||||
|
Make sure that when `proto` is unset you also unset `ports`. See next bullet point for details on that.
|
||||||
|
|
||||||
|
* Unsetting `proto` while at the same time leaving at least one `ports` value in place (which is the default with `ports = 80, 443`) is an error.
|
||||||
|
|
||||||
|
It will result in a rule that `firewalld` cannot load into `ip(6)tables`. It will report it as such in its systemd journal visible e.g. via `journalctl -fu firewalld.service`. This is because having at least one port configured will always result in adding a `--match multiport` which is only valid when also giving a `--protocol` such as `--protocol tcp`.
|
||||||
|
|
||||||
|
```
|
||||||
|
[DEFAULT]
|
||||||
|
ports = 80, 443
|
||||||
|
proto = tcp
|
||||||
|
|
||||||
|
[valid]
|
||||||
|
addr = example.net
|
||||||
|
ports = 22, 80, 443
|
||||||
|
|
||||||
|
[invalid]
|
||||||
|
addr = example.com
|
||||||
|
proto =
|
||||||
|
|
||||||
|
# Without 'ports' there will be no '--match multiport'
|
||||||
|
# and without /that/ you can safely unset 'proto':
|
||||||
|
[also-valid]
|
||||||
|
addr = example.org
|
||||||
|
ports =
|
||||||
|
proto =
|
||||||
|
```
|
||||||
|
* Protocol strings `icmpv6` and `icmp` are treated specially. You can use either one as your `proto =`, UFS will internally automatically use `icmpv6` for `ip6tables` and will use `icmp` for `iptables` rules.
|
||||||
|
|
||||||
|
* `state`, __*optional*__, defaults to `NEW`: Comma-separated list of connection tracking states against which a packet is matched. Most of the time your rules will want to use the default `NEW`. The final `DROP` rule present in the example `config.ini` file at [examples/config.ini.example](examples/config.ini.example) is one occasion where you'll want to deviate and unset `state` to an empty value. See ["state" extension man page in iptables docs](https://ipset.netfilter.org/iptables-extensions.man.html#lbCC) for reference.
|
||||||
|
|
||||||
|
```
|
||||||
|
# Valid example:
|
||||||
|
state =
|
||||||
|
```
|
||||||
|
|
||||||
|
* `hitcount`, **_optional_**, defaults to an empty value: A rate-limiting feature. Set this to `hits/seconds` to limit the amount of matched packets to `hits` over the course of `seconds`, e.g. `10/60` sets the maximum packet rate to 10 packets over the course of 60 seconds. Any packet exceeding the rate will be dropped.
|
||||||
|
|
||||||
|
Adding a `hitcount` will automatically add 2 `ip(6)tables` rules right before the actual rules. Rules follow the [iptables "recent" extension](https://ipset.netfilter.org/iptables-extensions.man.html#lbBW). The first rule does `--update`, the second one does `--set` followed by the rule you specified.
|
||||||
|
|
||||||
|
Given config section:
|
||||||
|
```
|
||||||
|
[anyone-may-access-mail-services]
|
||||||
|
ports = 143, 993, 110, 995, 25, 465, 587
|
||||||
|
hitcount = 120/60
|
||||||
|
```
|
||||||
|
UFS generates rules:
|
||||||
|
```
|
||||||
|
target
|
||||||
|
DROP ... multiport dports 143,993,110,995,25,465,587 recent: UPDATE seconds: 60 hit_count: 120 ...
|
||||||
|
... multiport dports 143,993,110,995,25,465,587 recent: SET ...
|
||||||
|
ACCEPT ... state NEW multiport dports 143,993,110,995,25,465,587 ...
|
||||||
|
```
|
||||||
|
Where the first `DROP` target will drop packets that have exceeded their hit count, the second `recent: SET` simply marks all matching packets to be added into the hitcount bucket and the third one is the actual `ACCEPT` rule permitting access **_if_** a source's hitcount permits it.
|
||||||
|
|
||||||
|
* `do_ipv6`, __*optional*__, defaults to `false`: Decide if you want `firewalld` to generate `ip6tables` rules in addition to `iptables` rules. A default install of Docker Engine will have its IPv6 support disabled in `/etc/docker/daemon.json`. You may still want your machine to handle incoming IPv6 traffic. If your machine truly doesn't use IPv6 feel free to leave this at `false`. Otherwise `update-firewall-source.py` generates unused rules that clutter your rule set.
|
||||||
|
|
||||||
|
If this is `true` IPv6 addresses found or resolved in `addr` in a `[section]` will be discarded.
|
||||||
|
|
||||||
|
```
|
||||||
|
# Valid example:
|
||||||
|
do_ipv6 = true
|
||||||
|
```
|
||||||
|
|
||||||
|
## iptables behind the scenes
|
||||||
|
|
||||||
|
In an `iptables` rule set in the `filter` table by default you'll see something like this. We're only focusing on `INPUT` and `FORWARD` chains here as these are the ones relevant to Docker.
|
||||||
|
|
||||||
|
```
|
||||||
|
Chain INPUT (policy ACCEPT 140 packets, 10115 bytes)
|
||||||
|
num pkts bytes target prot opt in out source destination
|
||||||
|
|
||||||
|
Chain FORWARD (policy ACCEPT 0 packets, 0 bytes)
|
||||||
|
num pkts bytes target prot opt in out source destination
|
||||||
|
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
Once you install a moderately modern Docker Engine it changes things up to this:
|
||||||
|
|
||||||
|
```
|
||||||
|
Chain INPUT (policy ACCEPT 1027 packets, 78370 bytes)
|
||||||
|
num pkts bytes target prot opt in out source destination
|
||||||
|
|
||||||
|
Chain FORWARD (policy ACCEPT 0 packets, 0 bytes)
|
||||||
|
num pkts bytes target prot opt in out source destination
|
||||||
|
1 0 0 DOCKER-USER all -- * * 0.0.0.0/0 0.0.0.0/0
|
||||||
|
2 0 0 DOCKER-ISOLATION-STAGE-1 all -- * * 0.0.0.0/0 0.0.0.0/0
|
||||||
|
3 0 0 ACCEPT all -- * docker0 0.0.0.0/0 0.0.0.0/0 ctstate RELATED,ESTABLISHED
|
||||||
|
4 0 0 DOCKER all -- * docker0 0.0.0.0/0 0.0.0.0/0
|
||||||
|
5 0 0 ACCEPT all -- docker0 !docker0 0.0.0.0/0 0.0.0.0/0
|
||||||
|
6 0 0 ACCEPT all -- docker0 docker0 0.0.0.0/0 0.0.0.0/0
|
||||||
|
|
||||||
|
...
|
||||||
|
|
||||||
|
Chain DOCKER-USER (1 references)
|
||||||
|
num pkts bytes target prot opt in out source destination
|
||||||
|
1 0 0 RETURN all -- * * 0.0.0.0/0 0.0.0.0/0
|
||||||
|
```
|
||||||
|
|
||||||
|
With UFS you're extending this into a configuration where UFS' `FILTERS` chain is called from both Docker's `DOCKER-USER` chain **_and_** the host OS `INPUT` chain. Ideally `FILTERS` ends with a `DROP` target to make sure nothing accesses your services that's not supposed to access them.
|
||||||
|
|
||||||
|
```
|
||||||
|
Chain INPUT (policy ACCEPT 56 packets, 4596 bytes)
|
||||||
|
num pkts bytes target prot opt in out source destination
|
||||||
|
1 2910 207K ACCEPT all -- lo * 0.0.0.0/0 0.0.0.0/0
|
||||||
|
2 1309 111K FILTERS all -- * * 0.0.0.0/0 0.0.0.0/0
|
||||||
|
|
||||||
|
Chain FORWARD (policy ACCEPT 0 packets, 0 bytes)
|
||||||
|
num pkts bytes target prot opt in out source destination
|
||||||
|
1 0 0 DOCKER-ISOLATION-STAGE-1 all -- * * 0.0.0.0/0 0.0.0.0/0
|
||||||
|
2 0 0 DOCKER-USER all -- * * 0.0.0.0/0 0.0.0.0/0
|
||||||
|
3 0 0 ACCEPT all -- * docker0 0.0.0.0/0 0.0.0.0/0 ctstate RELATED,ESTABLISHED
|
||||||
|
4 0 0 DOCKER all -- * docker0 0.0.0.0/0 0.0.0.0/0
|
||||||
|
5 0 0 ACCEPT all -- docker0 !docker0 0.0.0.0/0 0.0.0.0/0
|
||||||
|
6 0 0 ACCEPT all -- docker0 docker0 0.0.0.0/0 0.0.0.0/0
|
||||||
|
|
||||||
|
...
|
||||||
|
|
||||||
|
Chain FILTERS (2 references)
|
||||||
|
num pkts bytes target prot opt in out source destination
|
||||||
|
1 1267 107K ACCEPT all -- * * 0.0.0.0/0 0.0.0.0/0 state RELATED,ESTABLISHED
|
||||||
|
2 0 0 ACCEPT tcp -- * * 1.2.3.4 0.0.0.0/0 state NEW multiport dports 22,80,443 /* My home */
|
||||||
|
3 1 162 DROP all -- * * 0.0.0.0/0 0.0.0.0/0 /* deny-all */
|
||||||
|
|
||||||
|
Chain DOCKER-USER (1 references)
|
||||||
|
num pkts bytes target prot opt in out source destination
|
||||||
|
1 0 0 FILTERS all -- ens3 * 0.0.0.0/0 0.0.0.0/0
|
||||||
|
2 0 0 RETURN all -- * * 0.0.0.0/0 0.0.0.0/0
|
||||||
|
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Rule comments
|
||||||
|
|
||||||
|
The `[section]` name is used as `ip(6)tables` rule comment. The `[section]` name is truncated to the first 256 characters to fit into an `ip(6)tables` comment if needed.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```
|
||||||
|
[My home]
|
||||||
|
addr = some.dyndns.host.net
|
||||||
|
ports = 22, 80, 443
|
||||||
|
|
||||||
|
[deny-all]
|
||||||
|
target = DROP
|
||||||
|
addr =
|
||||||
|
ports =
|
||||||
|
proto =
|
||||||
|
state =
|
||||||
|
```
|
||||||
|
|
||||||
|
Results in `ip(6)tables` rules:
|
||||||
|
|
||||||
|
```
|
||||||
|
Chain FILTERS (2 references)
|
||||||
|
num pkts bytes target prot opt in out source destination
|
||||||
|
1 1267 107K ACCEPT all -- * * 0.0.0.0/0 0.0.0.0/0 state RELATED,ESTABLISHED
|
||||||
|
2 0 0 ACCEPT tcp -- * * 1.2.3.4 0.0.0.0/0 state NEW multiport dports 22,80,443 /* My home */
|
||||||
|
3 0 0 DROP all -- * * 0.0.0.0/0 0.0.0.0/0 /* deny-all */
|
||||||
|
```
|
||||||
|
|
||||||
|
# Development
|
||||||
|
|
||||||
|
## Conventional Commits
|
||||||
|
|
||||||
|
We use [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/#summary).
|
||||||
|
|
||||||
|
### Scopes
|
||||||
|
|
||||||
|
The following **_scopes_** are known for this project. A Conventional Commits commit message may optionally use one of the following scopes or none:
|
||||||
|
|
||||||
|
* `config`: Structure or content of a `config.ini` file
|
||||||
|
* `dbus`: Deals with functionality to restart the `firewalld.service` unit
|
||||||
|
* `systemd`: Deals with lifecycle as a systemd unit
|
||||||
|
* `meta`: Affects the project's repo layout, readme content, file names etc.
|
||||||
|
* `dns`: Resolution of DNS records
|
||||||
|
* `xml`: XML content handling for `firewalld` direct rules, includes segues into `ip(6)tables` territory
|
||||||
|
* `netdev`: Network devices
|
||||||
|
* `debug`: Deals with debuggability, concise messages to end user
|
||||||
|
|
||||||
|
### Types
|
||||||
|
|
||||||
|
The following **_types_** are known for this project in addition to Conventional Commits default types `fix` and `feat`. A Conventional Commits commit message must use either one of the two default types or optionally a type from this list:
|
||||||
|
|
||||||
|
* `build`: Project structure, directory layout, build instructions for roll-out
|
||||||
|
* `refactor`: Keeping functionality while streamlining or otherwise improving function flow
|
||||||
|
* `test`: Working on test coverage
|
||||||
|
* `docs`: Documentation for project or components
|
||||||
|
19
cog/render-readme.py
Normal file
19
cog/render-readme.py
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
# Base example looks like this. Put below code in empty 'cog' line, replace print() with cog.out()
|
||||||
|
# < !-- [[[cog
|
||||||
|
#
|
||||||
|
# ]]] -->
|
||||||
|
# <!-- [[[end]]] -->
|
||||||
|
|
||||||
|
import os
|
||||||
|
examples_dir = "examples"
|
||||||
|
config_ini_example = "config.ini.example"
|
||||||
|
config_ini_example_abs = os.path.join(examples_dir, config_ini_example)
|
||||||
|
try:
|
||||||
|
with open(config_ini_example_abs) as config_ini_example_handle:
|
||||||
|
config_ini_example_content = config_ini_example_handle.read()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
print(f"```\n"
|
||||||
|
f"{config_ini_example_content.rstrip()}\n"
|
||||||
|
f"```")
|
43
examples/config.ini.example
Normal file
43
examples/config.ini.example
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
[DEFAULT]
|
||||||
|
target = ACCEPT
|
||||||
|
addr =
|
||||||
|
ports = 80, 443
|
||||||
|
proto = tcp
|
||||||
|
state = NEW
|
||||||
|
hitcount =
|
||||||
|
do_ipv6 = false
|
||||||
|
firewalld_direct_file_abs = /etc/firewalld/direct.xml
|
||||||
|
restart_firewalld_after_change = true
|
||||||
|
|
||||||
|
[anyone-may-icmp-with-limit]
|
||||||
|
addr =
|
||||||
|
ports =
|
||||||
|
proto = icmp
|
||||||
|
state = NEW,UNTRACKED
|
||||||
|
hitcount = 120/60
|
||||||
|
|
||||||
|
[anyone-can-access-website]
|
||||||
|
|
||||||
|
# Unsetting 'proto' while having a 'ports' value results in an invalid section
|
||||||
|
# [these-guys-can-dns]
|
||||||
|
# addr = google.li, 142.251.36.195, lowendbox.com, 2606:4700:20::ac43:4775
|
||||||
|
# ports = 53
|
||||||
|
# proto =
|
||||||
|
# do_ipv6 = true
|
||||||
|
|
||||||
|
[maybe-a-webserver]
|
||||||
|
addr = 2606:4700:20::681a:804, lowendtalk.com
|
||||||
|
ports = 80, 443
|
||||||
|
do_ipv6 = true
|
||||||
|
|
||||||
|
[anyone-may-access-mail-services]
|
||||||
|
ports = 143, 993, 110, 995, 25, 465, 587
|
||||||
|
hitcount = 120/60
|
||||||
|
|
||||||
|
[deny-all]
|
||||||
|
target = DROP
|
||||||
|
addr =
|
||||||
|
ports =
|
||||||
|
proto =
|
||||||
|
state =
|
||||||
|
do_ipv6 = true
|
12
examples/update-firewall-source.service.example
Normal file
12
examples/update-firewall-source.service.example
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=firewalld direct rules generator
|
||||||
|
After=multi-user.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
RemainAfterExit=no
|
||||||
|
Environment='PATH=/usr/local/sbin:/usr/local/bin:/usr/bin' 'UFS_LOGLEVEL=INFO'
|
||||||
|
ExecStart=/opt/miniconda3/envs/update-firewall-source/bin/python /opt/python/update-firewall-source/dev/update-firewall-source.py
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
13
examples/update-firewall-source.timer.example
Normal file
13
examples/update-firewall-source.timer.example
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Run firewalld direct rules generator
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnCalendar=
|
||||||
|
OnCalendar=4,5,6:00,15,30,45 Asia/Shanghai
|
||||||
|
OnCalendar=1,10,14,18,22:00 Asia/Shanghai
|
||||||
|
OnBootSec=5min
|
||||||
|
RandomizedDelaySec=2min
|
||||||
|
Persistent=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
7
requirements.in
Normal file
7
requirements.in
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
lxml
|
||||||
|
rich
|
||||||
|
validators
|
||||||
|
dnspython
|
||||||
|
inflect
|
||||||
|
cogapp
|
||||||
|
dbus-python
|
26
requirements.txt
Normal file
26
requirements.txt
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
#
|
||||||
|
# This file is autogenerated by pip-compile with python 3.10
|
||||||
|
# To update, run:
|
||||||
|
#
|
||||||
|
# pip-compile
|
||||||
|
#
|
||||||
|
cogapp==3.3.0
|
||||||
|
# via -r requirements.in
|
||||||
|
commonmark==0.9.1
|
||||||
|
# via rich
|
||||||
|
dbus-python==1.2.18
|
||||||
|
# via -r requirements.in
|
||||||
|
decorator==5.1.1
|
||||||
|
# via validators
|
||||||
|
dnspython==2.2.1
|
||||||
|
# via -r requirements.in
|
||||||
|
inflect==5.6.0
|
||||||
|
# via -r requirements.in
|
||||||
|
lxml==4.9.1
|
||||||
|
# via -r requirements.in
|
||||||
|
pygments==2.12.0
|
||||||
|
# via rich
|
||||||
|
rich==12.4.4
|
||||||
|
# via -r requirements.in
|
||||||
|
validators==0.20.0
|
||||||
|
# via -r requirements.in
|
689
update-firewall-source.py
Normal file
689
update-firewall-source.py
Normal file
@@ -0,0 +1,689 @@
|
|||||||
|
# Exit with various exit codes
|
||||||
|
import sys
|
||||||
|
# Path and env manipulation
|
||||||
|
import os
|
||||||
|
# Manipulate style and content of logs
|
||||||
|
import logging
|
||||||
|
from rich.logging import RichHandler
|
||||||
|
# Use a config file
|
||||||
|
import configparser
|
||||||
|
# Verify IP address family
|
||||||
|
import ipaddress
|
||||||
|
# Resolve host names
|
||||||
|
import dns.resolver
|
||||||
|
# Validate if string is fqdn
|
||||||
|
import validators
|
||||||
|
# Build XML structure
|
||||||
|
import lxml.etree
|
||||||
|
import lxml.builder
|
||||||
|
# Correctly generate plurals, singular nouns etc.
|
||||||
|
import inflect
|
||||||
|
# Restart firewalld systemd service unit
|
||||||
|
import dbus
|
||||||
|
# Find physical network interface via 'find' command
|
||||||
|
import subprocess
|
||||||
|
# Diff new and existing firewalld direct rules XML structure
|
||||||
|
import difflib
|
||||||
|
|
||||||
|
|
||||||
|
# Exit codes
|
||||||
|
# 1 : Config file invalid, it has no sections
|
||||||
|
# 2 : Config file invalid, sections must define at least CONST.CFG_MANDATORY
|
||||||
|
# 3 : No physical network device found at "/sys/class/net"
|
||||||
|
# 4 : Linux find command exited non-zero trying to find a physical network device at "/sys/class/net"
|
||||||
|
# 5 : Unable to open firewalld direct rules file for reading
|
||||||
|
# 6 : Kernel sysfs export for network devices at "/sys/class/net" doesn't exist
|
||||||
|
# 7 : An option that must have a non-null value is either unset or null
|
||||||
|
# 8 : Exception while adding a chain XML element to firewalld direct rules
|
||||||
|
# 9 : Unable to open firewalld direct rules file for updating
|
||||||
|
# 10: Unable to restart systemd firewalld.service unit
|
||||||
|
|
||||||
|
|
||||||
|
class CONST(object):
|
||||||
|
__slots__ = ()
|
||||||
|
LOG_FORMAT = "%(message)s"
|
||||||
|
# How to find a config file
|
||||||
|
CFG_THIS_FILE_DIRNAME = os.path.dirname(__file__)
|
||||||
|
CFG_DEFAULT_FILENAME = "config.ini"
|
||||||
|
CFG_DEFAULT_ABS_PATH = os.path.join(CFG_THIS_FILE_DIRNAME, CFG_DEFAULT_FILENAME)
|
||||||
|
# Values you don't have to set, these are their internal defaults. You may optionally add a key 'is_global' equal
|
||||||
|
# to either True or False. By default if left off it'll be assumed False. Script will treat values where
|
||||||
|
# 'is_global' equals True as not being overridable in a '[section]'. It's a setting that only makes sense in a
|
||||||
|
# global context for the entire script. An option where 'empty_ok' equals True can safely be unset or set to
|
||||||
|
# an empty string. An example config.ini file may give a sane config example value here, removing that value
|
||||||
|
# still results in a valid file.
|
||||||
|
CFG_KNOWN_DEFAULTS = [
|
||||||
|
{"key": "target", "value": "ACCEPT", "is_global": False, "empty_ok": False},
|
||||||
|
{"key": "addr", "value": "", "is_global": False, "empty_ok": True},
|
||||||
|
{"key": "ports", "value": "80, 443", "is_global": False, "empty_ok": True},
|
||||||
|
{"key": "proto", "value": "tcp", "is_global": False, "empty_ok": True},
|
||||||
|
{"key": "state", "value": "NEW", "is_global": False, "empty_ok": True},
|
||||||
|
{"key": "hitcount", "value": "", "is_global": False, "empty_ok": True},
|
||||||
|
{"key": "do_ipv6", "value": "false", "is_global": False, "empty_ok": False},
|
||||||
|
{"key": "firewalld_direct_abs", "value": "/etc/firewalld/direct.xml", "is_global": True, "empty_ok": False},
|
||||||
|
{"key": "restart_firewalld_after_change", "value": "true", "is_global": True, "empty_ok": False}
|
||||||
|
]
|
||||||
|
# In all sections other than 'default' the following settings are known and accepted. We ignore other settings.
|
||||||
|
# Per CFG_KNOWN_DEFAULTS above most '[DEFAULT]' options are accepted by virtue of being defaults and overridable.
|
||||||
|
# The only exception are options where "is_global" equals True, they can't be overridden in '[sections]'; any
|
||||||
|
# attempt at doing it anyway will be ignored. The main purpose of this list is to name settings that do not have
|
||||||
|
# a default value but can - if set - influence how a '[section]' behaves. Repeating a '[DEFAULT]' here does not
|
||||||
|
# make sense. We use 'is_mandatory' to determine if we have to raise errors on missing settings. Here
|
||||||
|
# 'is_mandatory' means the setting must be given in a '[section]'. It may be empty.
|
||||||
|
CFG_KNOWN_SECTION = [
|
||||||
|
# {"key": "an_option", "is_mandatory": True},
|
||||||
|
# {"key": "another_one", "is_mandatory": False}
|
||||||
|
]
|
||||||
|
CFG_MANDATORY = [section_cfg["key"] for section_cfg in CFG_KNOWN_SECTION if section_cfg["is_mandatory"]]
|
||||||
|
|
||||||
|
|
||||||
|
is_systemd = any([systemd_env_var in os.environ for systemd_env_var in ["SYSTEMD_EXEC_PID", "INVOCATION_ID"]])
|
||||||
|
logging.basicConfig(
|
||||||
|
# Default for all modules is NOTSET so log everything
|
||||||
|
level="NOTSET",
|
||||||
|
format=CONST.LOG_FORMAT,
|
||||||
|
datefmt="[%X]",
|
||||||
|
handlers=[RichHandler(
|
||||||
|
show_time=False if is_systemd else True,
|
||||||
|
show_path=False if is_systemd else True,
|
||||||
|
show_level=False if is_systemd else True,
|
||||||
|
rich_tracebacks=True
|
||||||
|
)]
|
||||||
|
)
|
||||||
|
log = logging.getLogger("rich")
|
||||||
|
# Our own code logs with this level
|
||||||
|
log.setLevel(os.environ.get("UFS_LOGLEVEL") if "UFS_LOGLEVEL" in [k for k, v in os.environ.items()] else logging.INFO)
|
||||||
|
|
||||||
|
p = inflect.engine()
|
||||||
|
|
||||||
|
|
||||||
|
# Use this version of class ConfigParser to log.debug contents of our config file. When parsing sections other than
|
||||||
|
# 'default' we don't want to reprint defaults over and over again. This custom class achieves that.
|
||||||
|
class ConfigParser(
|
||||||
|
configparser.ConfigParser):
|
||||||
|
"""Can get options() without defaults
|
||||||
|
|
||||||
|
Taken from https://stackoverflow.com/a/12600066.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def options(self, section, no_defaults=False, **kwargs):
|
||||||
|
if no_defaults:
|
||||||
|
try:
|
||||||
|
return list(self._sections[section].keys())
|
||||||
|
except KeyError:
|
||||||
|
raise configparser.NoSectionError(section)
|
||||||
|
else:
|
||||||
|
return super().options(section)
|
||||||
|
|
||||||
|
|
||||||
|
ini_defaults = []
|
||||||
|
internal_defaults = {default["key"]: default["value"] for default in CONST.CFG_KNOWN_DEFAULTS}
|
||||||
|
internal_globals = [default["key"] for default in CONST.CFG_KNOWN_DEFAULTS if default["is_global"]]
|
||||||
|
internal_empty_ok = [default["key"] for default in CONST.CFG_KNOWN_DEFAULTS if default["empty_ok"]]
|
||||||
|
config = ConfigParser(defaults=internal_defaults,
|
||||||
|
converters={'list': lambda x: [i.strip() for i in x.split(',') if len(x) > 0]})
|
||||||
|
config.read(CONST.CFG_DEFAULT_ABS_PATH)
|
||||||
|
exit_code_desc = {
|
||||||
|
5: "Unable to open firewalld direct rules file for reading",
|
||||||
|
9: "Unable to open firewalld direct rules file for updating"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def print_section_header(
|
||||||
|
header: str) -> str:
|
||||||
|
return f"Loading config section '[{header}]' ..."
|
||||||
|
|
||||||
|
|
||||||
|
def validate_default_section(
|
||||||
|
config_obj: configparser.ConfigParser()) -> None:
|
||||||
|
log.debug(f"Loading config from file '{CONST.CFG_DEFAULT_ABS_PATH}' ...")
|
||||||
|
if not config_obj.sections():
|
||||||
|
log.debug(f"No config sections found in '{CONST.CFG_DEFAULT_ABS_PATH}'. Exiting 1 ...")
|
||||||
|
sys.exit(1)
|
||||||
|
if config.defaults():
|
||||||
|
log.debug(f"Symbol legend:\n"
|
||||||
|
f"* Default from section '[{config_obj.default_section}]'\n"
|
||||||
|
f": Global option from '[{config_obj.default_section}]', can not be overridden in local sections\n"
|
||||||
|
f"~ Local option, doesn't exist in '[{config_obj.default_section}]'\n"
|
||||||
|
f"+ Local override of a value from '[{config_obj.default_section}]'\n"
|
||||||
|
f"= Local override, same value as in '[{config_obj.default_section}]'\n"
|
||||||
|
f"# Local attempt at overriding a global, will be ignored")
|
||||||
|
log.debug(print_section_header(config_obj.default_section))
|
||||||
|
for default in config_obj.defaults():
|
||||||
|
ini_defaults.append({default: config_obj[config_obj.default_section][default]})
|
||||||
|
if default in internal_globals:
|
||||||
|
log.debug(f": {default} = {config_obj[config_obj.default_section][default]}")
|
||||||
|
else:
|
||||||
|
log.debug(f"* {default} = {config_obj[config_obj.default_section][default]}")
|
||||||
|
else:
|
||||||
|
log.debug(f"No defaults defined")
|
||||||
|
|
||||||
|
|
||||||
|
def config_has_valid_section(
|
||||||
|
config_obj: configparser.ConfigParser()) -> bool:
|
||||||
|
has_valid_section = False
|
||||||
|
for config_obj_section in config_obj.sections():
|
||||||
|
if set(CONST.CFG_MANDATORY).issubset(config_obj.options(config_obj_section)):
|
||||||
|
has_valid_section = True
|
||||||
|
break
|
||||||
|
return has_valid_section
|
||||||
|
|
||||||
|
|
||||||
|
def is_default(
|
||||||
|
config_key: str) -> bool:
|
||||||
|
return any(config_key in ini_default for ini_default in ini_defaults)
|
||||||
|
|
||||||
|
|
||||||
|
def is_global(
|
||||||
|
config_key: str) -> bool:
|
||||||
|
return config_key in internal_globals
|
||||||
|
|
||||||
|
|
||||||
|
def is_same_as_default(
|
||||||
|
config_kv_pair: dict) -> bool:
|
||||||
|
return config_kv_pair in ini_defaults
|
||||||
|
|
||||||
|
|
||||||
|
def we_have_unset_options(
|
||||||
|
config_obj: configparser.ConfigParser(),
|
||||||
|
section_name: str) -> list:
|
||||||
|
|
||||||
|
options_must_be_non_empty = []
|
||||||
|
|
||||||
|
for option in config_obj.options(section_name):
|
||||||
|
if not config_obj.get(section_name, option):
|
||||||
|
if option not in internal_empty_ok:
|
||||||
|
log.warning(f"In section '[{section_name}]' option '{option}' is empty, it mustn't be.")
|
||||||
|
options_must_be_non_empty.append(option)
|
||||||
|
|
||||||
|
return options_must_be_non_empty
|
||||||
|
|
||||||
|
|
||||||
|
def validate_config_sections(
|
||||||
|
config_obj: configparser.ConfigParser()) -> None:
|
||||||
|
for this_section in config_obj.sections():
|
||||||
|
log.debug(print_section_header(this_section))
|
||||||
|
|
||||||
|
unset_options = we_have_unset_options(config_obj, this_section)
|
||||||
|
if unset_options:
|
||||||
|
log.error(f"""{p.plural("Option", len(unset_options))} {unset_options} """
|
||||||
|
f"""{p.plural("is", len(unset_options))} unset. """
|
||||||
|
f"""{p.singular_noun("They", len(unset_options))} """
|
||||||
|
f"must have a non-null value. "
|
||||||
|
f"""{p.plural("Default", len(unset_options))} {p.plural("is", len(unset_options))}:""")
|
||||||
|
for unset_option in unset_options:
|
||||||
|
log.error(f"{unset_option} = {internal_defaults[unset_option]}")
|
||||||
|
log.error(f"Exiting 7 ...")
|
||||||
|
sys.exit(7)
|
||||||
|
|
||||||
|
if not set(CONST.CFG_MANDATORY).issubset(config_obj.options(this_section, no_defaults=True)):
|
||||||
|
log.warning(f"Config section '[{this_section}]' does not have all mandatory options "
|
||||||
|
f"{CONST.CFG_MANDATORY} set, skipping section ...")
|
||||||
|
config_obj.remove_section(this_section)
|
||||||
|
else:
|
||||||
|
for key in config_obj.options(this_section, no_defaults=True):
|
||||||
|
kv_prefix = "~"
|
||||||
|
remove_from_section = False
|
||||||
|
if is_global(key):
|
||||||
|
kv_prefix = "#"
|
||||||
|
remove_from_section = True
|
||||||
|
elif is_default(key):
|
||||||
|
kv_prefix = "+"
|
||||||
|
if is_same_as_default({key: config_obj[this_section][key]}):
|
||||||
|
kv_prefix = "="
|
||||||
|
log.debug(f"{kv_prefix} {key} = {config_obj[this_section][key]}")
|
||||||
|
if remove_from_section:
|
||||||
|
config_obj.remove_option(this_section, key)
|
||||||
|
|
||||||
|
|
||||||
|
def has_child_elem(elem_name: str, attr_value: str) -> bool:
|
||||||
|
global arg_fw_rule_data
|
||||||
|
attr_name = "ipv"
|
||||||
|
|
||||||
|
for elem in arg_fw_rule_data.findall(elem_name):
|
||||||
|
if elem.attrib[attr_name] == attr_value:
|
||||||
|
log.debug(f"""XML has element '<{elem_name} {attr_name}="{attr_value}" .../>'""")
|
||||||
|
return True
|
||||||
|
log.debug(f"""No XML element '<{elem_name} {attr_name}="{attr_value}" .../>'""")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def add_chain_elem(elem_name: str, addr_family: str) -> bool:
|
||||||
|
global arg_fw_rule_data
|
||||||
|
|
||||||
|
log.debug(f"Adding new ...")
|
||||||
|
for chain in ["FILTERS", "DOCKER-USER"]:
|
||||||
|
try:
|
||||||
|
lxml.etree.SubElement(arg_fw_rule_data, elem_name,
|
||||||
|
ipv=f"{addr_family}",
|
||||||
|
table="filter",
|
||||||
|
chain=chain)
|
||||||
|
except lxml.etree.LxmlError as le:
|
||||||
|
log.error(f"""Failed to add XML '<{elem_name} ipv=f"{addr_family}" .../>'\n"""
|
||||||
|
f"Verbatim exception was:\n"
|
||||||
|
f"f{le}\n"
|
||||||
|
f"Exiting 8 ...")
|
||||||
|
sys.exit(8)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def rules_count(
|
||||||
|
arg_ipv: str = "ipv4",
|
||||||
|
arg_chain: str = "FILTERS") -> int:
|
||||||
|
|
||||||
|
arg_rules_count = len([rule for rule in arg_fw_rule_data.findall("rule") if all([
|
||||||
|
rule.attrib["ipv"] == arg_ipv if arg_ipv else False,
|
||||||
|
rule.attrib["chain"] == arg_chain if arg_chain else False])])
|
||||||
|
|
||||||
|
log.debug(f"""Counted {arg_rules_count} {p.plural("rule", arg_rules_count)} matching """
|
||||||
|
f"""{"ipv=" + arg_ipv + " " if arg_ipv else ""}"""
|
||||||
|
f"""{"chain=" + arg_chain + " " if arg_chain else ""}""")
|
||||||
|
return arg_rules_count
|
||||||
|
|
||||||
|
|
||||||
|
def add_rule_elem(
|
||||||
|
address_family: str,
|
||||||
|
prio: int,
|
||||||
|
target: str,
|
||||||
|
/, *,
|
||||||
|
arg_section: str = None,
|
||||||
|
arg_proto: str = None,
|
||||||
|
arg_state: str = None,
|
||||||
|
arg_ports: list = None,
|
||||||
|
arg_hitcount: str = None,
|
||||||
|
arg_address: str = None,
|
||||||
|
arg_chain: str = "FILTERS",
|
||||||
|
arg_in_interface: str = None) -> bool:
|
||||||
|
|
||||||
|
global arg_fw_rule_data
|
||||||
|
|
||||||
|
if arg_proto == "icmpv6" and address_family == "ipv4":
|
||||||
|
arg_proto = "icmp"
|
||||||
|
if arg_proto == "icmp" and address_family == "ipv6":
|
||||||
|
arg_proto = "icmpv6"
|
||||||
|
|
||||||
|
if arg_hitcount:
|
||||||
|
try:
|
||||||
|
lxml.etree.SubElement(arg_fw_rule_data, "rule",
|
||||||
|
ipv=f"{address_family}",
|
||||||
|
table=f"filter",
|
||||||
|
chain=arg_chain,
|
||||||
|
priority=f"""{prio}""").text = \
|
||||||
|
f"""{"--in-interface " + arg_in_interface + " " if arg_in_interface else ""}""" \
|
||||||
|
f"""{"--protocol " + arg_proto + " " if arg_proto else ""}""" \
|
||||||
|
f"""{"--match multiport --destination-ports " + ",".join(arg_ports) + " " if arg_ports else ""}""" \
|
||||||
|
f"""{"--source " + arg_address + " " if arg_address else ""}""" \
|
||||||
|
f"""{"--match recent --name " + chr(34) + arg_section[:256] + chr(34) +
|
||||||
|
" --update --hitcount " + arg_hitcount.split("/")[0] + " --seconds " + arg_hitcount.split("/")[1] + " "
|
||||||
|
if arg_section else ""}""" \
|
||||||
|
f"""--jump DROP"""
|
||||||
|
prio += 1
|
||||||
|
|
||||||
|
lxml.etree.SubElement(arg_fw_rule_data, "rule",
|
||||||
|
ipv=f"{address_family}",
|
||||||
|
table=f"filter",
|
||||||
|
chain=arg_chain,
|
||||||
|
priority=f"""{prio}""").text = \
|
||||||
|
f"""{"--in-interface " + arg_in_interface + " " if arg_in_interface else ""}""" \
|
||||||
|
f"""{"--protocol " + arg_proto + " " if arg_proto else ""}""" \
|
||||||
|
f"""{"--match multiport --destination-ports " + ",".join(arg_ports) + " " if arg_ports else ""}""" \
|
||||||
|
f"""{"--source " + arg_address + " " if arg_address else ""}""" \
|
||||||
|
f"""{"--match recent --name " + chr(34) + arg_section[:256] + chr(34) +
|
||||||
|
" --set" if arg_section else ""}"""
|
||||||
|
prio += 1
|
||||||
|
except lxml.etree.LxmlError as le:
|
||||||
|
log.error(f"""Failed to add XML '<rule ipv=f"{address_family}" .../>'\n"""
|
||||||
|
f"Verbatim exception was:\n"
|
||||||
|
f"f{le}\n"
|
||||||
|
f"Exiting 8 ...")
|
||||||
|
sys.exit(8)
|
||||||
|
|
||||||
|
try:
|
||||||
|
lxml.etree.SubElement(arg_fw_rule_data, "rule",
|
||||||
|
ipv=f"{address_family}",
|
||||||
|
table=f"filter",
|
||||||
|
chain=arg_chain,
|
||||||
|
priority=f"""{prio}""").text = \
|
||||||
|
f"""{"--in-interface " + arg_in_interface + " " if arg_in_interface else ""}""" \
|
||||||
|
f"""{"--protocol " + arg_proto + " " if arg_proto else ""}""" \
|
||||||
|
f"""{"--match state --state " + arg_state + " " if arg_state else ""}""" \
|
||||||
|
f"""{"--match multiport --destination-ports " + ",".join(arg_ports) + " " if arg_ports else ""}""" \
|
||||||
|
f"""{"--source " + arg_address + " " if arg_address else ""}""" \
|
||||||
|
f"""--jump {target}""" \
|
||||||
|
f"""{" --match comment --comment " + chr(34) + arg_section[:256] + chr(34) if arg_section else ""}"""
|
||||||
|
except lxml.etree.LxmlError as le:
|
||||||
|
log.error(f"""Failed to add XML '<rule ipv=f"{address_family}" .../>'\n"""
|
||||||
|
f"Verbatim exception was:\n"
|
||||||
|
f"f{le}\n"
|
||||||
|
f"Exiting 8 ...")
|
||||||
|
sys.exit(8)
|
||||||
|
else:
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def get_phy_nics() -> list:
|
||||||
|
phy_nics = []
|
||||||
|
linux_sysfs_nics_abs = "/sys/class/net"
|
||||||
|
find_phy_nics = ["find", linux_sysfs_nics_abs, "-mindepth", "1", "-maxdepth", "1", "-not", "-lname", "*virtual*"]
|
||||||
|
# find_phy_nics = ["find", linux_sysfs_nics_abs, "-mindepth", "1", "-maxdepth", "1", "-lname", "*virtual*"]
|
||||||
|
|
||||||
|
if os.path.isdir(linux_sysfs_nics_abs):
|
||||||
|
try:
|
||||||
|
phy_nics_find = subprocess.run(find_phy_nics,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.STDOUT,
|
||||||
|
check=True,
|
||||||
|
encoding="UTF-8")
|
||||||
|
except subprocess.CalledProcessError as cpe:
|
||||||
|
log.error(f"Failed to find physical network device in {linux_sysfs_nics_abs!r}.\n"
|
||||||
|
f"Command was:\n"
|
||||||
|
f"{cpe.cmd}\n"
|
||||||
|
f"Verbatim command output was:\n"
|
||||||
|
f"{cpe.output.rstrip()}\n"
|
||||||
|
f"Exiting 4 ...")
|
||||||
|
sys.exit(4)
|
||||||
|
else:
|
||||||
|
if not phy_nics_find.stdout:
|
||||||
|
log.error(f"No physical network device found at {linux_sysfs_nics_abs!r}.\n"
|
||||||
|
f"Command was:\n"
|
||||||
|
f"{phy_nics_find.args}\n"
|
||||||
|
f"Exiting 3 ...")
|
||||||
|
sys.exit(3)
|
||||||
|
for line in phy_nics_find.stdout.rstrip().split("\n"):
|
||||||
|
log.debug(f"Found physical network device {(phy_nic := os.path.basename(line))!r}")
|
||||||
|
phy_nics.append(phy_nic)
|
||||||
|
else:
|
||||||
|
log.error(f"Path {linux_sysfs_nics_abs!r} does not exist. This might not be a Linux-y operating system. "
|
||||||
|
f"Without that location we'll not be able to separate physical network interfaces from virtual ones. "
|
||||||
|
f"Exiting 6 ...")
|
||||||
|
sys.exit(6)
|
||||||
|
|
||||||
|
log.debug(f"List of identified physical network interfaces: {phy_nics}")
|
||||||
|
return phy_nics
|
||||||
|
|
||||||
|
|
||||||
|
def add_fw_rule_to_xml(
|
||||||
|
config_obj: configparser.ConfigParser(),
|
||||||
|
section_name: str,
|
||||||
|
target: str,
|
||||||
|
ports: list,
|
||||||
|
proto: str,
|
||||||
|
hitcount: str) -> bool:
|
||||||
|
global arg_fw_rule_data
|
||||||
|
global arg_allow_sources
|
||||||
|
addr = arg_allow_sources
|
||||||
|
|
||||||
|
rules_already_added = {"ipv4": rules_count(arg_ipv="ipv4") + 1, "ipv6": rules_count(arg_ipv="ipv6") + 1}
|
||||||
|
log.debug(f"Current rules count: {rules_already_added}")
|
||||||
|
|
||||||
|
for address_family in ["ipv4", "ipv6"]:
|
||||||
|
if len(addr[address_family]):
|
||||||
|
if not has_child_elem("chain", address_family):
|
||||||
|
add_chain_elem("chain", address_family)
|
||||||
|
for address in addr[address_family]:
|
||||||
|
add_rule_elem(
|
||||||
|
address_family,
|
||||||
|
rules_already_added[address_family],
|
||||||
|
target,
|
||||||
|
arg_section=section_name,
|
||||||
|
arg_proto=proto,
|
||||||
|
arg_state=config_obj.get(section_name, "state"),
|
||||||
|
arg_ports=ports,
|
||||||
|
arg_hitcount=hitcount,
|
||||||
|
arg_address=address)
|
||||||
|
if hitcount:
|
||||||
|
rules_already_added[address_family] += 3
|
||||||
|
else:
|
||||||
|
rules_already_added[address_family] += 1
|
||||||
|
if not len(addr["ipv4"]) and not len(addr["ipv6"]):
|
||||||
|
if address_family == "ipv4" or (address_family == "ipv6"
|
||||||
|
and
|
||||||
|
config_obj.getboolean(section_name, "do_ipv6")):
|
||||||
|
if not has_child_elem("chain", address_family):
|
||||||
|
add_chain_elem("chain", address_family)
|
||||||
|
add_rule_elem(
|
||||||
|
address_family,
|
||||||
|
rules_already_added[address_family],
|
||||||
|
target,
|
||||||
|
arg_section=section_name,
|
||||||
|
arg_proto=proto,
|
||||||
|
arg_state=config_obj.get(section_name, "state"),
|
||||||
|
arg_ports=ports,
|
||||||
|
arg_hitcount=hitcount)
|
||||||
|
if hitcount:
|
||||||
|
rules_already_added[address_family] += 3
|
||||||
|
else:
|
||||||
|
rules_already_added[address_family] += 1
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_domain(domain: str) -> list[str]:
|
||||||
|
log.debug(f"Resolving DNS A and AAAA records for '{domain}' ...")
|
||||||
|
try:
|
||||||
|
a_records = dns.resolver.resolve(domain, rdtype=dns.rdatatype.A)
|
||||||
|
except dns.resolver.NoAnswer:
|
||||||
|
log.debug(f"DNS didn't return an A record for '{domain}', ignoring ...")
|
||||||
|
a_records = []
|
||||||
|
try:
|
||||||
|
aaaa_records = dns.resolver.resolve(domain, rdtype=dns.rdatatype.AAAA)
|
||||||
|
except dns.resolver.NoAnswer:
|
||||||
|
log.debug(f"DNS didn't return a AAAA record for '{domain}', ignoring ...")
|
||||||
|
aaaa_records = []
|
||||||
|
|
||||||
|
dns_records = []
|
||||||
|
[dns_records.append(dns_record.address) for dns_record in a_records if a_records]
|
||||||
|
[dns_records.append(dns_record.address) for dns_record in aaaa_records if aaaa_records]
|
||||||
|
log.info(f"""For {domain!r} found {p.plural("record", len(dns_records))}: {dns_records}""")
|
||||||
|
return dns_records
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_addresses(
|
||||||
|
config_obj: configparser.ConfigParser(),
|
||||||
|
section_name: str,
|
||||||
|
allow_list_mixed: list[str]) -> dict[str, list]:
|
||||||
|
global arg_allow_sources
|
||||||
|
allow_list_ip_only = []
|
||||||
|
|
||||||
|
log.info(f"""Verifying {p.plural("address", len(allow_list_mixed))} {allow_list_mixed!r} ...""")
|
||||||
|
for allow_source in allow_list_mixed:
|
||||||
|
log.debug(f"Checking if '{allow_source}' is a domain ...")
|
||||||
|
if validators.domain(allow_source):
|
||||||
|
log.debug(f"'{allow_source}' is a domain.")
|
||||||
|
[allow_list_ip_only.append(addr) for addr in resolve_domain(allow_source)]
|
||||||
|
else:
|
||||||
|
log.debug(f"'{allow_source}' is not a domain.")
|
||||||
|
allow_list_ip_only.append(allow_source)
|
||||||
|
|
||||||
|
for allow_source in allow_list_ip_only:
|
||||||
|
try:
|
||||||
|
ipv4_addr = str(ipaddress.IPv4Address(allow_source))
|
||||||
|
log.info(f"Adding IPv4 address '{allow_source}' ...")
|
||||||
|
arg_allow_sources["ipv4"].append(ipv4_addr)
|
||||||
|
except ipaddress.AddressValueError:
|
||||||
|
log.debug(f"Address '{allow_source}' is not a valid IPv4 address.")
|
||||||
|
if not config_obj.getboolean(section_name, "do_ipv6"):
|
||||||
|
log.info(f"For section '[{section_name}]' option 'do_ipv6' equals false. "
|
||||||
|
f"Skipping IPv6 handling of '{allow_source}' ...")
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
ipv6_addr = str(ipaddress.IPv6Address(allow_source))
|
||||||
|
except ipaddress.AddressValueError:
|
||||||
|
log.debug(f"Address '{allow_source}' is not a valid IPv6 address either. Ignoring ...")
|
||||||
|
else:
|
||||||
|
log.info(f"Adding IPv6 address '{allow_source}' ...")
|
||||||
|
arg_allow_sources["ipv6"].append(ipv6_addr)
|
||||||
|
|
||||||
|
return arg_allow_sources
|
||||||
|
|
||||||
|
|
||||||
|
def gen_fwd_direct_scaffolding() -> lxml.builder.ElementMaker:
|
||||||
|
data = lxml.builder.ElementMaker()
|
||||||
|
direct_tag = data.direct
|
||||||
|
fw_rule_data = direct_tag()
|
||||||
|
return fw_rule_data
|
||||||
|
|
||||||
|
|
||||||
|
def ose_handler(
|
||||||
|
os_error: OSError,
|
||||||
|
human_text: str = None,
|
||||||
|
exit_code: int = None) -> None:
|
||||||
|
nl = "\n"
|
||||||
|
log.error(f"{human_text if human_text else exit_code_desc.get(exit_code)}"
|
||||||
|
f"{nl}Verbatim exception was:\n"
|
||||||
|
f"{os_error}"
|
||||||
|
f"""{nl + "Exiting " + str(exit_code) + " ..." if exit_code else ""}""")
|
||||||
|
|
||||||
|
|
||||||
|
def get_xml_str_repr() -> str:
|
||||||
|
global arg_fw_rule_data
|
||||||
|
|
||||||
|
fwd_direct_xml_str = lxml.etree.tostring(arg_fw_rule_data,
|
||||||
|
pretty_print=True,
|
||||||
|
encoding="UTF-8",
|
||||||
|
xml_declaration=True).decode()
|
||||||
|
|
||||||
|
return fwd_direct_xml_str
|
||||||
|
|
||||||
|
|
||||||
|
def write_new_fwd_direct_xml(
|
||||||
|
config_obj: configparser.ConfigParser()) -> bool:
|
||||||
|
global arg_fw_rule_data
|
||||||
|
|
||||||
|
fwd_direct_xml_str = get_xml_str_repr()
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(config_obj.get(configparser.DEFAULTSECT, "firewalld_direct_abs"), "r+") as fwd_file_handle:
|
||||||
|
log.info(f"Writing new firewalld direct config ...")
|
||||||
|
log.debug(f"New content:\n"
|
||||||
|
f"{fwd_direct_xml_str.rstrip()}")
|
||||||
|
fwd_file_handle.seek(0)
|
||||||
|
fwd_file_handle.write(fwd_direct_xml_str)
|
||||||
|
fwd_file_handle.truncate()
|
||||||
|
except OSError as ose:
|
||||||
|
ose_handler(os_error=ose, exit_code=9)
|
||||||
|
sys.exit(9)
|
||||||
|
else:
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def restart_systemd_firewalld() -> bool:
|
||||||
|
sysbus = dbus.SystemBus()
|
||||||
|
systemd1 = sysbus.get_object("org.freedesktop.systemd1", "/org/freedesktop/systemd1")
|
||||||
|
manager = dbus.Interface(systemd1, "org.freedesktop.systemd1.Manager")
|
||||||
|
|
||||||
|
firewalld_unit = manager.LoadUnit("firewalld.service")
|
||||||
|
firewalld_proxy = sysbus.get_object("org.freedesktop.systemd1", str(firewalld_unit))
|
||||||
|
firewalld_active_state = firewalld_proxy.Get("org.freedesktop.systemd1.Unit",
|
||||||
|
"ActiveState",
|
||||||
|
dbus_interface="org.freedesktop.DBus.Properties")
|
||||||
|
|
||||||
|
if firewalld_active_state == "inactive":
|
||||||
|
log.info(f"systemd firewalld.service unit is inactive, ignoring restart instruction, leaving as-is ...")
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
log.info(f"Restarting systemd firewalld.service unit ...")
|
||||||
|
manager.TryRestartUnit('firewalld.service', 'fail')
|
||||||
|
except dbus.exceptions.DBusException as dbe:
|
||||||
|
log.error(f"Failed to restart systemd firewalld.service unit.\n"
|
||||||
|
f"Verbatim exception was:\n"
|
||||||
|
f"{dbe}\n"
|
||||||
|
f"You're going to want to check firewalld.service health.\n"
|
||||||
|
f"Exiting 10 ...")
|
||||||
|
sys.exit(10)
|
||||||
|
else:
|
||||||
|
log.info(f"Done")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def add_firewall_shim(arg_phy_nics: list) -> None:
|
||||||
|
global arg_fw_rule_data
|
||||||
|
|
||||||
|
log.debug(f"Adding ip(6)tables jump target to DOCKER-USER chain ...")
|
||||||
|
for addr_family in ["ipv4", "ipv6"]:
|
||||||
|
for phy_nic in arg_phy_nics:
|
||||||
|
if has_child_elem("chain", addr_family):
|
||||||
|
add_rule_elem(
|
||||||
|
addr_family,
|
||||||
|
rules_count(addr_family, arg_chain="INPUT"),
|
||||||
|
"ACCEPT",
|
||||||
|
arg_chain="INPUT",
|
||||||
|
arg_in_interface="lo"
|
||||||
|
)
|
||||||
|
for chain in ["INPUT", "DOCKER-USER"]:
|
||||||
|
add_rule_elem(
|
||||||
|
addr_family,
|
||||||
|
rules_count(addr_family, arg_chain=chain),
|
||||||
|
"FILTERS",
|
||||||
|
arg_chain=chain,
|
||||||
|
arg_in_interface=phy_nic if chain == "DOCKER-USER" else None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def has_xml_changed(
|
||||||
|
config_obj: configparser.ConfigParser()) -> bool:
|
||||||
|
arg_fwd_file_abs = os.path.abspath(config_obj.get(configparser.DEFAULTSECT, "firewalld_direct_file_abs"))
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(arg_fwd_file_abs, "r") as fwd_file_abs_handle:
|
||||||
|
fwd_file_abs_content = fwd_file_abs_handle.read()
|
||||||
|
fwd_direct_xml_str = get_xml_str_repr()
|
||||||
|
diff_result = difflib.Differ().compare(fwd_file_abs_content.splitlines(), fwd_direct_xml_str.splitlines())
|
||||||
|
s = difflib.SequenceMatcher(isjunk=None, a=fwd_file_abs_content, b=fwd_direct_xml_str, autojunk=False)
|
||||||
|
except OSError as ose:
|
||||||
|
ose_handler(os_error=ose, exit_code=5)
|
||||||
|
sys.exit(5)
|
||||||
|
else:
|
||||||
|
if s.ratio() < 1:
|
||||||
|
nl = "\n"
|
||||||
|
log.info(f"Changing firewalld rules. Diff as follows:\n"
|
||||||
|
f"""{nl.join(diff_result)}""")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
log.info(f"No diff in firewalld XML config, no need to write new file.")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
validate_default_section(config)
|
||||||
|
if config_has_valid_section(config):
|
||||||
|
validate_config_sections(config)
|
||||||
|
else:
|
||||||
|
log.error(f"No valid config section found. A valid config section has at least the mandatory options "
|
||||||
|
f"{CONST.CFG_MANDATORY} set. Exiting 2 ...")
|
||||||
|
sys.exit(2)
|
||||||
|
|
||||||
|
arg_fw_rule_data = gen_fwd_direct_scaffolding()
|
||||||
|
|
||||||
|
log.debug(f"Iterating over config sections ...")
|
||||||
|
for section in config.sections():
|
||||||
|
log.info(f"Generating rules from section '[{section}]' ...")
|
||||||
|
arg_fwd_addr = config.getlist(section, "addr")
|
||||||
|
arg_allow_sources = {"ipv4": [], "ipv6": []}
|
||||||
|
if arg_fwd_addr:
|
||||||
|
arg_allow_sources = resolve_addresses(config, section, arg_fwd_addr)
|
||||||
|
log.debug(arg_allow_sources)
|
||||||
|
else:
|
||||||
|
log.info(f"No source address given. Rules will apply to all sources.")
|
||||||
|
|
||||||
|
add_fw_rule_to_xml(config,
|
||||||
|
section,
|
||||||
|
target=config.get(section, "target"),
|
||||||
|
ports=config.getlist(section, "ports"),
|
||||||
|
proto=config.get(section, "proto"),
|
||||||
|
hitcount=config.get(section, "hitcount"))
|
||||||
|
for arg_address_family in ["ipv4", "ipv6"]:
|
||||||
|
if rules_count(arg_address_family):
|
||||||
|
add_rule_elem(
|
||||||
|
arg_address_family,
|
||||||
|
0,
|
||||||
|
"ACCEPT",
|
||||||
|
arg_state="ESTABLISHED,RELATED")
|
||||||
|
add_firewall_shim(get_phy_nics())
|
||||||
|
|
||||||
|
if has_xml_changed(config):
|
||||||
|
write_new_fwd_direct_xml(config)
|
||||||
|
if config.getboolean(configparser.DEFAULTSECT, "restart_firewalld_after_change"):
|
||||||
|
restart_systemd_firewalld()
|
Reference in New Issue
Block a user