Created
May 8, 2025 06:08
-
-
Save rgaufman/f8dd54e1f448cce40da43b290820d684 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env ruby | |
# frozen_string_literal: true | |
# | |
# NAT Setup Script for macOS | |
# ========================== | |
# | |
# This script sets up Network Address Translation (NAT) on macOS, allowing you to share | |
# your internet connection with devices connected to a secondary network interface. | |
# | |
# During setup, the script: | |
# - Enables IP forwarding via sysctl | |
# - Configures packet filtering (PF) for NAT | |
# - Sets up DNSMASQ for DHCP and DNS services | |
# - Configures a network interface with a static IP | |
# - Optionally adds static MAC to IP mappings | |
# | |
# During uninstall, the script: | |
# - Disables IP forwarding | |
# - Restores the original PF configuration | |
# - Stops and unloads DNSMASQ service | |
# - Removes DNSMASQ configuration files | |
# - Optionally resets the LAN interface configuration | |
# | |
# Usage examples: | |
# ./setup_nat.rb --list-interfaces # List available network interfaces | |
# ./setup_nat.rb --wan-interface en0 --lan-interface en5 # Basic setup | |
# ./setup_nat.rb --uninstall # Remove NAT configuration | |
require 'optparse' | |
require 'fileutils' | |
require 'semantic_logger' | |
require 'open3' | |
require 'ipaddr' | |
# Configure semantic logger | |
SemanticLogger.default_level = :info | |
SemanticLogger.add_appender(io: $stdout, formatter: :color) | |
# Utility class for network operations | |
class NetworkUtils | |
include SemanticLogger::Loggable | |
# List usable network interfaces, filtering out virtual and unusable ones | |
def self.list_usable_interfaces | |
interfaces = {} | |
# Get the output of ifconfig | |
output = `ifconfig` | |
# Parse the output to extract interface information | |
current_interface = nil | |
output.each_line do |line| | |
if line =~ /^([a-zA-Z0-9]+):/ | |
current_interface = $1 | |
interfaces[current_interface] = { status: 'unknown', type: 'unknown', has_ip: false } | |
elsif current_interface && line.include?('status:') | |
interfaces[current_interface][:status] = line.match(/status: (\w+)/)[1] rescue 'unknown' | |
elsif current_interface && line.include?('media:') | |
interfaces[current_interface][:type] = line.strip | |
elsif current_interface && line.include?('inet ') | |
interfaces[current_interface][:has_ip] = true | |
interfaces[current_interface][:ip] = line.match(/inet (\d+\.\d+\.\d+\.\d+)/)[1] rescue 'unknown' | |
end | |
end | |
# Filter out virtual and unusable interfaces | |
usable_interfaces = interfaces.select do |name, info| | |
# Skip loopback, virtual, and special interfaces | |
next false if name == 'lo0' # Loopback | |
next false if name =~ /^(gif|stf|pktap|vmenet)/ # Virtual interfaces | |
next false if name =~ /^utun/ # Tunnel interfaces | |
next false if name =~ /^bridge/ # Bridge interfaces | |
next false if name =~ /^awdl/ # Apple Wireless Direct Link | |
next false if name =~ /^llw/ # Low Latency WLAN | |
# Keep interfaces that are physical (en*, anpi*) | |
true | |
end | |
usable_interfaces | |
end | |
# Display usable interfaces in a formatted way | |
def self.display_usable_interfaces | |
interfaces = list_usable_interfaces | |
puts "\nUsable Network Interfaces:" | |
puts "============================" | |
if interfaces.empty? | |
puts "No usable interfaces found." | |
return | |
end | |
# Find the longest interface name for formatting | |
max_name_length = interfaces.keys.map(&:length).max | |
# Sort interfaces by name | |
interfaces.sort.each do |name, info| | |
status_indicator = info[:status] == 'active' ? '✅' : '❌' | |
ip_info = info[:has_ip] ? " (#{info[:ip]})" : "" | |
puts "#{status_indicator} #{name.ljust(max_name_length)} - #{info[:status]}#{ip_info}" | |
end | |
puts "\n✅ = active, ❌ = inactive" | |
puts "\nUse --wan-interface for your internet-connected interface" | |
puts "Use --lan-interface for the interface you want to use for NAT" | |
end | |
end | |
# Main class for setting up NAT | |
class NatSetup | |
include SemanticLogger::Loggable | |
def initialize(options) | |
@options = options | |
validate_required_options! unless @options[:uninstall] | |
# Set default values for optional parameters | |
@options[:static_ip] ||= '192.168.100.1' | |
@options[:dhcp_range] ||= '192.168.100.10,192.168.100.100,12h' | |
@options[:domain] ||= 'local' | |
@options[:dns] ||= '1.1.1.1' | |
@options[:add_static_mappings] ||= [] | |
@options[:remove_static_mappings] ||= [] | |
end | |
def setup | |
logger.info 'Starting NAT configuration...' | |
begin | |
sysctl_manager.ensure_ip_forwarding | |
pf_manager.configure | |
dnsmasq_manager.configure | |
interface_manager.configure | |
# Verify services are running | |
verify_services | |
logger.info 'Configuration complete.' | |
rescue StandardError => e | |
logger.error "Configuration failed: #{e.message}", exception: e | |
exit(1) | |
end | |
end | |
def uninstall | |
logger.info 'Starting NAT uninstallation...' | |
begin | |
# Uninstall in reverse order | |
interface_manager.uninstall if @options[:wan_interface] && @options[:lan_interface] | |
dnsmasq_manager.uninstall | |
pf_manager.uninstall | |
sysctl_manager.uninstall | |
logger.info 'Uninstallation complete.' | |
rescue StandardError => e | |
logger.error "Uninstallation failed: #{e.message}", exception: e | |
exit(1) | |
end | |
end | |
private | |
def validate_required_options! | |
return if @options[:wan_interface] && @options[:lan_interface] | |
logger.fatal 'WAN and LAN interfaces are required' | |
exit(1) | |
end | |
def verify_services | |
logger.info 'Verifying services...' | |
# Verify PF is enabled | |
raise 'Packet filter (PF) service is not running' unless pf_manager.verify_running | |
# Verify DNSMASQ is running | |
raise 'DNSMASQ service is not running' unless dnsmasq_manager.verify_running | |
# Verify interface configuration | |
raise 'Interface configuration failed' unless interface_manager.verify_configured | |
logger.info 'All services verified and running' | |
end | |
def sysctl_manager | |
@sysctl_manager ||= SysctlManager.new | |
end | |
def pf_manager | |
@pf_manager ||= PFManager.new(@options[:wan_interface], @options[:lan_interface]) | |
end | |
def dnsmasq_manager | |
@dnsmasq_manager ||= DNSMasqManager.new( | |
@options[:lan_interface], | |
@options[:static_ip], | |
@options[:dhcp_range], | |
@options[:domain], | |
@options[:dns], | |
@options[:add_static_mappings], | |
@options[:remove_static_mappings] | |
) | |
end | |
def interface_manager | |
@interface_manager ||= InterfaceManager.new(@options[:lan_interface], @options[:static_ip]) | |
end | |
end | |
# Base class for system operations | |
class SystemManager | |
include SemanticLogger::Loggable | |
def already_configured?(file, marker) | |
File.exist?(file) && File.read(file).include?(marker) | |
end | |
def execute_command(command, error_message = nil) | |
logger.debug "Executing: #{command}" | |
stdout, stderr, status = Open3.capture3(command) | |
if status.success? | |
logger.debug "Command succeeded: #{stdout.strip}" unless stdout.empty? | |
true | |
else | |
error = error_message || "Command failed: #{command}" | |
logger.error "#{error}: #{stderr.strip}" | |
raise "#{error}: #{stderr.strip}" | |
end | |
end | |
end | |
# Manages sysctl configuration | |
class SysctlManager < SystemManager | |
SYSCTL_CONF = '/etc/sysctl.conf' | |
IP_FORWARDING = 'net.inet.ip.forwarding=1' | |
IP_FORWARDING_DISABLE = 'net.inet.ip.forwarding=0' | |
def ensure_ip_forwarding | |
if already_configured?(SYSCTL_CONF, IP_FORWARDING) | |
logger.info "IP forwarding already enabled in #{SYSCTL_CONF}" | |
else | |
begin | |
File.open(SYSCTL_CONF, 'a') { |f| f.puts IP_FORWARDING } | |
execute_command("sudo sysctl -w #{IP_FORWARDING}", 'Failed to enable IP forwarding') | |
logger.info 'IP forwarding enabled' | |
rescue StandardError => e | |
logger.error "Failed to configure IP forwarding: #{e.message}", exception: e | |
raise | |
end | |
end | |
end | |
def uninstall | |
begin | |
# Disable IP forwarding | |
execute_command("sudo sysctl -w #{IP_FORWARDING_DISABLE}", 'Failed to disable IP forwarding') | |
# Remove IP forwarding from sysctl.conf if it exists | |
if File.exist?(SYSCTL_CONF) && File.read(SYSCTL_CONF).include?(IP_FORWARDING) | |
content = File.read(SYSCTL_CONF).gsub(/#{IP_FORWARDING}\n?/, '') | |
File.write(SYSCTL_CONF, content) | |
logger.info "Removed IP forwarding from #{SYSCTL_CONF}" | |
end | |
logger.info 'IP forwarding disabled' | |
rescue StandardError => e | |
logger.error "Failed to disable IP forwarding: #{e.message}", exception: e | |
raise | |
end | |
end | |
end | |
# Manages PF (Packet Filter) configuration | |
class PFManager < SystemManager | |
PF_CONF = '/etc/pf.conf' | |
PF_CONF_BAK = "#{PF_CONF}.bak" | |
def initialize(wan, lan) | |
@wan = wan | |
@lan = lan | |
end | |
def configure | |
rules = generate_rules | |
if already_configured?(PF_CONF, 'nat on $ext_if from $int_if') | |
logger.info 'pf.conf already configured' | |
else | |
begin | |
FileUtils.cp(PF_CONF, PF_CONF_BAK) if File.exist?(PF_CONF) | |
File.write(PF_CONF, rules) | |
logger.info 'pf.conf updated' | |
rescue StandardError => e | |
logger.error "Failed to update pf.conf: #{e.message}", exception: e | |
raise | |
end | |
end | |
# Load and enable PF | |
execute_command("sudo pfctl -f #{PF_CONF}", 'Failed to load PF configuration') | |
execute_command('sudo pfctl -e', 'Failed to enable PF') | |
logger.info 'Packet filtering (PF) configured and enabled' | |
end | |
def uninstall | |
begin | |
# Restore original pf.conf if backup exists | |
if File.exist?(PF_CONF_BAK) | |
FileUtils.cp(PF_CONF_BAK, PF_CONF) | |
logger.info "Restored original #{PF_CONF} from backup" | |
# Reload PF with original config | |
execute_command("sudo pfctl -f #{PF_CONF}", 'Failed to load original PF configuration') | |
logger.info 'Original PF configuration restored' | |
else | |
logger.warn "No backup of #{PF_CONF} found, cannot restore original configuration" | |
end | |
rescue StandardError => e | |
logger.error "Failed to restore PF configuration: #{e.message}", exception: e | |
raise | |
end | |
end | |
def verify_running | |
stdout, _, status = Open3.capture3('sudo pfctl -s info') | |
status.success? && stdout.include?('Status: Enabled') | |
end | |
private | |
def generate_rules | |
<<~PF | |
ext_if = "#{@wan}" | |
int_if = "#{@lan}" | |
nat on $ext_if from $int_if:network to any -> ($ext_if) | |
pass in on $int_if | |
pass out on $ext_if keep state | |
PF | |
end | |
end | |
# Manages DNSMasq configuration | |
class DNSMasqManager < SystemManager | |
DNSMASQ_CONF = '/opt/homebrew/etc/dnsmasq.conf' | |
RESOLV_CONF = '/etc/resolv.dnsmasq' | |
DHCP_HOST_REGEX = /^dhcp-host=([^,]+),([^,]+),([^,\s]+)/ | |
def initialize(lan, ip, dhcp_range, domain, dns, add_mappings = [], remove_mappings = []) | |
@lan = lan | |
@ip = ip | |
@dhcp_range = dhcp_range | |
@domain = domain | |
@dns = dns | |
@add_mappings = add_mappings | |
@remove_mappings = remove_mappings | |
end | |
def configure | |
begin | |
# Ensure dnsmasq is installed | |
ensure_dnsmasq_installed | |
FileUtils.mkdir_p(File.dirname(DNSMASQ_CONF)) | |
# Process static mappings | |
process_static_mappings | |
# Generate and write config | |
File.write(DNSMASQ_CONF, generate_config) | |
File.write(RESOLV_CONF, "nameserver #{@dns}\n") | |
# Restart dnsmasq service | |
execute_command('sudo brew services restart dnsmasq', 'Failed to restart dnsmasq service') | |
logger.info 'DNSMASQ configured and restarted' | |
rescue StandardError => e | |
logger.error "Failed to configure DNSMASQ: #{e.message}", exception: e | |
raise | |
end | |
end | |
def uninstall | |
begin | |
# Stop and unload dnsmasq service | |
execute_command('sudo brew services stop dnsmasq', 'Failed to stop dnsmasq service') | |
logger.info 'DNSMASQ service stopped' | |
# Remove configuration files | |
if File.exist?(DNSMASQ_CONF) | |
File.delete(DNSMASQ_CONF) | |
logger.info "Removed #{DNSMASQ_CONF}" | |
end | |
if File.exist?(RESOLV_CONF) | |
File.delete(RESOLV_CONF) | |
logger.info "Removed #{RESOLV_CONF}" | |
end | |
rescue StandardError => e | |
logger.error "Failed to uninstall DNSMASQ: #{e.message}", exception: e | |
raise | |
end | |
end | |
def verify_running | |
stdout, _, status = Open3.capture3('sudo brew services list | grep dnsmasq') | |
status.success? && stdout.include?('started') | |
end | |
private | |
def ensure_dnsmasq_installed | |
# Check if dnsmasq is installed | |
stdout, _, status = Open3.capture3('brew list --formula | grep dnsmasq') | |
unless status.success? && stdout.include?('dnsmasq') | |
logger.info 'DNSMASQ not found, installing...' | |
execute_command('brew install dnsmasq', 'Failed to install dnsmasq') | |
logger.info 'DNSMASQ installed successfully' | |
else | |
logger.info 'DNSMASQ is already installed' | |
end | |
end | |
def process_static_mappings | |
# Get existing mappings | |
existing_mappings = read_existing_mappings | |
# Process mappings to add | |
@add_mappings.each do |mapping| | |
mac, name, ip = parse_mapping(mapping) | |
# Format for dnsmasq: MAC,set:name,IP | |
formatted_mapping = "#{mac},set:#{name},#{ip}" | |
# Add to existing mappings if not already present | |
unless existing_mappings.include?(formatted_mapping) | |
existing_mappings << formatted_mapping | |
logger.info "Added static mapping: #{mac} → #{ip} (#{name})" | |
end | |
end | |
# Process mappings to remove | |
@remove_mappings.each do |mapping| | |
mac, name, ip = parse_mapping(mapping) | |
# Format for dnsmasq: MAC,set:name,IP | |
formatted_mapping = "#{mac},set:#{name},#{ip}" | |
# Remove from existing mappings if present | |
logger.info "Removed static mapping: #{mac} → #{ip} (#{name})" if existing_mappings.delete(formatted_mapping) | |
end | |
# Store updated mappings | |
@static_mappings = existing_mappings | |
end | |
def read_existing_mappings | |
mappings = [] | |
if File.exist?(DNSMASQ_CONF) | |
File.readlines(DNSMASQ_CONF).each do |line| | |
next unless line.start_with?('dhcp-host=') | |
# Extract just the mapping part (without the dhcp-host= prefix) | |
mapping = line.strip.sub(/^dhcp-host=/, '') | |
mappings << mapping unless mapping.empty? | |
end | |
end | |
mappings | |
end | |
def parse_mapping(mapping) | |
mac, name, ip = mapping.split(',').map(&:strip) | |
# Validate MAC address | |
raise ArgumentError, "Invalid MAC address format: #{mac}. Expected format: AA:BB:CC:DD:EE:FF" unless valid_mac?(mac) | |
# Validate hostname | |
unless valid_hostname?(name) | |
raise ArgumentError, "Invalid hostname: #{name}. Hostname should contain only letters, numbers, and hyphens" | |
end | |
# Validate IP address | |
raise ArgumentError, "Invalid IP address: #{ip}. Expected format: xxx.xxx.xxx.xxx" unless valid_ip?(ip) | |
[mac.upcase, name, ip] | |
end | |
def valid_mac?(mac) | |
!!(mac =~ /^([0-9A-F]{2}[:-]){5}([0-9A-F]{2})$/i) | |
end | |
def valid_hostname?(name) | |
!!(name =~ /^[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?$/) | |
end | |
def valid_ip?(ip) | |
IPAddr.new(ip) | |
true | |
rescue IPAddr::InvalidAddressError | |
false | |
end | |
def generate_config | |
config = <<~CONF | |
interface=#{@lan} | |
dhcp-range=#{@dhcp_range} | |
domain=#{@domain} | |
listen-address=127.0.0.1 | |
listen-address=#{@ip} | |
CONF | |
# Add static MAC to IP mappings | |
unless @static_mappings.empty? | |
@static_mappings.each do |mapping| | |
config += "dhcp-host=#{mapping}\n" | |
end | |
end | |
config | |
end | |
end | |
# Manages network interface configuration | |
class InterfaceManager < SystemManager | |
def initialize(lan, ip) | |
@lan = lan | |
@ip = ip | |
end | |
def configure | |
output = `ifconfig #{@lan}` | |
if output.include?(@ip) | |
logger.info "#{@lan} already has IP #{@ip}" | |
else | |
execute_command("sudo ifconfig #{@lan} inet #{@ip} netmask 255.255.255.0 up", | |
"Failed to assign IP #{@ip} to interface #{@lan}") | |
logger.info "IP #{@ip} assigned to #{@lan}" | |
end | |
rescue StandardError => e | |
logger.error "Failed to configure interface: #{e.message}", exception: e | |
raise | |
end | |
def uninstall | |
begin | |
# Check if the interface has our static IP | |
output = `ifconfig #{@lan}` | |
if output.include?(@ip) | |
# Remove the static IP by bringing the interface down and up again | |
execute_command("sudo ifconfig #{@lan} down", "Failed to bring down interface #{@lan}") | |
execute_command("sudo ifconfig #{@lan} up", "Failed to bring up interface #{@lan}") | |
logger.info "Reset interface #{@lan} configuration" | |
else | |
logger.info "Interface #{@lan} does not have our static IP, no reset needed" | |
end | |
rescue StandardError => e | |
logger.error "Failed to reset interface: #{e.message}", exception: e | |
raise | |
end | |
end | |
def verify_configured | |
output, _, status = Open3.capture3("ifconfig #{@lan}") | |
status.success? && output.include?(@ip) | |
end | |
end | |
# Parse command line options | |
options = {} | |
parser = OptionParser.new do |opts| | |
opts.banner = 'Usage: setup_nat.rb [options]' | |
opts.on('--wan-interface NAME', 'WAN interface (e.g., en0)') { |v| options[:wan_interface] = v } | |
opts.on('--lan-interface NAME', 'LAN interface (e.g., en5)') { |v| options[:lan_interface] = v } | |
opts.on('--static-ip IP', 'Static IP for LAN interface') { |v| options[:static_ip] = v } | |
opts.on('--dhcp-range RANGE', 'DHCP range (start,end,lease)') { |v| options[:dhcp_range] = v } | |
opts.on('--domain DOMAIN', 'DNS domain') { |v| options[:domain] = v } | |
opts.on('--dns DNS', 'Upstream DNS server') { |v| options[:dns] = v } | |
# Static mapping options | |
opts.on('--add-static-mapping MAPPING', 'Add static MAC to IP mapping (can be used multiple times)', | |
'Format: AA:BB:CC:DD:EE:FF,name,192.168.100.50') do |v| | |
options[:add_static_mappings] ||= [] | |
options[:add_static_mappings] << v | |
end | |
opts.on('--remove-static-mapping MAPPING', 'Remove static MAC to IP mapping (can be used multiple times)', | |
'Format: AA:BB:CC:DD:EE:FF,name,192.168.100.50') do |v| | |
options[:remove_static_mappings] ||= [] | |
options[:remove_static_mappings] << v | |
end | |
# Utility options | |
opts.on('--list-interfaces', 'List usable network interfaces') do | |
NetworkUtils.display_usable_interfaces | |
exit(0) | |
end | |
# Uninstall option | |
opts.on('--uninstall', 'Remove NAT configuration') do | |
options[:uninstall] = true | |
end | |
# Help option | |
opts.on('-h', '--help', 'Show this help message') do | |
puts opts | |
exit(0) | |
end | |
end | |
begin | |
parser.parse! | |
# Create NAT setup instance | |
nat_setup = NatSetup.new(options) | |
# Run setup or uninstall based on options | |
if options[:uninstall] | |
nat_setup.uninstall | |
else | |
nat_setup.setup | |
end | |
rescue OptionParser::InvalidOption, OptionParser::MissingArgument => e | |
puts "Error: #{e.message}" | |
puts parser | |
exit(1) | |
rescue ArgumentError => e | |
puts "Error: #{e.message}" | |
exit(1) | |
rescue StandardError => e | |
puts "Error: #{e.message}" | |
exit(1) | |
end |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment