commit 92767d41a25f65d234583d8499425a493930c0b9 Author: Mikko Ahlroth Date: Sun Feb 25 00:25:21 2018 +0200 Initial commit diff --git a/.formatter.exs b/.formatter.exs new file mode 100644 index 0000000..525446d --- /dev/null +++ b/.formatter.exs @@ -0,0 +1,4 @@ +# Used by "mix format" +[ + inputs: ["mix.exs", "{config,lib,test}/**/*.{ex,exs}"] +] diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..73c2738 --- /dev/null +++ b/.gitignore @@ -0,0 +1,26 @@ +# The directory Mix will write compiled artifacts to. +/_build/ + +# If you run "mix test --cover", coverage assets end up here. +/cover/ + +# The directory Mix downloads your dependencies sources to. +/deps/ + +# Where 3rd-party dependencies like ExDoc output generated docs. +/doc/ + +# Ignore .fetch files in case you like to edit your project deps locally. +/.fetch + +# If the VM crashes, it generates a dump, let's ignore it too. +erl_crash.dump + +# Also ignore archive artifacts (built via "mix archive.build"). +*.ez + +# Ignore package tarball (built via "mix hex.build"). +triggerer-*.tar + +# Testing scripts +test*.sh diff --git a/.on-save.json b/.on-save.json new file mode 100644 index 0000000..68ab8ff --- /dev/null +++ b/.on-save.json @@ -0,0 +1,6 @@ +[ + { + "files": "**/*.{ex,exs}", + "command": "mix format ${srcFile}" + } +] diff --git a/README.md b/README.md new file mode 100644 index 0000000..e41868a --- /dev/null +++ b/README.md @@ -0,0 +1,45 @@ +# Triggerer + +Triggerer is a small application that triggers execution of scripts when given endpoints are hit +with GET requests. It contains no permissions handling, no authentication, no request limiting. So +these must be handled in some other way, like with an Nginx reverse proxy. + +Should work on any \*nix system that has `sh`. + +## Installation + +1. Clone repo +2. `mix deps.get` +3. Build release with `MIX_ENV=prod COOKIE=yourcookie mix release --env=prod` +4. Run release in your preferred way, setting environment according to the next section + +## Environment variables + +To bind to a certain port (instead of default `6000`), add env `PORT=1337`. + +Configuration of endpoints and scripts is done via environment variables. Environment variables +are set in the following format: + +``` +TRIGGERER_FOO=/path/to/bar.sh +``` + +The variable consists of `TRIGGERER_` prefix, after which is the name of the endpoint. The value is +the path to the script to execute. The above variable would result in an endpoint `/foo`, which would +execute `/path/to/bar.sh`. + +The endpoint path is transformed to lowercase and underscores are replaced with dashes. See the +following examples: + +* `TRIGGERER_FOO` → `/foo` +* `TRIGGERER_CODE_STATS_BETA` → `/code-stats-beta` +* `TRIGGERER_AAA_` → `/aaa-` + +The path to the script can be absolute or relative, as long as it can be reached from the current +working dir. + +## Return value + +If the script was triggered, 200 OK is returned. If the script is not found or the request path is +wrong, 404 File not found is returned. If there is an error in script execution, 500 Internal server +error is returned and the error is logged. diff --git a/config/config.exs b/config/config.exs new file mode 100644 index 0000000..b547bfd --- /dev/null +++ b/config/config.exs @@ -0,0 +1,34 @@ +# This file is responsible for configuring your application +# and its dependencies with the aid of the Mix.Config module. +use Mix.Config + +# This configuration is loaded before any dependency and is restricted +# to this project. If another project depends on this project, this +# file won't be loaded nor affect the parent project. For this reason, +# if you want to provide default values for your application for +# 3rd-party users, it should be done in your "mix.exs" file. + +# You can configure your application as: +# +# config :triggerer, key: :value +# +# and access this configuration in your application as: +# +# Application.get_env(:triggerer, :key) +# +# You can also configure a 3rd-party app: +# +# config :logger, level: :info +# + +# It is also possible to import configuration files, relative to this +# directory. For example, you can emulate configuration per environment +# by uncommenting the line below and defining dev.exs, test.exs and such. +# Configuration from the imported file will override the ones defined +# here (which is why it is important to import them last). +# +env_config = "#{Mix.env()}.exs" + +if File.exists?(Path.join([__DIR__, env_config])) do + import_config(env_config) +end diff --git a/config/dev.exs b/config/dev.exs new file mode 100644 index 0000000..5d0e691 --- /dev/null +++ b/config/dev.exs @@ -0,0 +1,7 @@ +use Mix.Config + +secret = "dev.secret.exs" + +if File.exists?(Path.join([__DIR__, secret])) do + import_config(secret) +end diff --git a/config/prod.exs b/config/prod.exs new file mode 100644 index 0000000..2fde1d4 --- /dev/null +++ b/config/prod.exs @@ -0,0 +1,10 @@ +use Mix.Config + +# Do not print debug messages in production +config :logger, level: :error + +secret = "prod.secret.exs" + +if File.exists?(Path.join([__DIR__, secret])) do + import_config(secret) +end diff --git a/lib/router.ex b/lib/router.ex new file mode 100644 index 0000000..410cc07 --- /dev/null +++ b/lib/router.ex @@ -0,0 +1,49 @@ +defmodule Triggerer.Router do + @moduledoc """ + Router dispatching requests to the correct handlers. + """ + + use Plug.Router + use Plug.ErrorHandler + require Logger + + plug(Plug.Logger) + plug(:match) + plug(:dispatch) + + paths = + quote do + Triggerer.get_envs() + end + + match "/:path" do + case Map.get(unquote(paths), path) do + nil -> + route_not_found(conn) + + script -> + case Triggerer.run(script) do + :ok -> + send_resp(conn, 200, "OK") + + {:error, :file_not_found} -> + send_resp(conn, 404, "File not found.") + + error -> + Logger.error("Unknown error: #{inspect(error)}") + send_resp(conn, 500, "Unknown error.") + end + end + end + + match _ do + route_not_found(conn) + end + + defp route_not_found(conn), do: send_resp(conn, 404, "Route not found.") + + defp handle_errors(conn, data) do + Logger.error(inspect(data)) + send_resp(conn, conn.status, "Something went wrong.") + end +end diff --git a/lib/triggerer.ex b/lib/triggerer.ex new file mode 100644 index 0000000..637e5c1 --- /dev/null +++ b/lib/triggerer.ex @@ -0,0 +1,81 @@ +defmodule Triggerer do + require Logger + + @doc """ + Get all matching environment variables and their values. Returns a map where keys are the transformed + paths and values are the given script paths. + + ## Examples + + In these examples, sample env is given as argument. If no argument is given, system env is used. + + iex> Triggerer.get_envs(%{"TRIGGERER_FOO" => "./test.sh"}) + %{"foo" => "./test.sh"} + + iex> Triggerer.get_envs(%{}) + %{} + + iex> Triggerer.get_envs(%{"WRONG_FORMAT" => "no", "TRIGGERER_BAR_" => "baz", "TRIGGERER_" => "no", "TRIGGERER_GO_WILD" => "/bin/true"}) + %{"bar-" => "baz", "go-wild" => "/bin/true"} + """ + @spec get_envs(%{optional(String.t()) => String.t()}) :: map + def get_envs(envs \\ nil) do + envs = envs || System.get_env() + + envs + |> Map.keys() + |> Enum.reduce(%{}, fn + "TRIGGERER_" <> rest = key, acc when rest != "" -> + path = transform_path(rest) + Map.put(acc, path, envs[key]) + + _, acc -> + acc + end) + end + + @doc """ + Transform a given path from environment variable style to endpoint style. That is, downcase it and + replace underscores with dashes. + + ## Examples + + iex> Triggerer.transform_path("YKSI_KAKSI") + "yksi-kaksi" + + iex> Triggerer.transform_path("FOO") + "foo" + + iex> Triggerer.transform_path("ÄNKYRÄ_KÖNKYRÄ_") + "änkyrä-könkyrä-" + """ + @spec transform_path(String.t()) :: String.t() + def transform_path(path) when is_binary(path) do + String.split(path, "_") + |> Enum.map(&String.downcase/1) + |> Enum.join("-") + end + + @doc """ + Run given script or return an error. + """ + @spec run(String.t()) :: :ok | {:error, atom} + def run(script) do + runner = fn script -> + try do + {output, status} = System.cmd("sh", ["-c", script]) + Logger.debug("Executed #{script} with return status #{status}. Output:\n\n#{output}") + rescue + err -> Logger.error("#{script} execution failed with: #{inspect(err)}") + end + end + + if File.exists?(script) do + {:ok, pid} = Task.start(fn -> runner.(script) end) + Logger.debug("Started task #{script} with pid #{inspect(pid)}") + :ok + else + {:error, :file_not_found} + end + end +end diff --git a/lib/triggerer/application.ex b/lib/triggerer/application.ex new file mode 100644 index 0000000..09185d1 --- /dev/null +++ b/lib/triggerer/application.ex @@ -0,0 +1,30 @@ +defmodule Triggerer.Application do + # See https://hexdocs.pm/elixir/Application.html + # for more information on OTP Applications + @moduledoc false + + use Application + + def start(_type, _args) do + port = + case System.get_env("PORT") do + nil -> 6000 + port when is_binary(port) -> String.to_integer(port) + end + + # List all child processes to be supervised + children = [ + Plug.Adapters.Cowboy.child_spec( + :http, + Triggerer.Router, + [], + port: port + ) + ] + + # See https://hexdocs.pm/elixir/Supervisor.html + # for other strategies and supported options + opts = [strategy: :one_for_one, name: Triggerer.Supervisor] + Supervisor.start_link(children, opts) + end +end diff --git a/mix.exs b/mix.exs new file mode 100644 index 0000000..722ecea --- /dev/null +++ b/mix.exs @@ -0,0 +1,30 @@ +defmodule Triggerer.MixProject do + use Mix.Project + + def project do + [ + app: :triggerer, + version: "1.0.0", + elixir: "~> 1.6", + start_permanent: Mix.env() == :prod, + deps: deps() + ] + end + + # Run "mix help compile.app" to learn about applications. + def application do + [ + extra_applications: [:logger], + mod: {Triggerer.Application, []} + ] + end + + # Run "mix help deps" to learn about dependencies. + defp deps do + [ + {:plug, "~> 1.4.5"}, + {:distillery, "~> 1.5"}, + {:cowboy, "~> 1.1"} + ] + end +end diff --git a/mix.lock b/mix.lock new file mode 100644 index 0000000..15a27b3 --- /dev/null +++ b/mix.lock @@ -0,0 +1,8 @@ +%{ + "cowboy": {:hex, :cowboy, "1.1.2", "61ac29ea970389a88eca5a65601460162d370a70018afe6f949a29dca91f3bb0", [:rebar3], [{:cowlib, "~> 1.0.2", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "~> 1.3.2", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm"}, + "cowlib": {:hex, :cowlib, "1.0.2", "9d769a1d062c9c3ac753096f868ca121e2730b9a377de23dec0f7e08b1df84ee", [:make], [], "hexpm"}, + "distillery": {:hex, :distillery, "1.5.2", "eec18b2d37b55b0bcb670cf2bcf64228ed38ce8b046bb30a9b636a6f5a4c0080", [:mix], [], "hexpm"}, + "mime": {:hex, :mime, "1.2.0", "78adaa84832b3680de06f88f0997e3ead3b451a440d183d688085be2d709b534", [:mix], [], "hexpm"}, + "plug": {:hex, :plug, "1.4.5", "7b13869283fff6b8b21b84b8735326cc012c5eef8607095dc6ee24bd0a273d8e", [:mix], [{:cowboy, "~> 1.0.1 or ~> 1.1", [hex: :cowboy, repo: "hexpm", optional: true]}, {:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}], "hexpm"}, + "ranch": {:hex, :ranch, "1.3.2", "e4965a144dc9fbe70e5c077c65e73c57165416a901bd02ea899cfd95aa890986", [:rebar3], [], "hexpm"}, +} diff --git a/rel/config.exs b/rel/config.exs new file mode 100644 index 0000000..f79eab7 --- /dev/null +++ b/rel/config.exs @@ -0,0 +1,57 @@ +# Import all plugins from `rel/plugins` +# They can then be used by adding `plugin MyPlugin` to +# either an environment, or release definition, where +# `MyPlugin` is the name of the plugin module. +Path.join(["rel", "plugins", "*.exs"]) +|> Path.wildcard() +|> Enum.map(&Code.eval_file(&1)) + +use Mix.Releases.Config, + # This sets the default release built by `mix release` + default_release: :default, + # This sets the default environment used by `mix release` + default_environment: Mix.env() + +# For a full list of config options for both releases +# and environments, visit https://hexdocs.pm/distillery/configuration.html + +# You may define one or more environments in this file, +# an environment's settings will override those of a release +# when building in that environment, this combination of release +# and environment configuration is called a profile + +environment :dev do + # If you are running Phoenix, you should make sure that + # server: true is set and the code reloader is disabled, + # even in dev mode. + # It is recommended that you build with MIX_ENV=prod and pass + # the --env flag to Distillery explicitly if you want to use + # dev mode. + set(dev_mode: true) + set(include_erts: false) + set(cookie: :"jtfxziV4Hbzkc]9>uy,wHr6OuTroy?<]QCm)b8JEwJScvK/42{f]=85}rr7bO)&r") +end + +environment :prod do + set(include_erts: true) + set(include_src: false) + + set( + cookie: :crypto.hash(:sha256, System.get_env("COOKIE")) |> Base.encode16() |> String.to_atom() + ) +end + +# You may define one or more releases in this file. +# If you have not set a default release, or selected one +# when running `mix release`, the first release in the file +# will be used by default + +release :triggerer do + set(version: current_version(:triggerer)) + + set( + applications: [ + :runtime_tools + ] + ) +end diff --git a/test/test_helper.exs b/test/test_helper.exs new file mode 100644 index 0000000..869559e --- /dev/null +++ b/test/test_helper.exs @@ -0,0 +1 @@ +ExUnit.start() diff --git a/test/triggerer_test.exs b/test/triggerer_test.exs new file mode 100644 index 0000000..87ce97b --- /dev/null +++ b/test/triggerer_test.exs @@ -0,0 +1,4 @@ +defmodule TriggererTest do + use ExUnit.Case + doctest Triggerer +end