Workstation
The workstation view is the core view of the web app. Using this view users remote control their workstation. Initially, until the workstation is ready, a loading screen is displayed to the user.
Architecture
The workstations users remote control are technically docker containers. This is the main difference compared to Deskterm's predecessor, Instant Workstation. On Instant Workstation, the workstations are virtual machines. Another key difference is that the Deskterm workstations run on the Fly.io cloud. On Instant Workstations, the workstations run my home in Joensuu, Finland.
The main reason for switching to docker containers is to allow users to run their workstations in the Fly.io cloud close to where they are. For example, if the user is located in Auckland, New Zealand, they may choose to run their workstation at the nearest Fly.io location, in this case Sydney, Australia. With Instant Workstation, the user's machine always runs in Joensuu, Finland. The network latency between Auckland and Sydney is much lower than between Auckland and Joensuu.
Technically, I could have also chosen to run virtual machines in the cloud. However, this is actually much more costly. To run the user's virtual machines in a performant way, they need to be run at near native speed using for example Kernel-based Virtual Machine (KVM). If the machine that Instant Workstation is running on is already virtualized, then to run the user's virtual machine would require nested virtualiztion. Whilst this is technically possible, to do this in a performant way would require Nested KVM. Most cloud providers don't offer nested KVM in their offerings. Therefore if you ran a nested virtual machine the performance would be terrible. So, in order to actually run virtual machines worldwide in a performant way, I would have needed to rent dedicated servers (non-virtualized) at much higher costs. I would also lose the cloud benefits if I ran physical hardware.
Docker containers can be nested much more easily. Therefore containerization was chosen over virtualization for Deskterm.
Infrastructure
The workstations (containers) run on Fly.io virtual machines. Fly.io machines are created/started/stopped by Deskterm as needed. Essentially Deskterm is an orchestrator of user workstations on the Fly.io cloud.
Launch Sequence
How a workstation is started is defined by a launch sequence. The launch sequences can be found in launch_sequence.ex. There are different launch sequences for all of the below scenarios:
- Free user starts a workstation
- New user starts a workstation
- Old user starts a new workstation
- Old user starts an old workstation
A free user refers to an either authenticated or unauthenticated user that doesn't have a Deskterm subscription. A new user refers to a user that has never started a workstation on Deskterm before. An old user refers to a user that has previously started one ore more workstations on Deskterm.
There can be many steps in a launch sequence, for example for free users the launch sequence is as follows:
- Initialize Session
- Create Fly.io App
- Allocate IPv4 Address
- Allocate IPv6 Address
- Create Volume
- Create Machine
- Wait for Machine to come Online
- Wait for Docker to come Online
- Configure Firewall
- Pull Workstation's Docker Image
- Start Workstation Container
- Configure Authentication Proxy
- Start Authentication Proxy
- Update Session
- Start Session Countdown
Loading Screen
Starting a workstation for the first time can take a lot of time, approximately 1 to 2 minutes. Most of this time is spent on pulling the docker image. Starting an existing workstation, stored in persistent storage, takes approximately 10 to 20 seconds. Note that for free users the workstation is not stored in persistent storage, so for them the waiting time is always around 1 to 2 minutes.
With such a long waiting time it is important to give users frequent and accurate feedback about what is currently happening and how far they are in the launch process.
UI Log
The UI log shows users what is currently happening. In the case of API calls, it shows the endpoint being called. In the case of commands executed on Fly.io machines via SSH, both the command and the output from the command are shown on the UI log. The command output is streamed from the Fly.io machine to the user's web browser (via the Deskterm backend).
Whilst streaming command output from the orchestration commands may sound risky at first, it should be noted that no Deskterm secrets are located on the Fly.io machines. The only secret present on the Fly.io machines is the user's authentication token, however since the user's web browser also receives this token it is nothing to be kept secret from the user.
No secrets are also shown in the UI log when the endpoints calls are displayed. Only the HTTP method and URL are sent to the user's UI log, not the headers. Only the headers contain a secret (Fly.io API token). The URL should not contain any secrets.
Censorship
Despite no Deskterm secrets being leaked through the UI log to the user under normal circumstances, out of an abundance of caution, a censorship module was implemented. Before any data is sent to the user's UI log, it first passes through the censorship module. This modules replaces any secrets it finds in the output with stars (*). For even more caution, the censorship module looks for snippets/fragmnts of the secrets in the output. So even if only half of the FLY_API_TOKEN was present in the data, it would still get censored by the censorship module.
The censorship module also censors the user's own authentication token. Whilst this is not secret to the user, it is still better to censor it since it would be shown in plaintext on the user's screen. For example, this mitigates the risk where a malicious person looks over the user's shoulder and sees the user's authentication token.
Security
Authentication
To prevent unauthorized access to user's virtual machines, they are not directly accessible from the internet. Instead, what is publicly accessible, is an authentication proxy running on the same Fly.io machine as the user's workstation. The proxy only allows connections to the workstation for a whitelisted IP address. The user's IP address is whitelisted through an authenticate endpoint on the proxy which requires a valid token (issued for that particular user by the Deskterm backend).
The security that only the user that started the workstation can access it comes from two separate aspects:
- The Fly.io workstation URL includes 16 random characters (determined by Deskterm) that are hard to guess
- Each workstation gets a unique URL
- Workstation connections are only allowed from the user's own IP address
If the user's IP address changes, then on next machine start their new IP address would be whitelisted. The whitelisted IP address are only temporary and are forgotten when the user's session ends.
Isolation
To minimize security risks, it was chosen to create a separate Fly.io app for each user on Deskterm. In this sense the user isolation is essentially outsourced to Fly.io. Machines on Fly.io that are located in separate apps are isolated from each other and cannot see each other, even if they happen to run on the same physical host.
On Instant Workstation, the virtual machine isolation was handled in-house. This is much riskier as the setup is more complex and more error-prone. The Firewall rules needed for Instant Workstation are substantially more complex than those needed for Deskterm.
Abuse Prevention
To prevent abuse and runaway costs, sessions for free users are limited to 5 minutes. Furthermore internet access is blocked for free users. Internet access is only available to paid users. Without these precautions, there is a higher risk some malicious users may use the service for undesirable activity, for example:
- Crypto Mining
- Spam
- DDoS Attacks
Audit
Everytime a workstation is started, regardless of whether by free or paid users, the following is logged to the Deskterm database:
- User's IP address
- Workstation start time
- Workstation end time
- Deskterm User (if logged in)
This audit trail may help narrow down from which user the nefarious activity originated in case of complaints.
Bot Protection
To prevent bots from starting workstations, Cloudflare Turnstile is used to first establish that the request comes from a human. On the client-side, Cloudflare checks if the user seems human. If there are any doubts Cloudflare may present the user with a challenge to tick a box. If Cloudflare thinks the user is human, it issues a token to the client. When the client then starts a workstation, it includes the Cloudflare token with the request. The Deskterm backend then verifies the token from Cloudflare before proceeding with the workstation launch.
To take Cloudflare Turnstile into use, go to your Cloudflare dashboard, open the Application security accordion, select Turnstile and click on Add widget:
Enter a widget name, e.g. Deskterm, and click + Add Hostnames:
Enter or select the necessary hostnames as needed, e.g. deskterm.com and test.deskterm.com in the case of Deskterm. Once you are done click Add:
Now select Managed for the Widget Mode and click Create:
You should now see your Cloudlare Turnstile's Site key and Secret key. Note them down and store them in a secure location, e.g. your password manager.
Now in your GitLab CI, create a new CI variable with the Key set to TURNSTILE_SITE_KEY. Set the Visibility to Masked and hidden and enter the site key you saw on Cloudflare above into the variable's Value field. Then click Add variable:
Create a new CI variable with the Key set to TURNSTILE_SECRET_KEY. Set the Visibility to Masked and hidden and enter the secret key you saw on Cloudflare above into the variable's Value field. Then click Add variable:
Configuration
Fly.io API Token
The Deskterm backend needs a Fly.io API token to orchestrate user workstations. Update runtime.exs to configure the Fly.io API token. Place the below code just above the if config_env() == :prod do block in runtime.exs:
config :deskterm,
:fly_api_token,
System.get_env("FLY_API_TOKEN") ||
raise("""
Environment variable FLY_API_TOKEN is missing.
You can create one in your Fly.io account.
""")
Turnstile Configuration
To configure Cloudflare Turnstile for production, add the below code to the bottom of the if config_env() == :prod do block in runtime.exs:
turnstile_site_key =
System.get_env("TURNSTILE_SITE_KEY") ||
raise """
Environment variable TURNSTILE_SITE_KEY is missing.
You can find it from your Cloudflare account.
"""
turnstile_secret_key =
System.get_env("TURNSTILE_SECRET_KEY") ||
raise """
Environment variable TURNSTILE_SECRET_KEY is missing.
You can find it from your Cloudflare account.
"""
config :deskterm, :turnstile,
site_key: turnstile_site_key,
secret_key: turnstile_secret_key
For development and test environments, Cloudflare provides test keys that always pass verification. Add the below configuration to dev.exs and test.exs:
config :deskterm, :turnstile,
site_key: "1x00000000000000000000AA",
secret_key: "1x0000000000000000000000000000000AA"
Flyctl in Docker
The Deskterm backend needs the Fly.io CLI tool (flyctl) to execute commands on Fly.io machines via SSH. Update the Dockerfile to install curl and flyctl in the runner image:
RUN apt-get update -y && \
apt-get install -y libstdc++6 openssl libncursesw6 locales ca-certificates \
curl && apt-get clean && rm -f /var/lib/apt/lists/*_*
# Install flyctl
RUN FLYCTL_INSTALL=/usr/local sh -c "$(curl -fsSL https://fly.io/install.sh)"
CI/CD Deploy Pipelines
Update deploy-test-environment.yml to add a migration step before deploying and pass the FLY_API_TOKEN, TURNSTILE_SITE_KEY, and TURNSTILE_SECRET_KEY environment variables to both the migration and run commands:
- ssh -i $CI_PRIVATE_KEY -o StrictHostKeyChecking=no deployer@$TEST_ENVIRONMENT_IP "docker run --rm --env SECRET_KEY_BASE=$SECRET_KEY_BASE_TEST --env DATABASE_URL=$DATABASE_URL_TEST --env SUPABASE_URL=$SUPABASE_URL_TEST --env SUPABASE_API_KEY=$SUPABASE_API_KEY_TEST --env FLY_API_TOKEN=$FLY_API_TOKEN --env TURNSTILE_SITE_KEY=$TURNSTILE_SITE_KEY --env TURNSTILE_SECRET_KEY=$TURNSTILE_SECRET_KEY $CI_REGISTRY_IMAGE:test-environment /app/bin/migrate"
Update the docker run command to include the new environment variables:
- ssh -i $CI_PRIVATE_KEY -o StrictHostKeyChecking=no deployer@$TEST_ENVIRONMENT_IP "docker run -d --publish 80:80 --name test-environment --env SECRET_KEY_BASE=$SECRET_KEY_BASE_TEST --env DATABASE_URL=$DATABASE_URL_TEST --env PHX_HOST=test.deskterm.com --env SUPABASE_URL=$SUPABASE_URL_TEST --env SUPABASE_API_KEY=$SUPABASE_API_KEY_TEST --env FLY_API_TOKEN=$FLY_API_TOKEN --env TURNSTILE_SITE_KEY=$TURNSTILE_SITE_KEY --env TURNSTILE_SECRET_KEY=$TURNSTILE_SECRET_KEY $CI_REGISTRY_IMAGE:test-environment"
Update deploy-production-environment.yml with the same changes for the production environment:
- ssh -i $CI_PRIVATE_KEY -o StrictHostKeyChecking=no deployer@$PRODUCTION_ENVIRONMENT_IP "docker run --rm --env SECRET_KEY_BASE=$SECRET_KEY_BASE_PRODUCTION --env DATABASE_URL=$DATABASE_URL_PRODUCTION --env SUPABASE_URL=$SUPABASE_URL_PRODUCTION --env SUPABASE_API_KEY=$SUPABASE_API_KEY_PRODUCTION --env FLY_API_TOKEN=$FLY_API_TOKEN --env TURNSTILE_SITE_KEY=$TURNSTILE_SITE_KEY --env TURNSTILE_SECRET_KEY=$TURNSTILE_SECRET_KEY $CI_REGISTRY_IMAGE:production-environment /app/bin/migrate"
Update the docker run command to include the new environment variables:
- ssh -i $CI_PRIVATE_KEY -o StrictHostKeyChecking=no deployer@$PRODUCTION_ENVIRONMENT_IP "docker run -d --publish 80:80 --name production-environment --env SECRET_KEY_BASE=$SECRET_KEY_BASE_PRODUCTION --env DATABASE_URL=$DATABASE_URL_PRODUCTION --env PHX_HOST=deskterm.com --env SUPABASE_URL=$SUPABASE_URL_PRODUCTION --env SUPABASE_API_KEY=$SUPABASE_API_KEY_PRODUCTION --env FLY_API_TOKEN=$FLY_API_TOKEN --env TURNSTILE_SITE_KEY=$TURNSTILE_SITE_KEY --env TURNSTILE_SECRET_KEY=$TURNSTILE_SECRET_KEY $CI_REGISTRY_IMAGE:production-environment"
Tests
E2E Test
We will create an E2E test in Qase. Add a new test case to the Common suite named Workstation:
Automated Tests
Update test_helper.exs to define mocks for the Fly.io API and Turnstile API and configure them:
Mox.defmock(DesktermWeb.FlyAPIMock,
for: DesktermWeb.FlyAPI
)
Mox.defmock(DesktermWeb.TurnstileAPIMock,
for: DesktermWeb.TurnstileAPI
)
Application.put_env(
:deskterm,
:fly_api,
DesktermWeb.FlyAPIMock
)
Application.put_env(
:deskterm,
:turnstile_api,
DesktermWeb.TurnstileAPIMock
)
In the test/support directory, create a new file named secret_atoms.ex and populate it with the below content:
defmodule DesktermWeb.TestSecretAtoms do
def endpoint_secret, do: "Z7uPiIDI48"
def fly_api_token, do: "5olf3w83"
def repo_url, do: "ecto://pYCJeLeZg4:NFIaHye8s8@eqz6xh7Ryy/GQHP6lLimj"
def supabase_api_key, do: "mr62bOev2h"
def ws_auth_token, do: "srl2seixcu"
end
In the test/support/authentication directory, create a new file named user_atoms.ex and populate it with the below content:
defmodule DesktermWeb.TestUserAtoms do
@user_id "0e13a01f-4625-1209-a000-e22ad7ba0000"
@fly_app_name "user-JVzuEaNOgCRebgE1"
@fly_machine_id "ak7yud6oov9gykxj"
@session %{
token: "lwn3j89n",
refresh_token: "cu6dbswg",
user: %{
id: @user_id
}
}
@otp_code %{
"1" => "1",
"2" => "2",
"3" => "3",
"4" => "4",
"5" => "5",
"6" => "6"
}
def session, do: @session
def user_id, do: @user_id
def fly_app_name, do: @fly_app_name
def fly_machine_id, do: @fly_machine_id
def otp_code, do: @otp_code
end
Update test_utilities.ex to replace the contents with the below. This refactors the authentication test utilities to use the new TestUserAtoms module (which includes a user map with an id for profile creation):
defmodule DesktermWeb.TestUtilities do
@moduledoc false
import Mox
alias DesktermWeb.TestOAuthAtoms
alias DesktermWeb.TestUserAtoms
@email "[email protected]"
@otp_code "123456"
@otp_credentials %{
email: @email,
options: %{
should_create_user: true
}
}
@otp_params %{
email: @email,
token: @otp_code,
type: :magiclink
}
def when_get_client_fails do
expect(
DesktermWeb.SupabaseClientAPIMock,
:get_client,
fn -> {:error, :timeout} end
)
end
def when_get_client_succeeds do
expect(
DesktermWeb.SupabaseClientAPIMock,
:get_client,
fn -> {:ok, %Supabase.Client{}} end
)
end
def when_oauth_login_fails do
expected_credentials = TestOAuthAtoms.oauth_credentials()
expect(
DesktermWeb.SupabaseAuthAPIMock,
:sign_in_with_oauth,
fn _client, ^expected_credentials ->
{:error, :timeout}
end
)
end
def when_oauth_login_succeeds do
expected_credentials = TestOAuthAtoms.oauth_credentials()
expect(
DesktermWeb.SupabaseAuthAPIMock,
:sign_in_with_oauth,
fn _client, ^expected_credentials ->
{:ok,
%{
url: TestOAuthAtoms.oauth_url(),
code_verifier: TestOAuthAtoms.code_verifier()
}}
end
)
end
def when_otp_login_fails do
expect(
DesktermWeb.SupabaseAuthAPIMock,
:sign_in_with_otp,
fn _client, @otp_credentials ->
{:error, :timeout}
end
)
end
def when_otp_login_succeeds do
expect(
DesktermWeb.SupabaseAuthAPIMock,
:sign_in_with_otp,
fn _client, @otp_credentials ->
:ok
end
)
end
def when_verification_fails do
expect(
DesktermWeb.SupabaseAuthAPIMock,
:verify_otp,
fn _client, @otp_params ->
{:error, :timeout}
end
)
end
def when_verification_succeeds do
expect(
DesktermWeb.SupabaseAuthAPIMock,
:verify_otp,
fn _client, @otp_params ->
{:ok, TestUserAtoms.session()}
end
)
end
def when_code_exchange_fails do
expected_code = TestOAuthAtoms.code()
expect(
DesktermWeb.SupabaseAuthAPIMock,
:exchange_code_for_session,
fn _client, ^expected_code, nil ->
{:error, :timeout}
end
)
end
def when_code_exchange_succeeds do
expected_code = TestOAuthAtoms.code()
expect(
DesktermWeb.SupabaseAuthAPIMock,
:exchange_code_for_session,
fn _client, ^expected_code, nil ->
{:ok, TestUserAtoms.session()}
end
)
end
end
In the test/support directory, create a new file named fly_utilities.ex and populate it with the below content:
defmodule DesktermWeb.TestFlyUtilities do
import Mox
def when_fly_app_is_created do
when_create_app_succeeds()
when_allocate_ip_succeeds("shared_v4")
when_allocate_ip_succeeds("v6")
end
def when_machine_is_created do
when_create_volume_succeeds()
when_create_machine_succeeds()
when_wait_for_machine_succeeds()
end
def when_create_app_succeeds do
expect(
DesktermWeb.FlyAPIMock,
:create_app,
fn _app_name ->
:ok
end
)
end
def when_create_app_fails do
expect(
DesktermWeb.FlyAPIMock,
:create_app,
fn _app_name ->
:error
end
)
end
def when_allocate_ip_succeeds(type) do
expect(
DesktermWeb.FlyAPIMock,
:allocate_ip_address,
fn _app_name, ^type ->
:ok
end
)
end
def when_allocate_ipv4_address_fails do
when_allocate_ip_fails("shared_v4")
end
def when_allocate_ipv4_address_succeeds do
when_allocate_ip_succeeds("shared_v4")
end
def when_allocate_ipv6_address_fails do
when_allocate_ip_fails("v6")
end
defp when_allocate_ip_fails(type) do
expect(
DesktermWeb.FlyAPIMock,
:allocate_ip_address,
fn _app_name, ^type ->
:error
end
)
end
def when_create_volume_succeeds do
expect(
DesktermWeb.FlyAPIMock,
:create_volume,
fn _app_name, _volume ->
{:ok, "volume-id"}
end
)
end
def when_create_volume_fails do
expect(
DesktermWeb.FlyAPIMock,
:create_volume,
fn _app_name, _volume ->
:error
end
)
end
def when_create_machine_succeeds do
expect(
DesktermWeb.FlyAPIMock,
:create_machine,
fn _app_name, _workstation ->
{:ok, "machine-id"}
end
)
end
def when_create_machine_fails do
expect(
DesktermWeb.FlyAPIMock,
:create_machine,
fn _app_name, _workstation ->
:error
end
)
end
def when_start_machine_succeeds do
expect(
DesktermWeb.FlyAPIMock,
:start_machine,
fn _app_name, _machine_id ->
:ok
end
)
end
def when_start_machine_fails do
expect(
DesktermWeb.FlyAPIMock,
:start_machine,
fn _app_name, _machine_id ->
:error
end
)
end
def when_wait_for_machine_succeeds do
expect(
DesktermWeb.FlyAPIMock,
:wait_for_machine,
fn _app_name, _machine_id ->
:ok
end
)
end
def when_wait_for_machine_fails do
expect(
DesktermWeb.FlyAPIMock,
:wait_for_machine,
fn _app_name, _machine_id ->
:error
end
)
end
def when_stop_machine_succeeds do
expect(
DesktermWeb.FlyAPIMock,
:stop_machine,
fn _app_name, _machine_id ->
:ok
end
)
end
def when_stop_machine_fails do
expect(
DesktermWeb.FlyAPIMock,
:stop_machine,
fn _app_name, _machine_id ->
:error
end
)
end
def when_delete_app_fails do
expect(
DesktermWeb.FlyAPIMock,
:delete_app,
fn _app_name ->
:error
end
)
end
def when_delete_app_succeeds do
expect(
DesktermWeb.FlyAPIMock,
:delete_app,
fn _app_name ->
:ok
end
)
end
def when_execute_command_fails do
expect(
DesktermWeb.FlyAPIMock,
:execute_command_async,
fn _app_name, _command ->
:error
end
)
end
def when_execute_command_succeeds do
expect(
DesktermWeb.FlyAPIMock,
:execute_command_async,
fn _app_name, _command ->
{:ok, Task.async(fn -> Process.sleep(:infinity) end)}
end
)
end
def when_execute_command_completes(times \\ 1) do
expect(
DesktermWeb.FlyAPIMock,
:execute_command_async,
times,
fn _app_name, command ->
send(command.caller, {:stream_complete, :ok})
{:ok, Task.async(fn -> Process.sleep(:infinity) end)}
end
)
end
def when_execute_command_sync_succeeds do
expect(
DesktermWeb.FlyAPIMock,
:execute_command_sync,
fn _app_name, _command ->
:ok
end
)
end
def when_stream_fails do
expect(
DesktermWeb.FlyAPIMock,
:execute_command_async,
fn _app_name, command ->
send(command.caller, {:stream_complete, :error})
{:ok, Task.async(fn -> Process.sleep(:infinity) end)}
end
)
end
def when_stream_updates(update) do
expect(
DesktermWeb.FlyAPIMock,
:execute_command_async,
fn _app_name, command ->
send(command.caller, {:stream_update, update})
{:ok, Task.async(fn -> Process.sleep(:infinity) end)}
end
)
end
end
In the test/support directory, create a new file named launch_utilities.ex and populate it with the below content:
defmodule DesktermWeb.TestLaunchUtilities do
use DesktermWeb.ConnCase
import Phoenix.LiveViewTest
import Mox
@turnstile_token "9td4zkentloxmsj7"
def start_workstation(view, is_bot \\ false) do
if is_bot do
when_turnstile_fails()
else
when_turnstile_succeeds()
end
render_click(view, "select_workstation", %{ws_name: "Chrome"})
render_submit(view, "start_workstation", %{
ws_location: "arn",
turnstile_token: @turnstile_token
})
end
def assert_launch_aborted(view) do
assert_push_event(view, "show-toast", %{})
assert has_element?(view, "#overview")
assert has_element?(view, "#toast-error")
refute has_element?(view, "#loading-workstation")
end
def assert_launch_not_aborted(view) do
refute_push_event(view, "show-toast", %{})
assert has_element?(view, "#loading-workstation")
refute has_element?(view, "#overview")
refute has_element?(view, "#toast-error")
end
defp when_turnstile_succeeds do
expect(
DesktermWeb.TurnstileAPIMock,
:verify,
fn @turnstile_token ->
:ok
end
)
end
defp when_turnstile_fails do
expect(
DesktermWeb.TurnstileAPIMock,
:verify,
fn @turnstile_token ->
:error
end
)
end
end
In the test/support directory, create a new file named censorship_utilities.ex and populate it with the below content:
defmodule DesktermWeb.TestEnvironment do
defstruct app: nil, key: nil, value: nil
end
defmodule DesktermWeb.TestCensorshipUtilities do
import ExUnit.Callbacks
alias DesktermWeb.TestEnvironment
alias DesktermWeb.TestSecretAtoms
def initialize_environment do
whenever_environment_is(fly_api_token_env())
whenever_environment_is(supabase_client_env())
whenever_environment_is(endpoint_secret_env())
whenever_environment_is(repo_url_env())
end
def redacted, do: "******"
def socket(ws_auth_token \\ nil) do
%{assigns: %{ws_auth_token: ws_auth_token}}
end
def get_uncensored_secrets do
Enum.join(
[
TestSecretAtoms.fly_api_token(),
TestSecretAtoms.ws_auth_token(),
TestSecretAtoms.supabase_api_key(),
TestSecretAtoms.endpoint_secret(),
TestSecretAtoms.repo_url()
],
" | "
)
end
def get_censored_secrets do
Enum.join(
[
redacted() <> "83",
redacted() <> "ixcu",
redacted() <> "ev2h",
redacted() <> "DI48",
redacted() <>
redacted() <>
redacted() <>
redacted() <>
redacted() <>
redacted() <>
redacted() <>
redacted() <>
"mj"
],
" | "
)
end
defp fly_api_token_env do
%TestEnvironment{
app: :deskterm,
key: :fly_api_token,
value: TestSecretAtoms.fly_api_token()
}
end
defp supabase_client_env do
%TestEnvironment{
app: :deskterm,
key: Deskterm.Supabase.Client,
value: [api_key: TestSecretAtoms.supabase_api_key()]
}
end
defp endpoint_secret_env do
%TestEnvironment{
app: :deskterm,
key: DesktermWeb.Endpoint,
value: [secret_key_base: TestSecretAtoms.endpoint_secret()]
}
end
defp repo_url_env do
%TestEnvironment{
app: :deskterm,
key: Deskterm.Repo,
value: [url: TestSecretAtoms.repo_url()]
}
end
defp whenever_environment_is(environment) do
previous_value = Application.get_env(environment.app, environment.key)
Application.put_env(environment.app, environment.key, environment.value)
on_exit(fn ->
Application.put_env(environment.app, environment.key, previous_value)
end)
end
end
Replace the contents of app_utilities.ex with the below:
defmodule DesktermWeb.TestAppUtilities do
use DesktermWeb.ConnCase
import Phoenix.LiveViewTest
alias Deskterm.Repo
alias Deskterm.Schema.Profile
alias DesktermWeb.TestAppAtoms
alias DesktermWeb.TestUserAtoms
alias DesktermWeb.TestUtilities
def when_is_unauthenticated(conn) do
{:ok, view, _html} = live(conn, TestAppAtoms.path())
render_click(view, "continue_without_account")
render_click(view, "confirm_without_account")
view
end
def when_is_new_user(conn) do
%Profile{}
|> Profile.changeset(%{
user_id: TestUserAtoms.user_id(),
subscription_type: :basic
})
|> Repo.insert!()
when_otp_login_succeeds(conn)
end
def when_is_old_user(conn, has_workstation \\ false) do
profile =
%Profile{}
|> Profile.changeset(%{
user_id: TestUserAtoms.user_id(),
subscription_type: :basic,
fly_app_name: TestUserAtoms.fly_app_name(),
fly_machine_id: TestUserAtoms.fly_machine_id()
})
|> Repo.insert!()
if has_workstation do
Deskterm.Workstation.ensure_workstation(profile, %{name: "Chrome"})
end
when_otp_login_succeeds(conn)
end
defp when_otp_login_succeeds(conn) do
TestUtilities.when_get_client_succeeds()
TestUtilities.when_get_client_succeeds()
TestUtilities.when_otp_login_succeeds()
TestUtilities.when_verification_succeeds()
{:ok, view, _html} = live(conn, TestAppAtoms.path())
render_click(view, "login_with_otp", %{email: "[email protected]"})
assert_push_event(view, "show-toast", %{})
render_click(view, "verify_otp_code", %{code: TestUserAtoms.otp_code()})
assert_push_event(view, "show-toast", %{})
assert_push_event(view, "show-animation", %{})
view
end
end
In the test/deskterm directory, create a new file named profile_test.exs and populate it with the below content:
defmodule Deskterm.ProfileTest do
use Deskterm.DataCase, async: true
alias Deskterm.Profile
alias Deskterm.Schema.Profile, as: ProfileSchema
alias DesktermWeb.TestUserAtoms
@fly_volume_id "vol_CnGy1QUofswXZs6o"
@fly_volume_region "arn"
test "inserts profile record when user does not exist" do
user_id = Ecto.UUID.generate()
Profile.ensure_profile(user_id)
profile = Repo.get_by!(ProfileSchema, user_id: user_id)
assert profile.user_id == user_id
end
test "does not insert new profile record when user already exists" do
user_id = Ecto.UUID.generate()
Profile.ensure_profile(user_id)
Profile.ensure_profile(user_id)
assert Repo.aggregate(ProfileSchema, :count) == 1
end
test "updates profile record with app when no app was set previously" do
user_id = Ecto.UUID.generate()
{:ok, profile} = Profile.ensure_profile(user_id)
Profile.ensure_app_name(profile, TestUserAtoms.fly_app_name())
profile = Repo.get_by!(ProfileSchema, user_id: user_id)
assert profile.fly_app_name == TestUserAtoms.fly_app_name()
end
test "does not overwrite existing app" do
user_id = Ecto.UUID.generate()
{:ok, profile} = Profile.ensure_profile(user_id)
{:ok, profile} =
Profile.ensure_app_name(profile, TestUserAtoms.fly_app_name())
Profile.ensure_app_name(profile, "user-PcoYsKR84qyVgudJ")
profile = Repo.get_by!(ProfileSchema, user_id: user_id)
assert profile.fly_app_name == TestUserAtoms.fly_app_name()
end
test "updates profile record with volume when no volume was set previously" do
user_id = Ecto.UUID.generate()
{:ok, profile} = Profile.ensure_profile(user_id)
Profile.ensure_volume(profile, %{
id: @fly_volume_id,
region: @fly_volume_region
})
profile = Repo.get_by!(ProfileSchema, user_id: user_id)
assert profile.fly_volume_id == @fly_volume_id
assert profile.fly_volume_region == @fly_volume_region
end
test "does not overwrite existing volume" do
user_id = Ecto.UUID.generate()
{:ok, profile} = Profile.ensure_profile(user_id)
{:ok, profile} =
Profile.ensure_volume(profile, %{
id: @fly_volume_id,
region: @fly_volume_region
})
Profile.ensure_volume(profile, %{id: "vol_QWOj9jKBFcpsdSqn", region: "fra"})
profile = Repo.get_by!(ProfileSchema, user_id: user_id)
assert profile.fly_volume_id == @fly_volume_id
assert profile.fly_volume_region == @fly_volume_region
end
test "updates profile record with machine id when no machine id was set previously" do
user_id = Ecto.UUID.generate()
{:ok, profile} = Profile.ensure_profile(user_id)
Profile.ensure_machine_id(profile, TestUserAtoms.fly_machine_id())
profile = Repo.get_by!(ProfileSchema, user_id: user_id)
assert profile.fly_machine_id == TestUserAtoms.fly_machine_id()
end
test "does not overwrite existing machine id" do
user_id = Ecto.UUID.generate()
{:ok, profile} = Profile.ensure_profile(user_id)
{:ok, profile} =
Profile.ensure_machine_id(profile, TestUserAtoms.fly_machine_id())
Profile.ensure_machine_id(profile, "mry4i0kg6f2jg7qi")
profile = Repo.get_by!(ProfileSchema, user_id: user_id)
assert profile.fly_machine_id == TestUserAtoms.fly_machine_id()
end
test "does not lock profile for free user" do
assert :ok = Profile.lock(%ProfileSchema{}, :free_user)
assert Repo.aggregate(ProfileSchema, :count) == 0
end
test "locks profile when unlocked" do
user_id = Ecto.UUID.generate()
{:ok, profile} = Profile.ensure_profile(user_id)
assert :ok = Profile.lock(profile, :new_user)
profile = Repo.get_by!(ProfileSchema, user_id: user_id)
assert profile.launching_at != nil
end
test "does not lock profile when it was recently locked" do
user_id = Ecto.UUID.generate()
{:ok, profile} = Profile.ensure_profile(user_id)
:ok = Profile.lock(profile, :new_user)
assert :error = Profile.lock(profile, :new_user)
end
test "locks profile when previous lock has expired" do
user_id = Ecto.UUID.generate()
{:ok, profile} = Profile.ensure_profile(user_id)
expired_time = DateTime.add(DateTime.utc_now(), -15, :minute)
from(p in ProfileSchema, where: p.id == ^profile.id)
|> Repo.update_all(set: [launching_at: expired_time])
assert :ok = Profile.lock(profile, :new_user)
end
test "unlocking free user is a no-op" do
assert :ok = Profile.unlock(%ProfileSchema{}, :free_user)
end
test "unlocks locked profile" do
user_id = Ecto.UUID.generate()
{:ok, profile} = Profile.ensure_profile(user_id)
:ok = Profile.lock(profile, :new_user)
profile = Repo.get_by!(ProfileSchema, user_id: user_id)
Profile.unlock(profile, :new_user)
profile = Repo.get_by!(ProfileSchema, user_id: user_id)
assert profile.launching_at == nil
end
end
In the test/deskterm directory, create a new file named workstation_test.exs and populate it with the below content:
defmodule Deskterm.WorkstationTest do
use Deskterm.DataCase, async: true
alias Deskterm.Profile
alias Deskterm.Schema.Workstation, as: WorkstationSchema
alias Deskterm.Workstation
@ws_name "chrome"
test "inserts workstation record if workstation does not exist yet" do
profile = get_profile()
assert :ok = Workstation.ensure_workstation(profile, get_workstation())
assert Repo.get_by!(WorkstationSchema,
profile_id: profile.id,
ws_name: @ws_name
)
end
test "does not insert workstation if workstation already exists" do
profile = get_profile()
:ok = Workstation.ensure_workstation(profile, get_workstation())
:ok = Workstation.ensure_workstation(profile, get_workstation())
assert Repo.aggregate(WorkstationSchema, :count) == 1
end
defp get_profile do
user_id = Ecto.UUID.generate()
{:ok, profile} = Profile.ensure_profile(user_id)
profile
end
defp get_workstation(name \\ @ws_name) do
%{name: name}
end
end
In the test/deskterm directory, create a new file named session_test.exs and populate it with the below content:
defmodule Deskterm.SessionTest do
use Deskterm.DataCase, async: true
alias Deskterm.Session
test "gets orphaned sessions started before threshold" do
{:ok, very_old_session} = get_session(30)
{:ok, _recent_session} = get_session(5)
threshold = DateTime.add(DateTime.utc_now(), -5, :minute)
orphaned = Session.get_orphaned_sessions(threshold)
assert length(orphaned) == 1
assert hd(orphaned).id == very_old_session.id
end
test "does not consider ended sessions to be orphaned" do
{:ok, session} = get_session(30)
Session.end_session(session)
threshold = DateTime.add(DateTime.utc_now(), -5, :minute)
orphaned = Session.get_orphaned_sessions(threshold)
assert orphaned == []
end
test "gets count of active free sessions" do
{:ok, _} = get_session(15)
{:ok, _} = get_session(10)
{:ok, _} = get_session(5)
assert Session.get_active_free_sessions_count() == 3
end
test "does not count ended sessions" do
{:ok, session} = get_session(5)
Session.end_session(session)
assert Session.get_active_free_sessions_count() == 0
end
test "does not count non-free sessions" do
{:ok, _} = get_session(5, "new_user")
assert Session.get_active_free_sessions_count() == 0
end
defp get_session(started_minutes_ago, user_type \\ "free_user") do
started_at =
DateTime.utc_now()
|> DateTime.add(-started_minutes_ago, :minute)
attributes = %{
user_type: user_type,
fly_app_name: "free-38c915ee0f38b834",
ws_name: "chrome",
ip_address: "127.0.0.1",
started_at: started_at
}
Session.create(attributes)
end
end
In the test/deskterm directory, create a new file named session_cleanup_test.exs and populate it with the below content:
defmodule Deskterm.SessionCleanupTest do
use Deskterm.DataCase, async: true
import Mox
alias Deskterm.Schema.Session, as: SessionSchema
alias Deskterm.Session
alias Deskterm.SessionCleanup
alias DesktermWeb.TestFlyUtilities
setup :verify_on_exit!
test "works when there are no orphaned sessions" do
SessionCleanup.run()
end
test "does not end session for free user when Fly app deletion fails" do
TestFlyUtilities.when_delete_app_fails()
{:ok, session} = get_session("free_user")
SessionCleanup.run()
session = Repo.get!(SessionSchema, session.id)
assert session.ended_at == nil
end
test "ends session and deletes Fly app for free user" do
TestFlyUtilities.when_delete_app_succeeds()
{:ok, session} = get_session("free_user")
SessionCleanup.run()
session = Repo.get!(SessionSchema, session.id)
assert session.ended_at != nil
end
test "ends session for non-free user without deleting Fly app" do
{:ok, session} = get_session("old_user")
SessionCleanup.run()
session = Repo.get!(SessionSchema, session.id)
assert session.ended_at != nil
end
defp get_session(user_type) do
started_at =
DateTime.utc_now()
|> DateTime.add(-30, :minute)
Session.create(%{
user_type: user_type,
fly_app_name: "38c915ee0f38b834",
ws_name: "chrome",
ip_address: "127.0.0.1",
started_at: started_at
})
end
end
In the test/deskterm_web/live directory, create a new file named launch_verification_test.exs and populate it with the below content:
defmodule DesktermWeb.LaunchVerificationTest do
use DesktermWeb.ConnCase
import Mox
alias Deskterm.Session
alias DesktermWeb.TestAppUtilities
alias DesktermWeb.TestLaunchUtilities
setup :verify_on_exit!
test "aborts launch when turnstile verification fails", %{conn: conn} do
view = TestAppUtilities.when_is_unauthenticated(conn)
TestLaunchUtilities.start_workstation(view, true)
TestLaunchUtilities.assert_launch_aborted(view)
end
test "aborts launch when free user capacity is reached", %{conn: conn} do
create_free_sessions(30)
view = TestAppUtilities.when_is_unauthenticated(conn)
TestLaunchUtilities.start_workstation(view)
TestLaunchUtilities.assert_launch_aborted(view)
end
defp create_free_sessions(count) do
for _ <- 1..count do
{:ok, _} =
Session.create(%{
user_type: "free_user",
fly_app_name:
"free-#{:crypto.strong_rand_bytes(16) |> Base.encode16(case: :lower)}",
ws_name: "chrome",
ip_address: "127.0.0.1",
started_at: DateTime.utc_now()
})
end
end
end
In the test/deskterm_web/live directory, create a new file named censorship_test.exs and populate it with the below content:
defmodule DesktermWeb.CensorshipTest do
use ExUnit.Case, async: true
alias DesktermWeb.Censorship
alias DesktermWeb.TestCensorshipUtilities
alias DesktermWeb.TestSecretAtoms
setup do
TestCensorshipUtilities.initialize_environment()
end
test "works on empty input" do
assert Censorship.censor_secrets(TestCensorshipUtilities.socket(), "") == ""
assert Censorship.censor_secrets(TestCensorshipUtilities.socket(), []) == []
end
test "censors secret when present" do
uncensored = TestSecretAtoms.fly_api_token()
censored = TestCensorshipUtilities.redacted() <> "83"
assert Censorship.censor_secrets(
TestCensorshipUtilities.socket(),
uncensored
) == censored
assert Censorship.censor_secrets(TestCensorshipUtilities.socket(), [
uncensored
]) == [censored]
end
test "censors secret when present in the beginning of a line" do
uncensored = TestSecretAtoms.fly_api_token() <> " middle end"
censored = TestCensorshipUtilities.redacted() <> "83 middle end"
assert Censorship.censor_secrets(
TestCensorshipUtilities.socket(),
uncensored
) == censored
assert Censorship.censor_secrets(TestCensorshipUtilities.socket(), [
uncensored
]) == [censored]
end
test "censors secret when present in the middle of a line" do
uncensored = "start " <> TestSecretAtoms.fly_api_token() <> " end"
censored = "start " <> TestCensorshipUtilities.redacted() <> "83 end"
assert Censorship.censor_secrets(
TestCensorshipUtilities.socket(),
uncensored
) == censored
assert Censorship.censor_secrets(TestCensorshipUtilities.socket(), [
uncensored
]) == [censored]
end
test "censors secret when present at the end of a line" do
uncensored = "start middle " <> TestSecretAtoms.fly_api_token()
censored = "start middle " <> TestCensorshipUtilities.redacted() <> "83"
assert Censorship.censor_secrets(
TestCensorshipUtilities.socket(),
uncensored
) == censored
assert Censorship.censor_secrets(TestCensorshipUtilities.socket(), [
uncensored
]) == [censored]
end
test "censors secret when present multiple times" do
uncensored =
TestSecretAtoms.fly_api_token() <>
TestSecretAtoms.fly_api_token() <>
" middle " <> TestSecretAtoms.fly_api_token()
censored =
TestCensorshipUtilities.redacted() <>
"83" <>
TestCensorshipUtilities.redacted() <>
"83 middle " <> TestCensorshipUtilities.redacted() <> "83"
assert Censorship.censor_secrets(
TestCensorshipUtilities.socket(),
uncensored
) == censored
assert Censorship.censor_secrets(TestCensorshipUtilities.socket(), [
uncensored
]) == [censored]
end
test "censors secret when present on multiple lines" do
first_uncensored_line =
TestSecretAtoms.fly_api_token() <>
TestSecretAtoms.fly_api_token() <>
"\n" <> TestSecretAtoms.fly_api_token()
second_uncensored_line =
"start " <>
TestSecretAtoms.fly_api_token() <>
" middle " <> TestSecretAtoms.fly_api_token()
first_censored_line =
TestCensorshipUtilities.redacted() <>
"83" <>
TestCensorshipUtilities.redacted() <>
"83\n" <> TestCensorshipUtilities.redacted() <> "83"
second_censored_line =
"start " <>
TestCensorshipUtilities.redacted() <>
"83 middle " <> TestCensorshipUtilities.redacted() <> "83"
assert Censorship.censor_secrets(
TestCensorshipUtilities.socket(),
first_uncensored_line <> "\n" <> second_uncensored_line
) == first_censored_line <> "\n" <> second_censored_line
assert Censorship.censor_secrets(TestCensorshipUtilities.socket(), [
first_uncensored_line,
second_uncensored_line
]) == [first_censored_line, second_censored_line]
end
test "censors secrets from multiple sources" do
uncensored = TestCensorshipUtilities.get_uncensored_secrets()
censored = TestCensorshipUtilities.get_censored_secrets()
uncensored_list = String.split(uncensored, " | ")
censored_list = String.split(censored, " | ")
socket = TestCensorshipUtilities.socket(TestSecretAtoms.ws_auth_token())
assert Censorship.censor_secrets(socket, uncensored) == censored
assert Censorship.censor_secrets(socket, uncensored_list) == censored_list
end
end
In the test/deskterm_web/live directory, create a new file named ip_address_test.exs and populate it with the below content:
defmodule DesktermWeb.IPAddressTest do
use ExUnit.Case, async: true
alias DesktermWeb.IPAddress
test "gets proxied IP address when available" do
socket = get_socket(x_headers: [{"x-forwarded-for", "203.0.113.1"}])
assert IPAddress.get_client_ip_address(socket) == "203.0.113.1"
end
test "gets first IP from x-forwarded-for chain" do
socket =
get_socket(x_headers: [{"x-forwarded-for", "203.0.113.1, 10.0.0.1, 172.16.0.1"}])
assert IPAddress.get_client_ip_address(socket) == "203.0.113.1"
end
test "gets direct IP address when proxied IP address is not available" do
socket = get_socket(x_headers: [])
assert IPAddress.get_client_ip_address(socket) == "127.0.0.1"
end
test "normalizes IPv4-mapped IPv6 address to IPv4" do
socket =
get_socket(
x_headers: [],
address: {0, 0, 0, 0, 0, 65_535, 44_049, 1}
)
assert IPAddress.get_client_ip_address(socket) == "172.17.0.1"
end
defp get_socket(opts) do
x_headers = Keyword.get(opts, :x_headers, [])
address = Keyword.get(opts, :address, {127, 0, 0, 1})
%Phoenix.LiveView.Socket{
private: %{
connect_info: %{
x_headers: x_headers,
peer_data: %{address: address}
}
}
}
end
end
In the test/deskterm_web/live directory, create a new file named session_test.exs and populate it with the below content:
defmodule DesktermWeb.SessionTest do
use Deskterm.DataCase, async: true
import Mox
alias Deskterm.Schema.Session, as: SessionSchema
alias Deskterm.Session
alias DesktermWeb.TestFlyUtilities
@fly_machine_id "uc5q9oh95ia8bibj"
@free_user_app_name "free-6odlvsl1s2wokcvs"
@old_user_app_name "user-h4cctm546bmgmj2x"
setup :verify_on_exit!
test "does not end session for free user when Fly app deletion fails" do
TestFlyUtilities.when_delete_app_fails()
{:ok, session} = get_session("free_user")
DesktermWeb.Session.end_session(@free_user_app_name, session)
session = Repo.get!(SessionSchema, session.id)
assert session.ended_at == nil
end
test "ends session for free user when fly app deletion succeeds" do
TestFlyUtilities.when_delete_app_succeeds()
{:ok, session} = get_session("free_user")
DesktermWeb.Session.end_session(@free_user_app_name, session)
session = Repo.get!(SessionSchema, session.id)
assert session.ended_at != nil
end
test "does not end session for non-free user when stopping machine fails" do
TestFlyUtilities.when_execute_command_sync_succeeds()
TestFlyUtilities.when_stop_machine_fails()
{:ok, session} = get_session("old_user", @fly_machine_id)
DesktermWeb.Session.end_session(@old_user_app_name, session)
session = Repo.get!(SessionSchema, session.id)
assert session.ended_at == nil
end
test "ends session for non-free user when stopping machine succeeds" do
TestFlyUtilities.when_execute_command_sync_succeeds()
TestFlyUtilities.when_stop_machine_succeeds()
{:ok, session} = get_session("old_user", @fly_machine_id)
DesktermWeb.Session.end_session(@old_user_app_name, session)
session = Repo.get!(SessionSchema, session.id)
assert session.ended_at != nil
end
test "ends session for non-free user when machine does not exist" do
{:ok, session} = get_session("old_user")
DesktermWeb.Session.end_session(@old_user_app_name, session)
session = Repo.get!(SessionSchema, session.id)
assert session.ended_at != nil
end
defp get_session(user_type, fly_machine_id \\ nil) do
started_at =
DateTime.utc_now()
|> DateTime.add(-30, :minute)
attributes =
%{
user_type: user_type,
fly_app_name: "free-38c915ee0f38b834",
ws_name: "chrome",
ip_address: "203.0.113.1",
started_at: started_at,
fly_machine_id: fly_machine_id
}
Session.create(attributes)
end
end
In the test/deskterm_web/live directory, create a new file named ui_log_test.exs and populate it with the below content:
defmodule DesktermWeb.UILogStreamTest do
use DesktermWeb.ConnCase
import Mox
alias DesktermWeb.TestAppUtilities
alias DesktermWeb.TestFlyUtilities
alias DesktermWeb.TestLaunchUtilities
setup do
TestFlyUtilities.when_fly_app_is_created()
TestFlyUtilities.when_machine_is_created()
verify_on_exit!()
end
@error_message "Timed out waiting for log-update event containing:"
@stream_updates [
"Using default tag: latest",
"latest: Pulling from linuxserver/chrome"
]
test "aborts launch when stream fails", %{
conn: conn
} do
TestFlyUtilities.when_stream_fails()
view = TestAppUtilities.when_is_unauthenticated(conn)
TestLaunchUtilities.start_workstation(view)
TestLaunchUtilities.assert_launch_aborted(view)
end
test "updates ui log on stream update", %{
conn: conn
} do
TestFlyUtilities.when_stream_updates(@stream_updates)
view = TestAppUtilities.when_is_unauthenticated(conn)
TestLaunchUtilities.start_workstation(view)
assert_ui_log_contains(view, Enum.join(@stream_updates, "\n"))
end
defp assert_ui_log_contains(view, expected) do
%{proxy: {reference, _, _}} = view
receive do
{^reference, {:push_event, "log-update", %{log: log}}} ->
unless log =~ expected do
assert_ui_log_contains(view, expected)
end
after
500 -> flunk(@error_message <> " #{inspect(expected)}")
end
end
end
In the test/deskterm_web/live directory, create a new file named new_user_test.exs and populate it with the below content:
defmodule DesktermWeb.NewUserLaunchTest do
use DesktermWeb.ConnCase
import Mox
alias Deskterm.Repo
alias Deskterm.Schema.Profile
alias DesktermWeb.TestAppUtilities
alias DesktermWeb.TestFlyUtilities
alias DesktermWeb.TestLaunchUtilities
alias DesktermWeb.TestUserAtoms
setup :verify_on_exit!
test "persists fly app name, volume, and machine id on first workstation launch",
%{
conn: conn
} do
TestFlyUtilities.when_fly_app_is_created()
TestFlyUtilities.when_machine_is_created()
TestFlyUtilities.when_execute_command_completes(6)
view = TestAppUtilities.when_is_new_user(conn)
TestLaunchUtilities.start_workstation(view)
TestLaunchUtilities.assert_launch_not_aborted(view)
profile = Repo.get_by!(Profile, user_id: TestUserAtoms.user_id())
assert profile.fly_app_name != nil
assert profile.fly_volume_id != nil
assert profile.fly_machine_id != nil
end
end
In the test/deskterm_web/live/workstation directory, create a new file named workstation_test.exs and populate it with the below content:
defmodule DesktermWeb.WorkstationTest do
use DesktermWeb.ConnCase
import Mox
alias DesktermWeb.TestAppUtilities
alias DesktermWeb.TestFlyUtilities
alias DesktermWeb.TestLaunchUtilities
setup :verify_on_exit!
test "aborts launch when creating volume fails", %{
conn: conn
} do
TestFlyUtilities.when_fly_app_is_created()
TestFlyUtilities.when_create_volume_fails()
view = TestAppUtilities.when_is_unauthenticated(conn)
TestLaunchUtilities.start_workstation(view)
TestLaunchUtilities.assert_launch_aborted(view)
end
test "does not abort launch when creating volume succeeds", %{
conn: conn
} do
TestFlyUtilities.when_fly_app_is_created()
TestFlyUtilities.when_machine_is_created()
TestFlyUtilities.when_execute_command_succeeds()
view = TestAppUtilities.when_is_unauthenticated(conn)
TestLaunchUtilities.start_workstation(view)
TestLaunchUtilities.assert_launch_not_aborted(view)
end
test "aborts launch when creating machine fails", %{
conn: conn
} do
TestFlyUtilities.when_fly_app_is_created()
TestFlyUtilities.when_create_volume_succeeds()
TestFlyUtilities.when_create_machine_fails()
view = TestAppUtilities.when_is_unauthenticated(conn)
TestLaunchUtilities.start_workstation(view)
TestLaunchUtilities.assert_launch_aborted(view)
end
test "does not abort launch when creating machine succeeds", %{
conn: conn
} do
TestFlyUtilities.when_fly_app_is_created()
TestFlyUtilities.when_machine_is_created()
TestFlyUtilities.when_execute_command_succeeds()
view = TestAppUtilities.when_is_unauthenticated(conn)
TestLaunchUtilities.start_workstation(view)
TestLaunchUtilities.assert_launch_not_aborted(view)
end
test "aborts launch when starting machine fails", %{
conn: conn
} do
TestFlyUtilities.when_start_machine_fails()
view = TestAppUtilities.when_is_old_user(conn)
TestLaunchUtilities.start_workstation(view)
TestLaunchUtilities.assert_launch_aborted(view)
end
test "does not abort launch when starting machine succeeds", %{
conn: conn
} do
TestFlyUtilities.when_start_machine_succeeds()
TestFlyUtilities.when_wait_for_machine_succeeds()
TestFlyUtilities.when_execute_command_succeeds()
view = TestAppUtilities.when_is_old_user(conn)
TestLaunchUtilities.start_workstation(view)
TestLaunchUtilities.assert_launch_not_aborted(view)
end
test "aborts launch when waiting for machine fails", %{
conn: conn
} do
TestFlyUtilities.when_fly_app_is_created()
TestFlyUtilities.when_create_volume_succeeds()
TestFlyUtilities.when_create_machine_succeeds()
TestFlyUtilities.when_wait_for_machine_fails()
view = TestAppUtilities.when_is_unauthenticated(conn)
TestLaunchUtilities.start_workstation(view)
TestLaunchUtilities.assert_launch_aborted(view)
end
test "does not abort launch when waiting for machine succeeds", %{
conn: conn
} do
TestFlyUtilities.when_fly_app_is_created()
TestFlyUtilities.when_machine_is_created()
TestFlyUtilities.when_execute_command_succeeds()
view = TestAppUtilities.when_is_unauthenticated(conn)
TestLaunchUtilities.start_workstation(view)
TestLaunchUtilities.assert_launch_not_aborted(view)
end
end
In the test/deskterm_web/live/fly_app directory, create a new file named fly_app_test.exs and populate it with the below content:
defmodule DesktermWeb.FlyAppTest do
use DesktermWeb.ConnCase
import Mox
alias DesktermWeb.TestAppUtilities
alias DesktermWeb.TestFlyUtilities
alias DesktermWeb.TestLaunchUtilities
setup :verify_on_exit!
test "aborts launch when creating fly app fails", %{conn: conn} do
TestFlyUtilities.when_create_app_fails()
view = TestAppUtilities.when_is_unauthenticated(conn)
TestLaunchUtilities.start_workstation(view)
TestLaunchUtilities.assert_launch_aborted(view)
end
test "aborts launch when allocating ipv4 address fails", %{conn: conn} do
TestFlyUtilities.when_create_app_succeeds()
TestFlyUtilities.when_allocate_ipv4_address_fails()
view = TestAppUtilities.when_is_unauthenticated(conn)
TestLaunchUtilities.start_workstation(view)
TestLaunchUtilities.assert_launch_aborted(view)
end
test "aborts launch when allocating ipv6 address fails", %{conn: conn} do
TestFlyUtilities.when_create_app_succeeds()
TestFlyUtilities.when_allocate_ipv4_address_succeeds()
TestFlyUtilities.when_allocate_ipv6_address_fails()
view = TestAppUtilities.when_is_unauthenticated(conn)
TestLaunchUtilities.start_workstation(view)
TestLaunchUtilities.assert_launch_aborted(view)
end
test "does not abort launch when fly app is created", %{conn: conn} do
TestFlyUtilities.when_fly_app_is_created()
TestFlyUtilities.when_create_volume_succeeds()
view = TestAppUtilities.when_is_unauthenticated(conn)
TestLaunchUtilities.start_workstation(view)
TestLaunchUtilities.assert_launch_not_aborted(view)
end
end
In the test/deskterm_web/live/container directory, create a new file named container_test.exs and populate it with the below content:
defmodule DesktermWeb.ContainerTest do
use DesktermWeb.ConnCase
import Mox
alias DesktermWeb.TestAppUtilities
alias DesktermWeb.TestFlyUtilities
alias DesktermWeb.TestLaunchUtilities
setup :verify_on_exit!
test "aborts launch when waiting for docker fails", %{
conn: conn
} do
TestFlyUtilities.when_fly_app_is_created()
TestFlyUtilities.when_machine_is_created()
TestFlyUtilities.when_execute_command_fails()
view = TestAppUtilities.when_is_unauthenticated(conn)
TestLaunchUtilities.start_workstation(view)
TestLaunchUtilities.assert_launch_aborted(view)
end
test "does not abort launch when waiting for docker succeeds", %{
conn: conn
} do
TestFlyUtilities.when_fly_app_is_created()
TestFlyUtilities.when_machine_is_created()
TestFlyUtilities.when_execute_command_succeeds()
view = TestAppUtilities.when_is_unauthenticated(conn)
TestLaunchUtilities.start_workstation(view)
TestLaunchUtilities.assert_launch_not_aborted(view)
end
test "aborts launch when configuring firewall fails", %{
conn: conn
} do
TestFlyUtilities.when_fly_app_is_created()
TestFlyUtilities.when_machine_is_created()
TestFlyUtilities.when_execute_command_completes()
TestFlyUtilities.when_execute_command_fails()
view = TestAppUtilities.when_is_unauthenticated(conn)
TestLaunchUtilities.start_workstation(view)
TestLaunchUtilities.assert_launch_aborted(view)
end
test "does not abort launch when configuring firewall succeeds", %{
conn: conn
} do
TestFlyUtilities.when_fly_app_is_created()
TestFlyUtilities.when_machine_is_created()
TestFlyUtilities.when_execute_command_completes()
TestFlyUtilities.when_execute_command_succeeds()
view = TestAppUtilities.when_is_unauthenticated(conn)
TestLaunchUtilities.start_workstation(view)
TestLaunchUtilities.assert_launch_not_aborted(view)
end
test "aborts launch when pulling image fails", %{
conn: conn
} do
TestFlyUtilities.when_fly_app_is_created()
TestFlyUtilities.when_machine_is_created()
TestFlyUtilities.when_execute_command_completes(2)
TestFlyUtilities.when_execute_command_fails()
view = TestAppUtilities.when_is_unauthenticated(conn)
TestLaunchUtilities.start_workstation(view)
TestLaunchUtilities.assert_launch_aborted(view)
end
test "does not abort launch when pulling image succeeds", %{
conn: conn
} do
TestFlyUtilities.when_fly_app_is_created()
TestFlyUtilities.when_machine_is_created()
TestFlyUtilities.when_execute_command_completes(2)
TestFlyUtilities.when_execute_command_succeeds()
view = TestAppUtilities.when_is_unauthenticated(conn)
TestLaunchUtilities.start_workstation(view)
TestLaunchUtilities.assert_launch_not_aborted(view)
end
test "aborts launch when starting workstation container fails", %{
conn: conn
} do
TestFlyUtilities.when_fly_app_is_created()
TestFlyUtilities.when_machine_is_created()
TestFlyUtilities.when_execute_command_completes(3)
TestFlyUtilities.when_execute_command_fails()
view = TestAppUtilities.when_is_unauthenticated(conn)
TestLaunchUtilities.start_workstation(view)
TestLaunchUtilities.assert_launch_aborted(view)
end
test "does not abort launch when starting workstation container succeeds", %{
conn: conn
} do
TestFlyUtilities.when_fly_app_is_created()
TestFlyUtilities.when_machine_is_created()
TestFlyUtilities.when_execute_command_completes(3)
TestFlyUtilities.when_execute_command_succeeds()
view = TestAppUtilities.when_is_unauthenticated(conn)
TestLaunchUtilities.start_workstation(view)
TestLaunchUtilities.assert_launch_not_aborted(view)
end
test "aborts launch when configuring firewall for old user fails", %{
conn: conn
} do
TestFlyUtilities.when_start_machine_succeeds()
TestFlyUtilities.when_wait_for_machine_succeeds()
TestFlyUtilities.when_execute_command_completes()
TestFlyUtilities.when_execute_command_fails()
view = TestAppUtilities.when_is_old_user(conn, true)
TestLaunchUtilities.start_workstation(view)
TestLaunchUtilities.assert_launch_aborted(view)
end
test "does not abort launch when configuring firewall for old user succeeds",
%{
conn: conn
} do
TestFlyUtilities.when_start_machine_succeeds()
TestFlyUtilities.when_wait_for_machine_succeeds()
TestFlyUtilities.when_execute_command_completes()
TestFlyUtilities.when_execute_command_succeeds()
view = TestAppUtilities.when_is_old_user(conn, true)
TestLaunchUtilities.start_workstation(view)
TestLaunchUtilities.assert_launch_not_aborted(view)
end
test "aborts launch when resuming workstation container fails", %{
conn: conn
} do
TestFlyUtilities.when_start_machine_succeeds()
TestFlyUtilities.when_wait_for_machine_succeeds()
TestFlyUtilities.when_execute_command_completes(2)
TestFlyUtilities.when_execute_command_fails()
view = TestAppUtilities.when_is_old_user(conn, true)
TestLaunchUtilities.start_workstation(view)
TestLaunchUtilities.assert_launch_aborted(view)
end
test "does not abort launch when resuming workstation container succeeds", %{
conn: conn
} do
TestFlyUtilities.when_start_machine_succeeds()
TestFlyUtilities.when_wait_for_machine_succeeds()
TestFlyUtilities.when_execute_command_completes(2)
TestFlyUtilities.when_execute_command_succeeds()
view = TestAppUtilities.when_is_old_user(conn, true)
TestLaunchUtilities.start_workstation(view)
TestLaunchUtilities.assert_launch_not_aborted(view)
end
test "aborts launch when writing proxy configuration fails", %{
conn: conn
} do
TestFlyUtilities.when_fly_app_is_created()
TestFlyUtilities.when_machine_is_created()
TestFlyUtilities.when_execute_command_completes(4)
TestFlyUtilities.when_execute_command_fails()
view = TestAppUtilities.when_is_unauthenticated(conn)
TestLaunchUtilities.start_workstation(view)
TestLaunchUtilities.assert_launch_aborted(view)
end
test "does not abort launch when writing proxy configuration succeeds", %{
conn: conn
} do
TestFlyUtilities.when_fly_app_is_created()
TestFlyUtilities.when_machine_is_created()
TestFlyUtilities.when_execute_command_completes(4)
TestFlyUtilities.when_execute_command_succeeds()
view = TestAppUtilities.when_is_unauthenticated(conn)
TestLaunchUtilities.start_workstation(view)
TestLaunchUtilities.assert_launch_not_aborted(view)
end
test "aborts launch when starting proxy container fails", %{
conn: conn
} do
TestFlyUtilities.when_fly_app_is_created()
TestFlyUtilities.when_machine_is_created()
TestFlyUtilities.when_execute_command_completes(5)
TestFlyUtilities.when_execute_command_fails()
view = TestAppUtilities.when_is_unauthenticated(conn)
TestLaunchUtilities.start_workstation(view)
TestLaunchUtilities.assert_launch_aborted(view)
end
test "does not abort launch when starting proxy container succeeds", %{
conn: conn
} do
TestFlyUtilities.when_fly_app_is_created()
TestFlyUtilities.when_machine_is_created()
TestFlyUtilities.when_execute_command_completes(5)
TestFlyUtilities.when_execute_command_succeeds()
view = TestAppUtilities.when_is_unauthenticated(conn)
TestLaunchUtilities.start_workstation(view)
TestLaunchUtilities.assert_launch_not_aborted(view)
end
end
Update workstations_test.exs to use the renamed ws_name parameter:
render_click(view, "select_workstation", %{ws_name: "chrome"})
In the assets/test directory, create a new file named latency-measurement.test.ts and populate it with the below content:
import "@testing-library/jest-dom";
import {
AutomaticBoxes,
frankfurtID,
frankfurtLatency,
initializeAutomaticBoxes,
initializeHTML,
initializeLocationBoxes,
LocationBoxes,
stockholmID,
stockholmLatency
} from "./utilities/latency-test-utils";
import { determineLatencies } from "../ts/workstations/latency-measurement";
import { pingLocations } from "../ts/workstations/ping";
jest.mock("../ts/workstations/ping");
describe("Latency Measurement", () => {
const errorSVG = 'img[src="/images/icons/error.svg"]';
const hidden = "hidden";
const mockPingLocations =
pingLocations as jest.MockedFunction<typeof pingLocations>;
let automaticBoxes: AutomaticBoxes;
let locationBoxes: LocationBoxes;
beforeEach(() => {
initializeHTML();
automaticBoxes = initializeAutomaticBoxes();
locationBoxes = initializeLocationBoxes();
mockPingLocations.mockClear();
});
test("shows error when ping fails", () => {
wheneverPingFails();
determineLatencies(document.body);
expect(locationBoxes.stockholm.loadingSpinner).toHaveClass(hidden);
expect(locationBoxes.stockholm.latencyText).not.toHaveClass(hidden);
expect(locationBoxes.stockholm.latencyText.querySelector(errorSVG))
.not.toBeNull();
expect(locationBoxes.frankfurt.loadingSpinner).toHaveClass(hidden);
expect(locationBoxes.frankfurt.latencyText).not.toHaveClass(hidden);
expect(locationBoxes.frankfurt.latencyText.querySelector(errorSVG))
.not.toBeNull();
expect(automaticBoxes.wsAutomaticLoadingSpinner).toHaveClass(hidden);
expect(automaticBoxes.wsAutomaticLatencyText).not.toHaveClass(hidden);
expect(automaticBoxes.wsAutomaticLatencyText.querySelector(errorSVG))
.not.toBeNull();
expect(automaticBoxes.vpnAutomaticSpinner.querySelector(errorSVG))
.not.toBeNull();
expect(automaticBoxes.vpnAutomaticText.textContent).toBe('Error');
});
test("shows latencies when ping succeeds", () => {
wheneverPingSucceeds();
determineLatencies(document.body);
expect(automaticBoxes.wsAutomaticLoadingSpinner).toHaveClass(hidden);
expect(automaticBoxes.wsAutomaticLatencyText).not.toHaveClass(hidden);
expect(automaticBoxes.wsAutomaticLatencyText.textContent)
.toMatch(`${stockholmLatency} ms`);
expect(locationBoxes.stockholm.loadingSpinner).toHaveClass(hidden);
expect(locationBoxes.stockholm.latencyText).not.toHaveClass(hidden);
expect(locationBoxes.stockholm.latencyText.textContent)
.toMatch(`${stockholmLatency} ms`);
expect(locationBoxes.frankfurt.loadingSpinner).toHaveClass(hidden);
expect(locationBoxes.frankfurt.latencyText).not.toHaveClass(hidden);
expect(locationBoxes.frankfurt.latencyText.textContent)
.toMatch(`${frankfurtLatency} ms`);
expect(automaticBoxes.vpnAutomaticSpinner).toHaveClass(hidden);
expect(automaticBoxes.vpnAutomaticText.textContent).toBe('Stockholm');
});
test("updates location input when automatic is selected", () => {
wheneverPingSucceeds();
determineLatencies(document.body);
const locationInput =
document.getElementById("ws-location-input") as HTMLInputElement;
expect(locationInput.value).toBe(stockholmID);
});
test("updates location input when better latency result arrives later", () => {
wheneverLowestLatencyArrivesLater();
determineLatencies(document.body);
expect(automaticBoxes.wsAutomaticLatencyText.textContent)
.toMatch(`${stockholmLatency} ms`);
expect(automaticBoxes.vpnAutomaticText.textContent).toBe('Stockholm');
});
function wheneverPingFails(): void {
mockPingLocations.mockImplementation((_ids, callbacks) => {
callbacks.onError({ locationID: stockholmID });
callbacks.onError({ locationID: frankfurtID });
});
}
function wheneverPingSucceeds(): void {
mockPingLocations.mockImplementation((_ids, callbacks) => {
callbacks.onResult({
locationID: stockholmID,
latency: stockholmLatency
});
callbacks.onResult({
locationID: frankfurtID,
latency: frankfurtLatency
});
});
}
function wheneverLowestLatencyArrivesLater(): void {
mockPingLocations.mockImplementation((_ids, callbacks) => {
callbacks.onResult({
locationID: frankfurtID,
latency: frankfurtLatency
});
callbacks.onResult({
locationID: stockholmID,
latency: stockholmLatency
});
});
}
});
In the assets/test directory, create a new file named countdown.test.ts and populate it with the below content:
import "@testing-library/jest-dom";
import { initializeCountdown } from "../ts/countdown";
describe("Countdown", () => {
let container: HTMLElement;
let label: HTMLElement;
beforeEach(() => {
jest.useFakeTimers();
jest.setSystemTime(new Date("2026-04-15T15:00:00Z"));
document.body.innerHTML = `
<div id="countdown-container" data-expiration="2026-04-15T15:05:00Z">
<span data-countdown-label>05:00</span>
</div>
`;
container = document.getElementById("countdown-container") as HTMLElement;
label = container.querySelector("[data-countdown-label]") as HTMLElement;
});
afterEach(() => {
jest.useRealTimers();
});
test("works on views without countdown", () => {
document.body.innerHTML = ``;
expect(() => initializeCountdown(document.body)).not.toThrow();
});
test("updates time shown after one second", () => {
initializeCountdown(container);
jest.advanceTimersByTime(1000);
expect(label.textContent).toBe("04:59");
});
test("updates time shown as time passes", () => {
initializeCountdown(container);
jest.advanceTimersByTime(2.5 * 60 * 1000);
expect(label.textContent).toBe("02:30");
});
test("shows zero when countdown expires", () => {
initializeCountdown(container);
jest.advanceTimersByTime(5 * 60 * 1000);
expect(label.textContent).toBe("00:00");
});
test("continues to show zero when countdown is past expiration", () => {
initializeCountdown(container);
jest.advanceTimersByTime(10 * 60 * 1000);
expect(label.textContent).toBe("00:00");
});
});
In the assets/test directory, create a new file named loading-progress.test.ts and populate it with the below content:
import "@testing-library/jest-dom";
import { initializeLoadingProgress } from "../ts/loading-progress";
import {
advanceStep,
initializeHTML,
} from "./utilities/loading-progress-test-utils";
describe("Loading Progress", () => {
const LONG_DURATION = 100_000;
let progressContainer: HTMLElement;
let progressBar: HTMLElement;
let progressLabel: HTMLElement;
beforeEach(() => {
jest.clearAllMocks();
jest.useFakeTimers();
initializeHTML();
progressContainer =
document.getElementById("loading-workstation") as HTMLElement;
progressBar = document.querySelector("[data-progress-bar]") as HTMLElement;
progressLabel =
document.querySelector("[data-progress-label]") as HTMLElement;
initializeLoadingProgress(progressContainer);
});
afterEach(() => {
jest.useRealTimers();
});
test("works on pages without loading progress", () => {
document.body.innerHTML = ``;
expect(() => initializeLoadingProgress(document.body)).not.toThrow();
});
test("approaches full section completion after long duration", () => {
jest.advanceTimersByTime(LONG_DURATION);
const progressBarWidth = parseFloat(progressBar.style.width);
expect(progressBarWidth).toBeGreaterThan(24.5);
expect(progressBarWidth).toBeLessThanOrEqual(25);
expect(progressLabel.textContent).toBe("25%");
});
test("advances into next section on step progression", async () => {
advanceStep(progressContainer);
await Promise.resolve();
jest.advanceTimersByTime(LONG_DURATION);
const progressBarWidth = parseFloat(progressBar.style.width);
expect(progressBarWidth).toBeGreaterThan(49.5);
expect(progressBarWidth).toBeLessThanOrEqual(50);
expect(progressLabel.textContent).toBe("50%");
});
test("approaches full last section after long duration", async () => {
advanceStep(progressContainer);
advanceStep(progressContainer);
advanceStep(progressContainer);
await Promise.resolve();
jest.advanceTimersByTime(LONG_DURATION);
const progressBarWidth = parseFloat(progressBar.style.width);
expect(progressBarWidth).toBeGreaterThan(99.5);
expect(progressBarWidth).toBeLessThanOrEqual(100);
expect(progressLabel.textContent).toBe("100%");
});
});
In the assets/test/utilities directory, create a new file named loading-progress-test-utils.ts and populate it with the below content:
export function initializeHTML(): void {
document.body.innerHTML = `
<div
id="loading-workstation"
data-step-index="0"
data-total-steps="4"
>
<div data-progress-bar style="width: 0%"></div>
<p data-progress-label>0%</p>
</div>
`;
}
export function advanceStep(container: HTMLElement): void {
const currentStep = parseInt(container.dataset.stepIndex as string);
container.dataset.stepIndex = String(currentStep + 1);
}
Update latency-test-utils.ts to add an ws-location-input hidden input element to the test HTML and update the automatic button to include data-auto-location and a selected class:
export function initializeHTML(): void {
document.body.innerHTML = `
<input id="ws-location-input" value="automatic" />
${WSAutomaticView()}
${WSLocationsView()}
${VPNView()}
`;
}
function WSAutomaticView(): string {
return `
<button data-value="automatic" data-auto-location data-action="select" class="selected">
Move the test files authentication_test.exs, oauth_test.exs, and otp_test.exs from test/deskterm_web/live/ to test/deskterm_web/live/authentication/.
Update authentication_test.exs to remove the Code.require_file call, add aliases for Deskterm.Repo, Deskterm.Schema.Profile, and DesktermWeb.TestUserAtoms, and add a profile assertion at the end of the successful login test:
alias Deskterm.Repo
alias Deskterm.Schema.Profile
alias DesktermWeb.TestOAuthAtoms
alias DesktermWeb.TestUserAtoms
alias DesktermWeb.TestUtilities
assert info_message == @success
assert redirect_path == @path
assert Repo.get_by!(Profile, user_id: TestUserAtoms.user_id())
Update oauth_test.exs to remove the Code.require_file call.
Update otp_test.exs to remove the Code.require_file call and the @code module attribute, add aliases for Deskterm.Repo, Deskterm.Schema.Profile, and DesktermWeb.TestUserAtoms, replace @code references with TestUserAtoms.otp_code(), and add a profile assertion at the end of the successful verification test:
alias Deskterm.Repo
alias Deskterm.Schema.Profile
alias DesktermWeb.TestUserAtoms
alias DesktermWeb.TestUtilities
@path "/app"
render_click(view, "verify_otp_code", %{code: TestUserAtoms.otp_code()})
assert_push_event(view, "show-animation", %{})
assert Repo.get_by!(Profile, user_id: TestUserAtoms.user_id())
Production Code
Create the below database migrations in the priv/repo/migrations directory:
defmodule Deskterm.Repo.Migrations.CreateProfiles do
use Ecto.Migration
def change do
create table(:profiles) do
add :user_id, :binary_id, null: false
add :subscription_type, :string, null: false, default: "none"
add :fly_app_name, :string
add :fly_volume_id, :string
timestamps()
end
create unique_index(:profiles, [:user_id])
end
end
defmodule Deskterm.Repo.Migrations.CreateWorkstations do
use Ecto.Migration
def change do
create table(:workstations) do
add :profile_id, references(:profiles, on_delete: :delete_all), null: false
add :ws_name, :string, null: false
add :fly_machine_id, :string, null: false
timestamps()
end
create unique_index(:workstations, [:profile_id, :ws_name])
create index(:workstations, [:profile_id])
end
end
defmodule Deskterm.Repo.Migrations.AddFlyVolumeRegionToProfiles do
use Ecto.Migration
def change do
alter table(:profiles) do
add :fly_volume_region, :string
end
end
end
defmodule Deskterm.Repo.Migrations.AddLaunchingAtToProfiles do
use Ecto.Migration
def change do
alter table(:profiles) do
add :launching_at, :utc_datetime
end
end
end
defmodule Deskterm.Repo.Migrations.AddUniqueIndexOnFlyMachineIdToWorkstations do
use Ecto.Migration
def change do
create unique_index(:workstations, [:profile_id, :fly_machine_id])
end
end
defmodule Deskterm.Repo.Migrations.MoveFlyMachineIdToProfiles do
use Ecto.Migration
def change do
alter table(:profiles) do
add :fly_machine_id, :string
end
flush()
execute(
"UPDATE profiles SET fly_machine_id = w.fly_machine_id FROM workstations w WHERE w.profile_id = profiles.id",
"UPDATE workstations SET fly_machine_id = p.fly_machine_id FROM profiles p WHERE workstations.profile_id = p.id"
)
drop unique_index(:workstations, [:profile_id, :fly_machine_id])
alter table(:workstations) do
remove :fly_machine_id, :string
end
end
end
defmodule Deskterm.Repo.Migrations.CreateSessions do
use Ecto.Migration
def change do
create table(:sessions) do
add :user_type, :string, null: false
add :fly_app_name, :string, null: false
add :ws_name, :string, null: false
add :ip_address, :string
add :started_at, :utc_datetime, null: false
add :ended_at, :utc_datetime
timestamps()
end
create index(:sessions, [:ended_at])
end
end
defmodule Deskterm.Repo.Migrations.AddProfileAndMachineFieldsToSessions do
use Ecto.Migration
def change do
alter table(:sessions) do
add :profile_id, references(:profiles, on_delete: :nothing)
add :fly_machine_id, :string
add :fly_volume_id, :string
end
end
end
defmodule Deskterm.Repo.Migrations.EnableRls do
use Ecto.Migration
def up do
execute "ALTER TABLE profiles ENABLE ROW LEVEL SECURITY"
execute "ALTER TABLE workstations ENABLE ROW LEVEL SECURITY"
execute "ALTER TABLE sessions ENABLE ROW LEVEL SECURITY"
end
def down do
execute "ALTER TABLE profiles DISABLE ROW LEVEL SECURITY"
execute "ALTER TABLE workstations DISABLE ROW LEVEL SECURITY"
execute "ALTER TABLE sessions DISABLE ROW LEVEL SECURITY"
end
end
In the lib/deskterm/schemas directory, create a new file named profile.ex and populate it with the below content:
defmodule Deskterm.Schema.Profile do
use Ecto.Schema
import Ecto.Changeset
alias Deskterm.Schema.Workstation
schema "profiles" do
field :user_id, :binary_id
field :subscription_type, Ecto.Enum,
values: [:none, :basic, :pro, :elite],
default: :none
field :fly_app_name, :string
field :fly_machine_id, :string
field :fly_volume_id, :string
field :fly_volume_region, :string
field :launching_at, :utc_datetime
has_many :workstations, Workstation
timestamps()
end
def changeset(profile, attributes) do
profile
|> cast(
attributes,
[
:user_id,
:subscription_type,
:fly_app_name,
:fly_machine_id,
:fly_volume_id,
:fly_volume_region,
:launching_at
]
)
|> validate_required([:user_id])
|> unique_constraint(:user_id)
end
end
In the lib/deskterm/schemas directory, create a new file named workstation.ex and populate it with the below content:
defmodule Deskterm.Schema.Workstation do
use Ecto.Schema
import Ecto.Changeset
alias Deskterm.Schema.Profile
schema "workstations" do
belongs_to :profile, Profile
field :ws_name, :string
timestamps()
end
def changeset(workstation, attributes) do
workstation
|> cast(attributes, [:profile_id, :ws_name])
|> validate_required([:profile_id, :ws_name])
|> foreign_key_constraint(:profile_id)
|> unique_constraint([:profile_id, :ws_name])
end
end
In the lib/deskterm/schemas directory, create a new file named session.ex and populate it with the below content:
defmodule Deskterm.Schema.Session do
use Ecto.Schema
import Ecto.Changeset
schema "sessions" do
field :user_type, :string
field :fly_app_name, :string
field :ws_name, :string
field :ip_address, :string
field :fly_machine_id, :string
field :fly_volume_id, :string
field :started_at, :utc_datetime
field :ended_at, :utc_datetime
belongs_to :profile, Deskterm.Schema.Profile
timestamps()
end
def changeset(session, attributes) do
session
|> cast(attributes, [
:user_type,
:fly_app_name,
:ws_name,
:ip_address,
:profile_id,
:fly_machine_id,
:fly_volume_id,
:started_at,
:ended_at
])
|> validate_required([
:user_type,
:fly_app_name,
:ws_name,
:ip_address,
:started_at
])
|> validate_inclusion(:user_type, ~w(free_user new_user old_user))
end
end
In the lib/deskterm directory, create a new file named profile.ex and populate it with the below content:
defmodule Deskterm.Profile do
import Ecto.Query
alias Deskterm.Repo
alias Deskterm.Schema.Profile
@lock_expiry_minutes 5
def ensure_profile(user_id) do
%Profile{}
|> Profile.changeset(%{user_id: user_id})
|> Repo.insert(
on_conflict: [set: [user_id: user_id]],
conflict_target: :user_id,
returning: true
)
end
def ensure_app_name(%Profile{fly_app_name: nil} = profile, app_name) do
case profile
|> Profile.changeset(%{fly_app_name: app_name})
|> Repo.update() do
{:ok, profile} -> {:ok, profile}
_ -> :error
end
end
def ensure_app_name(profile, _app_name), do: {:ok, profile}
def ensure_volume(%Profile{fly_volume_id: nil} = profile, volume) do
case profile
|> Profile.changeset(%{
fly_volume_id: volume.id,
fly_volume_region: volume.region
})
|> Repo.update() do
{:ok, profile} -> {:ok, profile}
_ -> :error
end
end
def ensure_volume(profile, _volume), do: {:ok, profile}
def ensure_machine_id(%Profile{fly_machine_id: nil} = profile, machine_id) do
case profile
|> Profile.changeset(%{fly_machine_id: machine_id})
|> Repo.update() do
{:ok, profile} -> {:ok, profile}
_ -> :error
end
end
def ensure_machine_id(profile, _machine_id), do: {:ok, profile}
def lock(_, :free_user), do: :ok
def lock(profile, _) do
now = DateTime.utc_now()
expiry_time = DateTime.add(now, -@lock_expiry_minutes, :minute)
{count, _} =
from(profile_record in Profile,
where: profile_record.id == ^profile.id,
where:
is_nil(profile_record.launching_at) or
profile_record.launching_at < ^expiry_time
)
|> Repo.update_all(set: [launching_at: now])
case count do
1 -> :ok
0 -> :error
end
end
def unlock(_, :free_user), do: :ok
def unlock(profile, _) do
profile
|> Profile.changeset(%{launching_at: nil})
|> Repo.update()
end
end
In the lib/deskterm directory, create a new file named workstation.ex and populate it with the below content:
defmodule Deskterm.Workstation do
import Ecto.Query
alias Deskterm.Repo
alias Deskterm.Schema.Workstation
def ensure_workstation(profile, workstation) do
%Workstation{}
|> Workstation.changeset(%{
profile_id: profile.id,
ws_name: workstation.name
})
|> Repo.insert(
on_conflict: :nothing,
conflict_target: [:profile_id, :ws_name]
)
|> case do
{:ok, _} -> :ok
_ -> :error
end
end
def has_workstation?(profile, ws_name) do
Repo.exists?(
from workstation in Workstation,
where:
workstation.profile_id == ^profile.id and
workstation.ws_name == ^ws_name
)
end
end
In the lib/deskterm directory, create a new file named session.ex and populate it with the below content:
defmodule Deskterm.Session do
import Ecto.Query
alias Deskterm.Repo
alias Deskterm.Schema.Session
def create(attributes) do
%Session{}
|> Session.changeset(attributes)
|> Repo.insert()
end
def end_session(%Session{} = session) do
session
|> Session.changeset(%{ended_at: DateTime.utc_now()})
|> Repo.update()
end
def update_session(%Session{} = session, attributes) do
session
|> Session.changeset(attributes)
|> Repo.update()
end
def get_active_free_sessions_count do
from(session in Session,
where: session.user_type == "free_user" and is_nil(session.ended_at)
)
|> Repo.aggregate(:count)
end
def get_orphaned_sessions(oldest_allowed_start) do
from(session in Session,
where:
is_nil(session.ended_at) and session.started_at < ^oldest_allowed_start
)
|> Repo.all()
end
end
In the lib/deskterm directory, create a new file named session_cleanup.ex and populate it with the below content:
defmodule Deskterm.SessionCleanup do
use Task, restart: :temporary
require Logger
alias Deskterm.Session
def start_link(_arg) do
Task.start_link(__MODULE__, :run, [])
end
def run do
orphaned_sessions = Session.get_orphaned_sessions(DateTime.utc_now())
Logger.info("There are #{length(orphaned_sessions)} orphaned sessions")
Enum.each(orphaned_sessions, &clean_up_session/1)
rescue
_ ->
Logger.critical("Session cleanup failed")
end
defp clean_up_session(session) do
if session.user_type == "free_user" do
case DesktermWeb.FlyAPI.delete_app(session.fly_app_name) do
:ok ->
Logger.info("Deleted orphaned Fly.io app: #{session.fly_app_name}")
Session.end_session(session)
:error ->
Logger.warning(
"Failed to delete orphaned Fly.io app: #{session.fly_app_name}"
)
end
else
Session.end_session(session)
end
rescue
_ ->
Logger.error("Failed to clean up orphaned session #{session.id}")
end
end
Update application.ex to start the SessionCleanup task in the supervision tree:
children = [
DesktermWeb.Telemetry,
Deskterm.Repo,
Deskterm.SessionCleanup,
{DNSCluster,
query: Application.get_env(:deskterm, :dns_cluster_query) || :ignore},
{Phoenix.PubSub, name: Deskterm.PubSub},
DesktermWeb.Endpoint,
Deskterm.Supabase.Client
]
In the lib/deskterm_web/api/fly directory, create a new file named fly_api.ex and populate it with the below content:
defmodule DesktermWeb.FlyAPI do
alias DesktermWeb.Structs.Workstation.Command
alias DesktermWeb.Structs.Workstation.Volume
@callback create_app(name :: String.t()) :: :ok | :error
@callback allocate_ip_address(
app_name :: String.t(),
type :: String.t()
) :: :ok | :error
@callback create_volume(
app_name :: String.t(),
volume :: Volume.t()
) :: {:ok, String.t()} | :error
@callback create_machine(
app_name :: String.t(),
workstation :: Workstation.t()
) :: {:ok, String.t()} | :error
@callback start_machine(
app_name :: String.t(),
machine_id :: String.t()
) :: :ok | :error
@callback wait_for_machine(
app_name :: String.t(),
machine_id :: String.t()
) :: :ok | :error
@callback execute_command_async(
app_name :: String.t(),
command :: Command.t()
) :: {:ok, Task.t()} | :error
@callback execute_command_sync(
app_name :: String.t(),
command :: Command.t()
) :: :ok | :error
@callback stop_machine(
app_name :: String.t(),
machine_id :: String.t()
) :: :ok | :error
@callback delete_app(app_name :: String.t()) :: :ok | :error
def create_app(name), do: impl().create_app(name)
def allocate_ip_address(app_name, type), do: impl().allocate_ip_address(app_name, type)
def create_volume(app_name, volume), do: impl().create_volume(app_name, volume)
def create_machine(app_name, workstation), do: impl().create_machine(app_name, workstation)
def start_machine(app_name, machine_id), do: impl().start_machine(app_name, machine_id)
def wait_for_machine(app_name, machine_id), do: impl().wait_for_machine(app_name, machine_id)
def execute_command_async(app_name, command), do: impl().execute_command_async(app_name, command)
def execute_command_sync(app_name, command), do: impl().execute_command_sync(app_name, command)
def stop_machine(app_name, machine_id), do: impl().stop_machine(app_name, machine_id)
def delete_app(app_name), do: impl().delete_app(app_name)
def impl,
do: Application.get_env(:deskterm, :fly_api, DesktermWeb.Fly)
end
In the lib/deskterm_web/api/fly directory, create a new file named fly.ex and populate it with the below content. This is the production implementation of the Fly.io API behaviour:
defmodule DesktermWeb.Fly do
require Logger
alias DesktermWeb.FlyStream
alias DesktermWeb.FlyUtilities, as: Utilities
alias DesktermWeb.Structs.Workstation.StreamDetails
alias DesktermWeb.VirtualTerminal
@organization "personal"
@behaviour DesktermWeb.FlyAPI
@impl true
def create_app(name) do
case Req.post(
Utilities.client(),
url: "/apps",
json: %{app_name: name, org_slug: @organization}
) do
{:ok, %Req.Response{status: 201}} -> :ok
_ -> :error
end
end
@impl true
def allocate_ip_address(app_name, type) do
case Req.post(
Utilities.client(),
url: "/apps/#{app_name}/ip_assignments",
json: %{type: type}
) do
{:ok, %Req.Response{status: 200}} -> :ok
_ -> :error
end
end
@impl true
def create_volume(app_name, volume) do
case Req.post(
Utilities.client(),
url: "/apps/#{app_name}/volumes",
json: Utilities.get_create_volume_json(app_name, volume)
) do
{:ok, %Req.Response{status: 201, body: %{"id" => volume_id}}} ->
{:ok, volume_id}
_ -> :error
end
end
@impl true
def create_machine(app_name, workstation) do
case Req.post(
Utilities.client(),
url: "/apps/#{app_name}/machines",
json: Utilities.get_create_machine_json(workstation)
) do
{:ok, %Req.Response{status: 200, body: %{"id" => machine_id}}} ->
{:ok, machine_id}
_ -> :error
end
end
@impl true
def start_machine(app_name, machine_id) do
case Req.post(
Utilities.client(),
url: "/apps/#{app_name}/machines/#{machine_id}/start"
) do
{:ok, %Req.Response{status: 200}} -> :ok
_ -> :error
end
end
@impl true
def wait_for_machine(app_name, machine_id) do
case Req.get(
Utilities.client(),
url: "/apps/#{app_name}/machines/#{machine_id}/wait",
receive_timeout: 300_000,
params: [state: "started"]
) do
{:ok, %Req.Response{status: 200, body: %{"ok" => true}}} -> :ok
_ -> :error
end
end
@impl true
def execute_command_async(app_name, command) do
fly = System.find_executable("fly")
if fly do
args = Utilities.get_command_arguments(app_name, command)
task =
Task.async(fn ->
port =
Port.open({:spawn_executable, fly}, [
:binary,
:exit_status,
:stderr_to_stdout,
{:args, args},
{:env, [{~c"FLY_CONFIG_DIR", ~c"/tmp/.fly"}]}
])
stream_details = %StreamDetails{
caller: command.caller,
port: port,
buffer: VirtualTerminal.new()
}
FlyStream.stream_command_output(stream_details)
end)
{:ok, task}
else
Logger.critical("Fly executable not found")
:error
end
end
@impl true
def execute_command_sync(app_name, command) do
case Req.post(
Utilities.client(),
url: "/apps/#{app_name}/machines/#{command.machine_id}/exec",
json: %{cmd: command.command},
receive_timeout: 300_000
) do
{:ok, %Req.Response{status: 200}} -> :ok
_ -> :error
end
end
@impl true
def stop_machine(app_name, machine_id) do
case Req.post(
Utilities.client(),
url: "/apps/#{app_name}/machines/#{machine_id}/stop",
json: %{signal: "SIGTERM", timeout: "60s"}
) do
{:ok, %Req.Response{status: 200}} -> :ok
_ -> :error
end
end
@impl true
def delete_app(app_name) do
case Req.delete(
Utilities.client(),
url: "/apps/#{app_name}"
) do
{:ok, %Req.Response{status: status}} when status in [200, 202, 404] -> :ok
_ -> :error
end
end
end
In the lib/deskterm_web/api/fly directory, create a new file named fly_utilities.ex and populate it with the below content:
defmodule DesktermWeb.FlyUtilities do
@base_url "https://api.machines.dev/v1"
def client do
token = Application.fetch_env!(:deskterm, :fly_api_token)
Req.new(
base_url: @base_url,
headers: [{"authorization", "Bearer #{token}"}]
)
end
def get_create_volume_json(app_name, volume) do
%{
app_name: app_name,
name: "#{volume.region}_#{volume.size_gb}gb",
region: volume.region,
size_gb: volume.size_gb
}
end
def get_create_machine_json(workstation) do
config = get_machine_config(workstation)
%{
config: config,
name: workstation.name,
region: workstation.region
}
end
def get_command_arguments(app_name, command) do
[
"ssh",
"console",
"--app",
app_name,
"--machine",
command.machine_id,
"-C",
command.command |> Enum.map_join(" ", &shell_escape/1),
"-q"
]
end
defp get_machine_config(workstation) do
%{
env: %{
"DOCKER_TLS_CERTDIR" => "",
"DOCKERD_ROOTLESS_ROOTLESSKIT_FLAGS" => "",
"DOCKER_OPTS" => "--userns-remap=default"
},
guest: %{
memory_mb: workstation.machine.ram_mb
},
image: "docker:dind",
services: [
%{
autostart: false,
autostop: "stop",
force_https: true,
internal_port: 80,
min_machines_running: 0,
ports: [
%{port: 443, handlers: ["http", "tls"]}
],
protocol: "tcp"
}
],
auto_stop: %{
signal: "SIGTERM",
time_ms: 3_600_000
},
size: workstation.machine.size
}
|> attach_volume(workstation.machine.volume)
end
defp attach_volume(config, nil), do: config
defp attach_volume(config, volume) do
Map.put(config, :mounts, [
%{volume: volume.id, path: "/var/lib/docker"}
])
end
defp shell_escape(argument) do
if String.match?(argument, ~r/^[a-zA-Z0-9_.\/:-]+$/) do
argument
else
"'" <> String.replace(argument, "'", "'\\''") <> "'"
end
end
end
In the lib/deskterm_web/api/fly directory, create a new file named fly_stream.ex and populate it with the below content:
defmodule DesktermWeb.FlyStream do
require Logger
alias DesktermWeb.VirtualTerminal
@drain_timeout_ms 1
def stream_command_output(stream_details) do
port = stream_details.port
receive do
{^port, {:data, data}} ->
{new_buffer, exit_status} =
drain_port(
port,
VirtualTerminal.write(stream_details.buffer, data)
)
send(
stream_details.caller,
{:stream_update, VirtualTerminal.get_lines(new_buffer)}
)
handle_exit_status(exit_status, %{stream_details | buffer: new_buffer})
{^port, {:exit_status, 0}} ->
handle_success(stream_details)
{^port, {:exit_status, exit_code}} ->
handle_error(exit_code, stream_details)
end
end
defp drain_port(port, buffer) do
receive do
{^port, {:data, data}} ->
drain_port(port, VirtualTerminal.write(buffer, data))
{^port, {:exit_status, code}} ->
{buffer, code}
after
@drain_timeout_ms -> {buffer, nil}
end
end
defp handle_exit_status(exit_status, stream_details) do
case exit_status do
nil ->
stream_command_output(stream_details)
0 ->
send(stream_details.caller, {:stream_complete, :ok})
:ok
code ->
Logger.error("Command failed with exit code: #{code}")
send(stream_details.caller, {:stream_complete, :error})
:error
end
end
defp handle_error(exit_code, stream_details) do
Logger.error("Command failed with exit code: #{exit_code}")
send(
stream_details.caller,
{:stream_update, VirtualTerminal.get_lines(stream_details.buffer)}
)
send(stream_details.caller, {:stream_complete, :error})
:error
end
defp handle_success(stream_details) do
send(
stream_details.caller,
{:stream_update, VirtualTerminal.get_lines(stream_details.buffer)}
)
send(stream_details.caller, {:stream_complete, :ok})
:ok
end
end
In the lib/deskterm_web/api/fly directory, create a new file named virtual_terminal.ex and populate it with the below content:
defmodule DesktermWeb.VirtualTerminal do
alias DesktermWeb.TerminalUtilities, as: Utilities
@type t :: %__MODULE__{
lines: [String.t()],
row: non_neg_integer(),
col: non_neg_integer()
}
defstruct lines: [""], row: 0, col: 0
def new, do: %__MODULE__{}
def write(%__MODULE__{} = buf, <<>>), do: buf
def write(%__MODULE__{} = buf, <<"\e[", rest::binary>>) do
case Utilities.parse_csi(rest) do
{:ok, params, cmd, remainder} ->
write(Utilities.handle_csi(buf, params, cmd), remainder)
:incomplete ->
buf
end
end
def write(%__MODULE__{} = buf, <<"\e", _::binary-size(1), rest::binary>>) do
write(buf, rest)
end
def write(%__MODULE__{} = buf, <<"\e">>), do: buf
def write(%__MODULE__{} = buf, <<"\r", rest::binary>>) do
write(%{buf | col: 0}, rest)
end
def write(%__MODULE__{} = buf, <<"\n", rest::binary>>) do
write(%{buf | row: buf.row + 1, col: 0}, rest)
end
def write(%__MODULE__{} = buf, <<8, rest::binary>>) do
write(%{buf | col: max(buf.col - 1, 0)}, rest)
end
def write(%__MODULE__{} = buf, <<9, rest::binary>>) do
write(%{buf | col: (div(buf.col, 8) + 1) * 8}, rest)
end
def write(%__MODULE__{} = buf, <<c, rest::binary>>) when c < 0x20 do
write(buf, rest)
end
def write(%__MODULE__{} = buf, data) do
text_len = Utilities.printable_run_length(data, 0)
<<text::binary-size(text_len), rest::binary>> = data
write(Utilities.write_text(buf, text), rest)
end
def get_lines(%__MODULE__{lines: lines}) do
lines
|> Enum.reverse()
|> Enum.drop_while(&(&1 == ""))
|> Enum.reverse()
|> then(fn
[] -> [""]
trimmed -> trimmed
end)
end
end
In the lib/deskterm_web/api/fly directory, create a new file named terminal_utilities.ex and populate it with the below content:
defmodule DesktermWeb.TerminalUtilities do
alias DesktermWeb.VirtualTerminal
def handle_csi(buf, "?" <> _, _cmd), do: buf
def handle_csi(buf, params, ?A) do
n = param_int(params, 1)
%{buf | row: max(buf.row - n, 0)}
end
def handle_csi(buf, params, ?B) do
n = param_int(params, 1)
%{buf | row: buf.row + n}
end
def handle_csi(buf, params, ?C) do
n = param_int(params, 1)
%{buf | col: buf.col + n}
end
def handle_csi(buf, params, ?D) do
n = param_int(params, 1)
%{buf | col: max(buf.col - n, 0)}
end
def handle_csi(buf, params, cmd) when cmd in [?H, ?f] do
{r, c} = parse_cursor_pos(params)
%{buf | row: r, col: c}
end
def handle_csi(buf, params, ?K) do
case param_int(params, 0) do
0 -> update_line(buf, &String.slice(&1, 0, buf.col))
1 ->
update_line(
buf,
&(String.duplicate(" ", buf.col) <> String.slice(&1, buf.col..-1//1))
)
2 -> update_line(buf, fn _ -> "" end)
_ -> buf
end
end
def handle_csi(buf, params, ?J) do
case param_int(params, 0) do
2 -> %{buf | lines: [""], row: 0, col: 0}
_ -> buf
end
end
def handle_csi(buf, _params, _cmd), do: buf
def write_text(buf, text) do
buf = update_line(buf, &overwrite_at(&1, buf.col, text))
%{buf | col: buf.col + String.length(text)}
end
defp update_line(%VirtualTerminal{} = buf, fun) do
lines = ensure_row(buf.lines, buf.row)
current = Enum.at(lines, buf.row)
%{buf | lines: List.replace_at(lines, buf.row, fun.(current))}
end
defp overwrite_at(line, col, text) do
text_len = String.length(text)
padded = String.pad_trailing(line, col + text_len)
String.slice(padded, 0, col) <>
text <> String.slice(padded, (col + text_len)..-1//1)
end
def printable_run_length(<<>>, n), do: n
def printable_run_length(<<c, _::binary>>, n) when c < 0x20, do: n
def printable_run_length(<<_c, rest::binary>>, n),
do: printable_run_length(rest, n + 1)
defp ensure_row(lines, row) do
count = length(lines)
if row < count,
do: lines,
else: lines ++ List.duplicate("", row - count + 1)
end
def parse_csi(data), do: parse_csi_acc(data, <<>>)
defp parse_csi_acc(<<>>, _acc), do: :incomplete
defp parse_csi_acc(<<c, rest::binary>>, acc)
when c in ?0..?9 or c == ?; or c == ?? do
parse_csi_acc(rest, <<acc::binary, c>>)
end
defp parse_csi_acc(<<c, rest::binary>>, acc)
when c in ?A..?Z or c in ?a..?z do
{:ok, acc, c, rest}
end
defp parse_csi_acc(<<_c, rest::binary>>, _acc) do
{:ok, "", ??, rest}
end
defp param_int("", default), do: default
defp param_int(params, default) do
case hd(String.split(params, ";")) do
"" -> default
s -> String.to_integer(s)
end
end
defp parse_cursor_pos(params) do
parts = String.split(params, ";")
{csi_coord(Enum.at(parts, 0)), csi_coord(Enum.at(parts, 1))}
end
defp csi_coord(nil), do: 0
defp csi_coord(""), do: 0
defp csi_coord(s), do: max(String.to_integer(s) - 1, 0)
end
In the lib/deskterm_web/api/turnstile directory, create a new file named turnstile_api.ex and populate it with the below content:
defmodule DesktermWeb.TurnstileAPI do
@callback verify(token :: String.t()) :: :ok | :error
def verify(token) do
impl().verify(token)
end
def site_key do
Application.get_env(:deskterm, :turnstile)[:site_key]
end
defp impl,
do:
Application.get_env(
:deskterm,
:turnstile_api,
DesktermWeb.Turnstile
)
end
In the lib/deskterm_web/api/turnstile directory, create a new file named turnstile.ex and populate it with the below content:
defmodule DesktermWeb.Turnstile do
require Logger
@behaviour DesktermWeb.TurnstileAPI
@verify_url "https://challenges.cloudflare.com/turnstile/v0/siteverify"
@impl true
def verify(token) when is_binary(token) and token != "" do
secret_key = Application.get_env(:deskterm, :turnstile)[:secret_key]
case Req.post(@verify_url,
form: [secret: secret_key, response: token]
) do
{:ok, %Req.Response{status: 200, body: %{"success" => true}}} ->
:ok
{:ok,
%Req.Response{
status: 200,
body: %{"success" => false, "error-codes" => error_codes}
}} ->
Logger.warning("Turnstile verification failed: #{inspect(error_codes)}")
:error
_ ->
Logger.error("Failed to verify Turnstile token")
:error
end
end
def verify(_), do: :error
end
In the lib/deskterm_web/structs directory, create a new file named workstation.ex and populate it with the below content:
defmodule DesktermWeb.Structs.Workstation.Volume do
defstruct id: nil, region: nil, size_gb: nil
end
defmodule DesktermWeb.Structs.Workstation do
defstruct machine: nil, name: nil, region: nil
end
defmodule DesktermWeb.Structs.Workstation.Machine do
defstruct id: nil, ram_mb: nil, size: nil, volume: nil
end
defmodule DesktermWeb.Structs.Workstation.Command do
defstruct caller: nil, command: nil, machine_id: nil
end
defmodule DesktermWeb.Structs.Workstation.StreamDetails do
defstruct buffer: nil, caller: nil, port: nil
end
defmodule DesktermWeb.Structs.Workstation.APICallDetails do
defstruct json: nil, method: nil, path: nil
end
In the lib/deskterm_web/live/workstation directory, create a new file named launch_sequence.ex and populate it with the below content:
defmodule DesktermWeb.Workstation.LaunchSequence do
alias DesktermWeb.FlyApp
@free_user [
:initialize_session,
:create_app,
:allocate_ipv4_address,
:allocate_ipv6_address,
:create_volume,
:create_machine,
:wait_for_machine,
:wait_for_docker,
:configure_firewall,
:pull_image,
:start_workstation_container,
:write_proxy_configuration,
:start_proxy_container,
:update_session,
:start_session_countdown
]
@new_user [
:initialize_session,
:create_app,
:allocate_ipv4_address,
:allocate_ipv6_address,
:create_volume,
:create_machine,
:wait_for_machine,
:wait_for_docker,
:configure_firewall,
:pull_image,
:start_workstation_container,
:write_proxy_configuration,
:start_proxy_container,
:update_session
]
@old_user_old_workstation [
:initialize_session,
:start_machine,
:wait_for_machine,
:wait_for_docker,
:configure_firewall,
:resume_workstation_container,
:write_proxy_configuration,
:start_proxy_container,
:update_session
]
@old_user_new_workstation [
:initialize_session,
:start_machine,
:wait_for_machine,
:wait_for_docker,
:configure_firewall,
:pull_image,
:start_workstation_container,
:write_proxy_configuration,
:start_proxy_container,
:update_session
]
def get(profile, ws_name) do
case FlyApp.get_user_type(profile) do
:old_user ->
if Deskterm.Workstation.has_workstation?(profile, ws_name),
do: @old_user_old_workstation,
else: @old_user_new_workstation
user_type ->
launch_sequence(user_type)
end
end
defp launch_sequence(:free_user), do: @free_user
defp launch_sequence(:new_user), do: @new_user
end
In the lib/deskterm_web/live/workstation directory, create a new file named images.ex and populate it with the below content:
defmodule DesktermWeb.Workstation.Images do
@images %{
"Chrome" => "lscr.io/linuxserver/chrome",
"Chromium" => "lscr.io/linuxserver/chromium",
"Edge" => "lscr.io/linuxserver/msedge",
"Firefox" => "lscr.io/linuxserver/firefox",
"LibreWolf" => "lscr.io/linuxserver/librewolf",
"Mullvad" => "lscr.io/linuxserver/mullvad-browser",
"Opera" => "lscr.io/linuxserver/opera",
"Vivaldi" => "lscr.io/linuxserver/vivaldi",
"Altus" => "lscr.io/linuxserver/altus",
"Audacity" => "lscr.io/linuxserver/audacity",
"Discord" => "lscr.io/linuxserver/webcord",
"FileZilla" => "lscr.io/linuxserver/filezilla",
"GIMP" => "lscr.io/linuxserver/gimp",
"IDEA" => "lscr.io/linuxserver/intellij-idea",
"KeePassXC" => "lscr.io/linuxserver/keepassxc",
"Obsidian" => "lscr.io/linuxserver/obsidian",
"Pidgin" => "lscr.io/linuxserver/pidgin",
"PyCharm" => "lscr.io/linuxserver/pycharm",
"Signal" => "lscr.io/linuxserver/signal",
"Steam" => "lscr.io/linuxserver/steam",
"Telegram" => "lscr.io/linuxserver/telegram",
"Thunderbird" => "lscr.io/linuxserver/thunderbird",
"VS Code" => "lscr.io/linuxserver/openvscode-server",
"WeChat" => "lscr.io/linuxserver/weixin"
}
@process_names %{
"Chrome" => "chrome",
"Chromium" => "chromium",
"Edge" => "msedge",
"Firefox" => "firefox",
"LibreWolf" => "librewolf",
"Mullvad" => "mullvad-browser",
"Opera" => "opera",
"Vivaldi" => "vivaldi",
"Altus" => "altus",
"Audacity" => "audacity",
"Discord" => "webcord",
"FileZilla" => "filezilla",
"GIMP" => "gimp",
"IDEA" => "idea",
"KeePassXC" => "keepassxc",
"Obsidian" => "obsidian",
"Pidgin" => "pidgin",
"PyCharm" => "pycharm",
"Signal" => "signal-desktop",
"Steam" => "steam",
"Telegram" => "telegram-desktop",
"Thunderbird" => "thunderbird",
"VS Code" => "openvscode-server",
"WeChat" => "weixin"
}
def get_image(ws_name), do: Map.fetch!(@images, ws_name)
def get_process_name(ws_name), do: Map.get(@process_names, ws_name)
end
Move the file workstations.ex from lib/deskterm_web/live/utilities/ to lib/deskterm_web/live/workstation/.
In the lib/deskterm_web/live/workstation directory, create a new file named workstation.ex and populate it with the below content:
defmodule DesktermWeb.Workstation do
require Logger
alias DesktermWeb.FlyApp
alias DesktermWeb.Structs.Workstation.APICallDetails
alias DesktermWeb.UILog
alias DesktermWeb.Workstation.Utilities
def launch(socket) do
profile = socket.assigns.profile
user_type = FlyApp.get_user_type(profile)
launch_sequence = socket.assigns.launch_sequence
app_name =
socket.assigns.fly_app_name || FlyApp.generate_app_name(user_type)
socket = Phoenix.Component.assign(socket, fly_app_name: app_name)
with :ok <- Deskterm.Profile.lock(profile, user_type),
[first_step | _] <- launch_sequence do
send(self(), first_step)
{:noreply, socket}
else
_ ->
Logger.warning("Failed to lock profile")
Utilities.abort_launch(socket, false)
end
end
def create_volume(socket) do
app_name = socket.assigns.fly_app_name
region = socket.assigns.ws_location
profile = socket.assigns.profile
volume = get_volume(region, profile)
api_call_details = %APICallDetails{
method: "POST",
path: "/v1/apps/#{app_name}/volumes",
json: DesktermWeb.FlyUtilities.get_create_volume_json(app_name, volume)
}
socket = UILog.log_api_call_on_ui(socket, api_call_details)
FlyApp.execute_api_call(socket, fn ->
with {:ok, volume_id} <-
DesktermWeb.FlyAPI.create_volume(app_name, volume),
{:ok, profile} <- ensure_volume(profile, volume_id, region) do
{:ok, %{profile: profile, fly_volume_id: volume_id}}
end
end)
end
def create_machine(socket) do
app_name = socket.assigns.fly_app_name
profile = socket.assigns.profile
workstation = Utilities.get_workstation(socket)
json = DesktermWeb.FlyUtilities.get_create_machine_json(workstation)
socket =
UILog.log_api_call_on_ui(socket, %APICallDetails{
method: "POST",
path: "/v1/apps/#{app_name}/machines",
json: json
})
FlyApp.execute_api_call(socket, fn ->
with {:ok, machine_id} <-
DesktermWeb.FlyAPI.create_machine(app_name, workstation),
{:ok, profile} <- Utilities.ensure_machine_id(profile, machine_id) do
{:ok, %{fly_machine_id: machine_id, profile: profile}}
end
end)
end
def start_machine(socket) do
app_name = socket.assigns.fly_app_name
machine_id = socket.assigns.fly_machine_id
socket =
UILog.log_api_call_on_ui(socket, %APICallDetails{
method: "POST",
path: "/v1/apps/#{app_name}/machines/#{machine_id}/start"
})
FlyApp.execute_api_call(socket, fn ->
DesktermWeb.FlyAPI.start_machine(app_name, machine_id)
end)
end
def wait_for_machine(socket) do
app_name = socket.assigns.fly_app_name
machine_id = socket.assigns.fly_machine_id
socket =
UILog.log_api_call_on_ui(socket, %APICallDetails{
method: "GET",
path: "/v1/apps/#{app_name}/machines/#{machine_id}/wait?state=started"
})
FlyApp.execute_api_call(socket, fn ->
DesktermWeb.FlyAPI.wait_for_machine(app_name, machine_id)
end)
end
defp get_volume(region, profile) do
subscription_type = (profile && profile.subscription_type) || :none
%DesktermWeb.Structs.Workstation.Volume{
region: region,
size_gb: Utilities.get_volume_size(subscription_type)
}
end
defp ensure_volume(profile, volume_id, region) do
case FlyApp.get_user_type(profile) do
:free_user ->
{:ok, profile}
_ ->
Deskterm.Profile.ensure_volume(profile, %{id: volume_id, region: region})
end
end
end
In the lib/deskterm_web/live/workstation directory, create a new file named workstation_utilities.ex and populate it with the below content:
defmodule DesktermWeb.Workstation.Utilities do
import Phoenix.Component
import Phoenix.LiveView
alias Deskterm.Schema.Profile
alias DesktermWeb.FlyApp
def advance_step(socket) do
next_step = socket.assigns.launch_ws_step + 1
launch_sequence = socket.assigns.launch_sequence
next_task = Enum.at(launch_sequence, next_step)
profile = socket.assigns.profile
user_type = FlyApp.get_user_type(profile)
case next_task do
nil -> Deskterm.Profile.unlock(profile, user_type)
next_message -> send(self(), next_message)
end
{:noreply,
assign(socket,
launch_ws_step: next_step,
launch_ws_current_task: next_task
)}
end
def abort_launch(socket, unlock_profile \\ true) do
profile = socket.assigns.profile
user_type = FlyApp.get_user_type(profile)
if unlock_profile do
Deskterm.Profile.unlock(profile, user_type)
end
kill_active_task(socket)
socket =
socket
|> assign(
view: "overview",
active_task: nil,
console_command: nil
)
|> put_flash(:error, "Failed to launch workstation")
{:noreply, Phoenix.LiveView.push_event(socket, "show-toast", %{})}
end
def kill_active_task(socket) do
case socket.assigns[:active_task] do
%Task{} = task -> Task.shutdown(task, :brutal_kill)
_ -> :ok
end
end
def ensure_workstation(profile, workstation) do
case FlyApp.get_user_type(profile) do
:free_user -> :ok
_ -> Deskterm.Workstation.ensure_workstation(profile, workstation)
end
end
def ensure_machine_id(profile, machine_id) do
case FlyApp.get_user_type(profile) do
:free_user -> {:ok, profile}
_ -> Deskterm.Profile.ensure_machine_id(profile, machine_id)
end
end
def get_machine_size(:none), do: "performance-2x"
def get_machine_size(:basic), do: "performance-2x"
def get_machine_size(:pro), do: "performance-4x"
def get_machine_size(:elite), do: "performance-8x"
def get_ram_size(:none), do: 4096
def get_ram_size(:basic), do: 4096
def get_ram_size(:pro), do: 8192
def get_ram_size(:elite), do: 16_384
def get_volume_size(:none), do: 40
def get_volume_size(:basic), do: 40
def get_volume_size(:pro), do: 80
def get_volume_size(:elite), do: 160
def get_workstation(socket) do
profile = socket.assigns.profile
region = socket.assigns.ws_location
ws_name = socket.assigns.ws_name
volume_id = socket.assigns[:fly_volume_id]
%DesktermWeb.Structs.Workstation{
machine: get_machine(profile, volume_id),
name: ws_name,
region: region
}
end
defp get_machine(profile, volume_id) do
subscription_type = (profile && profile.subscription_type) || :none
volume = get_volume(profile) || get_volume(volume_id)
%DesktermWeb.Structs.Workstation.Machine{
ram_mb: get_ram_size(subscription_type),
size: get_machine_size(subscription_type),
volume: volume
}
end
defp get_volume(%Profile{fly_volume_id: id}) when not is_nil(id) do
%DesktermWeb.Structs.Workstation.Volume{id: id}
end
defp get_volume(%Profile{}), do: nil
defp get_volume(nil), do: nil
defp get_volume(id) when is_binary(id),
do: %DesktermWeb.Structs.Workstation.Volume{id: id}
end
In the lib/deskterm_web/live directory, create the below files.
Create a new file named launch_verification.ex and populate it with the below content:
defmodule DesktermWeb.LaunchVerification do
import Phoenix.LiveView
import Phoenix.Component
alias DesktermWeb.FlyApp
alias DesktermWeb.TurnstileAPI
alias DesktermWeb.Workstation.LaunchSequence
@turnstile_error_message "Bot verification failed"
@capacity_error_message "Out of capacity"
@max_free_concurrent_ws 1
def verify(socket, turnstile_token) do
with :ok <- verify_turnstile(turnstile_token),
:ok <- verify_capacity(socket) do
start_launch(socket)
else
{:error, message} ->
socket =
socket
|> assign(loading: nil)
|> put_flash(:error, message)
|> push_event("show-toast", %{})
{:noreply, socket}
end
end
defp verify_turnstile(turnstile_token) do
case TurnstileAPI.verify(turnstile_token) do
:ok -> :ok
:error -> {:error, @turnstile_error_message}
end
end
defp verify_capacity(socket) do
profile = socket.assigns.profile
if FlyApp.get_user_type(profile) == :free_user and
Deskterm.Session.get_active_free_sessions_count() >=
@max_free_concurrent_ws do
{:error, @capacity_error_message}
else
:ok
end
end
defp start_launch(socket) do
profile = socket.assigns.profile
launch_sequence = LaunchSequence.get(profile, socket.assigns.ws_name)
socket =
assign(socket,
view: "loading_workstation",
launch_sequence: launch_sequence,
launch_ws_step: 0,
launch_ws_current_task: hd(launch_sequence),
loading: nil
)
send(self(), :start_workstation)
{:noreply, socket}
end
end
Create a new file named censorship.ex and populate it with the below content:
defmodule DesktermWeb.Censorship do
@secret_fragment_size 6
@redacted "******"
def censor_secrets(socket, text) when is_binary(text) do
[censored] = censor_secrets(socket, [text])
censored
end
def censor_secrets(socket, lines) when is_list(lines) do
supabase_configuration =
Application.get_env(:deskterm, Deskterm.Supabase.Client, [])
endpoint_configuration =
Application.get_env(:deskterm, DesktermWeb.Endpoint, [])
repo_configuration = Application.get_env(:deskterm, Deskterm.Repo, [])
secrets =
[
Application.get_env(:deskterm, :fly_api_token) || "",
socket.assigns[:ws_auth_token] || "",
supabase_configuration[:api_key] || "",
endpoint_configuration[:secret_key_base] || "",
repo_configuration[:url] || ""
]
|> Enum.filter(&(&1 != ""))
fragments = build_fragments(secrets)
Enum.map(lines, fn line ->
Enum.reduce(fragments, line, fn fragment, acc ->
String.replace(acc, fragment, @redacted)
end)
end)
end
defp build_fragments(secrets) do
secrets
|> Enum.filter(&(byte_size(&1) >= @secret_fragment_size))
|> Enum.flat_map(fn secret ->
fragment_count = div(byte_size(secret), @secret_fragment_size)
for i <- 0..(fragment_count - 1) do
binary_part(secret, i * @secret_fragment_size, @secret_fragment_size)
end
end)
|> Enum.uniq()
end
end
Create a new file named ui_log.ex and populate it with the below content:
defmodule DesktermWeb.UILog do
import Phoenix.Component
import Phoenix.LiveView, only: [push_event: 3]
alias DesktermWeb.Censorship
alias DesktermWeb.Structs.Workstation.Command
def log_api_call_on_ui(socket, details) do
log_content =
case details.json do
nil ->
"#{details.method} #{details.path}"
json ->
"#{details.method} #{details.path}\n#{Jason.encode!(json, pretty: true)}"
end
push_event(socket, "log-update", %{log: log_content})
end
def log_command_on_ui(socket, %Command{} = command) do
command =
("$ " <> Enum.join(command.command, " "))
|> then(&Censorship.censor_secrets(socket, &1))
socket
|> assign(console_command: command)
|> push_event("log-update", %{log: command})
end
def log_command_on_ui(socket, command) when is_binary(command) do
command = Censorship.censor_secrets(socket, command)
socket
|> assign(console_command: command)
|> push_event("log-update", %{log: command})
end
end
Create a new file named ip_address.ex and populate it with the below content:
defmodule DesktermWeb.IPAddress do
use Phoenix.LiveView
def get_client_ip_address(socket) do
get_proxied_ip_address(socket) || get_direct_ip_address(socket)
end
defp get_proxied_ip_address(socket) do
with headers when is_list(headers) <- get_connect_info(socket, :x_headers),
{_, value} <- List.keyfind(headers, "x-forwarded-for", 0) do
value |> String.split(",") |> List.first() |> String.trim()
else
_ -> nil
end
end
defp get_direct_ip_address(socket) do
case get_connect_info(socket, :peer_data) do
%{address: address} ->
address |> :inet.ntoa() |> to_string() |> normalize_ip_address()
_ ->
nil
end
end
defp normalize_ip_address("::ffff:" <> ipv4), do: ipv4
defp normalize_ip_address(ip), do: ip
end
Create a new file named fly_app.ex and populate it with the below content:
defmodule DesktermWeb.FlyApp do
import Phoenix.Component
alias Deskterm.Schema.Profile
alias DesktermWeb.Structs.Workstation.APICallDetails
alias DesktermWeb.UILog
def create_app(socket) do
profile = socket.assigns.profile
app_name = socket.assigns.fly_app_name
api_call_details = %APICallDetails{
method: "POST",
path: "/v1/apps",
json: %{app_name: app_name, org_slug: "personal"}
}
socket = UILog.log_api_call_on_ui(socket, api_call_details)
execute_api_call(socket, fn ->
with :ok <- DesktermWeb.FlyAPI.create_app(app_name),
{:ok, profile} <- ensure_app_name(profile, app_name) do
{:ok, %{fly_app_name: app_name, profile: profile}}
end
end)
end
def allocate_ipv4_address(socket) do
app_name = socket.assigns.fly_app_name
api_call_details = %APICallDetails{
method: "POST",
path: "/v1/apps/#{app_name}/ip_assignments",
json: %{type: "shared_v4"}
}
socket = UILog.log_api_call_on_ui(socket, api_call_details)
execute_api_call(socket, fn ->
DesktermWeb.FlyAPI.allocate_ip_address(app_name, "shared_v4")
end)
end
def allocate_ipv6_address(socket) do
app_name = socket.assigns.fly_app_name
api_call_details = %APICallDetails{
method: "POST",
path: "/v1/apps/#{app_name}/ip_assignments",
json: %{type: "v6"}
}
socket = UILog.log_api_call_on_ui(socket, api_call_details)
execute_api_call(socket, fn ->
DesktermWeb.FlyAPI.allocate_ip_address(app_name, "v6")
end)
end
def get_user_type(profile) do
case profile do
nil -> :free_user
%Profile{subscription_type: :none} -> :free_user
%Profile{fly_app_name: nil} -> :new_user
%Profile{fly_app_name: _} -> :old_user
end
end
def execute_api_call(socket, api_call) do
caller = self()
task =
Task.async(fn ->
case api_call.() do
{:ok, assigns} -> send(caller, {:api_call_complete, :ok, assigns})
:ok -> send(caller, {:api_call_complete, :ok, %{}})
_ -> send(caller, {:api_call_complete, :error})
end
end)
{:noreply, assign(socket, active_task: task)}
end
def generate_app_name(user_type) do
prefix = get_app_name_prefix(user_type)
uuid =
:crypto.strong_rand_bytes(16)
|> Base.encode16(case: :lower)
"#{prefix}-#{uuid}"
end
defp get_app_name_prefix(:free_user), do: "free"
defp get_app_name_prefix(:new_user), do: "user"
defp ensure_app_name(profile, app_name) do
case get_user_type(profile) do
:free_user -> {:ok, profile}
_ -> Deskterm.Profile.ensure_app_name(profile, app_name)
end
end
end
Create a new file named session.ex in the lib/deskterm_web/live directory and populate it with the below content:
defmodule DesktermWeb.Session do
use Phoenix.LiveView
require Logger
alias DesktermWeb.FlyApp
alias DesktermWeb.Workstation.Utilities, as: WorkstationUtilities
@session_length_minutes 5
def initialize(socket) do
profile = socket.assigns.profile
user_type = FlyApp.get_user_type(profile)
attributes = %{
user_type: Atom.to_string(user_type),
fly_app_name: socket.assigns.fly_app_name,
ws_name: socket.assigns.ws_name,
ip_address: socket.assigns.client_ip_address,
profile_id: profile && profile.id,
started_at: DateTime.utc_now()
}
case Deskterm.Session.create(attributes) do
{:ok, session} ->
socket = assign(socket, ws_session: session)
WorkstationUtilities.advance_step(socket)
_ ->
Logger.critical("Failed to initialize session")
WorkstationUtilities.abort_launch(socket)
end
end
def update(socket) do
session = socket.assigns.ws_session
attributes = %{
fly_machine_id: socket.assigns.fly_machine_id,
fly_volume_id: socket.assigns.fly_volume_id
}
case Deskterm.Session.update_session(session, attributes) do
{:ok, session} ->
socket = assign(socket, ws_session: session)
WorkstationUtilities.advance_step(socket)
_ ->
Logger.critical("Failed to update session")
WorkstationUtilities.abort_launch(socket)
end
end
def start_countdown(socket) do
timer_reference =
Process.send_after(
self(),
:session_expiration,
:timer.minutes(@session_length_minutes)
)
session_expiration =
DateTime.utc_now()
|> DateTime.add(@session_length_minutes, :minute)
socket =
assign(socket,
session_timer_reference: timer_reference,
session_expiration: session_expiration
)
WorkstationUtilities.advance_step(socket)
end
def on_expiration(socket) do
app_name = socket.assigns.fly_app_name
session = socket.assigns.ws_session
Task.start(fn -> end_session(app_name, session) end)
socket =
socket
|> assign(
view: "overview",
overview_view: "browsers",
active_task: nil,
console_command: nil,
ws_session: nil,
session_timer_reference: nil,
session_expiration: nil
)
|> put_flash(:info, "Free session expired")
{:noreply, Phoenix.LiveView.push_event(socket, "show-toast", %{})}
end
def end_session(app_name, session) do
cond do
app_name && session.user_type == "free_user" ->
delete_fly_app(app_name, session)
app_name && session.fly_machine_id ->
stop_fly_machine(app_name, session)
true ->
Deskterm.Session.end_session(session)
end
end
defp delete_fly_app(app_name, session) do
case DesktermWeb.FlyAPI.delete_app(app_name) do
:ok -> Deskterm.Session.end_session(session)
:error -> Logger.error("Failed to delete Fly.io app: #{app_name}")
end
end
defp stop_fly_machine(app_name, session) do
stop_containers(app_name, session)
case DesktermWeb.FlyAPI.stop_machine(app_name, session.fly_machine_id) do
:ok -> Deskterm.Session.end_session(session)
:error -> Logger.error("Failed to stop Fly.io machine: #{session.fly_machine_id}")
end
end
defp stop_containers(app_name, session) do
stop_containers_command =
DesktermWeb.Container.Utilities.get_stop_containers_cmd(session.ws_name)
command = %DesktermWeb.Structs.Workstation.Command{
machine_id: session.fly_machine_id,
command: "sh -c '#{stop_containers_command}'"
}
DesktermWeb.FlyAPI.execute_command_sync(app_name, command)
end
end
In the lib/deskterm_web/live/container directory, create a new file named container.ex and populate it with the below content:
defmodule DesktermWeb.Container do
import Phoenix.Component
require Logger
alias DesktermWeb.Container.Firewall
alias DesktermWeb.Container.Utilities
alias DesktermWeb.Structs.Workstation.Command
alias DesktermWeb.UILog
alias DesktermWeb.Workstation.Images
alias DesktermWeb.Workstation.Utilities, as: WorkstationUtilities
@wait_for_docker_script """
elapsed=0
timeout=120
until docker info > /dev/null 2>&1; do
sleep 5
elapsed=$((elapsed+5))
if [ $elapsed -ge $timeout ]; then
echo 'Timed out waiting for Docker'
exit 1
fi
done
"""
def wait_for_docker(socket) do
app_name = socket.assigns.fly_app_name
machine_id = socket.assigns.fly_machine_id
command = %Command{
caller: self(),
machine_id: machine_id,
command: ["sh", "-c", @wait_for_docker_script]
}
case DesktermWeb.FlyAPI.execute_command_async(app_name, command) do
{:ok, task} ->
socket = UILog.log_command_on_ui(socket, command)
{:noreply, assign(socket, active_task: task)}
:error ->
Logger.error("Failed to wait for Docker")
WorkstationUtilities.abort_launch(socket)
end
end
def configure_firewall(socket) do
app_name = socket.assigns.fly_app_name
command = Firewall.get_firewall_command(socket)
case DesktermWeb.FlyAPI.execute_command_async(app_name, command) do
{:ok, task} ->
socket = UILog.log_command_on_ui(socket, command)
{:noreply, assign(socket, active_task: task)}
:error ->
Logger.error("Failed to configure firewall")
WorkstationUtilities.abort_launch(socket)
end
end
def pull_image(socket) do
app_name = socket.assigns.fly_app_name
ws_name = socket.assigns.ws_name
image = Images.get_image(ws_name)
command = Utilities.get_docker_pull_command(socket, image)
case DesktermWeb.FlyAPI.execute_command_async(app_name, command) do
{:ok, task} ->
socket = UILog.log_command_on_ui(socket, command)
{:noreply, assign(socket, active_task: task)}
:error ->
Logger.error("Failed to pull image")
WorkstationUtilities.abort_launch(socket)
end
end
def start_workstation_container(socket) do
app_name = socket.assigns.fly_app_name
profile = socket.assigns.profile
workstation = WorkstationUtilities.get_workstation(socket)
command = Utilities.get_run_ws_command(socket)
with :ok <- WorkstationUtilities.ensure_workstation(profile, workstation),
{:ok, task} <-
DesktermWeb.FlyAPI.execute_command_async(app_name, command) do
socket = UILog.log_command_on_ui(socket, command)
{:noreply, assign(socket, active_task: task)}
else
_ ->
Logger.error("Failed to start workstation container")
WorkstationUtilities.abort_launch(socket)
end
end
def resume_workstation_container(socket) do
app_name = socket.assigns.fly_app_name
command = Utilities.get_resume_ws_command(socket)
case DesktermWeb.FlyAPI.execute_command_async(app_name, command) do
{:ok, task} ->
socket = UILog.log_command_on_ui(socket, command)
{:noreply, assign(socket, active_task: task)}
:error ->
Logger.error("Failed to resume workstation container")
WorkstationUtilities.abort_launch(socket)
end
end
def write_proxy_configuration(socket) do
app_name = socket.assigns.fly_app_name
command = Utilities.get_proxy_config_command(socket)
case DesktermWeb.FlyAPI.execute_command_async(app_name, command) do
{:ok, task} ->
socket =
UILog.log_command_on_ui(
socket,
"$ echo '<auth_proxy_config>' | base64 -d > /etc/proxy/nginx.conf"
)
{:noreply, assign(socket, active_task: task)}
:error ->
Logger.error("Failed to write proxy configuration")
WorkstationUtilities.abort_launch(socket)
end
end
def start_proxy_container(socket) do
app_name = socket.assigns.fly_app_name
authentication_token =
:crypto.strong_rand_bytes(64)
|> Base.url_encode64(padding: false)
command = Utilities.get_start_proxy_command(socket, authentication_token)
socket = assign(socket, ws_auth_token: authentication_token)
case DesktermWeb.FlyAPI.execute_command_async(app_name, command) do
{:ok, task} ->
socket = UILog.log_command_on_ui(socket, command)
{:noreply, assign(socket, active_task: task)}
:error ->
Logger.error("Failed to start proxy container")
WorkstationUtilities.abort_launch(socket)
end
end
end
In the lib/deskterm_web/live/container directory, create a new file named container_utilities.ex and populate it with the below content:
defmodule DesktermWeb.Container.Utilities do
require Logger
alias DesktermWeb.Structs.Workstation.Command
alias DesktermWeb.Workstation.Images
def get_docker_pull_command(socket, image) do
machine_id = socket.assigns.fly_machine_id
pull_cmd = "docker pull #{image}"
script =
"apk add -q --no-cache util-linux-misc 2>/dev/null; " <>
"script -qfc \"#{pull_cmd}\" /dev/null 2>&1 || #{pull_cmd}"
%Command{
caller: self(),
machine_id: machine_id,
command: ["sh", "-c", script]
}
end
def get_run_ws_command(socket) do
machine_id = socket.assigns.fly_machine_id
ws_name = socket.assigns.ws_name
image = Images.get_image(ws_name)
%Command{
caller: self(),
machine_id: machine_id,
command: get_ws_docker_command(image, ws_name)
}
end
def get_resume_ws_command(socket) do
machine_id = socket.assigns.fly_machine_id
ws_name = socket.assigns.ws_name
%Command{
caller: self(),
machine_id: machine_id,
command: [
"sh",
"-c",
get_stop_containers_cmd() <> "docker start #{ws_name}"
]
}
end
def get_proxy_config_command(socket) do
machine_id = socket.assigns.fly_machine_id
auth_proxy_config_base64 = auth_proxy_config() |> Base.encode64()
%Command{
caller: self(),
machine_id: machine_id,
command: [
"sh",
"-c",
"mkdir -p /etc/proxy && rm -rf /etc/proxy/nginx.conf && echo '#{auth_proxy_config_base64}' | base64 -d > /etc/proxy/nginx.conf"
]
}
end
def get_start_proxy_command(socket, authentication_token) do
machine_id = socket.assigns.fly_machine_id
%Command{
caller: self(),
machine_id: machine_id,
command: [
"sh",
"-c",
"docker rm -f authentication-proxy 2>/dev/null; " <>
"docker run -d " <>
"-e AUTHENTICATION_TOKEN=#{authentication_token} " <>
"--name authentication-proxy " <>
"--network host " <>
"--restart unless-stopped " <>
"-v /etc/proxy/nginx.conf:/usr/local/openresty/nginx/conf/nginx.conf:ro " <>
"openresty/openresty:alpine"
]
}
end
def get_stop_containers_cmd(ws_name \\ nil) do
graceful_stop =
case Images.get_process_name(ws_name) do
nil ->
""
process_name ->
"docker exec #{ws_name} pkill -INT #{process_name} 2>/dev/null; sleep 5; "
end
graceful_stop <> "docker ps -q | xargs -r docker stop -t 10 2>/dev/null; "
end
defp get_ws_docker_command(image, ws_name) do
[
"sh",
"-c",
get_stop_containers_cmd() <>
"docker run -d " <>
"--cap-drop ALL " <>
"--cap-add CHOWN " <>
"--cap-add DAC_OVERRIDE " <>
"--cap-add FOWNER " <>
"--cap-add FSETID " <>
"--cap-add KILL " <>
"--cap-add NET_BIND_SERVICE " <>
"--cap-add NET_RAW " <>
"--cap-add SETGID " <>
"--cap-add SETPCAP " <>
"--cap-add SETUID " <>
"--cap-add SYS_CHROOT " <>
"-e PGID=1000 " <>
"-e PUID=1000 " <>
"-e TZ=Etc/UTC " <>
"--name #{ws_name} " <>
"-p 127.0.0.1:3000:3000 " <>
"--restart unless-stopped " <>
"--shm-size 1gb " <>
image
]
end
@proxy_configuration_path Path.join(__DIR__, "authentication_proxy.conf")
@external_resource @proxy_configuration_path
@proxy_configuration File.read!(@proxy_configuration_path)
defp auth_proxy_config, do: @proxy_configuration
end
In the lib/deskterm_web/live/container directory, create a new file named authentication_proxy.conf and populate it with the below content:
env AUTHENTICATION_TOKEN;
events {
worker_connections 1024;
}
http {
lua_shared_dict authenticated_ips 1m;
server {
listen 80;
location = /authenticate {
default_type text/html;
content_by_lua_block {
if ngx.req.get_method() ~= "POST" then
ngx.status = 405
ngx.say("Method not allowed")
return
end
ngx.req.read_body()
local args, err = ngx.req.get_post_args()
if not args or not args.token then
ngx.status = 400
ngx.say("Missing token")
return
end
local expected = os.getenv("AUTHENTICATION_TOKEN")
if args.token ~= expected then
ngx.status = 403
ngx.say("Invalid token")
return
end
local client_ip = ngx.var.remote_addr
ngx.shared.authenticated_ips:set(client_ip, true, 576000)
ngx.redirect("/")
}
}
location / {
access_by_lua_block {
local client_ip = ngx.var.remote_addr
if not ngx.shared.authenticated_ips:get(client_ip) then
ngx.status = 401
ngx.say("Unauthorized")
return ngx.exit(401)
end
}
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 576000s;
proxy_send_timeout 576000s;
}
}
}
In the lib/deskterm_web/live/container directory, create a new file named firewall.ex and populate it with the below content:
defmodule DesktermWeb.Container.Firewall do
alias DesktermWeb.FlyApp
alias DesktermWeb.Structs.Workstation.Command
def get_firewall_command(socket) do
machine_id = socket.assigns.fly_machine_id
profile = socket.assigns.profile
user_type = FlyApp.get_user_type(profile)
firewall_rules = get_firewall_rules(user_type)
%Command{
caller: self(),
machine_id: machine_id,
command: ["sh", "-c", firewall_rules]
}
end
defp get_firewall_rules(user_type) do
block_internet_access =
"iptables -C DOCKER-USER -i docker0 -j DROP 2>/dev/null || " <>
"iptables -A DOCKER-USER -i docker0 -j DROP; " <>
"iptables -C DOCKER-USER -i docker0 -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT 2>/dev/null || " <>
"iptables -I DOCKER-USER -i docker0 -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT; "
block_host_access =
"iptables -C INPUT -i docker0 -j DROP 2>/dev/null || " <>
"iptables -A INPUT -i docker0 -j DROP; " <>
"iptables -C INPUT -i docker0 -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT 2>/dev/null || " <>
"iptables -I INPUT -i docker0 -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT"
if user_type == :free_user do
block_internet_access <> block_host_access
else
block_host_access
end
end
end
Update endpoint.ex to include :peer_data and :x_headers in the WebSocket connect info, and to increase the WebSocket timeout:
socket "/live", Phoenix.LiveView.Socket,
websocket: [
connect_info: [:peer_data, :x_headers, session: @session_options],
timeout: 900_000
],
longpoll: [
connect_info: [:peer_data, :x_headers, session: @session_options]
]
Update app_live.ex to add the new assigns in mount/3, handle the start_workstation event, add handle_info callbacks for each launch step, and add a terminate/2 callback:
@impl true
def mount(_params, session, socket) do
client_ip_address = IPAddress.get_client_ip_address(socket)
{:ok,
assign(socket,
active_task: nil,
apps: Workstations.get_apps(),
browsers: Workstations.get_browsers(),
client_ip_address: client_ip_address,
console_command: nil,
email: nil,
fly_app_name: nil,
fly_machine_id: nil,
fly_volume_id: nil,
is_verifying: false,
launch_sequence: [],
launch_ws_step: 0,
launch_ws_current_task: "",
loading: nil,
modal: Authentication.get_modal(),
otp_code: %{},
overview_view: "browsers",
profile: nil,
session: nil,
session_data: session,
session_expiration: nil,
session_timer_reference: nil,
should_show_modal: false,
view: "login",
ws_auth_token: nil,
ws_location: nil,
ws_name: nil,
ws_session: nil
)}
end
Add the start_workstation event handler:
@impl true
def handle_event(
"start_workstation",
%{"ws_location" => ws_location, "turnstile_token" => turnstile_token},
socket
) do
socket =
socket
|> assign(loading: "start_workstation", ws_location: ws_location)
|> Phoenix.LiveView.clear_flash()
send(self(), {:verify_launch, turnstile_token})
{:noreply, socket}
end
Add handle_info callbacks for each step in the launch sequence:
@impl true
def handle_info({:verify_launch, turnstile_token}, socket) do
LaunchVerification.verify(socket, turnstile_token)
end
@impl true
def handle_info(:start_workstation, socket) do
Workstation.launch(socket)
end
@impl true
def handle_info(:create_app, socket), do: FlyApp.create_app(socket)
@impl true
def handle_info(:allocate_ipv4_address, socket), do: FlyApp.allocate_ipv4_address(socket)
@impl true
def handle_info(:allocate_ipv6_address, socket), do: FlyApp.allocate_ipv6_address(socket)
@impl true
def handle_info(:create_volume, socket), do: Workstation.create_volume(socket)
@impl true
def handle_info(:create_machine, socket), do: Workstation.create_machine(socket)
@impl true
def handle_info(:start_machine, socket), do: Workstation.start_machine(socket)
@impl true
def handle_info(:wait_for_machine, socket), do: Workstation.wait_for_machine(socket)
@impl true
def handle_info(:wait_for_docker, socket), do: Container.wait_for_docker(socket)
@impl true
def handle_info(:configure_firewall, socket), do: Container.configure_firewall(socket)
@impl true
def handle_info(:pull_image, socket), do: Container.pull_image(socket)
@impl true
def handle_info(:start_workstation_container, socket), do: Container.start_workstation_container(socket)
@impl true
def handle_info(:resume_workstation_container, socket), do: Container.resume_workstation_container(socket)
@impl true
def handle_info(:write_proxy_configuration, socket), do: Container.write_proxy_configuration(socket)
@impl true
def handle_info(:start_proxy_container, socket), do: Container.start_proxy_container(socket)
@impl true
def handle_info(:initialize_session, socket), do: Session.initialize(socket)
@impl true
def handle_info(:update_session, socket), do: Session.update(socket)
@impl true
def handle_info(:start_session_countdown, socket), do: Session.start_countdown(socket)
@impl true
def handle_info(:session_expiration, socket), do: Session.on_expiration(socket)
Add handlers for stream updates, API call completion, and process monitoring:
@impl true
def handle_info({:stream_update, lines}, socket) do
if socket.assigns.active_task do
lines = Censorship.censor_secrets(socket, lines)
header = socket.assigns[:console_command]
lines = [header, "" | lines]
{:noreply,
push_event(socket, "log-update", %{log: Enum.join(lines, "\n")})}
else
{:noreply, socket}
end
end
@impl true
def handle_info({:api_call_complete, :ok, assigns}, socket) do
socket = socket |> assign(active_task: nil) |> assign(assigns)
WorkstationUtilities.advance_step(socket)
end
@impl true
def handle_info({:api_call_complete, :error}, socket) do
socket = assign(socket, active_task: nil)
WorkstationUtilities.abort_launch(socket)
end
@impl true
def handle_info({:stream_complete, :ok}, socket) do
socket = assign(socket, active_task: nil)
WorkstationUtilities.advance_step(socket)
end
@impl true
def handle_info({:stream_complete, :error}, socket) do
socket = assign(socket, active_task: nil)
WorkstationUtilities.abort_launch(socket)
end
Add a terminate/2 callback to clean up resources when the LiveView process terminates:
@impl true
def terminate(_reason, socket) do
app_name = socket.assigns.fly_app_name
ws_session = socket.assigns.ws_session
session_timer_reference = socket.assigns.session_timer_reference
WorkstationUtilities.kill_active_task(socket)
if session_timer_reference do
Process.cancel_timer(session_timer_reference)
end
if ws_session do
Task.start(fn -> Session.end_session(app_name, ws_session) end)
end
:ok
end
Update app_live.html.heex to conditionally render the loading_workstation view when a workstation is being launched:
<%= if @view == "loading_workstation" do %>
<DesktermWeb.Views.loading_workstation
fly_app_name={@fly_app_name}
launch_ws_current_task={@launch_ws_current_task}
launch_sequence={@launch_sequence}
launch_ws_step={@launch_ws_step}
session_expiration={@session_expiration}
ws_auth_token={@ws_auth_token}
/>
<% end %>
Also pass the loading assign to the overview:
<DesktermWeb.Views.overview
apps={@apps}
browsers={@browsers}
loading={@loading}
overview_view={@overview_view}
/>
Update overview.html.heex to pass the loading assign to the launch view:
<DesktermWeb.Views.launch loading={@loading} />
Update workstations.html.heex to use ws_name instead of workstation as the phx-value parameter:
phx-value-ws_name={workstation}
Update views.ex to add launch_task_label/1 functions that map each launch step atom to a human-readable label:
def launch_task_label(:initialize_session), do: "Initializing Session"
def launch_task_label(:create_app), do: "Creating Fly.io App"
def launch_task_label(:allocate_ipv4_address), do: "Allocating IPv4 Address"
def launch_task_label(:allocate_ipv6_address), do: "Allocating IPv6 Address"
def launch_task_label(:create_volume), do: "Creating Volume"
def launch_task_label(:create_machine), do: "Creating Machine"
def launch_task_label(:start_machine), do: "Starting Machine"
def launch_task_label(:wait_for_machine), do: "Waiting for Machine"
def launch_task_label(:wait_for_docker), do: "Waiting for Docker"
def launch_task_label(:configure_firewall), do: "Configuring Firewall"
def launch_task_label(:pull_image), do: "Pulling Docker Image"
def launch_task_label(:start_workstation_container), do: "Starting Workstation"
def launch_task_label(:resume_workstation_container), do: "Resuming Workstation"
def launch_task_label(:write_proxy_configuration), do: "Configuring Authentication Proxy"
def launch_task_label(:start_proxy_container), do: "Starting Authentication Proxy"
def launch_task_label(:update_session), do: "Updating Session"
def launch_task_label(:start_session_countdown), do: "Starting Session Countdown"
def launch_task_label(nil), do: "Connecting to Workstation"
Move the files authentication.ex, oauth.ex, and otp.ex from lib/deskterm_web/live/utilities/ to lib/deskterm_web/live/authentication/.
Update authentication.ex to replace the nested case statements in exchange_code_for_session/2 with a with block that also fetches the user's profile:
@dialyzer {:no_match, exchange_code_for_session: 2}
def exchange_code_for_session(socket, pkce) do
with {:ok, client} <- supabase_client().get_client(),
{:ok, session} <-
supabase_auth().exchange_code_for_session(
client,
pkce.code,
pkce.code_verifier
),
{:ok, profile} <-
Deskterm.Profile.ensure_profile(session.user.id) do
{:noreply,
socket
|> assign(
session: session,
profile: profile,
fly_app_name: profile.fly_app_name,
fly_machine_id: profile.fly_machine_id,
view: "overview"
)
|> put_flash(:info, @success_message)
|> push_event("show-animation", %{})
|> push_patch(to: @path)}
else
{:error, _reason} ->
Logger.error("Failed to exchange code for session")
redirect_on_error(socket)
end
end
Update otp.ex to replace the case statement in supabase_verify_otp_code/2 with a with block that also fetches the user's profile and assigns it to the socket:
defp supabase_verify_otp_code(socket, authentication_details) do
with {:ok, session} <-
Authentication.supabase_auth().verify_otp(
authentication_details.client,
authentication_details.details
),
{:ok, profile} <-
Deskterm.Profile.ensure_profile(session.user.id) do
socket =
socket
|> assign(
loading: nil,
is_verifying: false,
session: session,
profile: profile,
fly_app_name: profile.fly_app_name,
fly_machine_id: profile.fly_machine_id,
view: "overview"
)
|> put_flash(:info, @verification_success_msg)
|> push_event("show-toast", %{})
|> push_event("show-animation", %{})
{:noreply, socket}
else
{:error, _reason} ->
socket =
socket
|> assign(loading: nil)
|> put_flash(:error, @verification_error_msg)
|> push_event("show-toast", %{})
Logger.error("Failed to verify OTP code")
{:noreply, socket}
end
end
Update [root.html.heex] to add the Cloudflare Turnstile script tag:
<!-- Cloudflare Turnstile -->
<script
src="https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit"
async
defer
>
</script>
Update login.html.heex to change min-h-screen to min-h-dvh on the outer container div:
<div
class="min-h-dvh flex flex-col justify-center px-6 py-12 lg:px-8"
id="login"
phx-hook="ClientError"
>
Update 404.html.heex and 500.html.heex to change min-h-screen to min-h-dvh.
When the initial pricing was drafted, I had still assumed that it is possible to use the shared CPU machines on Fly.io. However after testing workstations running with the shared CPUs, it was decided that the performance with those machines is too poor. Therefore performance machines will be used (with dedicated CPU cores). Naturally with this change the pricing must be adjusted. Update pricing.html.heex with the following changes:
Update the Basic tier price from €5 to €15, hours from 50 hours per month to 40 hours per month, and replace the No persistent storage, Browsers, Apps, Desktops, 15+ workstation locations, and 100+ VPN locations feature items with a single 40GB SSD item:
<div class="text-4xl font-extrabold mt-2">€15</div>
40 hours per month
40GB SSD
Update the Pro tier price from €15 to €45, hours from 100 hours per month to 80 hours per month, and replace the same feature items with 80GB SSD:
<div class="text-4xl font-extrabold mt-2">€45</div>
80 hours per month
80GB SSD
Rename the Pro Max tier to Elite, update price from €45 to €90, hours from 250 hours per month to 160 hours per month, and replace the same feature items with 160GB SSD:
<!-- Elite -->
<h3 class="text-3xl font-bold tracking-wide">Elite</h3>
<div class="text-4xl font-extrabold mt-2">€90</div>
160 hours per month
160GB SSD
In the lib/deskterm_web/views/templates directory, create a new file named loading_workstation.html.heex and populate it with the below content:
<div
id="loading-workstation"
class="flex flex-col items-center justify-center min-h-dvh p-6"
phx-hook="LoadingProgress"
data-step-index={@launch_ws_step}
data-total-steps={length(@launch_sequence)}
>
<!-- Spinning Logo -->
<div class="mb-10">
<img
src="/images/logo.png"
alt="Deskterm"
class="size-25 animate-spin"
/>
</div>
<!-- Progress Text -->
<p class="text-lg font-medium text-white mb-8">
{DesktermWeb.Views.launch_task_label(@launch_ws_current_task)}
</p>
<!-- Progress Bar -->
<div class="w-full max-w-sm">
<div class="relative">
<div class="
h-2
bg-gray-800
rounded-full
border
border-white/10
overflow-hidden">
<div
data-progress-bar
class="h-full rounded-full bg-green-400"
style="width: 0%"
/>
</div>
</div>
<p data-progress-label class="text-xs text-gray-400 text-center mt-3">
0%
</p>
</div>
<!-- Estimated Launch Time -->
<div class="mt-6 text-center">
<p class="text-xs font-medium text-gray-400">Estimated Launch Time</p>
<div class="
inline-grid
grid-cols-[auto_auto]
gap-x-4
gap-y-1
text-xs
text-gray-500">
<span class="text-right">New workstation</span>
<span class="text-left text-gray-400">1 – 3 minutes</span>
<span class="text-right">Existing workstation</span>
<span class="text-left text-gray-400">20 - 60 seconds</span>
</div>
</div>
<!-- UI Log -->
<div
id="ui-log"
phx-hook="UILog"
class="
w-full max-w-4xl mt-8
bg-black/60
backdrop-blur-sm
border border-white/5
rounded-xl
overflow-hidden
shadow-2xl
shadow-green-400/5
"
>
<div class="flex items-center px-4 py-2.5 bg-white/3 border-b border-white/5">
<div class="flex items-center gap-1.5">
<div class="size-1.5 rounded-full bg-green-400/60"></div>
<span class="text-[10px] text-gray-500 font-mono tracking-widest uppercase">
Log
</span>
</div>
</div>
<div
id="log-container"
phx-update="ignore"
class="
px-5 py-4
h-96
overflow-y-auto
scrollbar-none
"
>
<pre
id="log-content"
class="
font-mono
text-xs
text-green-400
leading-tight
whitespace-pre-wrap
break-all
m-0"
></pre>
</div>
</div>
<!-- Invisible Form for Authenticating with Authentication Proxy -->
<%= if @launch_ws_current_task == nil and @ws_auth_token do %>
<form
id="ws-auth-form"
method="POST"
action={"https://#{@fly_app_name}.fly.dev/authenticate"}
target="ws-iframe"
phx-hook="ConnectToWS"
class="hidden"
>
<input type="hidden" name="token" value={@ws_auth_token} />
</form>
<% end %>
<!-- Session Countdown Timer -->
<%= if @session_expiration do %>
<div
id="session-countdown"
phx-hook="Countdown"
data-expiration={DateTime.to_iso8601(@session_expiration)}
class="
fixed
top-4
left-1/2
-translate-x-1/2
z-60
px-4
py-2
bg-gray-900/90
backdrop-blur-sm
border
border-white/10
rounded-lg
shadow-lg"
>
<div class="flex items-center gap-2">
<div class="size-2 rounded-full bg-green-400 animate-pulse"></div>
<span class="text-sm font-mono text-white">
<span data-countdown-label>05:00</span>
</span>
</div>
</div>
<% end %>
</div>
<iframe
id="ws-iframe"
name="ws-iframe"
class="invisible pointer-events-none fixed inset-0 w-full h-full border-none z-50 bg-transparent"
allowfullscreen
>
</iframe>
Update launch.html.heex with the following changes:
Add phx-update="ignore" to the region selector container and change the hidden input name from region to ws_location:
<div id="region-selector" phx-hook="WSLocation" phx-update="ignore">
<label class="block text-xl font-medium text-gray-300 mb-2">
Workstation Location
</label>
<input
type="hidden"
name="ws_location"
id="ws-location-input"
value="automatic"
required
Add the data-auto-location attribute to the automatic location button:
data-value="automatic"
data-auto-location
data-action="select"
Add phx-update="ignore" to the VPN location selector container:
<div id="vpn-location-selector" phx-hook="VPNLocation" phx-update="ignore">
Add a new Bot Verification section after the VPN Location section and before the Start Workstation button:
<!-- Bot Verification -->
<div
class="bg-gray-900/50 rounded-2xl p-4 border border-white/10"
id="turnstile-hook"
phx-hook="Turnstile"
phx-update="ignore"
data-turnstile-site-key={DesktermWeb.TurnstileAPI.site_key()}
>
<label class="block text-xl font-medium text-gray-300 mb-4">
Bot Verification
</label>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4 items-center">
<div data-turnstile-container class="flex justify-center"></div>
<div class="flex justify-center">
<span data-turnstile-status>
<span
data-turnstile-status-verifying
class="flex flex-col items-center gap-2 text-gray-400"
>
<span class="[&>svg]:w-12 [&>svg]:h-12">
<DesktermWeb.AppComponents.loading_spinner
color="white"
id="turnstile-verifying"
/>
</span>
</span>
<span
data-turnstile-status-success
style="display: none"
class="flex flex-col items-center gap-4 text-green-400"
>
<span class="text-5xl">🧑 ✓</span>
<span class="text-sm">You seem to be human</span>
</span>
<span
data-turnstile-status-error
style="display: none"
class="flex flex-col items-center gap-4 text-red-400"
>
<span class="text-5xl">🤖 ✗</span>
<span class="text-sm">You seem to be a robot</span>
</span>
</span>
</div>
</div>
<input
type="hidden"
name="turnstile_token"
value=""
data-turnstile-token
/>
</div>
Update the Start Workstation button to add layout classes, a disabled attribute tied to @loading, and a loading spinner:
<button
type="submit"
class="
relative
flex
w-full
justify-center
items-center
px-4
py-1.5
rounded-md
bg-indigo-600
text-lg
font-semibold
text-white
shadow-xs
hover:bg-indigo-500
focus-visible:outline-2
focus-visible:ring-2
focus-visible:ring-indigo-400
cursor-pointer"
disabled={@loading != nil}
>
Start Workstation
<span
class="absolute right-4 flex items-center"
style="width: 24px; height: 24px;"
>
<%= if @loading == "start_workstation" do %>
<DesktermWeb.AppComponents.loading_spinner
color="white"
id={@loading}
/>
<% end %>
</span>
</button>
In the assets/ts directory, create a new file named turnstile.ts and populate it with the below content:
declare global {
interface Window {
turnstile?: {
render: (
container: string | HTMLElement,
options: Record<string, unknown>
) => string;
reset: (widgetId: string) => void;
remove: (widgetId: string) => void;
};
}
}
export function initializeTurnstile(hook: HTMLElement): Turnstile {
return new Turnstile(hook);
}
enum TurnstileStatus {
Verifying = "verifying",
Error = "error",
Success = "success"
}
export class Turnstile {
private static readonly pollIntervalMS = 100;
private widgetID: string | null = null;
private readonly container: HTMLElement | null;
private readonly tokenInput: HTMLInputElement | null;
private readonly siteKey: string | undefined;
private readonly statusElements: Record<TurnstileStatus, HTMLElement | null>;
constructor(hook: HTMLElement) {
this.container = hook.querySelector<HTMLElement>(
"[data-turnstile-container]"
);
this.tokenInput = hook.querySelector<HTMLInputElement>(
"[data-turnstile-token]"
);
this.siteKey = hook.dataset.turnstileSiteKey;
this.statusElements = {
[TurnstileStatus.Verifying]: hook.querySelector(
"[data-turnstile-status-verifying]"
),
[TurnstileStatus.Success]: hook.querySelector(
"[data-turnstile-status-success]"
),
[TurnstileStatus.Error]: hook.querySelector(
"[data-turnstile-status-error]"
),
};
if (!this.container || !this.tokenInput || !this.siteKey) return;
this.showStatus(TurnstileStatus.Verifying);
this.renderWhenReady();
}
removeWidget() {
if (this.widgetID && window.turnstile) {
window.turnstile.remove(this.widgetID);
this.widgetID = null;
}
}
private showStatus(status: TurnstileStatus) {
for (const key of Object.values(TurnstileStatus)) {
const statusElement = this.statusElements[key];
if (statusElement) {
const isActive = key === status;
statusElement.style.display = isActive ? "" : "none";
}
}
}
private renderWhenReady() {
if (!window.turnstile) {
setTimeout(
() => this.renderWhenReady(),
Turnstile.pollIntervalMS
);
return;
}
if (!this.container) return;
this.widgetID = window.turnstile.render(
this.container,
this.getRenderOptions()
);
}
private getRenderOptions(): Record<string, unknown> {
return {
sitekey: this.siteKey,
theme: "dark",
retry: "auto",
"retry-interval": 5000,
appearance: "always",
callback: (token: string) => {
if (this.tokenInput) this.tokenInput.value = token;
this.showStatus(TurnstileStatus.Success);
},
"error-callback": () => {
if (this.tokenInput) this.tokenInput.value = "";
this.showStatus(TurnstileStatus.Error);
},
"timeout-callback": () => {
if (this.tokenInput) this.tokenInput.value = "";
this.showStatus(TurnstileStatus.Verifying);
this.reset();
},
};
}
private reset() {
if (this.widgetID && window.turnstile) {
window.turnstile.reset(this.widgetID);
}
}
}
In the assets/ts directory, create a new file named ui-log.ts and populate it with the below content:
import type { ViewHookInterface } from "phoenix_live_view";
export function initializeUILog(hook: ViewHookInterface): void {
const logContainer = hook.el.querySelector<HTMLElement>('#log-container');
const logContent = hook.el.querySelector<HTMLElement>('#log-content');
if (!logContainer || !logContent) return;
hook.handleEvent("log-update", ({ log }: { log: string }) => {
logContent.textContent = log;
logContainer.scrollTop = logContainer.scrollHeight;
});
}
In the assets/ts directory, create a new file named countdown.ts and populate it with the below content:
const INTERVAL_MS = 1000;
interface Countdown {
label: HTMLElement;
expirationEpoch: number;
}
export function initializeCountdown(
container: HTMLElement
): number | undefined {
const label = container.querySelector<HTMLElement>("[data-countdown-label]");
const expiration = container.dataset.expiration;
if (!label || !expiration) return undefined;
const expirationEpoch = new Date(expiration).getTime();
const countdown: Countdown = { label, expirationEpoch };
const intervalId = setInterval(
() => updateCountdown(countdown, intervalId),
INTERVAL_MS
);
return intervalId;
}
function updateCountdown(
countdown: Countdown,
intervalId: number
) {
const remainingMS = countdown.expirationEpoch - Date.now();
if (remainingMS <= 0) {
countdown.label.textContent = "00:00";
clearInterval(intervalId);
return;
}
const remainingSeconds = Math.ceil(remainingMS / 1000);
const minutes = String(Math.floor(remainingSeconds / 60)).padStart(2, "0");
const seconds = String(remainingSeconds % 60).padStart(2, "0");
countdown.label.textContent = `${minutes}:${seconds}`;
}
In the assets/ts directory, create a new file named loading-progress.ts and populate it with the below content:
const EASE_FACTOR = 100;
const FRAME_INTERVAL_MS = 5;
const MIN_PROGRESS_UPDATE = 0.001;
const MAX_PROGRESS = 100;
interface ProgressState {
stepIndex: number;
totalSteps: number;
sectionSize: number;
displayProgress: number;
}
interface Progress {
bar: HTMLElement;
label: HTMLElement;
}
export function initializeLoadingProgress(container: HTMLElement): void {
const bar = container.querySelector<HTMLElement>('[data-progress-bar]');
const label = container.querySelector<HTMLElement>('[data-progress-label]');
if (!bar || !label) return;
const totalSteps = getTotalSteps(container);
const state: ProgressState = {
stepIndex: getStepIndex(container),
totalSteps,
sectionSize: 100 / totalSteps,
displayProgress: 0,
};
const progress: Progress = { bar, label };
animate(progress, state);
observeDOMForUpdates(container, state);
}
function getStepIndex(container: HTMLElement): number {
return parseInt(container.dataset.stepIndex as string);
}
function getTotalSteps(container: HTMLElement): number {
return parseInt(container.dataset.totalSteps as string);
}
function animate(
progress: Progress,
state: ProgressState
): void {
const sectionStart = state.stepIndex * state.sectionSize;
const maxTarget = sectionStart + state.sectionSize;
if (state.displayProgress < maxTarget) {
const remaining = maxTarget - state.displayProgress;
const delta = remaining / (state.sectionSize * EASE_FACTOR);
state.displayProgress = Math.min(
state.displayProgress + Math.max(delta, MIN_PROGRESS_UPDATE),
maxTarget
);
}
const progressPercentage = Math.min(state.displayProgress, MAX_PROGRESS);
progress.bar.style.width = `${progressPercentage}%`;
progress.label.textContent = `${Math.round(progressPercentage)}%`;
window.setTimeout(() => animate(progress, state), FRAME_INTERVAL_MS);
}
function observeDOMForUpdates(
container: HTMLElement,
state: ProgressState
): void {
const observer = new MutationObserver(() => {
state.stepIndex = getStepIndex(container);
});
observer.observe(container, {
attributes: true,
attributeFilter: ['data-step-index'],
});
}
In the assets/ts directory, create a new file named workstation-connection.ts and populate it with the below content:
export function connectToWorkstation(form: HTMLElement): void {
const iFrame =
document.getElementById("ws-iframe") as HTMLIFrameElement | null;
if (!(form instanceof HTMLFormElement) || !iFrame) {
return;
}
const visibilityHandler = (): void =>
onVisibilityChange(iFrame, visibilityHandler);
iFrame.addEventListener(
"load",
() => showIFrame(iFrame, visibilityHandler),
{ once: true }
);
document.addEventListener("visibilitychange", visibilityHandler);
form.submit();
}
function showIFrame(iFrame: HTMLIFrameElement, visibilityHandler: () => void): void {
iFrame.classList.remove("invisible", "pointer-events-none");
iFrame.focus();
document.removeEventListener("visibilitychange", visibilityHandler);
}
function onVisibilityChange(iFrame: HTMLIFrameElement, visibilityHandler: () => void): void {
if (document.visibilityState === "visible" && iFrame.contentWindow) {
try {
void iFrame.contentWindow.location.href;
} catch {
showIFrame(iFrame, visibilityHandler);
}
}
}
In the assets/ts/workstations directory, create a new file named latency-measurement.ts and populate it with the below content:
import {
getLatencyColor,
setWSAutoTextAndFlag,
updateButtonLabel,
updateVPNAutoErrorDisplay,
updateVPNAutoLocation,
} from './latency-utilities';
import { ERROR_SVG, HIDDEN } from './constants';
import {
LatencyError,
LatencyResult,
pingLocations,
PingResultCallbacks
} from './ping';
export function determineLatencies(container: HTMLElement): void {
const latencyElements =
container.querySelectorAll<HTMLElement>('[data-latency-id]');
const locationIDs: string[] = [];
latencyElements.forEach((latencyElement) => {
const locationID = latencyElement.dataset.latencyId as string;
if (locationID !== 'automatic') locationIDs.push(locationID);
});
const latencyResultCallbacks =
getLatencyResultCallbacks(container, locationIDs.length);
pingLocations(locationIDs, latencyResultCallbacks);
}
function getLatencyResultCallbacks(
container: HTMLElement,
totalLocations: number
): PingResultCallbacks {
let lowestLatency = Infinity;
let errorCount = 0;
return {
onResult: (latencyResult: LatencyResult) => {
updateWSLatencyOnUI(container, latencyResult);
if (latencyResult.latency < lowestLatency) {
lowestLatency = latencyResult.latency;
updateAutoLocations(container, latencyResult);
}
},
onError: (latencyError: LatencyError) => {
const { locationID } = latencyError;
updateWSErrorDisplay(container, locationID);
errorCount++;
if (errorCount === totalLocations) {
updateWSErrorDisplay(container, 'automatic');
updateVPNAutoErrorDisplay();
}
},
};
}
function updateWSLatencyOnUI(
container: HTMLElement,
latencyResult: LatencyResult): void {
const latencyElements =
container.querySelectorAll<HTMLElement>(
`[data-latency-id="${latencyResult.locationID}"]`
);
latencyElements.forEach((latencyElement) => {
const loadingSpinner =
latencyElement.querySelector<HTMLElement>('.ws-latency-spinner');
const latencyText =
latencyElement.querySelector<HTMLElement>('.ws-latency-text');
if (loadingSpinner) loadingSpinner.classList.add(HIDDEN);
if (latencyText) {
latencyText.textContent = `${latencyResult.latency} ms`;
latencyText.className =
`ws-latency-text text-xs ${getLatencyColor(latencyResult.latency)}`;
latencyText.classList.remove(HIDDEN);
}
});
}
function updateWSErrorDisplay(
container: HTMLElement,
locationId: string
): void {
const latencyElements =
container.querySelectorAll<HTMLElement>(
`[data-latency-id="${locationId}"]`
);
latencyElements.forEach((latencyElement) => {
const loadingSpinner =
latencyElement.querySelector<HTMLElement>('.ws-latency-spinner');
const latencyText =
latencyElement.querySelector<HTMLElement>('.ws-latency-text');
if (loadingSpinner) loadingSpinner.classList.add(HIDDEN);
if (latencyText) {
latencyText.innerHTML = ERROR_SVG;
latencyText.className = 'ws-latency-text text-xs';
latencyText.classList.remove(HIDDEN);
}
if (locationId === 'automatic') {
const button = latencyElement.closest('button');
if (button) updateButtonLabel(button, 'Error');
}
});
}
function updateAutoLocations(
container: HTMLElement,
latencyResult: LatencyResult
): void {
setWSAutoTextAndFlag(container, latencyResult.locationID);
const automaticButton = container
.querySelector<HTMLButtonElement>('button[data-auto-location]');
const isAutoSelected = automaticButton?.classList.contains('selected');
if (automaticButton) {
automaticButton.dataset.value = latencyResult.locationID;
}
const locationInput = container
.querySelector<HTMLInputElement>('#ws-location-input');
if (locationInput && isAutoSelected) {
locationInput.value = latencyResult.locationID;
}
const automaticLatencyResult: LatencyResult = {
locationID: 'automatic',
latency: latencyResult.latency,
};
updateWSLatencyOnUI(container, automaticLatencyResult);
updateVPNAutoLocation(latencyResult.locationID);
}
Update live-view.ts to add the below new Phoenix hooks:
hooks.ConnectToWS = getConnectToWSHook();
hooks.Countdown = getCountdownHook();
hooks.LoadingProgress = getLoadingProgressHook();
hooks.Turnstile = getTurnstileHook();
hooks.UILog = getUILogHook();
Now add the below hook functions to the bottom of the file:
function getConnectToWSHook() {
return {
mounted(this: ViewHookInterface) {
connectToWorkstation(this.el);
}
};
}
function getLoadingProgressHook() {
return {
mounted(this: ViewHookInterface) {
initializeLoadingProgress(this.el);
}
};
}
function getCountdownHook() {
let countdown: number | undefined;
return {
mounted(this: ViewHookInterface) {
countdown = initializeCountdown(this.el);
},
destroyed() {
clearInterval(countdown);
}
};
}
function getUILogHook() {
return {
mounted(this: ViewHookInterface) {
initializeUILog(this);
}
};
}
function getTurnstileHook() {
let turnstile: Turnstile | undefined;
return {
mounted(this: ViewHookInterface) {
turnstile = initializeTurnstile(this.el);
},
destroyed() {
turnstile?.removeWidget();
}
};
}
Phoenix LiveView dispatches phx:page-loading-start and phx:page-loading-stop events to show a progress bar (via topbar) during live navigation. To also show this progress bar when users click buttons or submit forms, use JS.push with the page_loading: true option instead of plain phx-click or phx-submit string events. Note that the phx-page-loading HTML attribute does not work in current versions of Phoenix LiveView.
Update launch.html.heex to use JS.push with page_loading: true on the form submit:
<form id="launch" phx-submit={JS.push("start_workstation", page_loading: true)} class="flex flex-col h-full">
Update workstations.html.heex to use JS.push with page_loading: true on the workstation selection:
phx-click={JS.push("select_workstation", value: %{ws_name: workstation}, page_loading: true)}
Update login.html.heex to use JS.push with page_loading: true on all OAuth buttons and the continue without account form. Apply this to all six OAuth provider buttons (gitlab, github, google, apple, azure, discord):
phx-click={JS.push("login_with_oauth", value: %{provider: "gitlab"}, page_loading: true)}
And on the continue without account form:
<form class="w-full" phx-submit={JS.push("continue_without_account", page_loading: true)}>
Update overview.html.heex to use JS.push with page_loading: true on all sidebar and mobile navigation buttons. Apply this to all ten select_overview_view buttons (browsers, apps, desktops, sessions, upgrade — in both sidebar and mobile navigation):
phx-click={JS.push("select_overview_view", value: %{view: "browsers"}, page_loading: true)}
Update app.css to add a spin animation for the loading logo:
--animate-spin: spin 2.5s linear infinite;
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}









