Current section

Files

Jump to
star_view lib mix tasks star_view.trust.ex
Raw

lib/mix/tasks/star_view.trust.ex

defmodule Mix.Tasks.StarView.Trust do
@shortdoc "Adds the StarView dev host and generates an mkcert certificate"
@moduledoc """
Adds the configured StarView development host to `/etc/hosts` and generates a
browser-trusted development certificate with `mkcert`.
The task infers the host from:
config :my_app, star_view: [dev_url: "https://my-app.test:4001"]
If no `:dev_url` is configured, it falls back to the current Mix application
name with underscores converted to hyphens.
This task asks before running local machine setup. If accepted, it may prompt
for your password through `sudo` when updating `/etc/hosts` or when `mkcert`
installs its local certificate authority.
`mkcert` must be installed first. On macOS:
brew install mkcert nss
## Examples
mix star_view.trust
mix star_view.trust --host my-app.test
mix star_view.trust --cert priv/cert/selfsigned.pem
mix star_view.trust --key priv/cert/selfsigned_key.pem
mix star_view.trust --yes
## Options
* `--host` - hostname to add and generate a certificate for. Defaults to
the configured StarView dev URL host.
* `--cert` - certificate path. Defaults to `priv/cert/selfsigned.pem`.
* `--key` - private key path. Defaults to `priv/cert/selfsigned_key.pem`.
* `--ip` - IP address for the hosts entry. Defaults to `127.0.0.1`.
* `--dry-run` - print the commands without running them.
* `--yes` - skip the confirmation prompt.
"""
use Mix.Task
@default_cert_path "priv/cert/selfsigned.pem"
@default_key_path "priv/cert/selfsigned_key.pem"
@default_hosts_file "/etc/hosts"
@default_ip "127.0.0.1"
@switches [
cert: :string,
dry_run: :boolean,
host: :string,
hosts_file: :string,
ip: :string,
key: :string,
yes: :boolean
]
@aliases [c: :cert, h: :host]
@impl Mix.Task
def run(argv) do
Mix.Task.run("app.config")
{options, args, invalid} = OptionParser.parse(argv, strict: @switches, aliases: @aliases)
validate_args!(args, invalid)
app_name = Mix.Project.config() |> Keyword.fetch!(:app)
host = Keyword.get(options, :host) || configured_host(app_name) || dev_host(app_name)
ip = Keyword.get(options, :ip, @default_ip)
cert_path = options |> Keyword.get(:cert, @default_cert_path) |> Path.expand()
key_path = options |> Keyword.get(:key, @default_key_path) |> Path.expand()
hosts_file = Keyword.get(options, :hosts_file, @default_hosts_file)
dry_run? = Keyword.get(options, :dry_run, false)
yes? = Keyword.get(options, :yes, false)
validate_host!(host)
validate_ip!(ip)
if confirmed?(host, yes?, dry_run?) do
ensure_mkcert!(dry_run?)
install_mkcert_ca(dry_run?)
add_hosts_entry(host, ip, hosts_file, dry_run?)
generate_certificate(host, cert_path, key_path, dry_run?)
Mix.shell().info("""
StarView trust setup complete for https://#{host}.
Restart `mix dev` if it was already running, and restart your browser if
it cached the previous certificate error.
""")
else
Mix.shell().info("Skipped StarView trust setup. You can run `mix star_view.trust` later.")
end
end
@doc false
def configured_host(app_name) do
app_name_dev_url =
app_name
|> Application.get_env(:star_view, [])
|> Keyword.get(:dev_url)
package_dev_url = Application.get_env(:star_view, :dev_url)
host_from_dev_url(app_name_dev_url) || host_from_dev_url(package_dev_url)
end
@doc false
def dev_host(app_name) do
app_name
|> to_string()
|> String.replace("_", "-")
|> Kernel.<>(".test")
end
@doc false
def host_from_dev_url(nil), do: nil
def host_from_dev_url(dev_url) when is_binary(dev_url) do
case URI.parse(dev_url) do
%URI{host: host} when is_binary(host) and host != "" -> host
_ -> nil
end
end
def host_from_dev_url(_), do: nil
@doc false
def hosts_entry_ip(contents, host) do
contents
|> String.split("\n")
|> Enum.find_value(fn line ->
line
|> strip_hosts_comment()
|> String.split()
|> case do
[ip | aliases] ->
if host in aliases, do: ip
_ ->
nil
end
end)
end
@doc false
def hosts_append_command(host, ip, hosts_file) do
entry = "#{ip} #{host}"
"printf '\\n%s\\n' #{shell_quote(entry)} >> #{shell_quote(hosts_file)}"
end
@doc false
def mkcert_certificate_args(host, cert_path, key_path) do
[
"-cert-file",
cert_path,
"-key-file",
key_path,
host,
"localhost",
"127.0.0.1",
"::1"
]
end
defp add_hosts_entry(host, ip, hosts_file, dry_run?) do
case File.read(hosts_file) do
{:ok, contents} ->
case hosts_entry_ip(contents, host) do
^ip ->
Mix.shell().info("#{host} already exists in #{hosts_file}.")
nil ->
append_hosts_entry(host, ip, hosts_file, dry_run?)
existing_ip ->
Mix.shell().error("""
#{host} already exists in #{hosts_file} with IP #{existing_ip}.
Leaving it unchanged.
""")
end
{:error, reason} ->
Mix.shell().error("Could not read #{hosts_file}: #{:file.format_error(reason)}")
append_hosts_entry(host, ip, hosts_file, dry_run?)
end
end
defp append_hosts_entry(host, ip, hosts_file, dry_run?) do
Mix.shell().info("Adding #{host} to #{hosts_file} with sudo.")
run_command(
"sudo",
["-p", "Password: ", "/bin/sh", "-c", hosts_append_command(host, ip, hosts_file)],
dry_run?
)
end
defp install_mkcert_ca(dry_run?) do
Mix.shell().info("Installing the local mkcert certificate authority if needed.")
run_command("mkcert", ["-install"], dry_run?)
end
defp generate_certificate(host, cert_path, key_path, dry_run?) do
ensure_certificate_directory!(cert_path, key_path, dry_run?)
Mix.shell().info("""
Generating mkcert certificate for #{host}, localhost, 127.0.0.1, and ::1.
Certificate: #{cert_path}
Key: #{key_path}
""")
run_command(
"mkcert",
mkcert_certificate_args(host, cert_path, key_path),
dry_run?
)
end
defp confirmed?(_host, true, _dry_run?), do: true
defp confirmed?(_host, _yes?, true), do: true
defp confirmed?(host, _yes?, _dry_run?) do
Mix.shell().info(prompt_intro(host))
confirm(prompt_question())
end
@doc false
def prompt_intro(host) do
"""
StarView can add `#{host}` to your hosts file and generate a
browser-trusted HTTPS certificate with mkcert.
This lets your browser open `https://#{host}` without certificate errors.
This may require sudo privileges for `/etc/hosts` and mkcert's local CA
installation.
"""
end
@doc false
def prompt_question() do
"Proceed with StarView trust setup? [Y/n] "
end
defp confirm(prompt) do
case IO.gets(prompt) do
nil ->
false
answer ->
answer
|> String.trim()
|> String.downcase()
|> case do
"" ->
true
yes when yes in ["y", "yes"] ->
true
no when no in ["n", "no"] ->
false
_ ->
Mix.shell().info("Please enter y or n.")
confirm(prompt)
end
end
end
defp run_command(command, args, true) do
Mix.shell().info("Would run: #{format_command(command, args)}")
end
defp run_command(command, args, false) do
command = format_command(command, args)
case Mix.shell().cmd(command) do
0 ->
:ok
status ->
Mix.raise("Command failed with status #{status}: #{command}")
end
end
defp validate_args!([], []), do: :ok
defp validate_args!(args, invalid) do
details =
[]
|> maybe_add_args_error(args)
|> maybe_add_invalid_error(invalid)
|> Enum.join("\n")
Mix.raise(details)
end
defp maybe_add_args_error(errors, []), do: errors
defp maybe_add_args_error(errors, args) do
["Unexpected arguments: #{Enum.join(args, " ")}" | errors]
end
defp maybe_add_invalid_error(errors, []), do: errors
defp maybe_add_invalid_error(errors, invalid) do
invalid =
Enum.map_join(invalid, ", ", fn
{option, nil} -> option
{option, value} -> "#{option}=#{value}"
end)
["Invalid options: #{invalid}" | errors]
end
defp ensure_mkcert!(true), do: :ok
defp ensure_mkcert!(false) do
if !System.find_executable("mkcert") do
Mix.raise("""
`mkcert` was not found.
Install it first, then rerun this command:
brew install mkcert nss
mix star_view.trust
""")
end
end
defp ensure_certificate_directory!(cert_path, key_path, true) do
[cert_path, key_path]
|> Enum.map(&Path.dirname/1)
|> Enum.uniq()
|> Enum.each(&Mix.shell().info("Would create directory: #{&1}"))
end
defp ensure_certificate_directory!(cert_path, key_path, false) do
[cert_path, key_path]
|> Enum.map(&Path.dirname/1)
|> Enum.uniq()
|> Enum.each(&File.mkdir_p!/1)
end
defp validate_host!(host) do
if Regex.match?(
~r/^(?=.{1,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/i,
host
) do
:ok
else
Mix.raise("Invalid DNS hostname for StarView trust setup: #{inspect(host)}")
end
end
defp validate_ip!(ip) do
ip
|> String.to_charlist()
|> :inet.parse_address()
|> case do
{:ok, _} -> :ok
{:error, _} -> Mix.raise("Invalid IP address for StarView trust setup: #{inspect(ip)}")
end
end
defp strip_hosts_comment(line) do
line
|> String.split("#", parts: 2)
|> List.first()
end
defp shell_quote(value) do
"'" <> String.replace(value, "'", "'\"'\"'") <> "'"
end
defp format_command(command, args) do
[command | args]
|> Enum.map_join(" ", &shell_quote/1)
end
end