# GNUmakefile for vpn-switch # Compatible with FreeBSD and Debian/Linux (uses GNU make features — on # FreeBSD install 'devel/gmake' and invoke as 'gmake'). # # Usage: # make - Show help # make platform - Set repo platform symlink (do this after clone) # make test - Run all tests # make install - Install to PREFIX (default: /usr/local) # make install PREFIX=/usr - Install to /usr # make uninstall - Remove installed files # # For development: # make metadata - Regenerate function metadata # make check - Syntax check # make shellcheck - Run shellcheck linter # make lint - Run all linters (check + shellcheck) # make clean - Remove build artifacts SHELL := /bin/sh #----------------------------------------------------------------------------- # Installation paths (FHS-compliant) #----------------------------------------------------------------------------- # Base installation prefix PREFIX ?= /usr/local # Staging directory (for package building) DESTDIR ?= # Binary directory (main script; user-runnable, never needs root). # /bin (not /sbin): vpn-switch itself does not require privileges — # privilege escalation happens per-function via interpreter directives # (sudo sh) only for the network operations that need it. BINDIR := $(PREFIX)/bin # System binary directory (root-only helpers). # resolvconf-up/down scripts that mutate DNS and reload local_unbound live # here. Phase scripts call them via their absolute /usr/local/sbin paths. SBINDIR := $(PREFIX)/sbin # Package-private library directory (include/, template/). # Holds both shell modules and template/data — see INSTALL.md for the FHS # rationale (package-private trees are an accepted FHS pattern). LIBDIR := $(PREFIX)/lib/vpn-switch # Documentation directory DOCDIR := $(PREFIX)/share/doc/vpn-switch # Man page directory (future) MANDIR := $(PREFIX)/share/man/man1 # bash-completion looks here for files (lazy-loaded on first Tab) COMPLETIONDIR := $(PREFIX)/share/bash-completion/completions # rc.d directory (FreeBSD service script; used by freebsd.mk plugin) RCDIR := $(PREFIX)/etc/rc.d #----------------------------------------------------------------------------- # Source files #----------------------------------------------------------------------------- SCRIPT := vpn-switch.sh BUILD_DIR := .build INDEX_DIR := $(BUILD_DIR)/index # Helper scripts (DNS resolvconf integration — currently FreeBSD-specific, # called by template/phase/{wireguard,base}/.../dns.sh from $(SBINDIR)/). HELPER_SCRIPTS := \ template/phase/base/dns/resolvconf/wg-resolvconf-up \ template/phase/base/dns/resolvconf/wg-resolvconf-down \ template/phase/base/dns/resolvconf/ovpn-resolvconf-up \ template/phase/base/dns/resolvconf/ovpn-resolvconf-down # Documentation files DOC_FILES := \ README.md \ LICENSE \ QUICK_REFERENCE.md #----------------------------------------------------------------------------- # Platform detection + plugin loading #----------------------------------------------------------------------------- # Platform name — drives both the 'platform' target and the plugin filename. # Two-level detection: the kernel name (uname) is enough for the BSDs, but it # can't tell Alpine (busybox + OpenRC) from Debian/Ubuntu (coreutils + systemd) # — both report "Linux". So for Linux we refine via /etc/os-release's ID. # Keep this logic in sync with include/validate.sh (_install_validate0) and # vpn-switch-integration-test.sh (install-layout check). # (if/else, not case: make counts parens to close $(shell ...), and the ')' in # case patterns like 'alpine)' would close it prematurely.) PLATFORM := $(shell kernel=$$(uname -s | tr A-Z a-z); \ if [ "$$kernel" = linux ] && [ -r /etc/os-release ]; then \ id=$$(. /etc/os-release 2>/dev/null; echo $$ID); \ if [ "$$id" = alpine ]; then echo alpine; else echo linux; fi; \ else echo "$$kernel"; fi) # install command — same name on FreeBSD (BSD install) and Linux (GNU coreutils). # Per-platform flag differences live in the per-platform .mk plugin if needed. INSTALL := install # Per-platform install hooks live in template/platform/$(PLATFORM).mk # (freebsd.mk, linux.mk). Each provides 'install-platform' and # 'uninstall-platform' target recipes; this main file just calls them. # # 'include' (not '-include'): if the .mk is missing, the build fails loudly # with a clear path to the missing file. To port to a new OS, drop in a new # .mk and the rest of the Makefile keeps working unchanged. include template/platform/$(PLATFORM).mk #----------------------------------------------------------------------------- # Targets #----------------------------------------------------------------------------- .PHONY: all help platform install install-base install-platform \ uninstall uninstall-base uninstall-platform \ test test-unit test-integration test-arch \ metadata manpage check shellcheck lint clean # Default target: show help all: help help: @echo "vpn-switch - VPN configuration management" @echo "" @echo "Development (operates only in the repo):" @echo " make platform Set repo's template/platform/default symlink" @echo " make test Run all tests" @echo " make test-unit Run unit tests only" @echo " make test-integration Run integration tests only" @echo " make test-arch Run architecture tests only" @echo " make metadata Regenerate function metadata + template/VERSION" @echo " make manpage Regenerate docs/vpn-switch.{1,html} from .1.md (needs pandoc)" @echo " make check Syntax check all scripts" @echo " make shellcheck Run shellcheck linter" @echo " make lint Run all linters (check + shellcheck)" @echo " make clean Remove build artifacts" @echo "" @echo "Installation (operates on \$$(DESTDIR)\$$(PREFIX)):" @echo " make install Install to $(PREFIX)" @echo " make install PREFIX=/usr Install to /usr" @echo " make install DESTDIR=/staging Stage install for packaging" @echo " make uninstall Remove installed files" @echo "" @echo "Variables:" @echo " PREFIX = $(PREFIX)" @echo " DESTDIR = $(DESTDIR)" @echo " BINDIR = $(BINDIR)" @echo " SBINDIR = $(SBINDIR)" @echo " LIBDIR = $(LIBDIR)" @echo " DOCDIR = $(DOCDIR)" @echo " PLATFORM = $(PLATFORM)" @echo "" @echo "Post-installation:" @echo " 1. Verify install: vpn-switch validate" @echo " 2. Initialize database: vpn-switch bootstrap ~/.vpn-switch/db minimal" @echo " 3. Import VPN config: vpn-switch wireguard import /path/to/config.conf" #----------------------------------------------------------------------------- # Repo-only convenience: set platform symlink in the SOURCE TREE so tests # and ad-hoc './vpn-switch.sh' invocations from a fresh clone work right away. # Does NOT touch the install destination — see 'install' for that. #----------------------------------------------------------------------------- platform: @echo "Setting repo platform: template/platform/default → $(PLATFORM).sh" @cd template/platform && rm -f default && ln -s $(PLATFORM).sh default @ls -l template/platform/default #----------------------------------------------------------------------------- # Installation # # install = install-base + install-platform (plugin hook from .mk) # install-base = platform-agnostic file copy + symlink in destination # install-platform = OS-specific extras (rc.d on FreeBSD, systemd on Linux later) #----------------------------------------------------------------------------- install: metadata install-base install-platform @echo "" @echo "Installation complete!" @echo "" @echo "Next steps:" @echo " 1. Verify installation: vpn-switch validate" @echo " 2. Initialize database: vpn-switch bootstrap ~/.vpn-switch/db minimal" @echo " 3. Import VPN config: vpn-switch wireguard import /path/to/config.conf" # install-base copies the platform-agnostic file set: # - Main script to $(BINDIR) (with $(LIBDIR) baked in via sed) # - resolvconf helpers to $(SBINDIR) (root-only; reload local_unbound). # Phase scripts call them by absolute /usr/local/sbin paths so this is # a contract. # - include/ modules and template/ tree to $(LIBDIR) (package-private) # - Documentation to $(DOCDIR) # - Platform symlink in the install destination (NOT the repo — that's # what the 'platform' target is for) install-base: @echo "Installing vpn-switch (base) to $(DESTDIR)$(PREFIX)..." @$(INSTALL) -d -m 755 $(DESTDIR)$(BINDIR) @$(INSTALL) -d -m 755 $(DESTDIR)$(SBINDIR) @$(INSTALL) -d -m 755 $(DESTDIR)$(LIBDIR) @$(INSTALL) -d -m 755 $(DESTDIR)$(LIBDIR)/include @$(INSTALL) -d -m 755 $(DESTDIR)$(LIBDIR)/template @$(INSTALL) -d -m 755 $(DESTDIR)$(DOCDIR) @$(INSTALL) -d -m 755 $(DESTDIR)$(MANDIR) @sed 's|: "$${VPN_SWITCH_LIBDIR:=.*}"|: "$${VPN_SWITCH_LIBDIR:=$(LIBDIR)}"|' \ $(SCRIPT) > $(DESTDIR)$(BINDIR)/vpn-switch @chmod 755 $(DESTDIR)$(BINDIR)/vpn-switch @for script in $(HELPER_SCRIPTS); do \ $(INSTALL) -m 755 $$script $(DESTDIR)$(SBINDIR)/; \ done @$(INSTALL) -m 644 include/*.sh $(DESTDIR)$(LIBDIR)/include/ @cp -R template/* $(DESTDIR)$(LIBDIR)/template/ @find $(DESTDIR)$(LIBDIR)/template -type f -exec chmod 644 {} \; @find $(DESTDIR)$(LIBDIR)/template -type d -exec chmod 755 {} \; @cd $(DESTDIR)$(LIBDIR)/template/platform && rm -f default && ln -s $(PLATFORM).sh default @for doc in $(DOC_FILES); do \ if [ -f "$$doc" ]; then $(INSTALL) -m 644 "$$doc" $(DESTDIR)$(DOCDIR)/; fi; \ done @if [ -f docs/vpn-switch.1 ]; then \ $(INSTALL) -m 644 docs/vpn-switch.1 $(DESTDIR)$(MANDIR)/vpn-switch.1; \ fi @$(INSTALL) -d -m 755 $(DESTDIR)$(COMPLETIONDIR) @$(INSTALL) -m 644 completion/vpn-switch.bash $(DESTDIR)$(COMPLETIONDIR)/vpn-switch @echo " → installed script, helpers, libs, templates, docs, man page, bash completion" @echo " → platform symlink: $(PLATFORM).sh" uninstall: uninstall-platform uninstall-base uninstall-base: @echo "Uninstalling vpn-switch (base) from $(DESTDIR)$(PREFIX)..." @rm -f $(DESTDIR)$(BINDIR)/vpn-switch @for script in $(HELPER_SCRIPTS); do \ rm -f $(DESTDIR)$(SBINDIR)/`basename $$script`; \ done @rm -rf $(DESTDIR)$(LIBDIR) @rm -f $(DESTDIR)$(COMPLETIONDIR)/vpn-switch @rm -rf $(DESTDIR)$(DOCDIR) @rm -f $(DESTDIR)$(MANDIR)/vpn-switch.1 @echo " → removed script, helpers, libs, templates, docs, man page" #----------------------------------------------------------------------------- # Testing #----------------------------------------------------------------------------- test: metadata @echo "Running all tests..." @echo "" @echo "=== Unit Tests ===" @sh vpn-switch-unit-test.sh minimal @echo "" @echo "=== Integration Tests ===" @sh vpn-switch-integration-test.sh minimal @echo "" @echo "=== Architecture Tests ===" @sh vpn-switch-architecture-test.sh minimal test-unit: metadata @echo "Running unit tests..." @sh vpn-switch-unit-test.sh minimal test-integration: metadata @echo "Running integration tests..." @sh vpn-switch-integration-test.sh minimal test-arch: metadata @echo "Running architecture tests..." @sh vpn-switch-architecture-test.sh minimal #----------------------------------------------------------------------------- # Development #----------------------------------------------------------------------------- metadata: @echo "Generating function metadata (vpn-switch.sh + embedded test copies)..." @sh scripts/generate-metadata.sh @for f in $$(grep -lF '# === AUTO-GENERATED METADATA' *-test.sh scripts/vpn-switch-walkthrough.sh); do \ printf ' + %s\n' "$$f"; \ sh scripts/generate-metadata.sh --target "$$f" >/dev/null; \ done @echo "Generating template/VERSION..." @git rev-parse HEAD > template/VERSION 2>/dev/null || cp VERSION template/VERSION 2>/dev/null || echo "unknown" > template/VERSION @echo " template/VERSION: $$(cat template/VERSION)" # Regenerate man page (groff) and HTML rendering from the single Markdown # source. Both outputs are committed so packagers/users don't need pandoc; # only contributors editing the manpage source need it. manpage: @command -v pandoc >/dev/null 2>&1 || { \ echo "pandoc not found. Install with:"; \ echo " FreeBSD: pkg install hs-pandoc"; \ echo " Debian: apt install pandoc"; \ exit 1; \ } @echo "Regenerating COMMANDS section from #@help corpus..." @sh scripts/generate-help-manpage.sh @echo "Generating docs/vpn-switch.1 (groff man page)..." @pandoc -s -t man docs/vpn-switch.1.md -o docs/vpn-switch.1 @echo "Generating docs/vpn-switch.html (web rendering: mandoc from the man page, the same look as the man page in a terminal)..." @mandoc -T html -O style=man.css docs/vpn-switch.1 > docs/vpn-switch.html @cp docs/vpn-switch.html docs/index.html # GitHub Pages landing page (served from /docs) @echo " → docs/vpn-switch.1" @echo " → docs/vpn-switch.html" @echo " → docs/index.html (GitHub Pages)" @echo " Preview man: man ./docs/vpn-switch.1" @echo " Preview html: \$$BROWSER docs/vpn-switch.html" check: @echo "Syntax check: $(SCRIPT)" @sh -n $(SCRIPT) && echo " OK" @echo "Syntax check: vpn-switch-unit-test.sh" @sh -n vpn-switch-unit-test.sh && echo " OK" @echo "Syntax check: vpn-switch-integration-test.sh" @sh -n vpn-switch-integration-test.sh && echo " OK" @echo "Syntax check: vpn-switch-architecture-test.sh" @sh -n vpn-switch-architecture-test.sh && echo " OK" @echo "" @echo "All syntax checks passed." # ShellCheck linting (requires shellcheck to be installed) # Configuration in .shellcheckrc, documentation in docs/SHELLCHECK.md shellcheck: @echo "Running shellcheck (using .shellcheckrc)..." @if command -v shellcheck >/dev/null 2>&1; then \ echo "Checking: $(SCRIPT)"; \ shellcheck $(SCRIPT) && echo " OK" || true; \ echo "Checking: vpn-switch-unit-test.sh"; \ shellcheck vpn-switch-unit-test.sh && echo " OK" || true; \ echo "Checking: vpn-switch-integration-test.sh"; \ shellcheck vpn-switch-integration-test.sh && echo " OK" || true; \ echo "Checking: vpn-switch-architecture-test.sh"; \ shellcheck vpn-switch-architecture-test.sh && echo " OK" || true; \ echo "Checking: include/*.sh"; \ shellcheck include/*.sh && echo " OK" || true; \ echo ""; \ echo "ShellCheck complete."; \ else \ echo "shellcheck not found. Install with:"; \ echo " FreeBSD: pkg install hs-ShellCheck"; \ echo " Debian: apt install shellcheck"; \ exit 1; \ fi lint: check shellcheck @echo "" @echo "All linting complete." clean: @echo "Cleaning build artifacts..." rm -rf $(BUILD_DIR) find . -name "*.bak" -delete @echo "Clean complete." #----------------------------------------------------------------------------- # Development utilities #----------------------------------------------------------------------------- .PHONY: index functions stats # Generate comprehensive index index: $(INDEX_DIR)/functions.txt $(INDEX_DIR)/anchors.txt @echo "Index generated in $(INDEX_DIR)/" @ls -lh $(INDEX_DIR)/ $(BUILD_DIR): @mkdir -p $(BUILD_DIR) $(INDEX_DIR): $(BUILD_DIR) @mkdir -p $(INDEX_DIR) $(INDEX_DIR)/functions.txt: $(SCRIPT) $(INDEX_DIR) @grep -n '^[_a-zA-Z][_a-zA-Z0-9]*()' $(SCRIPT) > $@ || true $(INDEX_DIR)/anchors.txt: $(SCRIPT) $(INDEX_DIR) @grep -n '^__*[a-z][a-z0-9_]*[0-9]()' $(SCRIPT) > $@ || true functions: $(INDEX_DIR)/functions.txt @echo "=== All Functions ===" @cat $(INDEX_DIR)/functions.txt stats: metadata @echo "=== Code Statistics ===" @echo "Total lines: $$(wc -l < $(SCRIPT))" @echo "Functions: $$(grep -c '^[_a-zA-Z][_a-zA-Z0-9]*()' $(SCRIPT))" @echo "Anchor functions: $$(grep -c '^__*[a-z][a-z0-9_]*[0-9]()' $(SCRIPT))" @echo "Include modules: $$(ls include/*.sh 2>/dev/null | wc -l)" @echo "Template files: $$(find template -type f | wc -l)" # --- package ---------------------------------------------------------------- # A FreeBSD package of this checkout: 'install' into a staging root, the # +MANIFEST from the name and the commit count (0.0. until a tag names # a release), the plist from the staged tree, pkg create into PKGDIR. The # rescue system of an elebake stage depends on it (elebake stage rescue tool # package vpn-switch). PKGNAME := vpn-switch PKGVERSION := 0.0.$(shell git rev-list --count HEAD 2>/dev/null || echo 0) PKGDIR ?= $(CURDIR)/pkg PKGSTAGE ?= $(CURDIR)/pkgstage PKGABI := $(shell pkg config ABI 2>/dev/null || echo unknown) .PHONY: package package-clean package: @test ! -d $(PKGSTAGE) || chmod -R u+w $(PKGSTAGE); rm -rf $(PKGSTAGE) && mkdir -p $(PKGDIR) @$(MAKE) install DESTDIR=$(PKGSTAGE) PREFIX=$(PREFIX) > /dev/null @printf 'name: "%s"\nversion: "%s"\norigin: "net/%s"\ncomment: "switch between WireGuard and OpenVPN sessions with pf and DNS phases"\n' \ '$(PKGNAME)' '$(PKGVERSION)' '$(PKGNAME)' > $(PKGSTAGE)/+MANIFEST @printf 'desc: "vpn-switch manages VPN connections as sessions: import configurations, patch them per phase (firewall, vpn, dns), start, stop, switch, dump and export the database as a signed pair. BSD-2-Clause."\n' >> $(PKGSTAGE)/+MANIFEST @printf 'maintainer: "dr.johannes.bruegmann@gmail.com"\nwww: "https://github.com/enk-ode/vpn-switch"\nprefix: "%s"\narch: "%s"\nlicenselogic: "single"\nlicenses: ["BSD2CLAUSE"]\ndeps: { "wireguard-tools": { origin: "net/wireguard-tools", version: "0" }, openvpn: { origin: "security/openvpn", version: "0" }, gnupg: { origin: "security/gnupg", version: "0" } }\n' \ '$(PREFIX)' '$(PKGABI)' >> $(PKGSTAGE)/+MANIFEST @(cd $(PKGSTAGE)$(PREFIX) && find . \( -type f -o -type l \) | sed 's|^\./||' | sort) > $(PKGSTAGE)/plist @pkg create -r $(PKGSTAGE) -M $(PKGSTAGE)/+MANIFEST -p $(PKGSTAGE)/plist -o $(PKGDIR) @echo "package: $(PKGDIR)/$(PKGNAME)-$(PKGVERSION).pkg ($$(wc -l < $(PKGSTAGE)/plist | tr -d ' ') files)" package-clean: @test ! -d $(PKGSTAGE) || chmod -R u+w $(PKGSTAGE); rm -rf $(PKGSTAGE)