#!/usr/bin/env ruby

# -------------------------------------------------------------------------- #
# Copyright 2002-2025, OpenNebula Project, OpenNebula Systems                #
#                                                                            #
# Licensed under the Apache License, Version 2.0 (the "License"); you may    #
# not use this file except in compliance with the License. You may obtain    #
# a copy of the License at                                                   #
#                                                                            #
# http://www.apache.org/licenses/LICENSE-2.0                                 #
#                                                                            #
# Unless required by applicable law or agreed to in writing, software        #
# distributed under the License is distributed on an "AS IS" BASIS,          #
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.   #
# See the License for the specific language governing permissions and        #
# limitations under the License.                                             #
#--------------------------------------------------------------------------- #

require 'getoptlong'
require 'ipaddr'
require 'json'
require 'logger'
require 'open3'
require 'tempfile'
require 'yaml'

CHAINS = {
    'PREROUTING'  => 'chain-onevnetmap-dnat',
    'POSTROUTING' => 'chain-onevnetmap-snat'
}

# Read the configuration file
def read_configuration_file(cfile)
    begin
        YAML.safe_load(File.read(cfile))
    rescue StandardError => e
        puts "Error: #{e}"
        exit(-1)
    end
end

# Get a list of NIC/NIC_ALIAS hashes as long as NIC_ALIAS belongs to
# public network and NIC belongs to private network
def do_ip_mapping(service, private_ip = '0.0.0.0/0', public_ip = '0.0.0.0/0')
    private_network = IPAddr.new private_ip
    public_network  = IPAddr.new public_ip

    retval = []

    roles = service['SERVICE']['roles'].flatten

    roles.each do |role|
        next unless role['nodes']

        role['nodes'].each do |node|
            nic_aliases =
                [node['vm_info']['VM']['TEMPLATE']['NIC_ALIAS']].flatten
            next unless nic_aliases.any?

            nics = [node['vm_info']['VM']['TEMPLATE']['NIC']].flatten
            nic_aliases.each do |nic_alias|
                next unless public_network.include?(nic_alias['IP'])

                nic = nics.detect {|n| n['NAME'] == nic_alias['PARENT'] }
                next unless private_network.include?(nic['IP'])

                retval << { 'NIC' => nic['IP'], 'NIC_ALIAS' => nic_alias['IP'] }
            end
        end
    end

    retval
end

# Get iptables rules to apply for NAT table if no NIC/NIC_ALIAS detected
def iptables_tnat_apply_init
    rules = ''

    CHAINS.each do |nat_chain, custom_chain|
        o, _, rc = Open3.capture3("iptables -tnat -S #{custom_chain}")

        if rc.exitstatus != 0
            # The chain does not exist, add rules to create it and add it to the
            # corresponding table NAT chain
            rules += "-N #{custom_chain}\n"
            rules += "-A #{nat_chain} -j #{custom_chain}\n"
        else
            o.each_line do |r|
                next if r.include?("-N #{custom_chain}")

                # The chain does exist, add all rules belonging to the chain and
                # mark them to be deleted initially
                rules += r.gsub(/-A (.*)/, '-D \1')
            end
        end
    end

    rules
end

# Merge intial iptables rules to apply for NAT with the ones needed by
# NIC/NIC_ALIAS mapping
def iptables_tnat_apply_merge(rules, nics_maps)
    nics_maps.each do |nat|
        # DNAT rule
        jdnat = "#{CHAINS['PREROUTING']} -d #{nat['NIC_ALIAS']}/32 -j DNAT \
--to-destination #{nat['NIC']}"
        # Try to delete -D DNAT rule which means previously NIC_ALIAS still
        # attached
        if !rules.gsub!(/-D #{jdnat}\n/, '')
            # Add -A rule if not DNAT rule found which means new NIC_ALIAS
            # has been attached
            rules += "-A #{jdnat}\n"
        end

        # DNAT rule
        jsnat = "#{CHAINS['POSTROUTING']} -s #{nat['NIC']}/32 -j SNAT \
--to-source #{nat['NIC_ALIAS']}"
        # Try to delete -D SNAT rule which means previously NIC_ALIAS still
        # attached
        next if rules.gsub!(/-D #{jsnat}\n/, '')

        # Add -A rule if not SNAT rule found which means new NIC_ALIAS
        # has been attached
        rules += "-A #{jsnat}\n"
    end

    rules
end

def usage
    puts <<~EOT
        #{File.basename($PROGRAM_NAME)} [-f|--configuration-file CONFIG_FILE] [-h|--help]
           -f, --configuration-file=CONFIG_FILE use CONFIG_FILE in YAML format like this:
                                                networks:
                                                  public: IP/MASK
                                                  private: IP/MASK
                                                Only NIC_ALIASes and NICs belonging to
                                                public and private networks respectively
                                                will be used.
    EOT
    STDOUT.flush
end

# Parse arguments
opts = GetoptLong.new(
    ['--configuration-file', '-f', GetoptLong::REQUIRED_ARGUMENT],
    ['--help', '-h', GetoptLong::NO_ARGUMENT]
)

# Configuration file path
cfile = ''

begin
    opts.each do |opt, arg|
        case opt
        when '--configuration-file'
            cfile = arg
        when '--help'
            usage
            exit(0)
        end
    end
rescue StandardError
    exit(-1)
end

# Read configuration
config = read_configuration_file(cfile) unless cfile.empty?

# Get service info through OneGate
o, e, rc = Open3.capture3('onegate service show --json --extended')
if rc.exitstatus != 0
    STDERR.puts "Error: #{o}"
    exit(-1)
end

service = JSON.parse(o)

# Get IP network addresses of private and public networks
# If no network configuration passed each NIC_ALIAS found will be mapped to the
# parent NIC
private_ip = '0.0.0.0/0'
public_ip  = '0.0.0.0/0'
if !config.nil?
    private_ip = config['networks']['private']
    public_ip  = config['networks']['public']
end

# Perform the NIC_ALIAS/NIC mapping
nics_maps = do_ip_mapping(service, private_ip, public_ip)

# Get initial iptables rules required as if there no NIC_ALIAS/NIC mappings
rules_pre  = iptables_tnat_apply_init
# Modify initial iptables rules with NIC_ALIAS/NIC mappings
rules_post = iptables_tnat_apply_merge(rules_pre, nics_maps)

# Apply the inferred iptables rules
rules_post.each_line do |rule|
    o, e, rc = Open3.capture3("iptables -tnat #{rule}")
    if rc.exitstatus != 0
        STDERR.puts e
    end
end
