diff --git a/lib/freedive/api/command.ex b/lib/freedive/api/command.ex new file mode 100644 index 0000000..c68ea50 --- /dev/null +++ b/lib/freedive/api/command.ex @@ -0,0 +1,160 @@ +defmodule Freedive.Api.Command do + @moduledoc """ + Freedive keeps the contexts that define your domain + and business logic. + + Contexts are also responsible for managing your data, regardless + if it comes from the database, an external API or others. + """ + require Logger + + @doas_path "/usr/local/bin/doas" + @jexec_path "/usr/sbin/jexec" + @env_path "/usr/bin/env" + @env_path_var "/sbin:/bin:/usr/sbin:/usr/bin:/usr/local/sbin:/usr/local/bin" + + @doc """ + Execute commands, raise error on failure. + + ## Examples + + iex> Freedive.execute!("whoami", [], doas: true) + "root" + + iex> Freedive.execute!("whoami", [], doas: "www") + "www" + + iex> Freedive.execute!("whoami", [], jail: "testjail") + "root" + + iex> Freedive.execute!("whoami", [], jail: "testjail", doas: "operator") + "operator" + + iex> Freedive.execute("hostname", [], jail: "testjail") + {:ok, "testjail"} + + iex> Freedive.execute!("sysctl", ["-n", "security.jail.jailed"]) + "0" + + iex> Freedive.execute!("sysctl", ["-n", "security.jail.jailed"], jail: "testjail") + "1" + + iex> Freedive.execute!("printenv", ["FOO"], jail: "testjail", env: [{"FOO", "bar"}]) + "bar" + """ + def execute!(command, args, opts \\ []), do: raise_on_error(execute(command, args, opts)) + + @doc """ + Execute commands, return {:ok, output} or {:error, {output, code}}. + + ## Examples + + iex> Freedive.execute("whoami", [], doas: true) + {:ok, "root"} + + iex> Freedive.execute("whoami", [], doas: "www") + {:ok, "www"} + + iex> Freedive.execute("whoami", [], jail: "testjail") + {:ok, "root"} + + iex> Freedive.execute("whoami", [], jail: "testjail", doas: "operator") + {:ok, "operator"} + + iex> Freedive.execute("hostname", [], jail: "testjail") + {:ok, "testjail"} + + iex> Freedive.execute("sysctl", ["-n", "security.jail.jailed"]) + {:ok, "0"} + + iex> Freedive.execute("sysctl", ["-n", "security.jail.jailed"], jail: "testjail") + {:ok, "1"} + + iex> Freedive.execute("printenv", ["FOO"], jail: "testjail", env: [{"FOO", "bar"}]) + {:ok, "bar"} + """ + @spec execute(String.t(), list(String.t()), Keyword.t()) :: + {:ok, String.t()} | {:error, {String.t(), integer()}} + def execute(command, args, opts \\ []) do + doas = Keyword.get(opts, :doas, false) + jail = Keyword.get(opts, :jail, nil) + + envars = + Keyword.get(opts, :env, [ + {"PATH", @env_path_var} + ]) + + opts = + opts + |> Keyword.delete(:doas) + |> Keyword.delete(:jail) + |> Keyword.put(:stderr_to_stdout, true) + + case {jail, doas} do + {nil, false} -> + cmd(command, args, opts) + + {nil, true} -> + cmd(@doas_path, [command] ++ args, opts) + + {nil, user} -> + cmd(@doas_path, ["-u", user, command] ++ args, opts) + + {_, false} -> + cmd( + @doas_path, + ["--", @jexec_path, "-l", jail, @env_path, "-P#{@env_path_var}", env(envars), command] ++ + args, + opts + ) + + {_, true} -> + cmd( + @doas_path, + ["--", @jexec_path, "-l", jail, @env_path, "-P#{@env_path_var}", env(envars), command] ++ + args, + opts + ) + + {_, user} -> + cmd( + @doas_path, + [ + "--", + @jexec_path, + "-l", + "-U", + user, + jail, + @env_path, + "-P#{@env_path_var}", + env(envars), + command + ] ++ args, + opts + ) + end + end + + defp cmd(command, args, opts) do + # Logger.debug("Executing command: #{command} #{inspect(args)} with opts: #{inspect(opts)}") + + case System.cmd(command, args, opts) do + {output, 0} -> {:ok, output |> String.trim()} + {output, code} -> {:error, {output |> String.trim(), code}} + end + end + + defp env(envars) do + envars + |> Enum.map(fn {k, v} -> "#{k}=#{v}" end) + |> Enum.join(" ") + end + + def raise_on_error(result) do + case result do + {:ok, output} -> output + {:error, message} -> raise message + end + end +end diff --git a/lib/freedive/api/service/cli.ex b/lib/freedive/api/service/cli.ex new file mode 100644 index 0000000..804ab14 --- /dev/null +++ b/lib/freedive/api/service/cli.ex @@ -0,0 +1,112 @@ +defmodule Freedive.Api.Service.Cli do + @moduledoc """ + Wraps `service` command to manage services on the host. + """ + require Logger + import Freedive.Api.Command + + @service_bin "/usr/sbin/service" + + def list_services() do + case execute(@service_bin, ["-l"]) do + {:ok, stdout} -> + service_names = + stdout + |> String.split("\n") + |> Enum.map(&String.trim/1) + |> Enum.reject( + &Enum.member?(["DAEMON", "FILESYSTEMS", "LOGIN", "NETWORKING", "SERVERS"], &1) + ) + + case execute(@service_bin, ["-e"]) do + {:ok, stdout} -> + enabled_service_names = + stdout + |> String.split("\n") + |> Enum.map(&String.trim/1) + |> Enum.map(&Path.basename/1) + |> Enum.into(%{}, &{&1, true}) + + services = + service_names + |> Enum.map(fn name -> + %{ + name: name, + icon: "puzzle", + enabled: Map.has_key?(enabled_service_names, name), + running: + if Map.has_key?(enabled_service_names, name) do + service_is_running?(name) + else + nil + end, + description: + if Map.has_key?(enabled_service_names, name) do + case service_description(name) do + {:ok, desc} -> desc + _ -> nil + end + else + nil + end, + commands: nil, + rcvars: nil + } + end) + |> Enum.into(%{}, &{&1[:name], &1}) + + {:ok, services} + + {:error, {stderr, _code}} -> + Logger.error("list_services enabled, log: #{inspect(stderr)}") + {:error, stderr} + end + + {:error, {stderr, _code}} -> + Logger.error("list_services, log: #{inspect(stderr)}") + {:error, stderr} + end + end + + def service_is_running?(name, args \\ []) do + case service(name, "onestatus", args) do + {:ok, _stdout} -> + true + + {:error, {_stderr, _code}} -> + false + end + end + + def service_is_enabled?(name, args \\ []) do + case service(name, "enabled", args) do + {:ok, _stdout} -> + true + + {:error, {_stderr, _code}} -> + false + end + end + + def service_description(name, args \\ []) do + case service(name, "onedescribe", args) do + {:ok, stdout} -> + stdout = String.trim(stdout) + {:ok, stdout} + + {:error, {stderr, _code}} -> + {:error, stderr} + end + end + + defp service(name, action, args) do + case execute(@service_bin, [name, action] ++ args, doas: true) do + {:ok, stdout} -> + # Logger.debug("service, log: #{inspect(stdout)}") + {:ok, stdout} + + {:error, {stderr, code}} -> + {:error, {stderr, code}} + end + end +end diff --git a/lib/freedive/api/service/server.ex b/lib/freedive/api/service/server.ex new file mode 100644 index 0000000..e6c860a --- /dev/null +++ b/lib/freedive/api/service/server.ex @@ -0,0 +1,36 @@ +defmodule Freedive.Api.Service do + @moduledoc """ + Provides API to manage services on the host. + """ + use GenServer + require Logger + import Freedive.Api.Service.Cli + + def start_link(opts) do + GenServer.start_link(__MODULE__, opts, name: __MODULE__) + end + + def list() do + GenServer.call(__MODULE__, {:list}) + end + + @impl true + def init(opts) do + state = %{opts: opts, services: []} + Logger.info("Starting Service.Server with opts: #{inspect(opts)}") + {:ok, state, {:continue, opts}} + end + + @impl true + def handle_continue(_opts, state) do + {:ok, services} = list_services() + state = %{state | services: services} + {:noreply, state} + end + + @impl true + def handle_call({:list}, _from, state) do + services = state[:services] + {:reply, services, state} + end +end diff --git a/lib/freedive/api/supervisor.ex b/lib/freedive/api/supervisor.ex new file mode 100644 index 0000000..fd71376 --- /dev/null +++ b/lib/freedive/api/supervisor.ex @@ -0,0 +1,18 @@ +defmodule Freedive.Api.Supervisor do + use Supervisor + + def start_link(opts) do + Supervisor.start_link(__MODULE__, opts, name: __MODULE__) + end + + @impl true + def init(opts) do + children = [ + Supervisor.child_spec({Freedive.Api.Service, opts}, + id: Freedive.Api.Service + ) + ] + + Supervisor.init(children, strategy: :one_for_one) + end +end diff --git a/lib/freedive/application.ex b/lib/freedive/application.ex index d352fd0..6eda289 100644 --- a/lib/freedive/application.ex +++ b/lib/freedive/application.ex @@ -22,8 +22,8 @@ defmodule Freedive.Application do {Phoenix.PubSub, name: Freedive.PubSub}, # Start the Finch HTTP client for sending emails {Finch, name: Freedive.Finch}, - # Start a worker by calling: Freedive.Worker.start_link(arg) - # {Freedive.Worker, arg}, + # Start API supervisor + {Freedive.Api.Supervisor, []}, # Start to serve requests, typically the last entry FreediveWeb.Endpoint, Freedive.Features diff --git a/lib/freedive_web/live/service_live.ex b/lib/freedive_web/live/service_live.ex index 577fe16..d69b54b 100644 --- a/lib/freedive_web/live/service_live.ex +++ b/lib/freedive_web/live/service_live.ex @@ -1,5 +1,6 @@ defmodule FreediveWeb.ServiceLive do use FreediveWeb.LiliformLive + alias Freedive.Api.Service def render(assigns) do ~H""" @@ -14,40 +15,7 @@ defmodule FreediveWeb.ServiceLive do end def items() do - %{ - "sshd" => %{ - name: "sshd", - path: "/services/ssh", - icon: "lock", - description: "Secure Shell Daemon", - enabled: true, - running: true - }, - "pf" => %{ - name: "pf", - path: "/services/pf", - icon: "shield", - description: "Packet Filter", - enabled: true, - running: true - }, - "ntpdate" => %{ - name: "ntpdate", - path: "/services/ntp", - icon: "clock", - description: "Network Time Protocol Daemon", - enabled: true, - running: false - }, - "httpd" => %{ - name: "httpd", - path: "/services/httpd", - icon: "globe", - description: "Hypertext Transfer Protocol Daemon", - enabled: false, - running: false - } - } + Service.list() end def filters() do