diff --git a/apps/fz_http/assets/css/main.scss b/apps/fz_http/assets/css/main.scss index 93aa817c7..a1205ab41 100644 --- a/apps/fz_http/assets/css/main.scss +++ b/apps/fz_http/assets/css/main.scss @@ -17,3 +17,7 @@ pre { background-color: $interface-000; color: $interface-600; } + +.dropdown-menu.is-large { + width: 26rem; +} diff --git a/apps/fz_http/assets/js/auth.js b/apps/fz_http/assets/js/auth.js new file mode 100644 index 000000000..e8f48d83e --- /dev/null +++ b/apps/fz_http/assets/js/auth.js @@ -0,0 +1,19 @@ +import css from "../css/app.scss" + +/* Application fonts */ +import "@fontsource/fira-sans" +import "@fontsource/open-sans" +import "@fontsource/fira-mono" + +import "phoenix_html" + +// Notification dismiss +document.addEventListener('DOMContentLoaded', () => { + (document.querySelectorAll('.notification .delete') || []).forEach(($delete) => { + const $notification = $delete.parentNode + + $delete.addEventListener('click', () => { + $notification.parentNode.removeChild($notification) + }) + }) +}) diff --git a/apps/fz_http/assets/js/device_config.js b/apps/fz_http/assets/js/device_config.js new file mode 100644 index 000000000..7b90b5656 --- /dev/null +++ b/apps/fz_http/assets/js/device_config.js @@ -0,0 +1,6 @@ +import {renderQrCode} from "./qrcode.js" + +window.addEventListener('DOMContentLoaded', () => { + console.log('loaded') + renderQrCode() +}) diff --git a/apps/fz_http/assets/js/hooks.js b/apps/fz_http/assets/js/hooks.js index eb36aff56..d4e12e2b0 100644 --- a/apps/fz_http/assets/js/hooks.js +++ b/apps/fz_http/assets/js/hooks.js @@ -1,47 +1,24 @@ import hljs from "highlight.js" import {FormatTimestamp} from "./util.js" +import {renderQrCode} from "./qrcode.js" -const QRCode = require('qrcode') - -const renderQrCode = function () { - let canvas = document.getElementById('qr-canvas') - let conf = document.getElementById('wg-conf') - - if (canvas && conf) { - QRCode.toCanvas(canvas, conf.innerHTML, { - errorCorrectionLevel: 'H', - margin: 0, - width: 200, - height: 200 - - }, function (error) { - if (error) alert('QRCode Encode Error: ' + error) - }) - } -} - -/* XXX: Sad we have to write custom JS for this. Keep an eye on - * https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.JS.html - * in case a toggleClass function is implemented. The toggle() - * function listed there automatically adds display: none which is not - * what we want in this case. - */ const toggleDropdown = function () { - const button = this.el + const button = ctx.el const dropdown = document.getElementById(button.dataset.target) document.addEventListener("click", e => { let ancestor = e.target do { - if (ancestor == button) return + if (ancestor == button || ancestor == dropdown) return ancestor = ancestor.parentNode } while(ancestor) dropdown.classList.remove("is-active") }) + button.addEventListener("click", e => { - dropdown.classList.toggle("is-active") + dropdown.classList.add("is-active") }) } @@ -54,7 +31,20 @@ const formatTimestamp = function () { this.el.innerHTML = FormatTimestamp(t) } +const clipboardCopy = function () { + let button = this.el + let data = button.dataset.clipboard + button.addEventListener("click", () => { + button.dataset.tooltip = "Copied!" + navigator.clipboard.writeText(data) + }) +} + let Hooks = {} +Hooks.ClipboardCopy = { + mounted: clipboardCopy, + updated: clipboardCopy +} Hooks.HighlightCode = { mounted: highlightCode, updated: highlightCode @@ -67,9 +57,5 @@ Hooks.FormatTimestamp = { mounted: formatTimestamp, updated: formatTimestamp } -Hooks.ToggleDropdown = { - mounted: toggleDropdown, - updated: toggleDropdown -} export default Hooks diff --git a/apps/fz_http/assets/js/qrcode.js b/apps/fz_http/assets/js/qrcode.js new file mode 100644 index 000000000..54b0f729b --- /dev/null +++ b/apps/fz_http/assets/js/qrcode.js @@ -0,0 +1,20 @@ +const QRCode = require('qrcode') + +const renderQrCode = function () { + let canvas = document.getElementById('qr-canvas') + let conf = document.getElementById('wg-conf') + + if (canvas && conf) { + QRCode.toCanvas(canvas, conf.innerHTML, { + errorCorrectionLevel: 'H', + margin: 0, + width: 200, + height: 200 + + }, function (error) { + if (error) alert('QRCode Encode Error: ' + error) + }) + } +} + +export { renderQrCode } diff --git a/apps/fz_http/assets/webpack.config.js b/apps/fz_http/assets/webpack.config.js index 5bccc2b42..7002b6f7d 100644 --- a/apps/fz_http/assets/webpack.config.js +++ b/apps/fz_http/assets/webpack.config.js @@ -12,16 +12,18 @@ module.exports = (env, options) => ({ ] }, entry: { - './js/app.js': glob.sync('./vendor/**/*.js').concat([ + 'app': glob.sync('./vendor/**/*.js').concat([ // Local JS files to include in the bundle './js/hooks.js', './js/app.js', './node_modules/admin-one-bulma-dashboard/src/js/main.js' - ]) + ]), + 'auth': ['./js/auth.js'], + 'device_config': ['./js/device_config.js'] }, output: { path: path.resolve(__dirname, '../priv/static/js'), - filename: 'app.js', + filename: '[name].js', publicPath: '/js/' }, module: { diff --git a/apps/fz_http/lib/fz_http/devices.ex b/apps/fz_http/lib/fz_http/devices.ex index cc98a278e..fae570914 100644 --- a/apps/fz_http/lib/fz_http/devices.ex +++ b/apps/fz_http/lib/fz_http/devices.ex @@ -4,12 +4,15 @@ defmodule FzHttp.Devices do """ import Ecto.Query, warn: false - alias FzCommon.NameGenerator + alias FzCommon.{FzCrypto, NameGenerator} alias FzHttp.{ConnectivityChecks, Devices.Device, Repo, Settings, Users.User} @ipv4_prefix "10.3.2." @ipv6_prefix "fd00:3:2::" + # Device configs can be viewable for 10 minutes + @config_token_expires_in_sec 600 + def list_devices do Repo.all(Device) end @@ -24,6 +27,15 @@ defmodule FzHttp.Devices do Repo.one(from d in Device, where: d.user_id == ^user_id, select: count()) end + def get_device!(config_token: config_token) do + now = DateTime.utc_now() + + Repo.one!( + from d in Device, + where: d.config_token == ^config_token and d.config_token_expires_at > ^now + ) + end + def get_device!(id), do: Repo.get!(Device, id) def create_device(attrs \\ %{}) do @@ -113,6 +125,17 @@ defmodule FzHttp.Devices do """ end + def create_config_token(device) do + expires_at = DateTime.add(DateTime.utc_now(), @config_token_expires_in_sec, :second) + + config_token_attrs = %{ + config_token: FzCrypto.rand_token(6), + config_token_expires_at: expires_at + } + + update_device(device, config_token_attrs) + end + defp dns_servers_config(device) when is_struct(device) do dns_servers = dns_servers(device) diff --git a/apps/fz_http/lib/fz_http/devices/device.ex b/apps/fz_http/lib/fz_http/devices/device.ex index a95c85435..1ea9ca7f7 100644 --- a/apps/fz_http/lib/fz_http/devices/device.ex +++ b/apps/fz_http/lib/fz_http/devices/device.ex @@ -31,6 +31,8 @@ defmodule FzHttp.Devices.Device do field :remote_ip, EctoNetwork.INET field :address, :integer, read_after_writes: true field :last_seen_at, :utc_datetime_usec + field :config_token, :string + field :config_token_expires_at, :utc_datetime_usec belongs_to :user, User @@ -69,7 +71,9 @@ defmodule FzHttp.Devices.Device do :private_key, :user_id, :name, - :public_key + :public_key, + :config_token, + :config_token_expires_at ]) end diff --git a/apps/fz_http/lib/fz_http_web/controllers/device_controller.ex b/apps/fz_http/lib/fz_http_web/controllers/device_controller.ex index b3394a3e6..b4ff91695 100644 --- a/apps/fz_http/lib/fz_http_web/controllers/device_controller.ex +++ b/apps/fz_http/lib/fz_http_web/controllers/device_controller.ex @@ -7,7 +7,7 @@ defmodule FzHttpWeb.DeviceController do import FzCommon.FzString, only: [sanitize_filename: 1] alias FzHttp.Devices - plug :redirect_unauthenticated + plug :redirect_unauthenticated, except: [:config] def index(conn, _params) do conn @@ -16,6 +16,23 @@ defmodule FzHttpWeb.DeviceController do def download_config(conn, %{"id" => device_id}) do device = Devices.get_device!(device_id) + render_download(conn, device) + end + + def download_shared_config(conn, %{"config_token" => config_token}) do + device = Devices.get_device!(config_token: config_token) + render_download(conn, device) + end + + def config(conn, %{"config_token" => config_token}) do + device = Devices.get_device!(config_token: config_token) + + conn + |> put_root_layout({FzHttpWeb.LayoutView, "device_config.html"}) + |> render("config.html", config: Devices.as_config(device), device: device) + end + + defp render_download(conn, device) do filename = "#{sanitize_filename(FzHttpWeb.Endpoint.host())}.conf" content_type = "text/plain" diff --git a/apps/fz_http/lib/fz_http_web/live/device_live/show.html.heex b/apps/fz_http/lib/fz_http_web/live/device_live/show.html.heex index d277e6b82..87aa7a229 100644 --- a/apps/fz_http/lib/fz_http_web/live/device_live/show.html.heex +++ b/apps/fz_http/lib/fz_http_web/live/device_live/show.html.heex @@ -86,14 +86,72 @@

WireGuard Configuration

- <%= link( - to: Routes.device_path(@socket, :download_config, @device), - class: "button") do %> - - - - Download Configuration - <% end %> +
+
" control dropdown is-right"} + phx-click-away="hide_config_token"> + + +
+ +
+ <%= link( + to: Routes.device_path(@socket, :download_config, @device), + class: "button") do %> + + + + Download Configuration + <% end %> +
+
diff --git a/apps/fz_http/lib/fz_http_web/live/device_live/show_live.ex b/apps/fz_http/lib/fz_http_web/live/device_live/show_live.ex index 9ef7f0a66..4209523c3 100644 --- a/apps/fz_http/lib/fz_http_web/live/device_live/show_live.ex +++ b/apps/fz_http/lib/fz_http_web/live/device_live/show_live.ex @@ -10,6 +10,7 @@ defmodule FzHttpWeb.DeviceLive.Show do def mount(params, session, socket) do {:ok, socket + |> assign(:dropdown_active_class, "") |> assign_defaults(params, session, &load_data/2)} end @@ -19,8 +20,37 @@ defmodule FzHttpWeb.DeviceLive.Show do end @impl Phoenix.LiveView - def handle_event("delete_device", %{"device_id" => device_id}, socket) do - device = Devices.get_device!(device_id) + def handle_event("create_config_token", _params, socket) do + device = socket.assigns.device + + if device.user_id == socket.assigns.current_user.id do + case Devices.create_config_token(device) do + {:ok, device} -> + {:noreply, + socket + |> assign(:dropdown_active_class, "is-active") + |> assign(:device, device)} + + {:error, _changeset} -> + {:noreply, + socket + |> put_flash(:error, "Could not create device config token.")} + end + else + {:noreply, not_authorized(socket)} + end + end + + @impl Phoenix.LiveView + def handle_event("hide_config_token", _params, socket) do + {:noreply, + socket + |> assign(:dropdown_active_class, "")} + end + + @impl Phoenix.LiveView + def handle_event("delete_device", _params, socket) do + device = socket.assigns.device if device.user_id == socket.assigns.current_user.id do case Devices.delete_device(device) do diff --git a/apps/fz_http/lib/fz_http_web/router.ex b/apps/fz_http/lib/fz_http_web/router.ex index 8439b79c6..e598adb23 100644 --- a/apps/fz_http/lib/fz_http_web/router.ex +++ b/apps/fz_http/lib/fz_http_web/router.ex @@ -21,8 +21,6 @@ defmodule FzHttpWeb.Router do scope "/", FzHttpWeb do pipe_through :browser - get "/", DeviceController, :index - get "/devices/:id/dl", DeviceController, :download_config resources "/session", SessionController, only: [:new, :create, :delete], singleton: true live "/users", UserLive.Index, :index @@ -35,6 +33,10 @@ defmodule FzHttpWeb.Router do live "/devices", DeviceLive.Index, :index live "/devices/:id", DeviceLive.Show, :show live "/devices/:id/edit", DeviceLive.Show, :edit + get "/devices/:id/dl", DeviceController, :download_config + get "/", DeviceController, :index + get "/device_config/:config_token", DeviceController, :config + get "/device_config/:config_token/dl", DeviceController, :download_shared_config live "/settings/default", SettingLive.Default, :default diff --git a/apps/fz_http/lib/fz_http_web/templates/device/config.html.heex b/apps/fz_http/lib/fz_http_web/templates/device/config.html.heex new file mode 100644 index 000000000..6c3ae3948 --- /dev/null +++ b/apps/fz_http/lib/fz_http_web/templates/device/config.html.heex @@ -0,0 +1,30 @@ +
+
+

WireGuard Configuration

+
+
+ <%= link(to: Routes.device_path(@conn, :download_shared_config, @device.config_token), class: "button") do %> + + + + Download Configuration + <% end %> +
+
+ +
+

+ WireGuard configuration for device <%= @device.name %> shown below. + Scan the QR code or copy and paste the configuration into your WireGuard application. +

+
+
+
+
<%= @config %>
+
+
+ + Generating QR code... + +
+
diff --git a/apps/fz_http/lib/fz_http_web/templates/layout/auth.html.heex b/apps/fz_http/lib/fz_http_web/templates/layout/auth.html.heex index 2ea47198f..c5df478db 100644 --- a/apps/fz_http/lib/fz_http_web/templates/layout/auth.html.heex +++ b/apps/fz_http/lib/fz_http_web/templates/layout/auth.html.heex @@ -7,11 +7,16 @@ <%= csrf_meta_tag() %> <%= live_title_tag assigns[:page_title] || "Firezone" %> - + - - - + + + + + + + +
diff --git a/apps/fz_http/lib/fz_http_web/templates/layout/device_config.html.heex b/apps/fz_http/lib/fz_http_web/templates/layout/device_config.html.heex new file mode 100644 index 000000000..72804a0da --- /dev/null +++ b/apps/fz_http/lib/fz_http_web/templates/layout/device_config.html.heex @@ -0,0 +1,35 @@ + + + + + + + <%= csrf_meta_tag() %> + Firezone + + + + + + + + + + + + + +
+
+
+
+
+ firez.one +
+
+ <%= @inner_content %> +
+
+
+ + diff --git a/apps/fz_http/priv/repo/migrations/20211216155557_add_config_token_to_devices.exs b/apps/fz_http/priv/repo/migrations/20211216155557_add_config_token_to_devices.exs new file mode 100644 index 000000000..50f2ad48b --- /dev/null +++ b/apps/fz_http/priv/repo/migrations/20211216155557_add_config_token_to_devices.exs @@ -0,0 +1,12 @@ +defmodule FzHttp.Repo.Migrations.AddConfigTokenToDevices do + use Ecto.Migration + + def change do + alter table("devices") do + add :config_token, :string + add :config_token_expires_at, :utc_datetime_usec + end + + create unique_index(:devices, :config_token) + end +end