Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Save rgaufman/f8dd54e1f448cce40da43b290820d684 to your computer and use it in GitHub Desktop.
Save rgaufman/f8dd54e1f448cce40da43b290820d684 to your computer and use it in GitHub Desktop.
#!/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