using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Text; using System.Text.Json; using Flurl; using ICSharpCode.SharpZipLib.Tar; using InnovEnergy.App.OpenVpnCertificatesServer.PKI; using InnovEnergy.Lib.Utils; using InnovEnergy.Lib.Victron.VictronVRM; using Org.BouncyCastle.Crypto; using static InnovEnergy.App.OpenVpnCertificatesServer.PKI.CertificateAuthority; namespace InnovEnergy.App.OpenVpnCertificatesServer; // dotnet publish OpenVpnCertificatesServer.csproj -c Release -r linux-x64 -p:PublishSingleFile=true --self-contained true && scp bin/Release/net6.0/linux-x64/publish/OpenVpnCertificatesServer ig@salidomo.innovenergy.ch:~/get_cert/get_cert // http://localhost:4000/get_cert?machine_serial=HQ2032UAWYM // http://localhost:4000/get_cert?unique_id=985dadd0cf29 // quotes!! // wget 'http://localhost:4000/get_cert?name=MYNAME&pw=MwBRbQb3QaX7l9XIaakq' [SuppressMessage("Trimming", "IL2026:Members annotated with \'RequiresUnreferencedCodeAttribute\' require dynamic access otherwise can break functionality when trimming application code")] public static class Program { // TODO: use fody weaver to obfuscate strings? private const String VpnSubnet = "10.2."; private const String VpnGateway = "10.2.0.1"; private const String VpnDir = "/etc/openvpn/server/Salino"; private const String CcdDir = VpnDir + "/ccd"; private const String CertsDir = VpnDir + "/certs"; private const String Endpoint = "http://localhost:4000/"; private static readonly AccToken AccToken = JsonSerializer.Deserialize(File.OpenRead("./token.json"))!; //"d4179e69413ad8c507e0965a55bb90fe712184af9c81c196b9d19db5bb083d5f"; private const String ManualPw = "MwBRbQb3QaX7l9XIaakq"; private const String QueryPath = "/get_cert"; private const String NameQueryParam = "name"; private const String UniqueIdQueryParam = "unique_id"; private const String MachineSerialQueryParam = "machine_serial"; private const String PwdQueryParam = "pw"; private const Int64 MachineSerialAttribute = 556; // TODO: enum private static readonly (String, String)? InvalidRequest = null; private static IEnumerable CcdFiles => Directory.GetFiles(CcdDir); public static void Main(String[] args) => Endpoint.ServeGet(GetCert); private static Byte[]? GetCert(Url url) { var request = ParseRequest(url).Result; if (!request.HasValue) return null; var (ccdName, humanReadableName) = request.Value; var ip = GetNextFreeIp(); var keyPair = CreateKeyPair(); var cert = CreateCertificate(ccdName, keyPair).Apply(Pem.Encode); var tar = PackTar(cert, humanReadableName, keyPair, ip); WriteServerCertificateFiles(ccdName, ip, tar); Console.WriteLine($"returning certificate for {humanReadableName}\n"); return tar; } private static Byte[] PackTar(String certificate, String certificateName, AsymmetricCipherKeyPair keys, String ip) { Console.WriteLine("packing tar"); var publicKey = keys.Public .Apply(Pem.Encode); var privateKey = keys.Private.Apply(Pem.Encode); using var ms = new MemoryStream(); using (var ts = new TarOutputStream(ms, Encoding.UTF8) {IsStreamOwner = false}) { ts.WriteTextFile("installation-ip" , ip); ts.WriteTextFile("installation-name" , certificateName); ts.WriteTextFile("ca-certificate" , CaCertificatePem.Replace(" ", "")); // remove leading spaces ts.WriteTextFile("client-certificate", certificate); ts.WriteTextFile("client-key.pub" , publicKey); ts.WriteTextFile("client-key" , privateKey); ts.WriteTextFile("innovenergy.conf" , Files.OvpnConf); ts.WriteTextFile("README.txt" , Files.ReadMeTxt); } // MUST Dispose to commit data to underlying MemoryStream !!!! return ms.ToArray(); } private static async Task<(String CcdName, String HumanReadableName)?> ParseRequest(Url? url = null) { Console.WriteLine($"got request {url}"); if (!url.Path.EndsWith(QueryPath)) { Console.WriteLine("Malformed request\n"); return InvalidRequest; } var ps = url.QueryParams; var ccdName = ps.FirstOrDefault(UniqueIdQueryParam) ?.ToString() ?? ps.FirstOrDefault(MachineSerialQueryParam)?.ToString() ?? ps.FirstOrDefault(NameQueryParam) ?.ToString(); if (!IsCcdNameValid(ccdName)) { Console.WriteLine($"Invalid ccd name {ccdName}\n"); return InvalidRequest; } if (!IsCcdNameAvailable(ccdName)) { Console.WriteLine($"ccd name {ccdName} is already in use\n"); return InvalidRequest; } return ps.Contains(NameQueryParam) ? ParseManualRequest(ps, ccdName!) : ps.Contains(UniqueIdQueryParam) ? await LookupInstallationNameByUniqueId(ccdName!) : ps.Contains(MachineSerialQueryParam) ? await LookupInstallationNameByMachineSerial(ccdName!) : InvalidRequest; } private static async Task<(String ccdName, String humanReadableName)?> LookupInstallationNameByUniqueId(String uniqueId) { Console.WriteLine($"looking up unique id {uniqueId} on VRM"); //var installationName = await LookupInstallationNameByUniqueId(ccdName); using var vrm = VrmAccount.Token(AccToken.idUser, AccToken.token); var installations = await vrm.GetInstallations(); var installationName = installations .Where(i => i.UniqueId == uniqueId) .Select(i => i.Name) .FirstOrDefault(); return !String.IsNullOrWhiteSpace(installationName) ? (uniqueId, installationName) : null; } private static async Task<(String ccdName, String humanReadableName)?> LookupInstallationNameByMachineSerial(String ccdName) { Console.WriteLine($"looking up {ccdName} on VRM"); using var vrm = VrmAccount.Token(AccToken.idUser, AccToken.token); var installations = await vrm.GetInstallations(); foreach (var installation in installations) { var details = await installation.GetDetails(); var id = details.MachineSerial(); if (id == ccdName) return (ccdName, installation.Name); } return null; } private static (String ccdName, String humanReadableName)? ParseManualRequest(QueryParamCollection ps, String ccdName) { Console.WriteLine("parsing manual request"); var pw = ps.FirstOrDefault(PwdQueryParam)?.ToString(); if (pw == ManualPw) return (ccdName, ccdName); // ccd and hr name are the same in this case Console.WriteLine("Malformed request\n"); return null; } private static Boolean IsCcdNameValid(String? ccdName) { return ccdName != null && ccdName.All(IsValidChar); Boolean IsValidChar(Char c) => Char.IsLetterOrDigit(c) || c is '_' or '-'; } private static Boolean IsCcdNameAvailable(String? ccdName) { Console.WriteLine($"checking ccdName {ccdName ?? ""}"); return ccdName != null && !CcdFiles .Select(Path.GetFileName) .Contains(ccdName); } private static void WriteServerCertificateFiles(String ccdName, String ip, Byte[] certsTar) { Console.WriteLine($"storing certificate {ccdName} in {CertsDir}"); var ccdContent = $"ifconfig-push {ip} {VpnGateway}"; var ccdFile = $"{CcdDir}/{ccdName}"; var tarFile = $"{CertsDir}/{ccdName}.tar"; File.WriteAllText (ccdFile, ccdContent); File.WriteAllBytes(tarFile, certsTar); } // TODO: this could be made simpler using OrderBy/ThenBy private static Int32 GetId(String ccdFile) { var ip = File .ReadAllLines(ccdFile) .Select(l => l.Trim()) .SingleOrDefault(l => l.StartsWith("ifconfig-push"))? .Split(" ", StringSplitOptions.RemoveEmptyEntries) .ElementAtOrDefault(1); if (ip == null) return 0; var bytes = ip.Split("."); var hi = bytes.ElementAtOrDefault(2); var lo = bytes.ElementAtOrDefault(3); // TODO: GetIp => tuple (a,b,hi,lo) ? if (hi == null || lo == null) return 0; if (!Byte.TryParse(hi, out var hiByte) || !Byte.TryParse(lo, out var loByte)) return 0; return hiByte * 256 + loByte; } // TODO: this could be made simpler using OrderBy/ThenBy private static String GetNextFreeIp() { // 10.2.hi.lo // id := 256 * hi + lo var id = CcdFiles.Max(GetId) + 1; var lo = id % 256; // x.y.z.0 and x.y.z.255 are reserved if (lo == 0) id += 1; if (lo == 255) id += 2; var hi = id / 256; lo = id % 256; Debug.Assert(lo != 0); Debug.Assert(lo != 255); Debug.Assert(hi != 255); return $"{VpnSubnet}{hi}.{lo}"; } } internal interface AccToken { public String token { get; init; } public String bearer { get; init; } public UInt64 idUser { get; init; } public String verification_mode { get; init; } public String idAccessToken { get; init; } public Boolean verification_sent { get; init; } public Boolean success { get; init; } }