Share device config

This commit is contained in:
Jamil Bou Kheir
2021-12-16 14:34:17 -08:00
parent dda2d1893a
commit 81126fb186
16 changed files with 307 additions and 54 deletions

View File

@@ -17,3 +17,7 @@ pre {
background-color: $interface-000;
color: $interface-600;
}
.dropdown-menu.is-large {
width: 26rem;
}

View File

@@ -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)
})
})
})

View File

@@ -0,0 +1,6 @@
import {renderQrCode} from "./qrcode.js"
window.addEventListener('DOMContentLoaded', () => {
console.log('loaded')
renderQrCode()
})

View File

@@ -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

View File

@@ -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 }

View File

@@ -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: {

View File

@@ -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)

View File

@@ -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

View File

@@ -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"

View File

@@ -86,14 +86,72 @@
<h4 class="title is-4">WireGuard Configuration</h4>
</div>
<div class="level-right">
<%= link(
to: Routes.device_path(@socket, :download_config, @device),
class: "button") do %>
<span class="icon is-small">
<i class="mdi mdi-download"></i>
</span>
<span>Download Configuration</span>
<% end %>
<div class="field has-addons">
<div class={@dropdown_active_class <> " control dropdown is-right"}
phx-click-away="hide_config_token">
<div class="dropdown-trigger">
<button
id="get-shareable-link"
phx-click="create_config_token"
class="button">
<span class="icon is-small">
<i class="mdi mdi-share"></i>
</span>
<span>
Get Shareable Link
</span>
</button>
</div>
<div class="dropdown-menu is-large">
<div class="dropdown-content">
<div class="dropdown-item">
<p>
Anyone with this link can view this device's configuration.
</p>
<p>
Note: Link expires in <strong>10 minutes</strong>.
</p>
</div>
<div class="dropdown-item">
<div class="level">
<div class="level-left">
<code id="shareable-link">
<%= if @device.config_token do %>
<%= Routes.device_url(@socket, :config, @device.config_token) %>
<% end %>
</code>
</div>
<div class="level-right">
<%= if @device.config_token do %>
<button id="copy-shareable-link-button"
data-clipboard={Routes.device_url(@socket, :config, @device.config_token)}
data-target="shareable-link"
class="button"
data-tooltip="Click to copy"
phx-hook="ClipboardCopy">
<span class="icon">
<i class="mdi mdi-content-copy"></i>
</span>
</button>
<% end %>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="control">
<%= link(
to: Routes.device_path(@socket, :download_config, @device),
class: "button") do %>
<span class="icon is-small">
<i class="mdi mdi-download"></i>
</span>
<span>Download Configuration</span>
<% end %>
</div>
</div>
</div>
</div>
<div class="block">

View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1,30 @@
<div class="level">
<div class="level-left">
<h4 class="title is-4">WireGuard Configuration</h4>
</div>
<div class="level-right">
<%= link(to: Routes.device_path(@conn, :download_shared_config, @device.config_token), class: "button") do %>
<span class="icon is-small">
<i class="mdi mdi-download"></i>
</span>
<span>Download Configuration</span>
<% end %>
</div>
</div>
<div class="content">
<p>
WireGuard configuration for device <strong><%= @device.name %></strong> shown below.
Scan the QR code or copy and paste the configuration into your WireGuard application.
</p>
</div>
<div class="columns">
<div class="column">
<pre><code id="wg-conf" class="language-toml"><%= @config %></code></pre>
</div>
<div class="column has-text-centered">
<canvas id="qr-canvas">
Generating QR code...
</canvas>
</div>
</div>

View File

@@ -7,11 +7,16 @@
<%= csrf_meta_tag() %>
<%= live_title_tag assigns[:page_title] || "Firezone" %>
<link phx-track-static rel="stylesheet" href={Routes.static_path(@conn, "/css/app.css")} />
<script defer phx-track-static type="text/javascript" src={Routes.static_path(@conn, "/js/app.js")}></script>
<script defer phx-track-static type="text/javascript" src={Routes.static_path(@conn, "/js/auth.js")}></script>
<!-- Fonts -->
<link rel="dns-prefetch" href="https://fonts.gstatic.com">
<link href="https://fonts.googleapis.com/css?family=Nunito" rel="stylesheet" type="text/css">
<!-- Favicon -->
<link rel="apple-touch-icon" sizes="180x180" href="/images/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="/images/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/images/favicon-16x16.png">
<link rel="manifest" href="/site.webmanifest">
<meta name="msapplication-config" content="/browserconfig.xml" />
<meta name="msapplication-TileColor" content="331700">
<meta name="theme-color" content="331700">
</head>
<body>
<section class="section hero is-fullheight is-error-section">

View File

@@ -0,0 +1,35 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta http-equiv="X-UA-Compatible" content="IE=edge"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<%= csrf_meta_tag() %>
<title>Firezone</title>
<link phx-track-static rel="stylesheet" href={Routes.static_path(@conn, "/css/app.css")} />
<script defer phx-track-static type="text/javascript" src={Routes.static_path(@conn, "/js/device_config.js")}></script>
<!-- Favicon -->
<link rel="apple-touch-icon" sizes="180x180" href="/images/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="/images/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/images/favicon-16x16.png">
<link rel="manifest" href="/site.webmanifest">
<meta name="msapplication-config" content="/browserconfig.xml" />
<meta name="msapplication-TileColor" content="331700">
<meta name="theme-color" content="331700">
</head>
<body>
<section class="section hero is-fullheight is-error-section">
<div id="app" class="hero-body">
<div class="container">
<div class="block">
<div class="has-text-centered">
<img src={Routes.static_path(@conn, "/images/logo-text.svg")} alt="firez.one">
</div>
</div>
<%= @inner_content %>
</div>
</div>
</section>
</body>
</html>

View File

@@ -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