VPN
Every workstation is connected to a third-party VPN. Users can select their connected VPN location. The VPN allows users to simulate their workstation being in another country/region. Whilst users can already select their workstation location, the VPN location selection offers more countries/regions available for selection. The VPN also gives the user more privacy.
Architecture
VPN Provider
The third-party VPN used in Deskterm is Hide.me. Hide.me is a Malaysian based and independently audited no-logs VPN. In addition to providing privacy benefits to users, the Hide.me VPN is also used to block ads, block trackers and block malware. This is achieved using Hide.me's DNS filtering based SmartGuard.
Deskterm has a business account with Hide.me. After setting up a business account, generate an API key for your Hide.me business account:
Create GitLab CI variables named HIDEME_API_TOKEN and HIDEME_API_EMAIL and populate them with the appropriate values. Be sure to select Masked and Hidden for these CI variables as they contain sensitive information.
VPN Accounts
For free users, a temporary VPN account is created with Hide.me on each workstation launch. When the workstation session ends, the VPN account is deleted.
For paid users, a permanent VPN account is created on the first time they launch a virtual machine. The VPN account remains active until the user deletes their Deskterm account or cancels their Deskterm subscription.
The VPN account creation and deletion is fully automated. The Hide.me API is used for this.
VPN accounts are never shared between users and always unique to a particular user. This also applies to the temporary accounts created for free users.
VPN Connection
The VPN connection is established on the Fly.io machine, not in the workstation container. Since the user only has access to the container and not the hosting Fly.io machine, this means the user cannot bypass the VPN, i.e. all of the user's internet traffic from the container goes through the VPN. Note that the remote control and streaming of the video/audio of the workstation does not go through the VPN. For more details, please see the Split-tunneling section below.
To establish the VPN connection, the Hide.me Linux CLI Tool is used.
Split-tunneling
To prevent the VPN from affecting the user's latency when remote controlling the workstation, the remote control of the workstation does not go through the VPN.
Split-tunneling is used to only route traffic to/from the workstation docker container through the VPN. All other traffic bound only to the host Fly.io machine and not the workstation container, e.g. Fly.io SSH, does not go through the VPN. Whilst the remote control of the container goes into the container, it still bypasses the VPN as the localhost network is used for this traffic and not the docker network. The remote control and video/audio stream are proxied through the authentication proxy. The traffic between the workstation container and the authentication proxy goes via the localhost network and not the docker network, thereby using the split-tunneling to bypass the VPN.
Configuration
Hide.me API Credentials
The Deskterm backend needs Hide.me API credentials to create and delete VPN accounts. Update runtime.exs to configure the Hide.me API token and email. Place the below code just below the Fly.io API token configuration in runtime.exs:
config :deskterm,
:hideme_api_token,
System.get_env("HIDEME_API_TOKEN") ||
raise("""
Environment variable HIDEME_API_TOKEN is missing.
You can find it in your hide.me business account.
""")
config :deskterm,
:hideme_api_email,
System.get_env("HIDEME_API_EMAIL") ||
raise("""
Environment variable HIDEME_API_EMAIL is missing.
This is the email associated with your hide.me business account.
""")
CI/CD Deploy Pipelines
Update deploy-test-environment.yml to pass the HIDEME_API_TOKEN and HIDEME_API_EMAIL 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 SUPABASE_ADMIN_API_KEY=$SUPABASE_ADMIN_API_KEY_TEST --env FLY_API_TOKEN=$FLY_API_TOKEN --env TURNSTILE_SITE_KEY=$TURNSTILE_SITE_KEY --env TURNSTILE_SECRET_KEY=$TURNSTILE_SECRET_KEY --env PIRSCH_ANALYTICS_ACCESS_TOKEN=$PIRSCH_ANALYTICS_TEST_ENVIRONMENT --env HIDEME_API_TOKEN=$HIDEME_API_TOKEN --env HIDEME_API_EMAIL=$HIDEME_API_EMAIL $CI_REGISTRY_IMAGE:test-environment /app/bin/migrate"
- 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 SUPABASE_ADMIN_API_KEY=$SUPABASE_ADMIN_API_KEY_TEST --env FLY_API_TOKEN=$FLY_API_TOKEN --env TURNSTILE_SITE_KEY=$TURNSTILE_SITE_KEY --env TURNSTILE_SECRET_KEY=$TURNSTILE_SECRET_KEY --env PIRSCH_ANALYTICS_ACCESS_TOKEN=$PIRSCH_ANALYTICS_TEST_ENVIRONMENT --env HIDEME_API_TOKEN=$HIDEME_API_TOKEN --env HIDEME_API_EMAIL=$HIDEME_API_EMAIL $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 SUPABASE_ADMIN_API_KEY=$SUPABASE_ADMIN_API_KEY_PRODUCTION --env FLY_API_TOKEN=$FLY_API_TOKEN --env TURNSTILE_SITE_KEY=$TURNSTILE_SITE_KEY --env TURNSTILE_SECRET_KEY=$TURNSTILE_SECRET_KEY --env PIRSCH_ANALYTICS_ACCESS_TOKEN=$PIRSCH_ANALYTICS_PRODUCTION --env HIDEME_API_TOKEN=$HIDEME_API_TOKEN --env HIDEME_API_EMAIL=$HIDEME_API_EMAIL $CI_REGISTRY_IMAGE:production-environment /app/bin/migrate"
- 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 SUPABASE_ADMIN_API_KEY=$SUPABASE_ADMIN_API_KEY_PRODUCTION --env FLY_API_TOKEN=$FLY_API_TOKEN --env TURNSTILE_SITE_KEY=$TURNSTILE_SITE_KEY --env TURNSTILE_SECRET_KEY=$TURNSTILE_SECRET_KEY --env PIRSCH_ANALYTICS_ACCESS_TOKEN=$PIRSCH_ANALYTICS_PRODUCTION --env HIDEME_API_TOKEN=$HIDEME_API_TOKEN --env HIDEME_API_EMAIL=$HIDEME_API_EMAIL $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 VPN:
Automated Tests
Update test_helper.exs to define a mock for the Hide.me API and configure it:
Mox.defmock(DesktermWeb.HideMeAPIMock,
for: DesktermWeb.HideMeAPI
)
Application.put_env(
:deskterm,
:hideme_api,
DesktermWeb.HideMeAPIMock
)
In the test/support directory, create a new file named vpn_atoms.ex and populate it with the below content:
defmodule DesktermWeb.TestVPNAtoms do
@hideme_username "deskterm_goyepfgkrm"
@hideme_password "nediqwqtavngxytbeqnmnddao"
@hideme_uuid "0xsfbxn3-towy-efff-bbve-6hiqm6fnh3lc"
def username, do: @hideme_username
def password, do: @hideme_password
def uuid, do: @hideme_uuid
end
In the test/support directory, create a new file named hideme_utilities.ex and populate it with the below content:
defmodule DesktermWeb.TestHideMeUtilities do
import Mox
alias DesktermWeb.TestVPNAtoms
def when_create_account_fails do
expect(
DesktermWeb.HideMeAPIMock,
:create_account,
fn _username, _password ->
:error
end
)
end
def when_create_account_succeeds(hideme_username \\ TestVPNAtoms.username()) do
expect(
DesktermWeb.HideMeAPIMock,
:create_account,
fn _username, _password ->
{:ok,
%{
hideme_uuid: TestVPNAtoms.uuid(),
hideme_username: hideme_username
}}
end
)
end
def when_delete_account_fails do
expect(
DesktermWeb.HideMeAPIMock,
:delete_account,
fn _uuid ->
:error
end
)
end
def when_delete_account_succeeds do
expect(
DesktermWeb.HideMeAPIMock,
:delete_account,
fn _uuid ->
:ok
end
)
end
end
Update launch_utilities.ex to add VPN-related atoms and pass vpn_location to the start workstation form submission, and add a when_vpn_account_creation_succeeds function:
@hideme_username "deskterm_goyepfgkrm"
@hideme_uuid "0xsfbxn3-towy-efff-bbve-6hiqm6fnh3lc"
def start_workstation(view) do
...
render_submit(view, "start_workstation", %{
ws_location: "arn",
vpn_location: "se",
turnstile_token: @turnstile_token
})
end
def when_vpn_account_creation_succeeds do
expect(
DesktermWeb.HideMeAPIMock,
:create_account,
fn _username, _password ->
{:ok, %{hideme_uuid: @hideme_uuid, hideme_username: @hideme_username}}
end
)
end
Update app_utilities.ex to accept keyword options for when_is_old_user/2 including has_vpn_account, and add helper functions to create VPN accounts and profiles with VPN account IDs:
def when_is_old_user(conn, opts \\ []) do
has_workstation = Keyword.get(opts, :has_workstation, false)
has_vpn_account = Keyword.get(opts, :has_vpn_account, false)
vpn_account_id =
if has_vpn_account do
{:ok, vpn_account} = create_vpn_account()
vpn_account.id
else
nil
end
profile = create_profile(vpn_account_id)
...
end
defp create_vpn_account do
Deskterm.VPNAccount.create(%{
hideme_uuid: TestVPNAtoms.uuid(),
username: TestVPNAtoms.username(),
password: TestVPNAtoms.password()
})
end
defp create_profile(vpn_account_id) do
%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(),
vpn_account_id: vpn_account_id
})
|> Repo.insert!()
end
In the test/deskterm directory, create a new file named vpn_account_test.exs and populate it with the below content:
defmodule Deskterm.VPNAccountTest do
use Deskterm.DataCase, async: true
alias Deskterm.Schema.VPNAccount, as: VPNAccountSchema
alias Deskterm.VPNAccount
alias DesktermWeb.TestVPNAtoms
alias DesktermWeb.VPNAccountDetails
test "inserts vpn account record when account does not exist" do
vpn_account_details = %VPNAccountDetails{
hideme_uuid: TestVPNAtoms.uuid(),
username: TestVPNAtoms.username(),
password: TestVPNAtoms.password()
}
VPNAccount.create(Map.from_struct(vpn_account_details))
vpn_account =
Repo.get_by!(VPNAccountSchema, username: TestVPNAtoms.username())
assert vpn_account.hideme_uuid == TestVPNAtoms.uuid()
assert vpn_account.username == TestVPNAtoms.username()
end
test "does not insert new vpn account record when account already exists" do
vpn_account_details = %VPNAccountDetails{
hideme_uuid: TestVPNAtoms.uuid(),
username: TestVPNAtoms.username(),
password: TestVPNAtoms.password()
}
VPNAccount.create(Map.from_struct(vpn_account_details))
VPNAccount.create(Map.from_struct(vpn_account_details))
assert Repo.aggregate(VPNAccountSchema, :count) == 1
end
test "encrypts password" do
vpn_account_details = %VPNAccountDetails{
hideme_uuid: TestVPNAtoms.uuid(),
username: TestVPNAtoms.username(),
password: TestVPNAtoms.password()
}
VPNAccount.create(Map.from_struct(vpn_account_details))
vpn_account =
Repo.get_by!(VPNAccountSchema, username: TestVPNAtoms.username())
assert vpn_account.encrypted_password != TestVPNAtoms.password()
end
test "records vpn account deletion time" do
vpn_account_details = %VPNAccountDetails{
hideme_uuid: TestVPNAtoms.uuid(),
username: TestVPNAtoms.username(),
password: TestVPNAtoms.password()
}
{:ok, vpn_account} =
VPNAccount.create(Map.from_struct(vpn_account_details))
VPNAccount.delete(vpn_account)
deleted_vpn_account = Repo.get!(VPNAccountSchema, vpn_account.id)
assert deleted_vpn_account.deleted_at != nil
end
end
In the test/deskterm directory, create a new file named vpn_password_encryption_test.exs and populate it with the below content:
defmodule Deskterm.VPNPasswordEncryptionTest do
use ExUnit.Case, async: true
alias Deskterm.VPNPasswordEncryption
alias DesktermWeb.TestVPNAtoms
test "password decrypts to original value" do
password = TestVPNAtoms.password()
encrypted_password = VPNPasswordEncryption.encrypt(password)
assert {:ok, ^password} = VPNPasswordEncryption.decrypt(encrypted_password)
end
end
In the test/deskterm_web/live directory, create a new file named vpn_test.exs and populate it with the below content:
defmodule DesktermWeb.VPNTest do
use DesktermWeb.ConnCase
import Mox
alias DesktermWeb.TestAppUtilities
alias DesktermWeb.TestFlyUtilities
alias DesktermWeb.TestHideMeUtilities
alias DesktermWeb.TestLaunchUtilities
setup :verify_on_exit!
@invalid_hideme_username "deskterm_goyepf1%[<"
test "aborts launch when creating VPN account fails", %{
conn: conn
} do
TestFlyUtilities.when_fly_app_is_created()
TestFlyUtilities.when_machine_is_created()
TestFlyUtilities.when_execute_command_completes(6)
TestHideMeUtilities.when_create_account_fails()
TestFlyUtilities.when_delete_app_succeeds()
view = TestAppUtilities.when_is_unauthenticated(conn)
TestLaunchUtilities.start_workstation(view)
TestLaunchUtilities.assert_launch_aborted(view)
end
test "aborts launch when hideme username contains invalid characters", %{
conn: conn
} do
TestFlyUtilities.when_fly_app_is_created()
TestFlyUtilities.when_machine_is_created()
TestFlyUtilities.when_execute_command_completes(6)
TestHideMeUtilities.when_create_account_succeeds(@invalid_hideme_username)
TestFlyUtilities.when_delete_app_succeeds()
view = TestAppUtilities.when_is_unauthenticated(conn)
TestLaunchUtilities.start_workstation(view)
TestLaunchUtilities.assert_launch_aborted(view)
end
test "does not abort launch when creating VPN account succeeds", %{
conn: conn
} do
TestFlyUtilities.when_fly_app_is_created()
TestFlyUtilities.when_machine_is_created()
TestFlyUtilities.when_execute_command_completes(6)
TestHideMeUtilities.when_create_account_succeeds()
TestFlyUtilities.when_execute_command_succeeds()
view = TestAppUtilities.when_is_unauthenticated(conn)
TestLaunchUtilities.start_workstation(view)
TestLaunchUtilities.assert_launch_not_aborted(view)
end
test "creates VPN account when old user is missing VPN account", %{
conn: conn
} do
TestFlyUtilities.when_start_machine_succeeds()
TestFlyUtilities.when_wait_for_machine_succeeds()
TestFlyUtilities.when_execute_command_completes(6)
TestHideMeUtilities.when_create_account_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 "does not abort launch when old user already has VPN account", %{
conn: conn
} do
TestFlyUtilities.when_start_machine_succeeds()
TestFlyUtilities.when_wait_for_machine_succeeds()
TestFlyUtilities.when_execute_command_completes(6)
TestFlyUtilities.when_execute_command_succeeds()
view = TestAppUtilities.when_is_old_user(conn, has_vpn_account: true)
TestLaunchUtilities.start_workstation(view)
TestLaunchUtilities.assert_launch_not_aborted(view)
end
test "aborts launch when VPN connection fails", %{
conn: conn
} do
TestFlyUtilities.when_fly_app_is_created()
TestFlyUtilities.when_machine_is_created()
TestFlyUtilities.when_execute_command_completes(6)
TestHideMeUtilities.when_create_account_succeeds()
TestFlyUtilities.when_execute_command_fails()
TestHideMeUtilities.when_delete_account_succeeds()
TestFlyUtilities.when_delete_app_succeeds()
view = TestAppUtilities.when_is_unauthenticated(conn)
TestLaunchUtilities.start_workstation(view)
TestLaunchUtilities.assert_launch_aborted(view)
end
test "does not abort launch when VPN connection succeeds", %{
conn: conn
} do
TestFlyUtilities.when_fly_app_is_created()
TestFlyUtilities.when_machine_is_created()
TestFlyUtilities.when_execute_command_completes(6)
TestHideMeUtilities.when_create_account_succeeds()
TestFlyUtilities.when_execute_command_succeeds()
view = TestAppUtilities.when_is_unauthenticated(conn)
TestLaunchUtilities.start_workstation(view)
TestLaunchUtilities.assert_launch_not_aborted(view)
end
end
Update session_cleanup_test.exs to add aliases for Deskterm.VPNAccount, DesktermWeb.TestHideMeUtilities, and DesktermWeb.TestVPNAtoms. Add VPN account creation to the get_session helper, add HideMe mock expectations to existing tests, and add a new test case for VPN account deletion failure:
test "does not end session for free user when VPN account deletion fails" do
TestFlyUtilities.when_delete_app_succeeds()
TestHideMeUtilities.when_delete_account_fails()
{:ok, session} = get_session("free_user")
SessionCleanup.run()
session = Repo.get!(SessionSchema, session.id)
assert session.ended_at == nil
end
Update session_test.exs (in test/deskterm_web/live) to add aliases for Deskterm.VPNAccount, DesktermWeb.TestHideMeUtilities, and DesktermWeb.TestVPNAtoms. Add VPN account creation to the get_session helper, add HideMe mock expectations to existing tests, and add a new test case for VPN account deletion failure:
test "does not end session for free user when VPN account deletion fails" do
TestFlyUtilities.when_delete_app_succeeds()
TestHideMeUtilities.when_delete_account_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
Update launch_verification_test.exs to add a new test case for invalid VPN location:
test "aborts launch when VPN location is invalid", %{conn: conn} do
view = TestAppUtilities.when_is_unauthenticated(conn)
render_click(view, "select_workstation", %{ws_name: "Chrome"})
render_submit(view, "start_workstation", %{
ws_location: "arn",
vpn_location: "invalid",
turnstile_token: TestLaunchUtilities.turnstile_token()
})
TestLaunchUtilities.assert_launch_aborted(view)
end
Update new_user_test.exs to add a call to TestLaunchUtilities.when_vpn_account_creation_succeeds() and increase the expected execute command completions from 6 to 7.
Update container_test.exs to use the keyword option syntax has_workstation: true when calling TestAppUtilities.when_is_old_user/2.
Update fly_app_test.exs to use the keyword option syntax has_workstation: true when calling TestAppUtilities.when_is_old_user/2.
Production Code
Create the below database migration in the priv/repo/migrations directory:
defmodule Deskterm.Repo.Migrations.CreateVpnAccounts do
use Ecto.Migration
def change do
create table(:vpn_accounts) do
add :hideme_uuid, :string, null: false
add :username, :string, null: false
add :encrypted_password, :binary, null: false
add :deleted_at, :utc_datetime
timestamps()
end
create index(:vpn_accounts, [:hideme_uuid], unique: true)
create index(:vpn_accounts, [:username], unique: true)
execute "ALTER TABLE vpn_accounts ENABLE ROW LEVEL SECURITY",
"ALTER TABLE vpn_accounts DISABLE ROW LEVEL SECURITY"
alter table(:sessions) do
add :vpn_location, :string
add :vpn_account_id, references(:vpn_accounts, on_delete: :nilify_all)
end
create index(:sessions, [:vpn_account_id])
alter table(:profiles) do
add :vpn_account_id, references(:vpn_accounts, on_delete: :nilify_all)
end
create index(:profiles, [:vpn_account_id])
end
end
In the lib/deskterm/schemas directory, create a new file named vpn_account.ex and populate it with the below content:
defmodule Deskterm.Schema.VPNAccount do
use Ecto.Schema
import Ecto.Changeset
schema "vpn_accounts" do
field :hideme_uuid, :string
field :username, :string
field :encrypted_password, :binary
field :deleted_at, :utc_datetime
timestamps()
end
def changeset(vpn_account, attributes) do
vpn_account
|> cast(attributes, [
:hideme_uuid,
:username,
:encrypted_password,
:deleted_at
])
|> validate_required([:hideme_uuid, :username, :encrypted_password])
|> unique_constraint(:hideme_uuid)
|> unique_constraint(:username)
end
end
Update profile.ex (schema) to add a belongs_to association for :vpn_account and add :vpn_account_id to the cast fields:
belongs_to :vpn_account, Deskterm.Schema.VPNAccount
Update session.ex (schema) to add vpn_location and vpn_account_id fields, a belongs_to association for :vpn_account, add the new fields to the cast list, and add a validation for vpn_location:
field :vpn_location, :string
belongs_to :vpn_account, Deskterm.Schema.VPNAccount
|> validate_inclusion(:vpn_location, VPNLocations.valid_locations())
In the lib/deskterm/vpn directory, create a new file named vpn_account.ex and populate it with the below content:
defmodule Deskterm.VPNAccount do
alias Deskterm.Repo
alias Deskterm.Schema.VPNAccount
alias Deskterm.VPNPasswordEncryption
def create(attributes) do
password = Map.fetch!(attributes, :password)
encrypted_password = VPNPasswordEncryption.encrypt(password)
attrs =
attributes
|> Map.delete(:password)
|> Map.put(:encrypted_password, encrypted_password)
%VPNAccount{}
|> VPNAccount.changeset(attrs)
|> Repo.insert()
end
def get(nil), do: nil
def get(vpn_account_id) do
Repo.get(VPNAccount, vpn_account_id)
end
def get_decrypted_password(encrypted_password) do
VPNPasswordEncryption.decrypt(encrypted_password)
end
def delete(vpn_account) do
vpn_account
|> VPNAccount.changeset(%{deleted_at: DateTime.utc_now()})
|> Repo.update()
end
end
In the lib/deskterm/vpn directory, create a new file named vpn_password_encryption.ex and populate it with the below content:
defmodule Deskterm.VPNPasswordEncryption do
@aad "deskterm_vpn_password"
def encrypt(password) do
key = get_key()
iv = :crypto.strong_rand_bytes(12)
{encrypted_password, tag} =
:crypto.crypto_one_time_aead(:aes_256_gcm, key, iv, password, @aad, true)
iv <> tag <> encrypted_password
end
def decrypt(<<iv::binary-12, tag::binary-16, encrypted_password::binary>>) do
key = get_key()
case :crypto.crypto_one_time_aead(
:aes_256_gcm,
key,
iv,
encrypted_password,
@aad,
tag,
false
) do
decrypted_password when is_binary(decrypted_password) ->
{:ok, decrypted_password}
_ ->
:error
end
end
def decrypt(_), do: :error
defp get_key do
secret_key_base =
Application.get_env(:deskterm, DesktermWeb.Endpoint)[:secret_key_base]
:crypto.hash(:sha256, "vpn_encryption:" <> secret_key_base)
end
end
In the lib/deskterm/vpn directory, create a new file named vpn_locations.ex and populate it with the below content. This file replaces the previous lib/deskterm/vpn_locations.ex which should be deleted.
defmodule Deskterm.VPNLocations do
@type location :: %{
id: String.t(),
name: String.t()
}
@type country :: %{
id: String.t(),
name: String.t(),
flag: String.t(),
locations: [location()]
}
@type region :: %{
id: String.t(),
name: String.t(),
icon: String.t(),
countries: [country()]
}
@regions [
%{
id: "americas",
name: "AMER",
icon: "🌎",
countries: [
%{
id: "ar",
name: "Argentina",
flag: "🇦🇷",
locations: [
%{id: "ar", name: "Argentina"}
]
},
%{
id: "br",
name: "Brazil",
flag: "🇧🇷",
locations: [
%{id: "br", name: "Brazil"}
]
},
%{
id: "ca",
name: "Canada",
flag: "🇨🇦",
locations: [
%{id: "calgary", name: "Calgary"},
%{id: "montreal", name: "Montreal"},
%{id: "toronto", name: "Toronto"},
%{id: "vancouver", name: "Vancouver"}
]
},
%{
id: "cl",
name: "Chile",
flag: "🇨🇱",
locations: [
%{id: "cl", name: "Chile"}
]
},
%{
id: "co",
name: "Colombia",
flag: "🇨🇴",
locations: [
%{id: "co", name: "Colombia"}
]
},
%{
id: "mx",
name: "Mexico",
flag: "🇲🇽",
locations: [
%{id: "mx", name: "Mexico"}
]
},
%{
id: "pe",
name: "Peru",
flag: "🇵🇪",
locations: [
%{id: "pe", name: "Peru"}
]
},
%{
id: "us",
name: "United States",
flag: "🇺🇸",
locations: [
%{id: "atlanta", name: "Atlanta, GA"},
%{id: "boston", name: "Boston, MA"},
%{id: "dallas", name: "Dallas, TX"},
%{id: "denver", name: "Denver, CO"},
%{id: "detroit", name: "Detroit, MI"},
%{id: "houston", name: "Houston, TX"},
%{id: "illinois", name: "Illinois"},
%{id: "kansascity", name: "Kansas City, MO"},
%{id: "losangeles", name: "Los Angeles, CA"},
%{id: "miami", name: "Miami, FL"},
%{id: "newyorkcity", name: "New York, NY"},
%{id: "phoenix", name: "Phoenix, AZ"},
%{id: "saltlakecity", name: "Salt Lake City, UT"},
%{id: "sanjose", name: "San Jose, CA"},
%{id: "seattle", name: "Seattle, WA"},
%{id: "washington-dc", name: "Washington, DC"}
]
}
]
},
%{
id: "emea",
name: "EMEA",
icon: "🌍",
countries: [
%{
id: "al",
name: "Albania",
flag: "🇦🇱",
locations: [
%{id: "al", name: "Albania"}
]
},
%{
id: "at",
name: "Austria",
flag: "🇦🇹",
locations: [
%{id: "at", name: "Austria"}
]
},
%{
id: "be",
name: "Belgium",
flag: "🇧🇪",
locations: [
%{id: "be", name: "Belgium"}
]
},
%{
id: "bg",
name: "Bulgaria",
flag: "🇧🇬",
locations: [
%{id: "bg", name: "Bulgaria"}
]
},
%{
id: "hr",
name: "Croatia",
flag: "🇭🇷",
locations: [
%{id: "hr", name: "Croatia"}
]
},
%{
id: "cz",
name: "Czech Republic",
flag: "🇨🇿",
locations: [
%{id: "cz", name: "Czech Republic"}
]
},
%{
id: "dk",
name: "Denmark",
flag: "🇩🇰",
locations: [
%{id: "dk", name: "Denmark"}
]
},
%{
id: "eg",
name: "Egypt",
flag: "🇪🇬",
locations: [
%{id: "cairo", name: "Egypt"}
]
},
%{
id: "fi",
name: "Finland",
flag: "🇫🇮",
locations: [
%{id: "fi", name: "Finland"}
]
},
%{
id: "fr",
name: "France",
flag: "🇫🇷",
locations: [
%{id: "bordeaux", name: "Bordeaux"},
%{id: "marseille", name: "Marseille"},
%{id: "paris", name: "Paris"}
]
},
%{
id: "de",
name: "Germany",
flag: "🇩🇪",
locations: [
%{id: "berlin", name: "Berlin"},
%{id: "frankfurt", name: "Frankfurt"}
]
},
%{
id: "gr",
name: "Greece",
flag: "🇬🇷",
locations: [
%{id: "gr", name: "Greece"}
]
},
%{
id: "hu",
name: "Hungary",
flag: "🇭🇺",
locations: [
%{id: "hu", name: "Hungary"}
]
},
%{
id: "is",
name: "Iceland",
flag: "🇮🇸",
locations: [
%{id: "is", name: "Iceland"}
]
},
%{
id: "ie",
name: "Ireland",
flag: "🇮🇪",
locations: [
%{id: "ireland", name: "Ireland"}
]
},
%{
id: "it",
name: "Italy",
flag: "🇮🇹",
locations: [
%{id: "milan", name: "Milan"},
%{id: "palermo", name: "Palermo"}
]
},
%{
id: "lt",
name: "Lithuania",
flag: "🇱🇹",
locations: [
%{id: "lt", name: "Lithuania"}
]
},
%{
id: "lu",
name: "Luxembourg",
flag: "🇱🇺",
locations: [
%{id: "lu", name: "Luxembourg"}
]
},
%{
id: "md",
name: "Moldova",
flag: "🇲🇩",
locations: [
%{id: "md", name: "Moldova"}
]
},
%{
id: "ma",
name: "Morocco",
flag: "🇲🇦",
locations: [
%{id: "ma", name: "Morocco"}
]
},
%{
id: "nl",
name: "Netherlands",
flag: "🇳🇱",
locations: [
%{id: "amsterdam", name: "Amsterdam"}
]
},
%{
id: "no",
name: "Norway",
flag: "🇳🇴",
locations: [
%{id: "no", name: "Norway"}
]
},
%{
id: "pl",
name: "Poland",
flag: "🇵🇱",
locations: [
%{id: "pl", name: "Poland"}
]
},
%{
id: "pt",
name: "Portugal",
flag: "🇵🇹",
locations: [
%{id: "pt", name: "Portugal"}
]
},
%{
id: "ro",
name: "Romania",
flag: "🇷🇴",
locations: [
%{id: "ro", name: "Romania"}
]
},
%{
id: "rs",
name: "Serbia",
flag: "🇷🇸",
locations: [
%{id: "rs", name: "Serbia"}
]
},
%{
id: "sk",
name: "Slovakia",
flag: "🇸🇰",
locations: [
%{id: "sk", name: "Slovakia"}
]
},
%{
id: "za",
name: "South Africa",
flag: "🇿🇦",
locations: [
%{id: "johannesburg", name: "South Africa"}
]
},
%{
id: "es",
name: "Spain",
flag: "🇪🇸",
locations: [
%{id: "es", name: "Spain"}
]
},
%{
id: "se",
name: "Sweden",
flag: "🇸🇪",
locations: [
%{id: "se", name: "Sweden"}
]
},
%{
id: "ch",
name: "Switzerland",
flag: "🇨🇭",
locations: [
%{id: "zug", name: "Zug"},
%{id: "zurich", name: "Zurich"}
]
},
%{
id: "tr",
name: "Turkey",
flag: "🇹🇷",
locations: [
%{id: "tr", name: "Turkey"}
]
},
%{
id: "ua",
name: "Ukraine",
flag: "🇺🇦",
locations: [
%{id: "ua", name: "Ukraine"}
]
},
%{
id: "ae",
name: "United Arab Emirates",
flag: "🇦🇪",
locations: [
%{id: "ae", name: "United Arab Emirates"}
]
},
%{
id: "uk",
name: "United Kingdom",
flag: "🇬🇧",
locations: [
%{id: "london", name: "London"},
%{id: "manchester", name: "Manchester"}
]
}
]
},
%{
id: "apac",
name: "APAC",
icon: "🌏",
countries: [
%{
id: "au",
name: "Australia",
flag: "🇦🇺",
locations: [
%{id: "melbourne", name: "Melbourne"},
%{id: "perth", name: "Perth"},
%{id: "sydney", name: "Sydney"}
]
},
%{
id: "bd",
name: "Bangladesh",
flag: "🇧🇩",
locations: [
%{id: "bd", name: "Bangladesh"}
]
},
%{
id: "kh",
name: "Cambodia",
flag: "🇰🇭",
locations: [
%{id: "phnompenh", name: "Cambodia"}
]
},
%{
id: "hk",
name: "Hong Kong",
flag: "🇭🇰",
locations: [
%{id: "hk", name: "Hong Kong"}
]
},
%{
id: "id",
name: "Indonesia",
flag: "🇮🇩",
locations: [
%{id: "id", name: "Indonesia"}
]
},
%{
id: "jp",
name: "Japan",
flag: "🇯🇵",
locations: [
%{id: "jp", name: "Japan"}
]
},
%{
id: "my",
name: "Malaysia",
flag: "🇲🇾",
locations: [
%{id: "my", name: "Malaysia"}
]
},
%{
id: "nz",
name: "New Zealand",
flag: "🇳🇿",
locations: [
%{id: "auckland", name: "New Zealand"}
]
},
%{
id: "ph",
name: "Philippines",
flag: "🇵🇭",
locations: [
%{id: "manila", name: "Philippines"}
]
},
%{
id: "sg",
name: "Singapore",
flag: "🇸🇬",
locations: [
%{id: "sg", name: "Singapore"}
]
},
%{
id: "kr",
name: "South Korea",
flag: "🇰🇷",
locations: [
%{id: "kr", name: "South Korea"}
]
},
%{
id: "tw",
name: "Taiwan",
flag: "🇹🇼",
locations: [
%{id: "taipei", name: "Taiwan"}
]
},
%{
id: "th",
name: "Thailand",
flag: "🇹🇭",
locations: [
%{id: "th", name: "Thailand"}
]
},
%{
id: "vn",
name: "Vietnam",
flag: "🇻🇳",
locations: [
%{id: "vn", name: "Vietnam"}
]
}
]
}
]
@location_ids Enum.flat_map(@regions, fn region ->
Enum.flat_map(region.countries, fn country ->
Enum.map(country.locations, & &1.id)
end)
end)
def regions, do: @regions
def valid_locations, do: @location_ids
def valid_location?(location_id), do: location_id in @location_ids
end
In the lib/deskterm_web/api/hide_me directory, create a new file named hide_me_api.ex and populate it with the below content:
defmodule DesktermWeb.HideMeAPI do
@moduledoc """
HideMe API interface. Allows for mocking in tests.
"""
@callback create_account(username :: String.t(), password :: String.t()) ::
{:ok, %{hideme_uuid: String.t(), hideme_username: String.t()}}
| :error
@callback delete_account(hideme_uuid :: String.t()) ::
:ok | :error
def create_account(username, password) do
impl().create_account(username, password)
end
def delete_account(hideme_uuid) do
impl().delete_account(hideme_uuid)
end
defp impl,
do: Application.get_env(:deskterm, :hideme_api, DesktermWeb.HideMe)
end
In the lib/deskterm_web/api/hide_me directory, create a new file named hide_me.ex and populate it with the below content. This is the production implementation of the Hide.me API behaviour:
defmodule DesktermWeb.HideMe do
@moduledoc """
Production implementation of the HideMe API interface.
"""
require Logger
@base_url "https://business.hide.me/api/v1"
@namespace_id "deskterm"
@product_id "12"
@behaviour DesktermWeb.HideMeAPI
@impl true
def create_account(username, password) do
body = get_body(username, password)
case Req.post(
client(),
url: "/wholesales/wholesale_accounts",
json: body
) do
{:ok, %Req.Response{status: status, body: body}} when status == 200 ->
{:ok,
%{hideme_uuid: body["hideme_uuid"], hideme_username: body["username"]}}
{:ok, %Req.Response{status: status, body: body}} ->
Logger.error("""
Failed to create VPN account
HTTP code: #{status}
HTTP body: #{inspect(body)}
""")
:error
_ ->
Logger.error("Failed to create VPN account")
:error
end
end
@impl true
def delete_account(hideme_uuid) do
case Req.delete(client(),
url: "/wholesales/wholesale_accounts/#{hideme_uuid}"
) do
{:ok, %Req.Response{status: status}} when status == 202 ->
:ok
{:ok, %Req.Response{status: status, body: body}} ->
Logger.error("""
Failed to delete VPN account
HTTP code: #{status}
HTTP body: #{inspect(body)}
""")
:error
_ ->
Logger.error("Failed to delete VPN account")
:error
end
end
defp client do
Req.new(
base_url: @base_url,
headers: [
{"X-Token", get_api_token()},
{"X-Email", get_api_email()},
{"Content-Type", "application/json"},
{"Accept", "application/json"}
]
)
end
defp get_body(username, password) do
%{
wholesale_account: %{
name: String.upcase(username),
username: username,
product_id: @product_id,
namespace_id: @namespace_id,
password: password
}
}
end
defp get_api_token, do: Application.get_env(:deskterm, :hideme_api_token)
defp get_api_email, do: Application.get_env(:deskterm, :hideme_api_email)
end
In the lib/deskterm_web/structs directory, create a new file named vpn.ex and populate it with the below content:
defmodule DesktermWeb.VPNAccountDetails do
defstruct hideme_uuid: nil, username: nil, password: nil
end
defmodule DesktermWeb.VPNConnectionDetails do
defstruct username: nil, password: nil, location: nil
end
In the lib/deskterm_web/live/vpn directory, create a new file named vpn.ex and populate it with the below content:
defmodule DesktermWeb.VPN do
import Phoenix.Component
require Logger
alias Deskterm.VPNAccount
alias DesktermWeb.HideMeAPI
alias DesktermWeb.Structs.Workstation.Command
alias DesktermWeb.UILog
alias DesktermWeb.VPN.Utilities
alias DesktermWeb.VPNAccountDetails
alias DesktermWeb.Workstation.Utilities, as: WorkstationUtilities
def create_vpn_account(socket) do
username = for(_ <- 1..8, do: Enum.random(?a..?z)) |> List.to_string()
password = for(_ <- 1..25, do: Enum.random(?a..?z)) |> List.to_string()
case HideMeAPI.create_account(username, password) do
{:ok, %{hideme_uuid: hideme_uuid, hideme_username: hideme_username}}
when is_binary(hideme_username) ->
if Regex.match?(~r/^[a-zA-Z0-9_-]+$/, hideme_username) do
vpn_account_details = %VPNAccountDetails{
hideme_uuid: hideme_uuid,
username: hideme_username,
password: password
}
Utilities.save_vpn_to_db(socket, vpn_account_details)
else
Logger.critical("VPN username contains invalid characters")
WorkstationUtilities.abort_launch(socket)
end
_ ->
Logger.error("Failed to create VPN account")
WorkstationUtilities.abort_launch(socket)
end
end
def ensure_vpn_account(socket) do
profile = socket.assigns.profile
with %Deskterm.Schema.VPNAccount{} = vpn_account <-
VPNAccount.get(profile.vpn_account_id),
{:ok, password} <-
VPNAccount.get_decrypted_password(vpn_account.encrypted_password) do
socket =
assign(socket,
vpn_account: vpn_account,
vpn_username: vpn_account.username,
vpn_password: password
)
WorkstationUtilities.advance_step(socket)
else
:error ->
Logger.error("Failed to ensure VPN account")
WorkstationUtilities.abort_launch(socket)
_ ->
create_vpn_account(socket)
end
end
def connect_to_vpn(socket) do
app_name = socket.assigns.fly_app_name
machine_id = socket.assigns.fly_machine_id
vpn_connection_details = Utilities.get_vpn_connection_details(socket)
vpn_connection_script =
Utilities.get_vpn_connection_script(vpn_connection_details)
command = %Command{
caller: self(),
machine_id: machine_id,
command: ["sh", "-c", vpn_connection_script]
}
case DesktermWeb.FlyAPI.execute_command_async(app_name, command) do
{:ok, task} ->
socket = UILog.log_command_on_ui(socket, vpn_connection_script)
{:noreply, assign(socket, active_task: task)}
_ ->
Logger.error("Failed to connect to VPN")
WorkstationUtilities.abort_launch(socket)
end
end
end
In the lib/deskterm_web/live/vpn directory, create a new file named vpn_utilities.ex and populate it with the below content:
defmodule DesktermWeb.VPN.Utilities do
import Phoenix.Component
require Logger
alias Deskterm.Schema.Profile, as: ProfileSchema
alias Deskterm.VPNAccount
alias DesktermWeb.FlyApp
alias DesktermWeb.VPNConnectionDetails
alias DesktermWeb.Workstation.Utilities, as: WorkstationUtilities
@hideme_cli_version "0.9.10"
@hideme_cli_url "https://github.com/eventure/hide.client.linux/releases/download/#{@hideme_cli_version}/hide.me-linux-amd64-#{@hideme_cli_version}.tar.gz"
@docker_traffic_mark 25
@routing_table_id 250
def save_vpn_to_db(socket, vpn_account_details) do
session = socket.assigns.ws_session
profile = socket.assigns.profile
with {:ok, vpn_account} <- save_vpn_to_vpn_account(vpn_account_details),
{:ok, updated_session} <- save_vpn_to_session(session, vpn_account),
:ok <- save_vpn_to_profile(profile, vpn_account) do
socket =
assign(socket,
ws_session: updated_session,
vpn_account: vpn_account,
vpn_username: vpn_account_details.username,
vpn_password: vpn_account_details.password
)
WorkstationUtilities.advance_step(socket)
else
_ ->
Logger.error("Failed to save VPN account to DB")
WorkstationUtilities.abort_launch(socket)
end
end
def get_vpn_connection_script(vpn_connection_details) do
"#{get_install_hideme_script()} && " <>
"#{get_gen_config_script(vpn_connection_details)} && " <>
"#{get_get_token_script(vpn_connection_details)} && " <>
"#{get_split_tunnel_script()} && " <>
"#{get_connect_to_vpn_script(vpn_connection_details)}\n" <>
"#{get_verify_vpn_script()}"
end
def get_vpn_connection_details(socket) do
vpn_username = socket.assigns.vpn_username
vpn_password = socket.assigns.vpn_password
vpn_location = socket.assigns.vpn_location
%VPNConnectionDetails{
username: vpn_username,
password: vpn_password,
location: vpn_location
}
end
defp save_vpn_to_vpn_account(vpn_account_details) do
VPNAccount.create(Map.from_struct(vpn_account_details))
end
defp save_vpn_to_session(session, vpn_account) do
Deskterm.Session.update_session(session, %{
vpn_account_id: vpn_account.id
})
end
defp save_vpn_to_profile(profile, vpn_account) do
user_type = FlyApp.get_user_type(profile)
if user_type == :free_user do
:ok
else
case profile
|> ProfileSchema.changeset(%{
vpn_account_id: vpn_account.id
})
|> Deskterm.Repo.update() do
{:ok, _} -> :ok
{:error, _} -> :error
end
end
end
defp get_install_hideme_script do
"cd /tmp && " <>
"apk add --no-cache util-linux; " <>
"wget '#{@hideme_cli_url}' -O hideme.tar.gz && " <>
"tar xzf hideme.tar.gz && " <>
"mkdir -p /opt/hide.me && " <>
"find /tmp -maxdepth 2 -name hide.me -type f -exec cp {} /opt/hide.me/ \\; && " <>
"find /tmp -maxdepth 2 -name CA.pem -type f -exec cp {} /opt/hide.me/ \\; && " <>
"chmod +x /opt/hide.me/hide.me && " <>
"rm -rf hideme.tar.gz"
end
defp get_gen_config_script(vpn_connection_details) do
"printf 'client:\n username: %s\n password: %s\n' " <>
"'#{vpn_connection_details.username}' " <>
"'#{vpn_connection_details.password}' " <>
"> /opt/hide.me/config.yml"
end
defp get_get_token_script(vpn_connection_details) do
"cd /opt/hide.me && " <>
"script -q -c './hide.me -c config.yml token #{vpn_connection_details.location}' /dev/null"
end
defp get_split_tunnel_script do
"iptables -t mangle -A PREROUTING -i docker0 -j MARK --set-mark #{@docker_traffic_mark}; " <>
"ip rule add fwmark #{@docker_traffic_mark} table #{@routing_table_id} prio 250; " <>
"iptables -t nat -A POSTROUTING -o vpn -j MASQUERADE"
end
defp get_connect_to_vpn_script(vpn_connection_details) do
"cd /opt/hide.me && " <>
"nohup ./hide.me " <>
"-k=false " <>
"-r #{@routing_table_id} " <>
"-R 50000 " <>
"--noAds " <>
"--noMalware " <>
"--noMalicious " <>
"--noTrackers " <>
"connect #{vpn_connection_details.location} > /var/log/hideme.log 2>&1 &"
end
defp get_verify_vpn_script do
"sleep 5 && " <>
"if ip link show vpn > /dev/null 2>&1; then " <>
"echo 'VPN connected successfully'; " <>
"else " <>
"echo 'VPN connection failed'; " <>
"cat /var/log/hideme.log; " <>
"exit 1; " <>
"fi"
end
end
Update launch_sequence.ex to add :create_vpn_account and :connect_to_vpn steps to the free user and new user launch sequences, and :ensure_vpn_account and :connect_to_vpn steps to the old user launch sequences. These steps are placed after :start_proxy_container and before :update_session:
:start_proxy_container,
:create_vpn_account,
:connect_to_vpn,
:update_session,
:start_proxy_container,
:ensure_vpn_account,
:connect_to_vpn,
:update_session
Update app_live.ex to add VPN-related assigns (vpn_account, vpn_location, vpn_password, vpn_username) in mount/3, accept vpn_location in the start_workstation event, validate it using Deskterm.VPNLocations.valid_location?/1, and add handle_info callbacks for :create_vpn_account, :ensure_vpn_account, and :connect_to_vpn:
@impl true
def handle_info(:create_vpn_account, socket) do
VPN.create_vpn_account(socket)
end
@impl true
def handle_info(:ensure_vpn_account, socket) do
VPN.ensure_vpn_account(socket)
end
@impl true
def handle_info(:connect_to_vpn, socket) do
VPN.connect_to_vpn(socket)
end
Update views.ex to add launch_task_label/1 functions for the new VPN launch steps:
def launch_task_label(:create_vpn_account), do: "Creating VPN Account"
def launch_task_label(:ensure_vpn_account), do: "Retrieving VPN Account"
def launch_task_label(:connect_to_vpn), do: "Connecting to VPN"
Update censorship.ex to add the Hide.me API credentials and VPN username/password to the list of secrets that are censored in the UI log:
defp get_secrets(socket) do
[
Application.get_env(:deskterm, :fly_api_token),
Application.get_env(:deskterm, :hideme_api_token),
Application.get_env(:deskterm, :hideme_api_email),
socket.assigns[:ws_auth_token],
socket.assigns[:vpn_username],
socket.assigns[:vpn_password],
supabase_configuration[:api_key],
endpoint_configuration[:secret_key_base],
repo_configuration[:url]
]
end
Update firewall.ex to replace the previous firewall rules with new rules that allow traffic from the workstation container only through the VPN interface (vpn) and block direct internet access via eth0:
defp get_firewall_rules do
block_host_access =
"iptables -C INPUT -i docker0 -m state --state ESTABLISHED,RELATED -j ACCEPT 2>/dev/null || " <>
"iptables -I INPUT -i docker0 -m state --state ESTABLISHED,RELATED -j ACCEPT; " <>
"iptables -C INPUT -i docker0 -j DROP 2>/dev/null || " <>
"iptables -A INPUT -i docker0 -j DROP"
block_direct_internet =
"iptables -C DOCKER-USER -i docker0 -o vpn -j ACCEPT 2>/dev/null || " <>
"iptables -I DOCKER-USER -i docker0 -o vpn -j ACCEPT; " <>
"iptables -C DOCKER-USER -i docker0 -o eth0 -j DROP 2>/dev/null || " <>
"iptables -A DOCKER-USER -i docker0 -o eth0 -j DROP"
block_host_access <> "; " <> block_direct_internet
end
Update session.ex (in lib/deskterm_web/live) to add vpn_location to the session initialization attributes, and update end_session/2 for free users to also delete the VPN account before deleting the Fly app:
def end_session(app_name, session) do
cond do
app_name && session.user_type == "free_user" ->
vpn_deletion_result = delete_vpn_account(session)
fly_deletion_result = delete_fly_app(app_name)
if vpn_deletion_result == :ok && fly_deletion_result == :ok do
Deskterm.Session.end_session(session)
end
...
end
end
Update session_cleanup.ex to also delete the VPN account when cleaning up orphaned free user sessions. The session is only ended when both the VPN account deletion and Fly app deletion succeed:
defp clean_up_session(session) do
if session.user_type == "free_user" do
vpn_deletion_result = delete_vpn_account(session)
fly_deletion_result = delete_fly_app(session)
if vpn_deletion_result == :ok && fly_deletion_result == :ok do
Session.end_session(session)
end
else
Session.end_session(session)
end
end
Update workstation_utilities.ex to add VPN-related assigns (vpn_account, vpn_location, vpn_password, vpn_username, ws_location) to the reset_assigns/1 function for both free and paid user types.
Update latency-utilities.ts to update the WS_TO_VPN_LOCATION mapping to use Hide.me location IDs instead of the previous format, add a updateVPNInput helper function, and update updateVPNAutoLocation to also update the VPN location hidden input when automatic is selected:
export const WS_TO_VPN_LOCATION: Record<string, string> = {
gru: 'br',
yyz: 'toronto',
iad: 'washington-dc',
ord: 'illinois',
dfw: 'dallas',
lax: 'losangeles',
sjc: 'sanjose',
ewr: 'newyorkcity',
cdg: 'paris',
fra: 'frankfurt',
ams: 'amsterdam',
jnb: 'johannesburg',
arn: 'se',
lhr: 'london',
syd: 'sydney',
bom: 'bd',
nrt: 'jp',
sin: 'sg',
};
Update account_modal.ex to remove "No internet access" from the free user limitations bullet list, since all users now have internet access through the VPN.
Update launch.html.heex to:
- Remove "No internet access" from the free user limitations list
- Add a "Powered by Hide.me VPN" branding link with the Hide.me logo to the VPN location selector section (with responsive variants for mobile and desktop)
- Add a hidden input
vpn_locationfor the VPN location form submission - Update VPN country buttons to support single-location countries (using
data-action="select"directly instead ofdata-action="narrow") - Add
emojiclass to the Turnstile human/robot emoji spans
Update overview.html.heex to replace the search bar with a "Made with ♥️ in Joensuu, Finland 🇫🇮 🇪🇺" footer text.
Update home.html.heex to:
- Remove the KDE desktop entry from the desktop environments list
- Rename "Americas" to "AMER" and "Asia Pacific" to "APAC" in the workstation locations section
- Update the VPN locations country flag lists to reflect the locations available through Hide.me
- Replace "Proton VPN" branding with "Hide.me VPN"
- Move "Company based in Finland" from a bullet point to inline with the VPN branding
- Rename "Containers run on" to "Workstations run on"
Update third_party.html.heex to add Hide.me and Hide.me Logo entries to the third-party services table, linking to the Hide.me Terms of Service and Hide.me Assets pages respectively.
Update 404.html.heex and 500.html.heex to remove dark: prefixed classes and use the dark-mode styles as default.
Update latency-measurement.test.ts to add the swedenVPNID import and update tests to verify that both the ws-location-input and vpn-location-input hidden inputs are updated when automatic location is selected.
Update latency-test-utils.ts to add swedenVPNID export, add a vpn-location-input hidden input to the test HTML, update VPN button data-value attributes to use Hide.me location IDs, and add selected class to the VPN automatic button.
Update location-html.ts to update VPN location button data-value attributes from the old format (e.g. se-stockholm) to the new Hide.me location IDs (e.g. stockholm).
Update location-test-utils.ts to update the Stockholm VPN button selector to use the new data-value='stockholm' format.
Update location-selector.test.ts to update the expected selected VPN location value from se-stockholm to stockholm.
Add the Hide.me logo image file at priv/static/images/logos/hideme.png.



