/*
 * Click nbfs://nbhost/SystemFileSystem/Templates/Licenses/license-default.txt to change this license
 * Click nbfs://nbhost/SystemFileSystem/Templates/Classes/Class.java to edit this template
 */
package at.nieslony.arachne.openvpn;

import at.nieslony.arachne.firewall.FirewallRuleModel;
import at.nieslony.arachne.firewall.SiteFirewallBasicsSettings;
import at.nieslony.arachne.firewall.UserFirewallBasicsSettings;
import at.nieslony.arachne.openvpn.vpnsite.SiteVerification;
import at.nieslony.arachne.pki.CertificateRepository;
import at.nieslony.arachne.pki.Pki;
import at.nieslony.arachne.pki.PkiException;
import at.nieslony.arachne.settings.Settings;
import at.nieslony.arachne.settings.SettingsException;
import at.nieslony.arachne.utils.FolderFactory;
import at.nieslony.arachne.utils.ShellQuote;
import at.nieslony.arachne.utils.net.NetUtils;
import at.nieslony.arachne.utils.net.TransportProtocol;
import jakarta.annotation.PostConstruct;
import java.io.File;
import java.io.FileOutputStream;
import java.io.FileWriter;
import java.io.IOException;
import java.io.PrintWriter;
import java.io.StringWriter;
import java.io.Writer;
import java.nio.file.FileAlreadyExistsException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.security.cert.X509CRL;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Date;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
import lombok.extern.slf4j.Slf4j;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.PropertySource;
import org.springframework.stereotype.Controller;

/**
 *
 * @author claas
 */
@Controller
@PropertySource("classpath:arachne.properties")
@Slf4j
public class OpenVpnController {

    @Autowired
    private Settings settings;

    @Autowired
    private Pki pki;

    @Autowired
    private FolderFactory folderFactory;

    @Autowired
    private CertificateRepository certificateRepository;

    @Autowired
    private VpnSiteController vpnSiteController;

    @Value("${plugin_path}")
    String pluginPath;

    static final String FN_OPENVPN_USER_SERVER_CONF = "openvpn-user-server.conf";
    static final String FN_OPENVPN_SITE_SERVER_CONF = "openvpn-site-server.conf";
    static final String FN_OPENVPN_PLUGIN_USER_CONF = "openvpn-plugin-arachne-user.conf";
    static final String FN_OPENVPN_PLUGIN_SITE_CONF = "openvpn-plugin-arachne-site.conf";
    static final String FN_OPENVPN_CRL = "crl.pem";
    static final String FN_OPENVPN_CLIENT_CONF_DIR = "site-client-conf.d";

    @PostConstruct
    public void init() {
        writeCrl();
        writeDummySiteConfig(FN_OPENVPN_USER_SERVER_CONF, "user", 1);
        writeDummySiteConfig(FN_OPENVPN_SITE_SERVER_CONF, "site", 2);
    }

    public void writeDummySiteConfig(String fn, String serverType, int lasrOctett) {
        String fileName = folderFactory.getVpnConfigDir(fn);
        File f = new File(fileName);
        if (!f.exists() || f.length() == 0) {
            log.info("Creating dummy configuration " + fileName);
            try (PrintWriter pw = new PrintWriter(f)) {
                pw.println("dev tun");
                pw.println("local 127.11.94.%d".formatted(lasrOctett));
                pw.println(
                        "writepid " + folderFactory.getOpenVpnPidPath(serverType)
                );
            } catch (IOException ex) {
                log.error("Cannot create dummy confoigirattion %s: %s"
                        .formatted(fileName, ex.getMessage())
                );
            }
        }
    }

    public void writeCrl() {
        if (certificateRepository.count() > 0) {
            X509CRL crl = pki.getCrl(() -> {
                return certificateRepository.findByRevocationDateIsNotNullOrderByValidToDesc();
            });

            String crlString = Pki.asBase64(crl);
            String fn = folderFactory.getVpnConfigDir("crl.pem");
            try (FileWriter fw = new FileWriter(fn)) {
                fw.write(crlString);
            } catch (IOException ex) {
                log.error("Cannot write %s: %s".formatted(fn, ex.getMessage()));
            }
        }
    }

    private void writeConfigHeader(PrintWriter pw) {
        Date now = new Date();
        pw.println("# Generated by Arachne on %s, do not modify".formatted(now.toString()));
        pw.println("#");
    }

    private String getSitePluginConfTemplate() {
        return folderFactory.getVpnConfigDir(FN_OPENVPN_CLIENT_CONF_DIR + "/plugin_%cn.conf");
    }

    private String getSitePluginConf(String hostname) {
        return getSitePluginConfTemplate().replaceFirst("%cn", hostname);
    }

    public void writeOpenVpnPluginSiteConfig(
            OpenVpnSiteSettings openVpnSettings,
            SiteFirewallBasicsSettings firewallBasicsSettings
    ) {
        String fileName = folderFactory.getVpnConfigDir(FN_OPENVPN_PLUGIN_SITE_CONF);
        log.info("Writing openvpn-plugin-arache config to " + fileName);
        try (FileWriter fw = new FileWriter(fileName)) {
            PrintWriter writer = new PrintWriter(fw);
            writeConfigHeader(writer);
            writer.println("enable-routing = " + firewallBasicsSettings.getEnableRoutingMode().name());
            writer.println("client-config = " + getSitePluginConfTemplate());
            writer.println("enable-firewall = %b".formatted(
                    firewallBasicsSettings.isEnableFirewall()
            ));
            if (firewallBasicsSettings.isEnableFirewall()) {
                writer.println("firewall-zone = %s".formatted(
                        firewallBasicsSettings.getFirewallZone()
                ));
            }
        } catch (IOException ex) {
            log.error(
                    "Cannot write to %s: %s"
                            .formatted(fileName, ex.getMessage())
            );
        }
    }

    public void writeOpenVpnPluginUserConfig(
            OpenVpnUserSettings openVpnSettings,
            UserFirewallBasicsSettings firewallBasicsSettings
    ) {
        String fileName = folderFactory.getVpnConfigDir(FN_OPENVPN_PLUGIN_USER_CONF);
        log.info("Writing openvpn-plugin-arache config to " + fileName);

        try (FileWriter fw = new FileWriter(fileName)) {
            PrintWriter writer = new PrintWriter(fw);
            writeConfigHeader(writer);
            writer.println("url-login = %s/api/login".formatted(
                    openVpnSettings.getAuthHttpUrl()));
            writer.println("enable-routing = %s".formatted(
                    firewallBasicsSettings.getEnableRoutingMode().name()
            ));
            writer.println("enable-firewall = %b".formatted(
                    firewallBasicsSettings.isEnableFirewall()
            ));
            if (firewallBasicsSettings.isEnableFirewall()) {
                writer.println("firewall-zone = %s".formatted(
                        firewallBasicsSettings.getFirewallZone()
                ));
                writer.println("firewall-rules = %s".formatted(
                        folderFactory.getFirewallRulesPath(FirewallRuleModel.VpnType.USER)
                ));
                writer.println("url-firewall-user = %s/api/firewall/matching_rules"
                        .formatted(openVpnSettings.getAuthHttpUrl())
                );
            }
        } catch (IOException ex) {
            log.error(
                    "Cannot write to %s: %s"
                            .formatted(fileName, ex.getMessage())
            );
        }
    }

    public void writeOpenVpnUserServerConfig(OpenVpnUserSettings settings) {
        String fileName = folderFactory.getVpnConfigDir(FN_OPENVPN_USER_SERVER_CONF);
        log.info("Writing openvpn user server config to " + fileName);

        writeCrl();

        try (FileWriter fw = new FileWriter(fileName)) {
            PrintWriter writer = new PrintWriter(fw);
            writeConfigHeader(writer);
            writer.println("server %s %s"
                    .formatted(
                            settings.getClientNetwork(),
                            NetUtils.maskLen2Mask(settings.getClientMask())
                    )
            );
            writer.println("local %s".formatted(settings.getListenIp()));
            writer.println("proto %s".formatted(
                    settings.getListenProtocol().name().toLowerCase())
            );
            writer.println("port %d".formatted(settings.getListenPort()));
            writer.println("dev-type %s".formatted(settings.getDeviceType()));
            writer.println("dev %s".formatted(settings.getDeviceName()));
            writer.println("keepalive %d %d"
                    .formatted(
                            settings.getKeepaliveInterval(),
                            settings.getKeepaliveTimeout()));
            writer.println("topology subnet");
            if (settings.getListenProtocol() == TransportProtocol.UDP && settings.getMtuTest()) {
                writer.println("mtu-test");
            }
            writer.println(
                    "status %s %d"
                            .formatted(
                                    folderFactory.getOpenVpnStatusPath("user"),
                                    settings.getStatusUpdateSecs()
                            )
            );
            writer.println("status-version 2");
            writer.println("writepid " + folderFactory.getOpenVpnPidPath("user"));
            for (String dnsServer : settings.getPushDnsServers()) {
                writer.println("push \"dhcp-option DNS " + dnsServer + "\"");
            }
            for (String domain : settings.getDnsSearch()) {
                writer.println("push \"DOMAIN-SEARCH " + domain + "\"");
            }
            for (String route : settings.getPushRoutes()) {
                String[] components = route.split("/");
                if (components.length == 2) {
                    components[1] = NetUtils.maskLen2Mask(Integer.parseInt(components[1]));
                    writer.println(
                            "push \"route %s %s\""
                                    .formatted(components[0], components[1])
                    );
                } else {
                    log.warn("Invalid route: " + route);
                }
            }
            if (settings.getAuthType() == OpenVpnUserSettings.AuthType.USERNAME_PASSWORD) {
                writer.println("client-cert-not-required");
            }
            if (settings.getAuthType() != OpenVpnUserSettings.AuthType.CERTIFICATE) {
                switch (settings.getPasswordVerificationType()) {
                    case HTTP_URL -> {
                        writer.println("plugin %s %s"
                                .formatted(findPlugin("arachne.so"),
                                        folderFactory.getVpnConfigDir(FN_OPENVPN_PLUGIN_USER_CONF)
                                )
                        );
                    }
                    case PAM -> {
                        writer.println(
                                "plugin %s %s".formatted(
                                        findPlugin("openvpn-plugin-auth-pam.so"),
                                        settings.getAuthPamService()
                                )
                        );
                    }
                }
            }

            writer.println("crl-verify " + folderFactory.getVpnConfigDir(FN_OPENVPN_CRL));
            writer.println("<ca>\n%s</ca>".formatted(pki.getRootCertAsBase64()));
            writer.println("<cert>\n%s</cert>".formatted(pki.getServerCertAsBase64()));
            writer.println("<key>\n%s</key>".formatted(pki.getServerKeyAsBase64()));
            writer.println("<dh>\n%s</dh>".formatted(pki.getDhParams()));
        } catch (PkiException ex) {
            log.error("# pki not yet initialized");
        } catch (IOException ex) {
            log.error(
                    "Cannot write to %s: %s"
                            .formatted(fileName, ex.getMessage())
            );
        } catch (SettingsException ex) {
            log.error("Settings exception: " + ex.getMessage());
        }
    }

    public String openVpnUserConfig(String username) throws PkiException, SettingsException {
        OpenVpnUserSettings vpnSettings = settings.getSettings(OpenVpnUserSettings.class);

        String userCert = pki.getUserCertAsBase64(username);
        String privateKey = pki.getUserKeyAsBase64(username);
        String caCert = pki.getRootCertAsBase64();

        String serverCertSubject = pki
                .getServerCert()
                .getSubjectX500Principal()
                .getName();

        StringWriter sw = new StringWriter();
        PrintWriter writer = new PrintWriter(sw);
        writeConfigHeader(writer);
        writer.println("client");
        writer.println("dev tun");
        writer.println("proto %s".formatted(
                vpnSettings.getListenProtocol().name().toLowerCase())
        );
        writer.println("remote %s %d".formatted(vpnSettings.getRemote(), vpnSettings.getListenPort()));
        writer.println("verify-x509-name '%s'".formatted(serverCertSubject));
        if (vpnSettings.getAuthType() != OpenVpnUserSettings.AuthType.CERTIFICATE) {
            writer.println("""
                           <auth-user-pass>
                           %s
                           </auth-user-pass>""".formatted(username));
        }
        writer.println("<ca>\n%s\n</ca>".formatted(caCert));
        writer.println("<cert>\n%s\n</cert>".formatted(userCert));
        writer.println("<key>\n%s</key>".formatted(privateKey));

        return sw.toString();
    }

    public String openVpnUserConfigShell(String username) throws PkiException, SettingsException {
        OpenVpnUserSettings vpnSettings = settings.getSettings(OpenVpnUserSettings.class);
        String userCert = pki.getUserCertAsBase64(username);
        String privateKey = pki.getUserKeyAsBase64(username);
        String caCert = pki.getRootCertAsBase64();

        List<String> vpnOpts = new ArrayList<>(Arrays.asList(
                "ipv4.dns-search %s"
                        .formatted(String.join(",", vpnSettings.getDnsSearch())),
                "ipv4.dns %s"
                        .formatted(String.join(",", vpnSettings.getPushDnsServers())
                        ),
                "connection.autoconnect no",
                "connection.permissions user:$USER"
        ));
        if (!vpnSettings.getInternetThrouphVpn()) {
            vpnOpts.add("ipv4.never-default yes");
        }

        List<String> vpnData = new ArrayList<>(Arrays.asList(
                "ca = ${ca_crt_path@E}",
                "cert = ${crt_path@E}",
                "cert-pass-flags = 4",
                "connection-type = password-tls",
                "dev-type = tun",
                "key = ${key_path@E}",
                "password-flags = %d".formatted(
                        vpnSettings.getNetworkManagerRememberPassword().getCfgValue()
                ),
                "proto-tcp = %s".formatted(
                        vpnSettings.getListenProtocol() == TransportProtocol.TCP
                        ? "yes"
                        : "no"
                ),
                "remote = %s:%d".formatted(
                        vpnSettings.getRemote(),
                        vpnSettings.getListenPort()
                ),
                "username = %s".formatted(username)
        ));

        String config
                = """
                #!/bin/bash
                set -eu
                LANG=C

                con_name=%s
                ca_crt_fn=%s
                crt_fn=%s
                key_fn=%s
                cert_folder=${HOME@E}/.cert
                ca_crt_path=${cert_folder@E}/${ca_crt_fn@E}
                crt_path=${cert_folder@E}/${crt_fn@E}
                key_path=${cert_folder@E}/${key_fn@E}

                mkdir -vp "${cert_folder@E}"

                cat <<EOF > "${ca_crt_path@E}"
                %s
                EOF

                cat -v <<EOF > "${crt_path@E}"
                %s
                EOF

                cat -v <<EOF > "${key_path@E}"
                %s
                EOF

                chmod -v 600 "${key_path@E}"

                vpn_opts="
                %s
                "

                vpn_data="
                %s
                "

                nmcli connection add \\
                    type vpn \\
                    vpn-type openvpn \\
                    con-name "${con_name@E}" \\
                    ${vpn_opts@E} \\
                    vpn.data "${vpn_data@E}"
                """.formatted(
                        ShellQuote.shellQuote(
                                vpnSettings.getFormattedClientConfigName(username)
                        ),
                        ShellQuote.shellQuote(getCaCertFilename()),
                        ShellQuote.shellQuote(getUserCertFilename(username)),
                        ShellQuote.shellQuote(getUserKeyFilename(username)),
                        caCert,
                        userCert,
                        privateKey,
                        vpnOpts.stream()
                                .map((o) -> "    " + o)
                                .collect(Collectors.joining("\n")),
                        vpnData.stream()
                                .map((o) -> "    " + o)
                                .collect(Collectors.joining(",\n"))
                );
        return config;
    }

    public String getCaCertFilename() {
        return "arachne_%s.crt"
                .formatted(
                        pki.getRootCert()
                                .getSubjectX500Principal()
                                .toString()
                                .replaceAll("[a-zA-Z]+=", "")
                )
                .replaceAll(",", "");
    }

    public String getUserCertFilename(String username) {
        OpenVpnUserSettings openVpnSettings = settings.getSettings(OpenVpnUserSettings.class);
        String clientConfigName = openVpnSettings.getFormattedClientConfigName(username);
        return "arachne_%s.crt".formatted(clientConfigName)
                .replaceAll(",", "");
    }

    public String getUserKeyFilename(String username) {
        OpenVpnUserSettings openVpnSettings = settings.getSettings(OpenVpnUserSettings.class);
        String clientConfigName = openVpnSettings.getFormattedClientConfigName(username);
        return "arachne_%s.key".formatted(clientConfigName)
                .replaceAll(",", "");
    }

    String openVpnUserConfigJson(String username) throws JSONException, PkiException, SettingsException {
        OpenVpnUserSettings vpnSettings = settings.getSettings(OpenVpnUserSettings.class);

        String userCert = pki.getUserCertAsBase64(username);
        String privateKey = pki.getUserKeyAsBase64(username);
        String caCert = pki.getRootCertAsBase64();

        JSONObject certs = new JSONObject();
        certs.put("userCert", userCert);
        certs.put("privateKey", privateKey);
        certs.put("caCert", caCert);
        certs.put("userCertFilename", getUserCertFilename(username));
        certs.put("privateKeyFilename", getUserKeyFilename(username));
        certs.put("caCertFilename", getCaCertFilename());

        JSONObject data = new JSONObject();
        data.put("remote",
                "%s:%d"
                        .formatted(
                                vpnSettings.getRemote(),
                                vpnSettings.getListenPort()
                        )
        );
        data.put("username", username);
        data.put("cert-pass-flags", "4");
        data.put("connection-type", "password-tls");
        data.put(
                "password-flags",
                vpnSettings.getNetworkManagerRememberPassword().getCfgValue()
        );
        data.put("dev-type", vpnSettings.getDeviceType());
        if (vpnSettings.getListenProtocol() == TransportProtocol.TCP) {
            data.put("proto-tcp", "yes");
        }

        JSONObject ipv4 = new JSONObject();
        ipv4.put("never-default", !vpnSettings.getInternetThrouphVpn());
        ipv4.put("dns-search", new JSONArray(vpnSettings.getDnsSearch()));
        ipv4.put("dns", new JSONArray(vpnSettings.getPushDnsServers()));

        JSONObject json = new JSONObject();
        String conName = vpnSettings.getFormattedClientConfigName(username);
        json.put("name", conName);
        json.put("certificates", certs);
        json.put("data", data);
        json.put("ipv4", ipv4);

        return json.toString(2) + "\n";
    }

    private String findPlugin(String pluginName) {
        log.info("Searching for plugin in " + pluginPath);
        for (String dir : pluginPath.split(":")) {
            Path fn = Path.of(dir, pluginName);
            if (Files.exists(fn)) {
                String absFn = fn.toAbsolutePath().toString();
                log.info("Found plugin: " + absFn);
                return absFn;
            }
        }

        log.error(
                "Plugin %s not found in search path %s"
                        .formatted(pluginName, pluginPath)
        );
        return "";
    }

    public void prepareSiteClientDir() {
        String clientConfDirName = folderFactory.getVpnConfigDir(FN_OPENVPN_CLIENT_CONF_DIR);
        try {
            Files.createDirectories(Path.of(clientConfDirName));
        } catch (IOException ex) {
            log.error("Cannot create %s: %s".formatted(clientConfDirName, ex.getMessage()));
            return;
        }
        File clientConfDir = new File(clientConfDirName);
        for (File f : clientConfDir.listFiles()) {
            if (f.isFile()) {
                log.info("Removing " + f.getPath());
                f.delete();
            }
        }
    }

    public void writeOpenVpnSiteServerSitesPluginConfig() {
        for (VpnSite site : vpnSiteController.getAll()) {
            if (site.isDefaultSite()) {
                continue;
            }
            String fileName = getSitePluginConf(site.getSiteHostname());
            log.info("Creating plugin site config " + fileName);
            try (FileOutputStream fos = new FileOutputStream(fileName); PrintWriter pw = new PrintWriter(fos)) {
                writeConfigHeader(pw);
                pw.println("site-verification = " + site.getSiteVerification().name());
                if (site.getSiteVerification() == SiteVerification.WHITELIST) {
                    pw.println("ip-wihtelist = " + String.join(", ", site.getIpWhiteList()));
                }
                pw.println("no-remote-networks = " + site.getRemoteNetworks().size());
                for (int i = 0; i < site.getRemoteNetworks().size(); i++) {
                    pw.println(
                            "remote-network%d-address = %s".formatted(
                                    i,
                                    site.getRemoteNetworks().get(i).getAddress()
                            )
                    );
                    pw.println(
                            "remote-network%d-mask = %s".formatted(
                                    i,
                                    NetUtils.maskLen2Mask(
                                            site.getRemoteNetworks().get(i).getMask()
                                    )
                            )
                    );
                }
                pw.close();
                fos.close();
            } catch (IOException ex) {
                log.error("Cannot write configuration to %s: %s"
                        .formatted(fileName, ex.getMessage())
                );
            }
        }
    }

    public void writeOpenVpnSiteServerSitesConfig() {
        VpnSite defaultSite = vpnSiteController.getDefaultSite();
        OpenVpnSiteSettings openVpnSiteSettings = settings.getSettings(OpenVpnSiteSettings.class);
        String clientConfDirName = folderFactory.getVpnConfigDir(FN_OPENVPN_CLIENT_CONF_DIR);

        for (VpnSite site : vpnSiteController.getNonDefaultSites()) {
            String fileName
                    = "%s/%s".formatted(
                            clientConfDirName,
                            site.getSiteHostname()
                    );
            site.updateInheritedValues(defaultSite);
            log.info("Creating site configuration " + fileName);
            try (FileOutputStream fos = new FileOutputStream(fileName); PrintWriter pw = new PrintWriter(fos)) {
                writeConfigHeader(pw);
                for (String dnsServer : site.getPushDnsServers()) {
                    pw.println(
                            "push \"dhcp-option DNS %s\""
                                    .formatted(dnsServer)
                    );
                }
                for (String route : site.getPushRoutes()) {
                    String[] components = route.split("/");
                    if (components.length == 2) {
                        components[1] = NetUtils.maskLen2Mask(Integer.parseInt(components[1]));
                        pw.println(
                                "push \"route %s %s\""
                                        .formatted(components[0], components[1])
                        );
                    } else {
                        log.warn("Invalid route: " + route);
                    }
                }
                if (site.isRouteInternetThroughVpn()) {
                    pw.println("push \"redirect-gateway\"");
                }
                if (!site.getPushSearchDomains().isEmpty()) {
                    pw.println(
                            "push \"dns search-domains %s\""
                                    .formatted(String.join(" ", site.getPushSearchDomains()))
                    );
                }
                switch (site.getClientIpMode()) {
                    case AUTO -> {
                    }
                    case BY_HOSTNAME -> {
                        pw.println();
                        pw.println("ifconfig-push %s %s".formatted(
                                site.getClientHostname(),
                                NetUtils.maskLen2Mask(
                                        openVpnSiteSettings.getSiteNetworkMask()
                                )
                        ));
                    }
                    case FIXED_IP -> {
                        pw.println();
                        pw.println("ifconfig-push %s %s".formatted(
                                site.getClientIp(),
                                NetUtils.maskLen2Mask(
                                        openVpnSiteSettings.getSiteNetworkMask()
                                )
                        ));
                    }
                }
                pw.close();
                fos.close();
            } catch (IOException ex) {
                log.error("Cannot write configuration to %s: %s"
                        .formatted(fileName, ex.getMessage())
                );
            }
        }
    }

    public void writeOpenVpnSiteServerConfig() {
        OpenVpnSiteSettings openVpnSiteSettings = settings.getSettings(OpenVpnSiteSettings.class);
        String fileName = folderFactory.getVpnConfigDir(FN_OPENVPN_SITE_SERVER_CONF);
        String clientConfigDir = folderFactory.getVpnConfigDir(FN_OPENVPN_CLIENT_CONF_DIR);
        log.info("Writing site VPN configuration to " + fileName);

        try {
            Files.createDirectory(Paths.get(clientConfigDir));
        } catch (FileAlreadyExistsException ex) {
            log.info("Client config dir %s already exisrs".formatted(clientConfigDir));
        } catch (IOException ex) {
            log.error(
                    "Cannot create client config dir %s: %s"
                            .formatted(clientConfigDir, ex.getMessage())
            );
            return;
        }

        try (FileOutputStream fos = new FileOutputStream(fileName); PrintWriter pw = new PrintWriter(fos)) {
            writeConfigHeader(pw);
            pw.println(
                    "server %s %s"
                            .formatted(
                                    openVpnSiteSettings.getSiteNetwork(),
                                    NetUtils.maskLen2Mask(openVpnSiteSettings.getSiteNetworkMask())
                            )
            );
            pw.println("local " + openVpnSiteSettings.getListenIp());
            pw.println("proto " + openVpnSiteSettings
                    .getListenProtocol()
                    .toString()
                    .toLowerCase()
            );
            pw.println("port %d".formatted(openVpnSiteSettings.getListenPort()));
            pw.println("dev-type " + openVpnSiteSettings.getDeviceType());
            pw.println("dev " + openVpnSiteSettings.getDeviceName());
            pw.println(
                    "keepalive %d %d"
                            .formatted(
                                    openVpnSiteSettings.getKeepaliveInterval(),
                                    openVpnSiteSettings.getKeepaliveTimeout()
                            )
            );
            pw.println("topology subnet");

            pw.println(
                    "status %s %d"
                            .formatted(
                                    folderFactory.getOpenVpnStatusPath("site"),
                                    60
                            )
            );
            pw.println("status-version 2");
            pw.println("writepid " + folderFactory.getOpenVpnPidPath("site"));

            if (openVpnSiteSettings.getListenProtocol() == TransportProtocol.UDP
                    && openVpnSiteSettings.getMtuTest()) {
                pw.println("mtu-test");
            }
            pw.println("plugin %s %s"
                    .formatted(findPlugin("arachne.so"),
                            folderFactory.getVpnConfigDir(FN_OPENVPN_PLUGIN_SITE_CONF)
                    )
            );

            pw.println("crl-verify " + folderFactory.getVpnConfigDir(FN_OPENVPN_CRL));
            pw.println("client-config-dir " + clientConfigDir);
            pw.println("<ca>\n%s</ca>".formatted(pki.getRootCertAsBase64()));
            pw.println("<cert>\n%s</cert>".formatted(pki.getServerCertAsBase64()));
            pw.println("<key>\n%s</key>".formatted(pki.getServerKeyAsBase64()));
            pw.println("<dh>\n%s</dh>".formatted(pki.getDhParams()));
            pw.close();
        } catch (IOException | PkiException | SettingsException ex) {
            log.error(
                    "Cannot write configuration to %s: %s"
                            .formatted(fileName, ex.getMessage())
            );
        }
    }

    public void writeOpenVpnSiteRemoteConfig(long siteId, Writer writer) {
        OpenVpnSiteSettings openVpnSiteSettings
                = settings.getSettings(OpenVpnSiteSettings.class);
        Optional<VpnSite> site = vpnSiteController.getById(siteId);
        if (site.isEmpty()) {
            log.error("Site %i not found".formatted(siteId));
            return;
        }

        try (PrintWriter pw = new PrintWriter(writer)) {
            pw.println(
                    "# Created by Arachne on %s, do not modify"
                            .formatted(new Date().toString())
            );
            pw.println("client");
            pw.println(
                    "remote %s %s %s"
                            .formatted(
                                    openVpnSiteSettings.getConnectToHost(),
                                    openVpnSiteSettings.getListenPort(),
                                    openVpnSiteSettings.getListenProtocol()
                                            .toString()
                                            .toLowerCase()
                            )
            );
            pw.println("dev " + openVpnSiteSettings.getDeviceType());
            pw.println("""
                   <ca>
                   %s
                   </ca>
                   """.formatted(pki.getRootCertAsBase64())
            );
            pw.println("""
                   <cert>
                   %s
                   </cert>
                   """
                    .formatted(pki.getUserCertAsBase64(site.get().getSiteHostname()))
            );
            pw.println("""
                   <key>
                   %s
                   </key>
                   """
                    .formatted(pki.getUserKeyAsBase64(site.get().getSiteHostname()))
            );
        } catch (PkiException | SettingsException ex) {
            log.error("Cannot write site remote configuration: " + ex.getMessage());
        }
    }

    public String getOpenVpnSiteRemoteConfigFileName(
            OpenVpnSiteSettings siteSettings,
            VpnSite vpnSite
    ) {
        return getOpenVpnSiteRemoteConfigName(siteSettings, vpnSite) + ".conf";
    }

    public String getOpenVpnSiteRemoteConfigName(
            OpenVpnSiteSettings siteSettings,
            VpnSite vpnSite
    ) {
        return "arachne_%s_%s".formatted(siteSettings.getConnectToHost(), vpnSite.getSiteHostname());
    }
}
