Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 25 additions & 6 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ on:
pull_request:
branches: [main, master]

permissions:
contents: read

jobs:
lint-and-test:
runs-on: ubuntu-latest
Expand All @@ -32,15 +35,13 @@ jobs:
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install -r requirements-dev.txt
python -m pip install '.[dev]'

- name: Run pre-commit hooks
uses: pre-commit/action@2c7b3805fd2a0fd8c1884dcaebf91fc102a13ecd # v3.0.1

- name: Run tests with pytest
run: |
pytest --cov --cov-report=xml --cov-report=term-missing
run: python -m pytest --cov --cov-report=xml --cov-report=term-missing

- name: Upload coverage to Codecov
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
Expand All @@ -51,6 +52,25 @@ jobs:
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}

package:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.11"

- name: Build wheel and source distribution
run: |
python -m pip install --upgrade pip build
python -m build

- name: Install the wheel and smoke-test the console command
run: |
python -m pip install dist/*.whl
! cflan-set-dns

type-check:
runs-on: ubuntu-latest
steps:
Expand All @@ -64,8 +84,7 @@ jobs:
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install mypy types-PyYAML types-requests
python -m pip install '.[dev]'

- name: Run mypy type checking
run: mypy set_dns.py
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Changelog

All notable changes to CFLAN are documented here.

## Unreleased

### Changed

- Migrated the updater to the supported Cloudflare Python SDK interface.
- Replaced delete-and-create record changes with an in-place Cloudflare PATCH update.
- Added preferred root-volume configuration names: `cflan_vars.yaml` and `cflan_sops_vars.yaml`.
- Preserved `vars.yaml` and `sops_vars.yaml` as root-volume compatibility aliases.
- Added configuration validation, safer IPv4 checks, duplicate-record protection, package build verification, and public contributor/security guidance.
24 changes: 24 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Contributing to CFLAN

## Scope and safety

CFLAN changes Cloudflare DNS from a NetworkManager dispatcher hook. Do not commit API tokens, decrypted SOPS files, real hostnames, DNS values, or root-volume contents. Do not test changes against production DNS as part of a pull request.

Preserve these compatibility contracts unless a change explicitly documents a migration:

- Dispatcher entry point: `/etc/NetworkManager/dispatcher.d/set_dns`
- Preferred root-volume configuration: `/cflan_vars.yaml` or `/cflan_sops_vars.yaml`
- Legacy root-volume aliases: `/vars.yaml` or `/sops_vars.yaml`

## Development workflow

1. Create a focused branch and add unit tests for behavior changes.
2. Install development dependencies with `python -m pip install '.[dev]'`.
3. Run `pre-commit run --all-files`, `python -m pytest --cov`, `mypy set_dns.py`, and `python -m build`.
4. Open a pull request explaining configuration, DNS, and rollback impact.

CI is the validation authority. A passing unit-test suite does not prove that a changed dispatcher hook, SOPS configuration, or Cloudflare token works in an installed host.

## Pull requests

Keep changes narrow. Document any changed default configuration name, API permission, record behavior, package version, or installed-path contract. Reviewers must be able to determine whether the change is source-only or requires a separate installation step.
207 changes: 45 additions & 162 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,199 +1,82 @@
# CFLAN

[![CI](https://github.com/welchworks/cflan/actions/workflows/ci.yml/badge.svg)](https://github.com/welchworks/cflan/actions/workflows/ci.yml)
[![Python 3.9+](https://img.shields.io/badge/python-3.9+-blue.svg)](https://www.python.org/downloads/)
[![CI](https://github.com/makeitworkcloud/cflan/actions/workflows/ci.yml/badge.svg)](https://github.com/makeitworkcloud/cflan/actions/workflows/ci.yml)
[![Python 3.10+](https://img.shields.io/badge/python-3.10%2B-blue.svg)](https://www.python.org/downloads/)
[![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg)](LICENSE)
[![Code style: Ruff](https://img.shields.io/badge/code%20style-ruff-000000.svg)](https://github.com/astral-sh/ruff)

> Integrating LAN infrastructure with Cloudflare services
> NetworkManager-driven Cloudflare DNS updates for LAN hosts.

CFLAN automatically updates Cloudflare DNS records with your local machine's IP address whenever your network connection changes. This is particularly useful for home servers, NAS devices, or any machine that needs a consistent DNS name despite having a dynamic local IP.
CFLAN reconciles one Cloudflare A record with a machine's active IPv4 address when a NetworkManager interface comes up. It is designed for root-owned configuration supplied from the host's root/volume area; it does not create, persist, or print credentials.

## Features
## Requirements

- **Automatic DNS Updates**: Updates Cloudflare DNS A records when network interfaces come up
- **NetworkManager Integration**: Runs as a NetworkManager dispatcher script
- **SOPS Support**: Supports encrypted configuration using [SOPS](https://github.com/getsops/sops)
- **IP Validation**: Ensures the correct interface IP is used before updating
- **Idempotent**: Only updates DNS when the IP address has actually changed
- Linux with NetworkManager dispatcher support
- Python 3.10 or later
- A Cloudflare API token limited to **Zone / DNS / Edit** for the target zone
- Optional: SOPS available and configured for root when using encrypted configuration

## Prerequisites
## Configuration contract

- Python 3.9 or higher
- NetworkManager (for dispatcher script functionality)
- Root access (for installing dispatcher scripts)
- Cloudflare API Token with DNS edit permissions
- (Optional) SOPS for encrypted configuration files
CFLAN searches these root-volume paths in order:

## Installation
1. `/cflan_vars.yaml` — preferred plaintext name
2. `/cflan_sops_vars.yaml` — preferred SOPS-encrypted name
3. `/vars.yaml` — supported legacy alias
4. `/sops_vars.yaml` — supported legacy SOPS alias

### From Source
`CFLAN_CONFIG` can override the path for a controlled deployment. The existing `/vars.yaml` and `/sops_vars.yaml` contract remains supported; no secret is copied into another directory by the updater.

```bash
# Clone the repository
git clone https://github.com/welchworks/cflan.git
cd cflan

# Create configuration file (see Configuration section below)
# Then install (requires root)
sudo python install.py
```

### Development Installation

```bash
pip install -r requirements.txt
pip install -r requirements-dev.txt
pre-commit install
```

## Configuration

Create a configuration file with your Cloudflare credentials:

### Option 1: Plain YAML (`vars.yaml`)
Example structure (do **not** commit a real token):

```yaml
cf_token: "your-cloudflare-api-token"
cf_domain_name: "example.com"
```

### Option 2: Encrypted with SOPS (`sops_vars.yaml`)

```bash
# Create encrypted config
cat > vars.yaml <<EOF
cf_token: "your-cloudflare-api-token"
cf_token: "replace-with-a-Cloudflare-API-token"
cf_domain_name: "example.com"
EOF

# Encrypt with SOPS
sops encrypt vars.yaml > sops_vars.yaml
rm vars.yaml
# Optional full FQDN; defaults to <hostname>.<cf_domain_name>
# cf_record_name: "host.example.com"
# Optional; 1 means Cloudflare automatic TTL
# cf_ttl: 1
# Optional; defaults to false to avoid proxying a LAN address
# cf_proxied: false
```

### Cloudflare API Token Setup

1. Go to [Cloudflare API Tokens](https://dash.cloudflare.com/profile/api-tokens)
2. Click "Create Token"
3. Use the "Edit zone DNS" template
4. Select your zone (domain)
5. Create the token and copy it for your config

## Usage

### As a NetworkManager Dispatcher Script
## Installation

When installed via `install.py`, the script runs automatically when network interfaces change:
The existing installation model is preserved: place one configuration file beside the scripts, then run the root-only installer.

```bash
# Trigger manually (for testing)
sudo /etc/NetworkManager/dispatcher.d/set_dns eth0 up
git clone https://github.com/makeitworkcloud/cflan.git
cd cflan
sudo python3 install.py
```

### Standalone Execution
The installer copies `set_dns.py` to `/etc/NetworkManager/dispatcher.d/set_dns` with mode `0700`. It copies the first configuration filename it finds in the priority listed above to its matching root-volume path with mode `0600`.

```bash
# From the project directory
python set_dns.py
```
> A Cloudflare DNS record containing an RFC1918 address is useful only for clients that can route to that LAN. CFLAN does not make a private address reachable from the public Internet.

The script will:
1. Detect your local IP address
2. Read configuration from `vars.yaml` or decrypt `sops_vars.yaml`
3. Find the Cloudflare zone for your domain
4. Check for an existing DNS record
5. Create or update the A record with your current IP
## Behavior and safety

## Project Structure

```
cflan/
├── .github/workflows/ # CI/CD configuration
├── tests/ # Test suite
├── set_dns.py # Main application script
├── install.py # Installation script
├── vars.yaml # Configuration (unencrypted)
├── pyproject.toml # Project metadata and tool config
├── requirements.txt # Production dependencies
├── requirements-dev.txt # Development dependencies
└── README.md # This file
```
- Only NetworkManager `up` events update DNS; other dispatcher events are skipped.
- The interface IPv4 address must equal the resolved primary host IPv4 address.
- Loopback, multicast, unspecified, and malformed addresses are rejected before any API call.
- Exactly one matching zone and zero or one matching A record are required. Duplicate records fail closed.
- Existing records are updated with Cloudflare PATCH rather than delete-and-recreate, preserving the record and avoiding an avoidable DNS gap.
- SOPS plaintext exists only in the updater process memory.

## Development

### Running Tests

```bash
pytest
```

With coverage:

```bash
pytest --cov=cflan --cov-report=term-missing
```

### Code Quality

This project uses:
- **Ruff**: Fast Python linter and formatter
- **MyPy**: Static type checking
- **Pre-commit**: Git hooks for code quality

```bash
# Run linting
python -m pip install '.[dev]'
python -m pytest --cov
ruff check .
ruff format .

# Run type checking
ruff format --check .
mypy set_dns.py

# Run all pre-commit hooks
pre-commit run --all-files
python -m build
```

### Continuous Integration

GitHub Actions runs the following on every push and PR:
- Pre-commit hooks
- Tests across Python 3.9-3.13
- Type checking with mypy
- Coverage reporting

## Security Notes

- The configuration file (`vars.yaml` or `sops_vars.yaml`) is installed with `600` permissions (readable only by root)
- The dispatcher script is installed with `700` permissions (executable only by root)
- Use SOPS encryption for production deployments to protect API tokens
- Store your Cloudflare API Token securely; it grants DNS edit access

## Troubleshooting

### "Must run as root"
The install script requires root privileges to install files to `/etc/NetworkManager/dispatcher.d/`.
CI runs formatting/linting hooks, unit tests and coverage on Python 3.10–3.13, mypy, and a wheel build/install smoke test. Unit tests do not contact Cloudflare or invoke NetworkManager.

### "sops must be installed"
If using `sops_vars.yaml`, ensure SOPS is installed: https://github.com/getsops/sops

### "The IP address is a value for localhost"
The script prevents updating DNS with localhost addresses (127.0.0.x). Check your network configuration.
See [CONTRIBUTING.md](CONTRIBUTING.md) and [SECURITY.md](SECURITY.md) before opening an issue or pull request.

## License

This project is licensed under the GNU General Public License v3.0 - see the [LICENSE](LICENSE) file for details.

## Contributing

Contributions are welcome! Please:
1. Fork the repository
2. Create a feature branch
3. Make your changes
4. Run tests and linting
5. Submit a pull request

## Author

**Steven Welch** - steven@makeitwork.cloud

Project Link: [https://github.com/welchworks/cflan](https://github.com/welchworks/cflan)
CFLAN is licensed under [GPL-3.0](LICENSE).
15 changes: 15 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Security policy

## Reporting a vulnerability

Do **not** disclose Cloudflare tokens, decrypted SOPS data, host-specific paths, internal DNS values, or reproduction data containing credentials in a public issue.

Use GitHub private vulnerability reporting for this repository when it is available. If it is unavailable, contact the maintainer through the public address in the project metadata and include only the minimum information needed to establish impact.

## Supported versions

Security fixes are made against the current `main` branch until versioned releases are published. No release branch or older version currently receives security support.

## Deployment guidance

Use a Cloudflare API token restricted to the specific zone and the minimum DNS-edit capability. Keep plaintext and encrypted configuration files root-owned with mode `0600`. CFLAN never needs a global API key.
Loading
Loading