Skip to content

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:

Hideme 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:

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:

deploy-test-environment.yml
    - 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"
deploy-test-environment.yml
    - 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:

deploy-production-environment.yml
    - 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"
deploy-production-environment.yml
    - 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:

VPN Test 1

VPN Test 2

VPN Test 3

Automated Tests

Update test_helper.exs to define a mock for the Hide.me API and configure it:

test_helper.exs
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:

vpn_atoms.ex
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:

hideme_utilities.ex
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:

launch_utilities.ex
  @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:

app_utilities.ex
  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:

vpn_account_test.exs
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:

vpn_password_encryption_test.exs
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:

vpn_test.exs
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:

session_cleanup_test.exs
  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:

session_test.exs
  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:

launch_verification_test.exs
  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:

20260509120000_create_vpn_accounts.exs
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:

vpn_account.ex
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:

profile.ex
    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:

session.ex
    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:

vpn_account.ex
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:

vpn_password_encryption.ex
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.

vpn_locations.ex
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:

hide_me_api.ex
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:

hide_me.ex
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:

vpn.ex
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:

vpn.ex
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:

vpn_utilities.ex
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:

launch_sequence.ex
    :start_proxy_container,
    :create_vpn_account,
    :connect_to_vpn,
    :update_session,
launch_sequence.ex
    :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:

app_live.ex
@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:

views.ex
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:

censorship.ex
  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:

firewall.ex
  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:

session.ex
  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:

session_cleanup.ex
  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:

latency-utilities.ts
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_location for the VPN location form submission
  • Update VPN country buttons to support single-location countries (using data-action="select" directly instead of data-action="narrow")
  • Add emoji class 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.