Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .github/workflows/go.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,13 @@ jobs:
with:
go-version-file: "go.mod"

- name: Check for slog overwrite calls in tests
run: |
if grep -rn 'slog\.SetDefault\|slog\.SetLogLoggerLevel' --include='*_test.go' .; then
echo "::error::Test files should not upate the slog.Default logger or level. This pollutes the output."
exit 1
fi
- name: Build
run: go build -v -tags with_clash_api ./...
- name: Test
Expand Down
99 changes: 77 additions & 22 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,50 +33,105 @@ Available variables:
* `RADIANCE_FEATURE_OVERRIDE`: Comma-separated list of feature flags to force-enable on the server side. If set, the value is sent as the `X-Lantern-Feature-Override` header on config requests in any environment, and it is recommended for testing/non-production use. For example, `RADIANCE_FEATURE_OVERRIDE=bandit_assignment` enables bandit-based proxy assignment during testing.


## Packages
## Architecture

Use `common.Init` to setup directories and configure loggers.
> [!note]
> This isn't necessary if `NewRadiance` was called as it will call `Init` for you.
Radiance is structured around a `LocalBackend` pattern that ties together all core functionality: configuration, servers, VPN connection, account management, issue reporting, and telemetry. The `LocalBackend` is the central coordinator and should be the primary interface for interacting with Radiance programmatically.

### `vpn`
In addition to being the core of the [Lantern client](https://github.com/getlantern/lantern), radiance also provides a standalone daemon and CLI:

The `vpn` package provides high-level functions for controlling the VPN tunnel.
- **`lanternd`** — the VPN daemon that runs the `LocalBackend` and exposes an IPC server. It can run in the foreground or be installed as a system service.
- **`lantern`** — a CLI client that communicates with the daemon over IPC.

To connect to the best available server, you can use the `QuickConnect` function. This function takes a server group (`servers.SGLantern`, `servers.SGUser`, or `"all"`) and a `PlatformInterface` as input. For example:
### Building CLI & Daemon

```go
err := vpn.QuickConnect(servers.SGLantern, platIfce)
From the `cmd/` directory:

```sh
# Build the daemon
just build-daemon
# or
make build-daemon

# Build the CLI
just build-cli
# or
make build-cli
```

will connect to the best Lantern server, while:
Both binaries are output to `bin/`. You can also run the daemon directly with `make run-daemon`.

```go
err := vpn.QuickConnect("all", platIfce)
### Running

```sh
# Start the daemon
lanternd run --data-path ~/data --log-path ~/logs

# Install/uninstall as a system service
lanternd install --data-path ~/data --log-path ~/logs
lanternd uninstall

# CLI commands (requires a running daemon)
lantern connect [--tag <server-tag>]
lantern disconnect
lantern status
lantern servers
lantern account login
lantern subscription
lantern split-tunnel
lantern logs
lantern ip
```

will connect to the best overall.
## Packages

Use `common.Init` to setup directories and configure loggers.
> [!note]
> This isn't necessary if `NewLocalBackend` was called as it will call `Init` for you.

### `backend`

The `backend` package provides `LocalBackend`, the main entry point for all Radiance functionality. Create one with `NewLocalBackend(ctx, opts)` and call `Start()` to begin fetching configuration and serving requests. `LocalBackend` owns and coordinates the `VPNClient`, `ServerManager`, `ConfigHandler`, `AccountClient`, `IssueReporter`, and telemetry.

### `vpn`

You can also connect to a specific server using `ConnectToServer`. This function requires a server group, a server tag, and a `PlatformInterface`. For example:
The `vpn` package provides `VPNClient`, which manages the lifecycle of the VPN tunnel.

```go
err := vpn.ConnectToServer(servers.SGUser, "my-server", platIfce)
client := vpn.NewVPNClient(dataPath, logger, platformIfce)
err := client.Connect(boxOptions)
```

Both `QuickConnect` and `ConnectToServer` can be called without disconnecting first, allowing you to seamlessly switch between servers or connection modes.
`Connect` can be called without disconnecting first, allowing you to seamlessly switch between servers. Once connected, you can query status or view `Connections`. To stop the VPN, call `Disconnect`.

Once connected, you can check the `GetStatus` or view `ActiveConnections`. To stop the VPN, simply call `Disconnect`. The package also supports reconnecting to the last used server with `Reconnect`.
> [!note]
> In most cases, you should use the `LocalBackend` methods (`ConnectVPN`, `DisconnectVPN`, `RestartVPN`, `VPNStatus`) rather than using `VPNClient` directly.

This package also includes split tunneling capabilities, allowing you to include or exclude specific applications, domains, or IP addresses from the VPN tunnel. You can manage split tunneling by creating a `SplitTunnel` handler with `NewSplitTunnelHandler`. This handler allows you to `Enable` or `Disable` split tunneling, `AddItem` or `RemoveItem` from the filter, and view the current `Filters`.
This package also includes split tunneling capabilities via the `SplitTunnel` type, allowing you to include or exclude specific applications, domains, or IP addresses from the VPN tunnel.

### `servers`

The `servers` package is responsible for managing all VPN server configurations, separating them into two groups: `lantern` (official Lantern servers) and `user` (user-provided servers).
The `servers` package manages all VPN server configurations, separating them into two groups: `lantern` (official Lantern servers fetched from the config) and `user` (user-provided servers).

The `Manager` allows you to `AddServers` and `RemoveServer` configurations. You can retrieve the config for a specific server with `GetServerByTag` or use `Servers` to retrieve all configs.
The `Manager` allows you to `AddServers` and `RemoveServers` configurations. You can retrieve the config for a specific server with `GetServerByTag` or use `Servers` to retrieve all configs.

> [!caution]
> While you can get a new `Manager` instance with `NewManager`, it is recommended to use `Radiance.ServerManager`. This will return the shared manager instance. `NewManager` can be useful for retrieving server information if you don't have access to the shared instance, but the new instance should not be kept as it won't stay in sync and adding server configs to it will overwrite existing configs if both manager instances are pointed to the same server file.
> While you can get a new `Manager` instance with `NewManager`, it is recommended to use the `LocalBackend`'s server methods (`Servers`, `AddServers`, `RemoveServers`, `GetServerByTag`). These use the shared manager instance. `NewManager` can be useful for retrieving server information if you don't have access to the shared instance, but the new instance should not be kept as it won't stay in sync.

A key feature of this package is the ability to add private servers from a server manager via an access token using `AddPrivateServer`. This process uses Trust-on-first-use (TOFU) to securely add the server. Once a private server is added, you can invite other users with `InviteToPrivateServer` and revoke access with `RevokePrivateServerInvite`.

### `ipc`

The `ipc` package provides the communication layer between the `lantern` CLI and the `lanternd` daemon. The `ipc.Server` exposes an HTTP API backed by the `LocalBackend`, and the `ipc.Client` provides a typed Go client for calling it. All communication happens over a local socket.

### `account`

The `account` package handles user authentication (email/password and OAuth), signup, email verification, account recovery, device management, and subscription operations. It communicates with the Lantern account server and caches authentication state locally.

### `config`

The `config` package fetches proxy configuration from the Lantern API on a polling interval and emits `NewConfigEvent` events when the configuration changes. The `LocalBackend` subscribes to these events to update server configurations automatically.

### `events`

A key feature of this package is the ability to add private servers from a server manager via an access token using `AddPrivateServer`. This process uses Trust-on-first-use (TOFU) to securely add the server. Once a private server is added, you can use the manager to invite other users to it with `InviteToPrivateServer` and revoke access with `RevokePrivateServerInvite`.
A generic pub-sub event system used throughout Radiance for decoupled communication between components (config changes, VPN status updates, log entries, etc.).

117 changes: 117 additions & 0 deletions account/auth.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
package account

import (
"context"
"crypto/rand"
"crypto/sha256"
"errors"
"fmt"
"math/big"

"github.com/1Password/srp"
"golang.org/x/crypto/pbkdf2"
"google.golang.org/protobuf/proto"

"github.com/getlantern/radiance/account/protos"
)

func (a *Client) fetchSalt(ctx context.Context, email string) (*protos.GetSaltResponse, error) {
query := map[string]string{"email": email}
resp, err := a.sendRequest(ctx, "GET", "/users/salt", query, nil, nil)
if err != nil {
return nil, err
}
var salt protos.GetSaltResponse
if err := proto.Unmarshal(resp, &salt); err != nil {
return nil, fmt.Errorf("unmarshaling salt response: %w", err)
}
return &salt, nil
}

// clientProof performs the SRP authentication flow to generate the client proof for the given email and password.
func (a *Client) clientProof(ctx context.Context, email, password string, salt []byte) ([]byte, error) {
srpClient, err := newSRPClient(email, password, salt)
if err != nil {
return nil, err
}

A := srpClient.EphemeralPublic()
data := &protos.PrepareRequest{
Email: email,
A: A.Bytes(),
}
resp, err := a.sendRequest(ctx, "POST", "/users/prepare", nil, nil, data)
if err != nil {
return nil, err
}

var srpB protos.PrepareResponse
if err := proto.Unmarshal(resp, &srpB); err != nil {
return nil, fmt.Errorf("unmarshaling prepare response: %w", err)
}
B := big.NewInt(0).SetBytes(srpB.B)
if err = srpClient.SetOthersPublic(B); err != nil {
return nil, err
}

key, err := srpClient.Key()
if err != nil || key == nil {
return nil, fmt.Errorf("generating Client key %w", err)
}
if !srpClient.GoodServerProof(salt, email, srpB.Proof) {
return nil, fmt.Errorf("checking server proof %w", err)
}

proof, err := srpClient.ClientProof()
if err != nil {
return nil, fmt.Errorf("generating client proof %w", err)
}
return proof, nil
}

// getSalt retrieves the salt for the given email address or it's cached value.
func (a *Client) getSalt(ctx context.Context, email string) ([]byte, error) {
if cached := a.getSaltCached(); cached != nil {
return cached, nil
}
resp, err := a.fetchSalt(ctx, email)
if err != nil {
return nil, err
}
return resp.Salt, nil
}

const group = srp.RFC5054Group3072

func newSRPClient(email, password string, salt []byte) (*srp.SRP, error) {
if len(salt) == 0 || len(password) == 0 || len(email) == 0 {
return nil, errors.New("salt, password and email should not be empty")
}

encryptedKey, err := generateEncryptedKey(password, email, salt)
if err != nil {
return nil, fmt.Errorf("failed to generate encrypted key: %w", err)
}

return srp.NewSRPClient(srp.KnownGroups[group], encryptedKey, nil), nil
}

func generateEncryptedKey(password, email string, salt []byte) (*big.Int, error) {
if len(salt) == 0 || len(password) == 0 || len(email) == 0 {
return nil, errors.New("salt or password or email is empty")
}
combinedInput := password + email
encryptedKey := pbkdf2.Key([]byte(combinedInput), salt, 4096, 32, sha256.New)
encryptedKeyBigInt := big.NewInt(0).SetBytes(encryptedKey)
return encryptedKeyBigInt, nil
}

func generateSalt() ([]byte, error) {
salt := make([]byte, 16)
if n, err := rand.Read(salt); err != nil {
return nil, err
} else if n != 16 {
return nil, errors.New("failed to generate 16 byte salt")
}
return salt, nil
}
Loading