#!/usr/bin/ruby

#
# Generate and update NetworkManager configuration files
# based on network information in Puavo.
#
# Reads json from /etc/puavo/wlan.json
# and excepts it to contain something like this
# (only "type", "ssid" and possibly
#  "password"/"identity"/"certs"/"phase2-auth" are used,
# "wlan_ap" is not used by this script).
# The "type" can be "open", "psk", "eap-tls" or "eap-ttls".
#
# EXAMPLES:
#
# [
#   {
#     "type":     "psk",
#     "ssid":     "Ankkalinna",
#     "wlan_ap":  true,
#     "password": "AkuAnkka"
#   },
#   {
#     "type":     "psk",
#     "ssid":     "Hanhivaara",
#     "wlan_ap":  true,
#     "hidden":   true,
#     "password": "RoopeAnkka"
#   },
#   {
#     "type":     "open",
#     "ssid":     "Humppaajat",
#     "wlan_ap":  null,
#     "password": ""
#   },
#   {
#     "type":     "eap-tls",
#     "ssid":     "Baggins",
#     "wlan_ap":  null,
#     "identity": "Shire",
#     "certs": {
#       "ca_cert":             "...",
#       "client_cert":         "...",
#       "client_key":          "...",
#       "client_key_password": "mysecretclientkeypassword"
#     }
#   },
#   {
#     "type":     "eap-ttls",
#     "ssid":     "Hogwarts
#     "wlan_ap":  null,
#     "identity": "Dumbledore",
#     "password": "H0rcrux"
#   },
#   {
#     "type":     "gsm",
#     "id":       "DNA Postpaid (contract) public IP address",
#     "apn":      "data.dna.fi",        # optional
#     "number":   "*99#",               # optional
#     "pin":      "1234"                # optional
#   }
# ]
#

Encoding.default_internal = Encoding::UTF_8
Encoding.default_external = Encoding::UTF_8

require 'puavo/conf'

puavoconf = Puavo::Conf.new
exit(0) unless puavoconf.get('puavo.service.NetworkManager.enabled') == 'true'

require 'json'

status = 0

begin
  no_autoconnect_networks \
    = JSON.parse( puavoconf.get('puavo.wireless.nm.no_autoconnect_networks') )
  raise 'puavo.wireless.nm.no_autoconnect_networks is in unsupported format' \
    unless no_autoconnect_networks.kind_of?(Array) \
             && no_autoconnect_networks.all? { |x| x.kind_of?(String) }
rescue StandardError => e
  warn 'Problem parsing puavo.wireless.nm.no_autoconnect_networks:' \
         + " #{ e.message }"
  no_autoconnect_networks = []
  status = 1
end

auto_gsm = puavoconf.get('puavo.wireless.nm.gsm.automatic.enabled') == 'true'
shared_wired = puavoconf.get('puavo.wired.nm.shared.enabled') == 'true'
puavo_hosttype = puavoconf.get('puavo.hosttype')

puavoconf.close

require 'base64'
require 'fileutils'
require 'json'
require 'uuidtools'

Nm_config_directory = puavo_hosttype == 'exam' \
                        ? '/etc/NetworkManager/system-connections' \
                        : '/state/etc/NetworkManager/system-connections'
Puavo_network_label = '# automatically generated from Puavo'

def read_network_specs(config_path)
  begin
    network_specs = JSON.parse( IO.read(config_path) )
    raise "bad network spec format in #{ config_path }" \
      unless network_specs.kind_of?(Array) \
               && network_specs.all? { |x| x.kind_of?(Hash) }
    return network_specs
  rescue Errno::ENOENT
    return []
  rescue StandardError => e
    warn "Could not read and interpret #{ config_path }: #{ e.message }"
    raise e
  end
end

def shared_wired_specs
  [ { 'id' => 'wired-share', 'type' => 'ethernet' } ]
end

class Network
  attr_reader :id

  def initialize(id)
    raise "Network id is not set" if (id.nil? || id.empty?)

    @id = "puavo-#{ id }"
    # try to read uuid from the old config file, or generate with uuidgen
    @uuid = read_uuid_from_config || uuidgen
  end

  def config(config_text)
    Puavo_network_label + "\n" + config_text
  end

  def nm_config_path
    "#{ Nm_config_directory }/#{ @id }"
  end

  def read_uuid_from_config
    begin
      section = ''
      IO.readlines(nm_config_path).each do |line|
        section_match = line.match(/^\[(.*)\]$/)
        if section_match then
          section = section_match[1]
          next
        end

        uuid_match = line.match(/^uuid=(.*)$/)
        if section == 'connection' and uuid_match
          return uuid_match[1]
        end
      end
    rescue
      nil       # return nil in case we there was an error
                # (could be limited to Errno::ENOENT perhaps?)
    end

    nil         # return nil in case there was file but we could not find
                # uuid in it
  end

  def update_file
    tmpfile_path = "#{ nm_config_path }.tmp"
    File.open(tmpfile_path, 'w', 0600) { |f| f.print config }

    if (FileUtils.compare_file(nm_config_path, tmpfile_path) rescue false) then
      File.delete(tmpfile_path)
    else
      File.rename(tmpfile_path, nm_config_path)
    end
  end

  def uuidgen
    UUIDTools::UUID.random_create.to_s
  end
end

class Ethernet < Network
  def config
    super(<<"EOF")
[connection]
id=#{ @id }
uuid=#{ @uuid }
type=ethernet
autoconnect-priority=-999

[ipv4]
dns-search=
method=shared

[ipv6]
addr-gen-mode=stable-privacy
dns-search=
ip6-privacy=0
method=auto
EOF
  end
end

class Gsm < Network
  def initialize(id, apn, number, pin)
    super(id)

    @apn    = apn       # can be nil
    @number = number    # can be nil
    @pin    = pin       # can be nil
  end

  def config
    gsm_section = '# no gsm section'

    if @apn || @number || @pin then
      gsm_section = "[gsm]\n"
      gsm_section += "apn=#{ @apn }"       if @apn
      gsm_section += "number=#{ @number }" if @number
      gsm_section += "pin=#{ @pin }"       if @pin
    end

    super(<<"EOF")
[connection]
id=#{ @id }
uuid=#{ @uuid }
type=gsm
permissions=

#{ gsm_section }

[serial]
baud=115200

[ipv4]
dns-search=
method=auto

[ipv6]
addr-gen-mode=stable-privacy
dns-search=
method=ignore
EOF
  end
end

class Wlan < Network
  def initialize(ssid, priority, hidden)
    raise "Wlan ssid is not set" if (ssid.nil? || ssid.empty?)

    super(ssid.scan(/[[:alnum:]_-]/).join)

    @hidden   = (hidden == true)
    @priority = priority
    @ssid     = ssid
  end

  def autoconnect_priority
    @priority.to_s.length > 0 ? @priority.to_s : "0"
  end
end

class Wlan::Eap < Wlan
  # This path must be linked under /state on laptops, that is why it is
  # under /etc/NetworkManager/system-connections.
  Network_manager_certs_dir = '/etc/NetworkManager/system-connections/.certs'

  def certdir
    "#{ Network_manager_certs_dir }/#{ @ssid }"
  end

  def ca_cert_path;     "#{ certdir }/ca_cert";     end
  def client_cert_path; "#{ certdir }/client_cert"; end
  def client_key_path;  "#{ certdir }/client_key";  end

  def initialize(ssid, identity, password, certs, phase2_auth, priority, hidden)
    raise 'Wlan eap-tls ssid is not a non-empty string' \
      unless ssid.kind_of?(String) && !ssid.empty?
    raise 'Wlan eap-tls identity is not a non-empty string' \
      unless identity.kind_of?(String) && !identity.empty?

    @identity    = identity
    @password    = password
    @phase2_auth = phase2_auth

    @ca_cert             = nil
    @client_cert         = nil
    @client_key          = nil
    @client_key_password = nil

    if certs.kind_of?(Hash) then
      if certs['ca_cert'].kind_of?(String) && !certs['ca_cert'].empty? then
        @ca_cert = Base64.decode64(certs['ca_cert'])
      end
      if certs['client_cert'].kind_of?(String) \
           && !certs['client_cert'].empty? then
        @client_cert = Base64.decode64(certs['client_cert'])
      end
      if certs['client_key'].kind_of?(String) \
           && !certs['client_key'].empty? then
        @client_key = Base64.decode64(certs['client_key'])
      end
      if certs['client_key_password'].kind_of?(String) \
           && !certs['client_key_password'].empty? then
        @client_key_password = certs['client_key_password']
      end
    end

    super(ssid, priority, hidden)
  end

  def config
    super(<<"EOF")
[connection]
id=#{ @id }
uuid=#{ @uuid }
type=wifi
permissions=
autoconnect-priority=#{ autoconnect_priority }

[wifi]
#{ @hidden ? "hidden=true\n" : "" }mode=infrastructure
ssid=#{ @ssid }

[wifi-security]
auth-alg=open
key-mgmt=wpa-eap

[802-1x]
#{
  [ "eap=#{ eap_type }",
    "identity=#{ @identity }",
    @password ? "password=#{ @password }" : nil,
    @phase2_auth ? "phase2-auth=#{ @phase2_auth }" : nil,
    certificates_for_network_manager,
  ].compact.join("\n")
}

[ipv4]
dns-search=
method=auto

[ipv6]
addr-gen-mode=stable-privacy
dns-search=
method=auto
EOF
  end

  def certificates_for_network_manager
    return nil \
      unless @ca_cert || @client_cert || @client_key || @client_key_password

    [
      @ca_cert     ? "ca-cert=#{ ca_cert_path }"         : nil,
      @client_cert ? "client-cert=#{ client_cert_path }" : nil,
      @client_key  ? "private-key=#{ client_key_path }"  : nil,
      @client_key_password \
        ? "private-key-password=#{ @client_key_password }" : nil,
    ].compact.join("\n")
  end

  def update_file
    if @ca_cert || @client_cert || @client_key then
      begin
        Dir.mkdir(Network_manager_certs_dir)
      rescue Errno::EEXIST
      end
      begin
        Dir.mkdir(certdir)
      rescue Errno::EEXIST
      end
    end

    cert_file_paths = {
      ca_cert_path()     => @ca_cert,
      client_cert_path() => @client_cert,
      client_key_path()  => @client_key,
    }

    cert_file_paths.each do |path, data|
      if data then
        tmppath = "#{ path }.tmp"
        File.open(tmppath, 'w') { |f| f.write(data) }
        File.rename(tmppath, path)
      else
        File.unlink(path) rescue true
      end
    end

    if !@ca_cert && !@client_cert && !@client_key then
      Dir.rmdir(certdir) rescue true
    end

    super
  end
end

class Wlan::Eap::Peap < Wlan::Eap
  def eap_type; 'peap'; end
end

class Wlan::Eap::Tls < Wlan::Eap
  def eap_type; 'tls'; end
end

class Wlan::Eap::Ttls < Wlan::Eap
  def eap_type; 'ttls'; end
end

class Wlan::Open < Wlan
  def config
    super(<<"EOF")
[connection]
id=#{ @id }
uuid=#{ @uuid }
type=wifi
permissions=
autoconnect-priority=#{ autoconnect_priority }

[wifi]
#{ @hidden ? "hidden=true\n" : "" }mode=infrastructure
ssid=#{ @ssid }

[ipv4]
dns-search=
method=auto

[ipv6]
addr-gen-mode=stable-privacy
dns-search=
method=auto
EOF
  end

  def autoconnect_priority
    @priority.to_s.length > 0 ? @priority.to_s : "-1"
  end
end

class Wlan::Psk < Wlan
  def initialize(ssid, psk, priority, hidden)
    raise "Wlan password is not set for ssid=#{ ssid }" \
      if (psk.nil? || psk.empty?)
    @psk = psk

    super(ssid, priority, hidden)
  end

  def config
    super(<<"EOF")
[connection]
id=#{ @id }
uuid=#{ @uuid }
type=wifi
permissions=
autoconnect-priority=#{ autoconnect_priority }

[wifi]
#{ @hidden ? "hidden=true\n" : "" }mode=infrastructure
ssid=#{ @ssid }

[wifi-security]
auth-alg=open
key-mgmt=wpa-psk
psk=#{ @psk }

[ipv4]
dns-search=
method=auto

[ipv6]
addr-gen-mode=stable-privacy
dns-search=
method=auto
EOF
  end
end

automatic_gsm_config_path = '/etc/puavo/local/auto-gsm.json'
local_config_path         = '/etc/puavo/local/wlan.json'
config_path               = '/etc/puavo/wlan.json'

networks_specs = []
networks_specs += read_network_specs(automatic_gsm_config_path) if auto_gsm
networks_specs += shared_wired_specs() if shared_wired
networks_specs += read_network_specs(config_path)
networks_specs += read_network_specs(local_config_path)

puavo_networks = []

networks_specs.each do |network_spec|
  eap_network_fn = lambda do |network_class, network_spec|
                     network_class.new(network_spec['ssid'],
                                       network_spec['identity'],
                                       network_spec['password'],
                                       network_spec['certs'],
                                       network_spec['phase2_auth'],
                                       network_spec['priority'],
                                       network_spec['hidden'])
                   end

  begin
    network \
      = case network_spec['type']
          when 'eap-peap'
            eap_network_fn.call(Wlan::Eap::Peap, network_spec)
          when 'eap-tls'
            eap_network_fn.call(Wlan::Eap::Tls, network_spec)
          when 'eap-ttls'
            eap_network_fn.call(Wlan::Eap::Ttls, network_spec)
          when 'ethernet'
            Ethernet.new(network_spec['id'])
          when 'gsm'
            Gsm.new(network_spec['id'],
                    network_spec['apn'],
                    network_spec['number'],
                    network_spec['pin'])
          when 'open'
            Wlan::Open.new(network_spec['ssid'],
                           network_spec['priority'],
                           network_spec['hidden'])
          when 'psk'
            Wlan::Psk.new(network_spec['ssid'],
                          network_spec['password'],
                          network_spec['priority'],
                          network_spec['hidden'])
          else
            warn "Unsupported wlan type #{ network_spec['type'] }"
            nil
        end

    if network then
      network.update_file
      puavo_networks << network.id
    end
  rescue StandardError => e
    warn "Problem applying network settings: #{ e.message }"
    status = 1
  end
end

# remove stale network settings
Dir.glob("#{ Nm_config_directory }/puavo-*") do |nm_conf_path|
  begin
    is_automatic = false
    id = ''

    File.readlines(nm_conf_path).each do |line|
      case line.chomp
        when Puavo_network_label
          is_automatic = true
        when /^id=(.*)$/
          id = $1
      end
    end

    if !puavo_networks.include?(id) && is_automatic then
      File.unlink(nm_conf_path)
    end
  rescue StandardError => e
    warn "Problem removing stale network setting #{ nm_conf_path }:" \
           + " #{ e.message }"
    status = 1
  end
end

# Add autoconnect=false to network-connections as suggested by
# puavo.wireless.nm.no_autoconnect_networks puavo-conf variable.
# These might be some other networks than configured in Puavo.
# This goes one way, networks are never marked as "autoconnect=true",
# except for Puavo-networks (implicitly) which are created before
# this gets executed.
Dir.glob("#{ Nm_config_directory }/*") do |nm_conf_path|
  begin
    add_autoconnect_false = false

    lines = File.readlines(nm_conf_path)
    lines.each do |line|
      match = line.chomp.match(/^ssid=(.*)$/)
      if match && no_autoconnect_networks.include?(match[1]) then
        add_autoconnect_false = true
        break
      end
    end

    next unless add_autoconnect_false

    autoconnect_written = false

    tmpfile = "#{ nm_conf_path }.tmp"
    File.open(tmpfile, 'w') do |f|
      lines.each do |line|
        case line
          when /^type=/
            f.write(line)
            f.write("autoconnect=false\n")
            autoconnect_written = true
          when /^autoconnect=/
            f.write(line) unless autoconnect_written
          else
            f.write(line)
        end
      end
    end

    File.rename(tmpfile, nm_conf_path)

    raise 'Could not find a place to write autoconnect=false' \
      unless autoconnect_written

  rescue StandardError => e
    warn "Could not add autoconnect=false to #{ nm_conf_path }: #{ e.message }"
    status = 1
  end
end

exit(status)
