import scala.util._ import scala.util.chaining._ import scala.collection.immutable.SortedMap import cats._ import cats.syntax.all._ object Generator { object Domain { import java.nio.charset.StandardCharsets.UTF_8 import scala.xml.Elem case class VPNProvider(name: String, domain: Vector[String]) { import java.util.UUID def org(reverse: Boolean, prefixes: String*): String = (prefixes ++ domain).pipe(xs => if (reverse) xs.reverseIterator else xs).mkString(".") def uuid(prefixes: String*): UUID = UUID.nameUUIDFromBytes(org(false, prefixes: _*).getBytes(UTF_8)) } opaque type VPNLang = String object VPNLang { def apply(name: String): VPNLang = name given Show[Option[VPNLang]] = _.fold(Monoid.empty[String])(_.show) } opaque type VPNNumber = Int object VPNNumber { final val Prefix = "0" def unapply(str: String): Option[VPNNumber] = str.stripPrefix(Prefix).toIntOption given Show[VPNNumber] = { case n if n < 10 => s"$Prefix$n" case n => s"$n" } } enum VPNTier { case Free, Basic, Plus } object VPNTier { import io.circe.Decoder import CommandLineParser.FromString def orEmpty(tiers: Seq[VPNTier]): Set[VPNTier] = if (tiers.nonEmpty) tiers.toSet else Set(VPNTier.Free) def fromOrdinalEither(ordinal: Int): Either[String, VPNTier] = VPNTier.values.find(_.ordinal == ordinal).toRight(s"No such ${classOf[VPNTier].getSimpleName} value: $ordinal") given Order[VPNTier] = Order.by(_.ordinal) given Show[VPNTier] = { case Free => s"$Free" case _ => Monoid.empty[String] } given Decoder[VPNTier] = Decoder[Int].emap(fromOrdinalEither) given FromString[VPNTier] = _.toInt.pipe(fromOrdinalEither).fold(e => throw IllegalArgumentException(e), identity) } sealed trait VPNEntity object VPNEntity { import cats.instances.order._ case class Single(tier: VPNTier, num: VPNNumber, lang1: VPNLang, lang2: Option[VPNLang], postfix: Option[String]) extends VPNEntity { import VPNLang.given import VPNNumber.given val (name, host) = Vector(lang1.show, lang2.show, tier.show) pipe { prefix => def fmt(it: Iterable[String]*)(up: Boolean) = Iterator.concat(it: _*).filter(_.nonEmpty).mkString("-").pipe(s => if (up) s.toUpperCase else s.toLowerCase) show"${fmt(prefix, postfix)(up = true)}#$num" -> fmt(prefix :+ num.show, postfix)(up = false) } } class Many(underlying: Vector[Single], grouped: SortedMap[VPNTier, Int]) extends VPNEntity with Iterable[Single] { def tiers: String = grouped.keys.mkString(" + ") def countByTier: String = grouped.view.map { case (t, n) => s"${n}x $t" }.mkString(" + ") def iterator: Iterator[Single] = underlying.iterator } def apply(entities: Vector[Single]): Many = entities.sorted.pipe { sorted => Many(sorted, sorted.groupMapReduce(_.tier)(_ => 1)(_ + _).to(SortedMap)) } def apply( tier: VPNTier = VPNTier.Free, lang1: VPNLang, lang2: Option[VPNLang] = None, num: VPNNumber, postfix: Option[String] = None ): Single = Single(tier, num, lang1, lang2, postfix) given Order[Single] = Order.fromOrdering( Ordering .by[Single, VPNTier](_.tier) .orElseBy(it => (it.lang1, it.lang2)) .orElseBy(_.num) .orElseBy(_.postfix) ) } opaque type VPNSchema = Elem object VPNSchema { def apply(e: Elem): VPNSchema = e given Show[VPNSchema] = schema => { import java.io.StringWriter import java.lang.System.{lineSeparator => EOL} import scala.xml.PrettyPrinter import scala.xml.dtd.{DocType, PublicID} StringBuilder() .append(s"""""") .append(EOL) .append(DocType("plist", PublicID("-//Apple//DTD PLIST 1.0//EN", "http://www.apple.com/DTDs/PropertyList-1.0.dtd"), Nil)) .append(EOL) .tap(PrettyPrinter(width = 120, step = 2).format(schema, _)) .result } } case class Credentials(usr: String, pwd: String, cert: String) } object Parse { import io.circe.{Json, Decoder} import io.circe.jawn.JawnParser import Domain._ def apply(bytes: Array[Byte], tiers: Set[VPNTier]): Either[String, Vector[VPNEntity.Single]] = { val N: VPNNumber.type = VPNNumber val L: VPNLang.type = VPNLang extension (json: Json) def field[A: Decoder](field: String): Either[String, A] = json.hcursor.get[A](field).leftMap(_.show) JawnParser(false) .parseByteArray(bytes) .leftMap(_.show) .flatMap(_.field[Vector[Json]]("LogicalServers")) .flatMap(_.traverseFilter { server => (server.field[VPNTier]("Tier"), server.field[String]("Name")).mapN { (tier, name) => if (!tiers(tier)) None else name match { case s"$lang-FREE#${N(num)}" => VPNEntity(lang1 = L(lang), num = num).some case s"$lang1-$lang2#${N(num)}-$postfix" => VPNEntity(tier, L(lang1), L(lang2).some, num, postfix.some).some case s"$lang1-$lang2#${N(num)}" => VPNEntity(tier, L(lang1), L(lang2).some, num).some case s"$lang#${N(num)}-$postfix" => VPNEntity(tier, L(lang), num = num, postfix = postfix.some).some case s"$lang#${N(num)}" => VPNEntity(tier, L(lang), num = num).some } } }) } } object Download { import java.net.URI import java.net.http.{HttpRequest, HttpClient, HttpResponse} import java.time.Duration import java.nio.file.{Paths, Files} import java.util.Base64.{getEncoder => B64} import Domain._ def cert(prov: VPNProvider, url: String): Either[String, String] = { // download(url, s"${prov.name} Root CA")(B64.encodeToString) Right(B64.encodeToString(Files.readAllBytes(Paths.get("/Users/rb/Documents/projects/Scala/VPNConf/data/ProtonVPN_Root_CA.der")))) } def registry(prov: VPNProvider, url: String, tiers: Set[VPNTier]): Either[String, Vector[VPNEntity.Single]] = { // download(url, s"${prov.name} Registry")(Parse(_, tiers).fold(sys.error, identity)) Parse(Files.readAllBytes(Paths.get("/Users/rb/Documents/projects/Scala/VPNConf/data/VPNs.json")), tiers) } def download[A](url: String, name: String)(fn: Array[Byte] => A): Either[String, A] = (for { client <- Try(HttpClient.newBuilder.connectTimeout(Duration.ofSeconds(20)).build) res <- Try(client.send(HttpRequest.newBuilder(URI(url)).build, HttpResponse.BodyHandlers.ofByteArray())) decoded <- Try(fn(res.body)) } yield decoded) .fold(e => s"Failed to download $name: $e".asLeft, _.asRight) } object Make { import Domain._ def vpn(prov: VPNProvider, vpn: VPNEntity.Single, cred: Credentials) = PayloadIdentifier {prov.org(reverse = true, vpn.host, "vpn")} PayloadUUID {prov.uuid(vpn.host, "vpn")} PayloadType com.apple.vpn.managed PayloadVersion 1 PayloadDisplayName {prov.name} {vpn.name} UserDefinedName {prov.name} {vpn.name} VPNType IKEv2 IKEv2 RemoteAddress {prov.org(reverse = false, vpn.host)} RemoteIdentifier {prov.org(reverse = false, vpn.host)} LocalIdentifier {cred.usr} ServerCertificateIssuerCommonName {prov.name} Root CA TLSMinimumVersion 1.2 EnablePFS 1 DisableRedirect OnDemandEnabled 0 OnDemandRules Action Connect AuthenticationMethod Certificate ExtendedAuthEnabled 1 AuthName {cred.usr} AuthPassword {cred.pwd} def cert(prov: VPNProvider, cred: Credentials) = PayloadIdentifier {prov.org(reverse = true, "ca")} PayloadUUID {prov.uuid("ca")} PayloadType com.apple.security.root PayloadVersion 1 PayloadContent {cred.cert} def profile(prov: VPNProvider, entities: VPNEntity.Many, cred: Credentials) = VPNSchema { PayloadIdentifier {prov.org(reverse = true, "profile")} PayloadUUID {prov.uuid("profile")} PayloadType Configuration PayloadVersion 1 PayloadDisplayName {prov.name} {entities.tiers} PayloadDescription This profile installs {entities.countByTier} VPN servers from {prov.name} using native IKEv2. PayloadContent {cert(prov, cred)} {entities.map { x => println(x.name); x }.flatMap(vpn(prov, _, cred))} } } @main def main(user: String, password: String, tiers: Domain.VPNTier*): Unit = { import Domain._ import VPNSchema.given val provider = VPNProvider( name = "ProtonVPN", domain = Vector("protonvpn", "com") ) (for { cert <- Download.cert(provider, "https://protonvpn.com/download/ProtonVPN_ike_root.der") reg <- Download.registry(provider, "https://api.protonmail.ch/vpn/logicals", VPNTier.orEmpty(tiers)) } yield Make.profile( prov = provider, entities = VPNEntity(reg), cred = Credentials(usr = user, pwd = password, cert = cert) )) .fold(sys.error, _.show.tap(println)) } }