#!/usr/bin/env lua
-- SPDX-License-Identifier: GPL-2.0-or-later
--
-- Copyright (C) 2015 Red Hat, Inc.
--

-- Script for importing/converting Cisco VPN configuration files (.pcf) to NetworkManager
-- In general, the implementation follows the logic of import() from
-- https://git.gnome.org/browse/network-manager-vpnc/tree/properties/nm-vpnc.c

----------------------
-- Helper functions --
----------------------
function read_all(in_file)
  local f, msg = io.open(in_file, "r")
  if not f then return nil, msg; end
  local content = f:read("*all")
  f:close()
  return content
end

function uuid()
  math.randomseed(os.time())
  local template ='xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'
  local uuid = string.gsub(template, '[xy]', function (c)
    local v = (c == 'x') and math.random(0, 0xf) or math.random(8, 0xb)
    return string.format('%x', v)
  end)
  return uuid
end

function vpn_settings_to_text(vpn_settings)
  local t = {}
  for k,v in pairs(vpn_settings) do
    t[#t+1] = k.."="..v
  end
  return table.concat(t, "\n")
end

function usage()
  local basename = string.match(arg[0], '[^/\\]+$') or arg[0]
  print(basename .. " - convert/import Cisco VPN (.pcf) configuration to NetworkManager")
  print("Usage:")
  print("  " .. basename .. " <input-file> <output-file>")
  print("    - converts Cisco VPN config to NetworkManager keyfile")
  print("")
  print("  " .. basename .. " --import <input-file1> <input-file2> ...")
  print("    - imports Cisco VPN config(s) to NetworkManager")
  os.exit(1)
end


-------------------------------------------
-- Functions for VPN options translation --
-------------------------------------------
function set_option(t, option, value)
  g_switches[value[1]] = value[2]
end
function handle_generic(t, option, value)
  t[option] = value[2]
end
function handle_yes(t, option, value)
  t[option] = "yes"
end
function handle_bool(t, option, value)
  if tonumber(value[2]) == 1 then
    t[option] = "true"
  elseif tonumber(value[2]) == 0 then
    t[option] = "false"
  else
    io.stderr:write(string.format("Warning: ignoring invalid option '%s'\n", value[1]))
  end
end
function handle_DHGroup(t, option, value)
  local dhgroups = { [1]="dh1", [2]="dh2", [5]="dh5" }
  dhgroup = dhgroups[tonumber(value[2])]
  if not dhgroup then io.stderr:write(string.format("Warning: invalid value for 'DHGroup': %s\n", value[2])) end
  t[option] = dhgroup
end
function handle_PeerTimeout(t, option, value)
  if not value[2] then io.stderr:write("Warning: ignoring invalid option 'PeerTimeout'\n") end
  if tonumber(value[2]) == 0 or (tonumber(value[2]) >=10 and tonumber(value[2] <= 86400)) then
    t[option] = value[2]
  else io.stderr:write(string.format("Warning: invalid value for 'PeerTimeout': %s\n", value[2])) end
end
function handle_(t, option, value)
  io.stderr:write("Warning: enc_GroupPwd: encrypted group passwords are not supported by this script.\n")
end
function handle_TunnelingMode(t, option, value)
  if value[2] == 1 then
    io.stderr:write("Warning: TCP tunneling is not supported by vpnc. " ..
                    "The connection will be used with TCP tunneling disabled, " ..
                    "however it may not work as expected.\n")
  end
end
function handle_UseLegacyIKEPort(t, option, value)
  if value[2] ~= 0 then
    t[option] = 500
  end
end
function handle_routes(t, option, value)
  local function splitroutes(str)
    local sep, fields = " ", {}
    local pattern = string.format("([^%s]+)", sep)
    str:gsub(pattern,
             function(c)
               local c1,c2 = c:match("^(%d+%.%d+%.%d+%.%d+)/(%d+)$")
               if c1 then
                 fields[#fields+1] = { c1, c2 }
               else
                 io.stderr:write("Warning: ignoring invalid route: '" .. c .. "'\n")
               end
             end)
    return fields
  end
  t[option] = splitroutes(value[2])
end

-- global variables -
g_vpn_data = {}
g_vpn_pwds = {}
g_con_data = {}
g_ip4_data = {}
g_switches = {}

vpn2nm = {
  ["Description"]            = { nm_opt="id",                func=handle_generic, tbl=g_con_data },
  ["InterfaceName"]          = { nm_opt="interface-name",    func=handle_generic, tbl=g_con_data },
  ["EnableLocalLAN"]         = { nm_opt="never-default",     func=handle_bool,    tbl=g_ip4_data },
  ["X-NM-Routes"]            = { nm_opt="routes",            func=handle_routes,  tbl=g_ip4_data },
  ["Host"]                   = { nm_opt="IPSec gateway",     func=handle_generic, tbl=g_vpn_data },
  ["GroupName"]              = { nm_opt="IPSec ID",          func=handle_generic, tbl=g_vpn_data },
  ["Username"]               = { nm_opt="Xauth username",    func=handle_generic, tbl=g_vpn_data },
  ["UserPassword"]           = { nm_opt="Xauth password",    func=handle_generic, tbl=g_vpn_pwds },
  ["SaveUserPassword"]       = { nm_opt="",                  func=set_option,     tbl={}         },
  ["GroupPwd"]               = { nm_opt="IPSec secret",      func=handle_generic, tbl=g_vpn_pwds },
  ["DHGroup"]                = { nm_opt="IKE DH Group",      func=handle_DHGroup, tbl=g_vpn_data },
  ["NTDomain"]               = { nm_opt="Domain",            func=handle_generic, tbl=g_vpn_data },
  ["SingleDES"]              = { nm_opt="Enable Single DES", func=handle_yes,     tbl=g_vpn_data },
  ["EnableNat"]              = { nm_opt="",                  func=set_option,     tbl={}         },
  ["X-NM-Use-NAT-T"]         = { nm_opt="",                  func=set_option,     tbl={}         },
  ["X-NM-Force-NAT-T"]       = { nm_opt="",                  func=set_option,     tbl={}         },
  ["X-NM-SaveGroupPassword"] = { nm_opt="",                  func=set_option,     tbl={}         },
  ["UseLegacyIKEPort"]       = { nm_opt="Local Port",        func=handle_UseLegacyIKEPort,      tbl=g_vpn_data },
  ["PeerTimeout"]            = { nm_opt="DPD idle timeout (our side)", func=handle_PeerTimeout, tbl=g_vpn_data },
  ["TunnelingMode"]          = { nm_opt="",                  func=handle_TunnelingMode, tbl= {} },
  ["enc_UserPassword"]       = { nm_opt="",                  func=handle_enc_pwd,       tbl= {} },
  ["enc_GroupPwd"]           = { nm_opt="",                  func=handle_enc_pwd,       tbl= {} },
}

------------------------------------------------------
-- Read and convert the config into the global vars --
------------------------------------------------------
function read_and_convert(in_file)
  local function line_split(str)
    -- split at '=' character
    local sep, fields = "=", {}
    local pattern = string.format("([^%s]+)%s(.+)", sep, sep)
    fields[1], fields[2] = str:match(pattern)
    return fields
  end

  in_text, msg = read_all(in_file)
  if not in_text then return false, msg end

  -- loop through the config and convert it
  for line in in_text:gmatch("[^\r\n]+") do
    repeat
      -- skip comments and empty lines
      if line:find("^%s*[#;]") or line:find("^%s*$") then break end
      -- trim leading and trailing spaces
      line = line:find("^%s*$") and "" or line:match("^%s*(.*%S)")

      local words = line_split(line)
      local val = vpn2nm[words[1]]
      if val then
        if type(val) == "table" then val.func(val.tbl, val.nm_opt, words)
        else print(string.format("debug: '%s': val=%s", line, val)) end
      end
    until true
  end

  -- check if mandatory options exist
  if not g_vpn_data["IPSec gateway"] then
    local msg = in_file .. ": Not a valid Cisco VPN configuration (no Host)"
    return false, msg
  end
  if not g_vpn_data["IPSec ID"] then
    local msg = in_file .. ": Not a valid OpenVPN configuration (no GroupName)"
    return false, msg
  end

  -- process inter-option dependencies
  -- NAT traversal mode
  local natt_mode = {
    NONE = "none",
    NATT = "natt",
    NATT_ALWAYS = "force-natt",
    CISCO = "cisco-udp"
  }
  g_vpn_data["NAT Traversal Mode"] = natt_mode.CISCO
  if tonumber(g_switches["EnableNat"]) == 0 then
    g_vpn_data["NAT Traversal Mode"] = natt_mode.NONE
  elseif tonumber(g_switches["EnableNat"]) == 1 then
    if tonumber(g_switches["X-NM-Force-NAT-T"]) == 1 then
      g_vpn_data["NAT Traversal Mode"] = natt_mode.NATT_ALWAYS
    elseif tonumber(g_switches["X-NM-Use-NAT-T"]) == 1 then
      g_vpn_data["NAT Traversal Mode"] = natt_mode.NATT
    end
  else
    io.stderr:write("Warning: invalid value for EnableNat\n")
    g_vpn_data["NAT Traversal Mode"] = natt_mode.CISCO
  end

  -- set secret flags
  g_vpn_data["Xauth password-flags"] = 1
  if tonumber(g_switches["SaveUserPassword"]) == 1 then
    g_vpn_data["xauth-password-type"] = "save"
  else
    g_vpn_data["Xauth password-flags"] = 3
  end
  if g_vpn_data["IPSec ID"] then
    g_vpn_data["IPSec ID-flags"] = 1
  end
  if g_switches["X-NM-SaveGroupPassword"] then
    if tonumber(g_switches["X-NM-SaveGroupPassword"]) == 1 then
      g_vpn_data["ipsec-secret-type"] = "save"
      g_vpn_data["IPSec ID-flags"] = 1
    else
      g_vpn_data["IPSec ID-flags"] = 3
    end
  else
    g_vpn_data["ipsec-secret-type"] = "save"
  end

  return true
end


--------------------------------------------------------
-- Create and write connection file in keyfile format --
--------------------------------------------------------
function write_vpn_to_keyfile(in_file, out_file)
  connection = [[
[connection]
id=__NAME_PLACEHOLDER__
uuid=__UUID_PLACEHOLDER__
__IFNAME_PLACEHOLDER__
type=vpn
autoconnect=no

[ipv4]
method=auto
never-default=__NEVER_DEFAULT_PLACEHOLDER__
__ROUTES_PLACEHOLDER__

[ipv6]
method=auto

[vpn]
service-type=org.freedesktop.NetworkManager.vpnc
]]
  connection = connection .. vpn_settings_to_text(g_vpn_data)
  connection = connection .. "\n\n[vpn-secrets]\n"
  connection = connection .. vpn_settings_to_text(g_vpn_pwds)

  local con_name = g_con_data["id"] or (out_file:gsub(".*/", ""))
  local ifname = g_con_data["interface-name"]
  local never_default = g_ip4_data["never-default"] or "false"
  local routes = ""
  if ifname then ifname = "interface-name="..ifname.."\n"  else ifname = "" end
  for idx, r in ipairs(g_ip4_data["routes"] or {}) do
    routes = routes .. string.format("routes%d=%s/%s\n", idx, r[1], r[2])
  end

  connection = string.gsub(connection, "__NAME_PLACEHOLDER__", con_name)
  connection = string.gsub(connection, "__UUID_PLACEHOLDER__", uuid())
  connection = string.gsub(connection, "__IFNAME_PLACEHOLDER__\n", ifname)
  connection = string.gsub(connection, "__NEVER_DEFAULT_PLACEHOLDER__", never_default)
  connection = string.gsub(connection, "__ROUTES_PLACEHOLDER__\n", routes)

  -- write output file
  local f, err = io.open(out_file, "w")
  if not f then io.stderr:write(err) return false end
  f:write(connection)
  f:close()

  local ofname = out_file:gsub(".*/", "")
  io.stderr:write("Successfully converted VPN configuration: " .. in_file .. " => " .. out_file .. "\n")
  io.stderr:write("To use the connection, do:\n")
  io.stderr:write("# cp " .. out_file .. " /etc/NetworkManager/system-connections\n")
  io.stderr:write("# chmod 600 /etc/NetworkManager/system-connections/" .. ofname .. "\n")
  io.stderr:write("# nmcli con load /etc/NetworkManager/system-connections/" .. ofname .. "\n")
  return true
end

---------------------------------------------
-- Import VPN connection to NetworkManager --
---------------------------------------------
function import_vpn_to_NM(filename)
  local lgi = require 'lgi'
  local GLib = lgi.GLib
  local NM = lgi.NM

  -- function creating NMConnection
  local function create_profile(name)
    local profile = NM.SimpleConnection.new()
    local never_default = g_ip4_data["never-default"] == "true"

    s_con = NM.SettingConnection.new()
    s_vpn = NM.SettingVpn.new()
    s_ip4 = NM.SettingIP4Config.new()

    s_con[NM.SETTING_CONNECTION_ID] = name
    s_con[NM.SETTING_CONNECTION_UUID] = uuid()
    s_con[NM.SETTING_CONNECTION_INTERFACE_NAME] = g_con_data["interface-name"]
    s_con[NM.SETTING_CONNECTION_TYPE] = "vpn"
    s_vpn[NM.SETTING_VPN_SERVICE_TYPE] = "org.freedesktop.NetworkManager.vpnc"
    s_ip4[NM.SETTING_IP_CONFIG_METHOD] = NM.SETTING_IP4_CONFIG_METHOD_AUTO
    s_ip4[NM.SETTING_IP_CONFIG_NEVER_DEFAULT] = never_default

    -- add routes
    local AF_INET = 2
    for _, r in ipairs(g_ip4_data["routes"] or {}) do
      route = NM.IPRoute.new(AF_INET, r[1], r[2], nil, -1)
      s_ip4:add_route(route)
    end

    -- add vpn data
    for k,v in pairs(g_vpn_data) do
      s_vpn:add_data_item(k, v)
    end
    -- add vpn secrets
    for k,v in pairs(g_vpn_pwds) do
      s_vpn:add_secret(k, v)
    end

    profile:add_setting(s_con)
    profile:add_setting(s_vpn)
    profile:add_setting(s_ip4)
    return profile
  end

  -- callback function for add_connection()
  local function added_cb(client, result, data)
    local con,err,code = client:add_connection_finish(result)
    if con then
      print(string.format("%s: Imported to NetworkManager: %s - %s",
                          filename, con:get_uuid(), con:get_id()))
    else
      io.stderr:write(code .. ": " .. err .. "\n");
      return false
    end
    main_loop:quit()
  end

  local profile_name = g_con_data["id"] or string.match(filename, '[^/\\]+$') or filename
  main_loop = GLib.MainLoop(nil, false)
  local con = create_profile(profile_name)
  local client = NM.Client.new()

  -- send the connection to NetworkManager
  client:add_connection_async(con, true, nil, added_cb, nil)

  -- run main loop so that the callback could be called
  main_loop:run()
  return true
end


---------------------------
-- Main code starts here --
---------------------------
local import_mode = false
local infile, outfile

-- parse command-line arguments
if not arg[1] or arg[1] == "--help" or arg[1] == "-h" then usage() end
if arg[1] == "--import" or arg[1] == "-i" then
  infile = arg[2]
  if not infile then usage() end
  import_mode = true
else
  infile  = arg[1]
  outfile = arg[2]
  if not infile or not outfile then usage() end
  if arg[3] then usage() end
end

if import_mode then
  -- check if lgi is available
  local success,msg = pcall(require, 'lgi')
  if not success then
    io.stderr:write("Lua lgi module is not available, please install it (usually lua-lgi package)\n")
    -- print(msg)
    os.exit(1)
  end
  -- read configs, convert them and import to NM
  for i = 2, #arg do
    ok, err_msg = read_and_convert(arg[i])
    if ok then import_vpn_to_NM(arg[i])
    else io.stderr:write(err_msg .. "\n") end
    -- reset global vars
    g_vpn_data = {}
    g_vpn_pwds = {}
    g_con_data = {}
    g_ip4_data = {}
    g_switches = {}
  end
else
  -- read configs, convert them and write as NM keyfile connection
  ok, err_msg = read_and_convert(infile)
  if ok then write_vpn_to_keyfile(infile, outfile)
  else io.stderr:write(err_msg .. "\n") end
end

