Innovenergy_trunk/csharp/App/OpenVpnCertificatesServer/Program.cs

268 lines
9.0 KiB
C#

using System.Diagnostics;
using System.Text;
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'
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 const String VrmUser = "victron@innov.energy";
private const String VrmPwd = "NnoVctr201002";
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<String> 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 = await VrmAccount.Login(VrmUser, VrmPwd); // TODO: use 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 = await VrmAccount.Login(VrmUser, VrmPwd); // TODO: use 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}";
}
}