mirror of
https://github.com/outbackdingo/firezone.git
synced 2026-01-28 02:18:50 +00:00
Share device config
This commit is contained in:
@@ -17,3 +17,7 @@ pre {
|
||||
background-color: $interface-000;
|
||||
color: $interface-600;
|
||||
}
|
||||
|
||||
.dropdown-menu.is-large {
|
||||
width: 26rem;
|
||||
}
|
||||
|
||||
19
apps/fz_http/assets/js/auth.js
Normal file
19
apps/fz_http/assets/js/auth.js
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
6
apps/fz_http/assets/js/device_config.js
Normal file
6
apps/fz_http/assets/js/device_config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
import {renderQrCode} from "./qrcode.js"
|
||||
|
||||
window.addEventListener('DOMContentLoaded', () => {
|
||||
console.log('loaded')
|
||||
renderQrCode()
|
||||
})
|
||||
@@ -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
|
||||
|
||||
20
apps/fz_http/assets/js/qrcode.js
Normal file
20
apps/fz_http/assets/js/qrcode.js
Normal 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 }
|
||||
@@ -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: {
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user