268 lines
9.0 KiB
C#
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}";
|
|||
|
}
|
|||
|
}
|