(* (c) 2018 Hannes Mehnert, all rights reserved *)
let ( let* ) = Result.bind

let find_or_generate_key key_filename keytype keydata seed bits =
  let* f_exists = Bos.OS.File.exists key_filename in
  if f_exists then
    let* data = Bos.OS.File.read key_filename in
    X509.Private_key.decode_pem data
  else
    let* key =
      match keydata with
      | None -> Ok (X509.Private_key.generate ?seed ~bits keytype)
      | Some s ->
        let* s = Base64.decode s in
        X509.Private_key.of_octets s keytype
    in
    let pem = X509.Private_key.encode_pem key in
    let* () = Bos.OS.File.write ~mode:0o600 key_filename pem in
    Ok key

let query_certificate sock fqdn csr =
  match Dns_certify.query Mirage_crypto_rng.generate (Ptime_clock.now ()) fqdn csr with
  | Error e -> Error e
  | Ok (out, cb) ->
    Dns_cli.send_tcp sock out;
    let data = Dns_cli.recv_tcp sock in
    cb data

let nsupdate_csr sock host keyname zone dnskey csr =
  match Dns_certify.nsupdate Mirage_crypto_rng.generate Ptime_clock.now ~host ~keyname ~zone dnskey csr with
  | Error s -> Error s
  | Ok (out, cb) ->
    Dns_cli.send_tcp sock out;
    let data = Dns_cli.recv_tcp sock in
    match cb data with
    | Ok () -> Ok ()
    | Error e -> Error (`Msg (Fmt.str "nsupdate reply error %a" Dns_certify.pp_u_err e))

let jump _ server_ip port hostname more_hostnames dns_key_opt csr key keytype keydata seed bits cert force =
  Mirage_crypto_rng_unix.use_default ();
  let fn suffix = function
    | None -> Fpath.(v (Domain_name.to_string hostname) + suffix)
    | Some x -> Fpath.v x
  in
  let csr_filename = fn "req" csr
  and key_filename = fn "key" key
  and cert_filename = fn "pem" cert
  in
  let* csr =
    let* f_exists = Bos.OS.File.exists csr_filename in
    if f_exists then
      let* data = Bos.OS.File.read csr_filename in
      X509.Signing_request.decode_pem data
    else
      let* key = find_or_generate_key key_filename keytype keydata seed bits in
      let* csr = Dns_certify.signing_request hostname ~more_hostnames key in
      let pem = X509.Signing_request.encode_pem csr in
      let* () = Bos.OS.File.write csr_filename pem in
      Ok csr
  in
  (* before doing anything, let's check whether cert_filename is present,
     the public key matches, and the certificate is still valid *)
  let now = Ptime_clock.now () in
  let tomorrow =
    let (d, ps) = Ptime.Span.to_d_ps (Ptime.to_span now) in
    Ptime.v (succ d, ps)
  in
  let* cert =
    let* f_exists = Bos.OS.File.exists cert_filename in
    if f_exists then
      let* data = Bos.OS.File.read cert_filename in
      let* certs = X509.Certificate.decode_pem_multiple data in
      match List.filter (fun c -> X509.Certificate.supports_hostname c hostname) certs with
      | [] -> Ok None
      | [ cert ] -> Ok (Some cert)
      | _ -> Error (`Msg "multiple certificates that match the hostname")
    else
      Ok None
  in
  let* () =
    match cert with
    | Some cert ->
      if not force && Dns_certify.cert_matches_csr ~until:tomorrow now csr cert then
        Error (`Msg "valid certificate with matching key already present")
      else
        Ok ()
    | None -> Ok ()
  in
  (* strategy: unless force is provided, we can request DNS, and if a
     certificate is present, compare its public key with csr public key *)
  let write_certificate certs =
    let data = X509.Certificate.encode_pem_multiple certs in
    let* () = Bos.OS.File.delete cert_filename in
    Bos.OS.File.write cert_filename data
  in
  let sock = Dns_cli.connect_tcp server_ip port in
  let* should_update =
    if force then
      Ok true
    else match query_certificate sock hostname csr with
      | Ok (server, chain) ->
        Logs.app (fun m -> m "found cached certificate in DNS");
        let* () = write_certificate (server :: chain) in
        Ok false
      | Error `No_tlsa ->
        Logs.debug (fun m -> m "no TLSA found, sending update");
        Ok true
      | Error (`Msg m) -> Error (`Msg m)
      | Error ((`Decode _ | `Bad_reply _ | `Unexpected_reply _) as e) ->
        Error (`Msg (Fmt.str "error %a while parsing TLSA reply"
                       Dns_certify.pp_q_err e))
  in
  if not should_update then
    Ok ()
  else
    let* () =
      match dns_key_opt with
      | None -> Error (`Msg "no dnskey provided, but required for uploading CSR")
      | Some (keyname, zone, dnskey) ->
        let* () = nsupdate_csr sock hostname keyname zone dnskey csr in
        let rec request retries =
          match query_certificate sock hostname csr with
          | Error (`Msg msg) -> Error (`Msg msg)
          | Error #Dns_certify.q_err when retries = 0 ->
            Error (`Msg "failed to retrieve certificate (tried 10 times)")
          | Error `No_tlsa ->
            Logs.warn (fun m -> m "still no tlsa, sleeping two more seconds");
            Unix.sleep 2;
            request (pred retries)
          | Error (#Dns_certify.q_err as e) ->
            Logs.err (fun m -> m "error %a while handling TLSA reply (retrying)"
                         Dns_certify.pp_q_err e);
            request (pred retries)
          | Ok (server, chain) -> write_certificate (server :: chain)
        in
        request 10
    in
    Logs.app (fun m -> m "success! your certificate is stored in %a (private key %a, csr %a)"
                 Fpath.pp cert_filename Fpath.pp key_filename Fpath.pp csr_filename);
    Ok ()

open Cmdliner

let dns_server =
  let doc = "DNS server IP" in
  Arg.(required & pos 0 (some Dns_cli.ip_c) None & info [] ~doc ~docv:"IP")

let port =
  let doc = "Port to connect to" in
  Arg.(value & opt int 53 & info [ "port" ] ~doc)

let dns_key =
  let doc = "nsupdate key (name:alg:b64key, where name is YYY._update.zone)" in
  Arg.(value & opt (some Dns_cli.namekey_c) None & info [ "dns-key" ] ~doc ~docv:"KEY")

let hostname =
  let doc = "Hostname (FQDN) to issue a certificate for" in
  Arg.(required & pos 1 (some Dns_cli.name_c) None & info [] ~doc ~docv:"HOSTNAME")

let more_hostnames =
  let doc = "Additional hostnames to be included in the certificate as SubjectAlternativeName extension" in
  Arg.(value & opt_all Dns_cli.domain_name_c [] & info ["additional"] ~doc ~docv:"HOSTNAME")

let csr =
  let doc = "certificate signing request filename (defaults to hostname.req)" in
  Arg.(value & opt (some string) None & info [ "csr" ] ~doc)

let key =
  let doc = "private key filename (default to hostname.key)" in
  Arg.(value & opt (some string) None & info [ "key" ] ~doc)

let seed =
  let doc = "private key seed (or full private key if keytype is a EC key)" in
  Arg.(value & opt (some string) None & info [ "seed" ] ~doc)

let bits =
  let doc = "private key bits" in
  Arg.(value & opt int 4096 & info [ "bits" ] ~doc)

let keydata =
  let doc = "private key (base64 encoded)" in
  Arg.(value & opt (some string) None & info [ "data" ] ~doc)

let keytype =
  let doc = "keytype to generate" in
  Arg.(value & opt (enum X509.Key_type.strings) `RSA & info [ "type" ] ~doc)

let cert =
  let doc = "certificate filename (defaults to hostname.pem)" in
  Arg.(value & opt (some string) None & info [ "certificate" ] ~doc)

let force =
  let doc = "force signing request to DNS" in
  Arg.(value & flag & info [ "force" ] ~doc)

let ocertify =
  let doc = "ocertify requests a signed certificate" in
  let man = [ `S "BUGS"; `P "Submit bugs to me";] in
  let term =
    Term.(term_result (const jump $ Dns_cli.setup_log $ dns_server $ port $ hostname $ more_hostnames $ dns_key $ csr $ key $ keytype $ keydata $ seed $ bits $ cert $ force))
  and info = Cmd.info "ocertify" ~version:"10.2.2" ~doc ~man
  in
  Cmd.v info term

let () = exit (Cmd.eval ocertify)
