Compare commits
18 Commits
09205f8db2
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| d23bf52c50 | |||
| ea5a0861fd | |||
| cb7257ed6f | |||
| 6c7730eb7f | |||
| 31fc9fe654 | |||
| 4b22a19837 | |||
| 544cbaf553 | |||
| 1893e63d08 | |||
| d2db097445 | |||
| f0c5261e7b | |||
| 2f75b75703 | |||
| 356b049849 | |||
|
|
230a9212fe | ||
|
|
28918880da | ||
|
|
37090d80b0 | ||
|
|
a45a9b0392 | ||
|
|
4ce94a5b17 | ||
|
|
fe8376dd6d |
37
.gitea/workflows/ci.yml
Normal file
37
.gitea/workflows/ci.yml
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
name: ci
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ci-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
checks:
|
||||||
|
name: Rust Checks
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install Rust toolchain
|
||||||
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
with:
|
||||||
|
components: rustfmt, clippy
|
||||||
|
|
||||||
|
- name: Cache Rust build
|
||||||
|
uses: Swatinem/rust-cache@v2
|
||||||
|
|
||||||
|
- name: Cargo check
|
||||||
|
run: cargo check --workspace --all-targets --locked
|
||||||
|
|
||||||
|
- name: Cargo fmt
|
||||||
|
run: cargo fmt --all -- --check
|
||||||
|
|
||||||
|
- name: Cargo clippy
|
||||||
|
run: cargo clippy --workspace --all-targets --locked -- -D warnings
|
||||||
|
|
||||||
|
- name: Cargo test
|
||||||
|
run: cargo test --workspace --all-targets --locked
|
||||||
198
.gitea/workflows/release.yml
Normal file
198
.gitea/workflows/release.yml
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
name: release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
packages: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
env:
|
||||||
|
OWNER: ${{ github.repository_owner }}
|
||||||
|
REPO: ${{ github.event.repository.name }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install Rust
|
||||||
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
|
||||||
|
- name: Install packaging tools
|
||||||
|
run: |
|
||||||
|
cargo install cargo-deb --locked
|
||||||
|
cargo install cargo-rpm --locked
|
||||||
|
|
||||||
|
- name: Build release binaries
|
||||||
|
run: cargo build --release -p relay -p client
|
||||||
|
|
||||||
|
- name: Resolve version
|
||||||
|
id: version
|
||||||
|
run: |
|
||||||
|
if [[ "${GITHUB_REF}" == refs/tags/* ]]; then
|
||||||
|
VERSION="${GITHUB_REF#refs/tags/}"
|
||||||
|
IS_PRERELEASE=false
|
||||||
|
else
|
||||||
|
VERSION="main-${GITHUB_SHA:0:7}"
|
||||||
|
IS_PRERELEASE=true
|
||||||
|
fi
|
||||||
|
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "is_prerelease=${IS_PRERELEASE}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Package tarball
|
||||||
|
run: |
|
||||||
|
mkdir -p dist
|
||||||
|
cp target/release/relay dist/
|
||||||
|
cp target/release/client dist/
|
||||||
|
tar -czf "dist/play-dvv-${{ steps.version.outputs.version }}-linux-x86_64.tar.gz" -C dist relay client
|
||||||
|
|
||||||
|
- name: Build deb packages
|
||||||
|
run: |
|
||||||
|
cargo deb -p relay --no-build
|
||||||
|
cargo deb -p client --no-build
|
||||||
|
cp target/debian/*.deb dist/ || true
|
||||||
|
|
||||||
|
- name: Build rpm packages
|
||||||
|
run: |
|
||||||
|
(cd relay && cargo rpm build --release)
|
||||||
|
(cd client && cargo rpm build --release)
|
||||||
|
find . -path "*/target/release/rpmbuild/RPMS/*.rpm" -exec cp {} dist/ \;
|
||||||
|
|
||||||
|
# -------------------------------------------------
|
||||||
|
# FIXED: Generic package upload (Gitea correct API)
|
||||||
|
# -------------------------------------------------
|
||||||
|
- name: Publish generic packages
|
||||||
|
env:
|
||||||
|
TOKEN: ${{ secrets.TOKEN }}
|
||||||
|
run: |
|
||||||
|
if [[ -z "${TOKEN}" ]]; then
|
||||||
|
echo "TOKEN missing; skipping generic packages"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
VERSION="${{ steps.version.outputs.version }}"
|
||||||
|
|
||||||
|
for file in dist/*; do
|
||||||
|
name=$(basename "$file")
|
||||||
|
curl -sSf -X PUT \
|
||||||
|
-H "Authorization: token ${TOKEN}" \
|
||||||
|
-H "Content-Type: application/octet-stream" \
|
||||||
|
--data-binary @"$file" \
|
||||||
|
"${GITHUB_SERVER_URL}/api/packages/${OWNER}/generic/${REPO}/${VERSION}/${name}"
|
||||||
|
done
|
||||||
|
|
||||||
|
# -------------------------------------------------
|
||||||
|
# OCI Images (fixed repo casing issue)
|
||||||
|
# -------------------------------------------------
|
||||||
|
- name: Build and push OCI images
|
||||||
|
env:
|
||||||
|
TOKEN: ${{ secrets.TOKEN }}
|
||||||
|
USERNAME: ${{ secrets.USERNAME }}
|
||||||
|
run: |
|
||||||
|
if [[ -z "${TOKEN}" || -z "${USERNAME}" ]]; then
|
||||||
|
echo "TOKEN or USERNAME missing; skipping OCI"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
VERSION="${{ steps.version.outputs.version }}"
|
||||||
|
IMAGE_BASE="git.dvv.one/${OWNER}/${REPO}"
|
||||||
|
|
||||||
|
echo "${TOKEN}" | docker login git.dvv.one -u "${USERNAME}" --password-stdin
|
||||||
|
|
||||||
|
printf '%s\n' \
|
||||||
|
'FROM debian:bookworm-slim' \
|
||||||
|
'COPY target/release/relay /usr/local/bin/relay' \
|
||||||
|
'EXPOSE 7000 7001 25565' \
|
||||||
|
'ENTRYPOINT ["/usr/local/bin/relay"]' \
|
||||||
|
> Dockerfile.relay
|
||||||
|
|
||||||
|
printf '%s\n' \
|
||||||
|
'FROM debian:bookworm-slim' \
|
||||||
|
'COPY target/release/client /usr/local/bin/client' \
|
||||||
|
'ENTRYPOINT ["/usr/local/bin/client"]' \
|
||||||
|
> Dockerfile.client
|
||||||
|
|
||||||
|
docker build -f Dockerfile.relay -t ${IMAGE_BASE}/relay:${VERSION} .
|
||||||
|
docker build -f Dockerfile.client -t ${IMAGE_BASE}/client:${VERSION} .
|
||||||
|
|
||||||
|
if [[ "${GITHUB_REF}" == "refs/heads/main" ]]; then
|
||||||
|
docker tag ${IMAGE_BASE}/relay:${VERSION} ${IMAGE_BASE}/relay:main
|
||||||
|
docker tag ${IMAGE_BASE}/client:${VERSION} ${IMAGE_BASE}/client:main
|
||||||
|
fi
|
||||||
|
|
||||||
|
docker push ${IMAGE_BASE}/relay:${VERSION}
|
||||||
|
docker push ${IMAGE_BASE}/client:${VERSION}
|
||||||
|
|
||||||
|
if [[ "${GITHUB_REF}" == "refs/heads/main" ]]; then
|
||||||
|
docker push ${IMAGE_BASE}/relay:main
|
||||||
|
docker push ${IMAGE_BASE}/client:main
|
||||||
|
fi
|
||||||
|
|
||||||
|
# -------------------------------------------------
|
||||||
|
# FIXED: Proper Gitea Cargo registry setup
|
||||||
|
# -------------------------------------------------
|
||||||
|
- name: Configure Cargo for Gitea
|
||||||
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
|
run: |
|
||||||
|
mkdir -p ~/.cargo
|
||||||
|
printf '%s\n' \
|
||||||
|
'[registries.gitea]' \
|
||||||
|
"index = \"sparse+${GITHUB_SERVER_URL}/api/packages/${OWNER}/cargo/\"" \
|
||||||
|
> ~/.cargo/config.toml
|
||||||
|
|
||||||
|
- name: Publish Cargo packages
|
||||||
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
|
env:
|
||||||
|
CARGO_REGISTRIES_GITEA_TOKEN: ${{ secrets.TOKEN }}
|
||||||
|
run: |
|
||||||
|
TAG="${GITHUB_REF#refs/tags/}"
|
||||||
|
|
||||||
|
RELAY_VER=$(cargo metadata --format-version 1 | jq -r '.packages[] | select(.name=="relay") | .version')
|
||||||
|
CLIENT_VER=$(cargo metadata --format-version 1 | jq -r '.packages[] | select(.name=="client") | .version')
|
||||||
|
|
||||||
|
if [[ "v${RELAY_VER}" == "${TAG}" ]]; then
|
||||||
|
cargo publish -p relay --registry gitea
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "v${CLIENT_VER}" == "${TAG}" ]]; then
|
||||||
|
cargo publish -p client --registry gitea
|
||||||
|
fi
|
||||||
|
|
||||||
|
# -------------------------------------------------
|
||||||
|
# FIXED: Gitea Release Creation
|
||||||
|
# -------------------------------------------------
|
||||||
|
- name: Create release
|
||||||
|
env:
|
||||||
|
TOKEN: ${{ secrets.TOKEN }}
|
||||||
|
run: |
|
||||||
|
if [[ -z "${TOKEN}" ]]; then
|
||||||
|
echo "TOKEN missing; skipping release"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
VERSION="${{ steps.version.outputs.version }}"
|
||||||
|
PRERELEASE=${{ steps.version.outputs.is_prerelease }}
|
||||||
|
|
||||||
|
release=$(curl -sSf -X POST \
|
||||||
|
-H "Authorization: token ${TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"tag_name\":\"${VERSION}\",\"target_commitish\":\"${GITHUB_SHA}\",\"name\":\"${VERSION}\",\"body\":\"Automated release for ${VERSION}.\",\"prerelease\":${PRERELEASE}}" \
|
||||||
|
"${GITHUB_SERVER_URL}/api/v1/repos/${OWNER}/${REPO}/releases")
|
||||||
|
|
||||||
|
release_id=$(echo "$release" | jq -r '.id')
|
||||||
|
|
||||||
|
for file in dist/*; do
|
||||||
|
name=$(basename "$file")
|
||||||
|
curl -sSf -X POST \
|
||||||
|
-H "Authorization: token ${TOKEN}" \
|
||||||
|
-F "attachment=@${file}" \
|
||||||
|
"${GITHUB_SERVER_URL}/api/v1/repos/${OWNER}/${REPO}/releases/${release_id}/assets?name=${name}"
|
||||||
|
done
|
||||||
8
.yamllint
Normal file
8
.yamllint
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
extends: default
|
||||||
|
|
||||||
|
rules:
|
||||||
|
document-start: disable
|
||||||
|
line-length: disable
|
||||||
|
truthy:
|
||||||
|
allowed-values: ['true', 'false', 'on', 'off']
|
||||||
|
check-keys: true
|
||||||
1229
Cargo.lock
generated
1229
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
10
Cargo.toml
10
Cargo.toml
@@ -19,3 +19,13 @@ axum = "0.8"
|
|||||||
redis = { version = "0.32", features = ["tokio-comp", "connection-manager"] }
|
redis = { version = "0.32", features = ["tokio-comp", "connection-manager"] }
|
||||||
jsonwebtoken = "10"
|
jsonwebtoken = "10"
|
||||||
chrono = { version = "0.4", features = ["serde", "clock"] }
|
chrono = { version = "0.4", features = ["serde", "clock"] }
|
||||||
|
metrics = "0.24"
|
||||||
|
metrics-exporter-prometheus = "0.17"
|
||||||
|
tokio-postgres = { version = "0.7", features = ["with-chrono-0_4", "with-serde_json-1"] }
|
||||||
|
hmac = "0.12"
|
||||||
|
sha2 = "0.10"
|
||||||
|
hex = "0.4"
|
||||||
|
quinn = "0.11"
|
||||||
|
rustls = "0.23"
|
||||||
|
rcgen = "0.12"
|
||||||
|
rustls-pemfile = "2.1"
|
||||||
|
|||||||
@@ -15,3 +15,7 @@ tokio.workspace = true
|
|||||||
tracing.workspace = true
|
tracing.workspace = true
|
||||||
tracing-subscriber.workspace = true
|
tracing-subscriber.workspace = true
|
||||||
fastrand.workspace = true
|
fastrand.workspace = true
|
||||||
|
metrics.workspace = true
|
||||||
|
metrics-exporter-prometheus.workspace = true
|
||||||
|
tokio-postgres.workspace = true
|
||||||
|
uuid.workspace = true
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use std::{net::SocketAddr, sync::Arc};
|
use std::{net::SocketAddr, sync::Arc, time::Instant};
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use axum::{
|
use axum::{
|
||||||
@@ -10,14 +10,19 @@ use axum::{
|
|||||||
};
|
};
|
||||||
use chrono::{Duration, Utc};
|
use chrono::{Duration, Utc};
|
||||||
use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation, decode, encode};
|
use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation, decode, encode};
|
||||||
|
use metrics_exporter_prometheus::{PrometheusBuilder, PrometheusHandle};
|
||||||
use redis::AsyncCommands;
|
use redis::AsyncCommands;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tokio_postgres::{Client as PgClient, NoTls};
|
||||||
use tracing::{info, warn};
|
use tracing::{info, warn};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
struct AppState {
|
struct AppState {
|
||||||
jwt_secret: Arc<String>,
|
jwt_secret: Arc<String>,
|
||||||
redis: Option<redis::aio::ConnectionManager>,
|
redis: Option<redis::aio::ConnectionManager>,
|
||||||
|
pg: Option<Arc<PgClient>>,
|
||||||
|
metrics: PrometheusHandle,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
@@ -58,12 +63,9 @@ struct ValidateResponse {
|
|||||||
max_tunnels: Option<u32>,
|
max_tunnels: Option<u32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Clone)]
|
||||||
struct StripeWebhookEvent {
|
struct PlanEntitlement {
|
||||||
event_type: String,
|
|
||||||
user_id: String,
|
|
||||||
tier: String,
|
tier: String,
|
||||||
#[serde(default = "default_max_tunnels")]
|
|
||||||
max_tunnels: u32,
|
max_tunnels: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,6 +80,10 @@ async fn main() -> Result<()> {
|
|||||||
|
|
||||||
let bind = std::env::var("AUTH_BIND").unwrap_or_else(|_| "0.0.0.0:8080".into());
|
let bind = std::env::var("AUTH_BIND").unwrap_or_else(|_| "0.0.0.0:8080".into());
|
||||||
let jwt_secret = std::env::var("JWT_SECRET").unwrap_or_else(|_| "dev-secret-change-me".into());
|
let jwt_secret = std::env::var("JWT_SECRET").unwrap_or_else(|_| "dev-secret-change-me".into());
|
||||||
|
let metrics = PrometheusBuilder::new()
|
||||||
|
.install_recorder()
|
||||||
|
.context("install prometheus recorder")?;
|
||||||
|
|
||||||
let redis = if let Ok(url) = std::env::var("REDIS_URL") {
|
let redis = if let Ok(url) = std::env::var("REDIS_URL") {
|
||||||
let client = redis::Client::open(url.clone()).context("open redis client")?;
|
let client = redis::Client::open(url.clone()).context("open redis client")?;
|
||||||
match redis::aio::ConnectionManager::new(client).await {
|
match redis::aio::ConnectionManager::new(client).await {
|
||||||
@@ -94,16 +100,20 @@ async fn main() -> Result<()> {
|
|||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let pg = connect_postgres().await?;
|
||||||
|
|
||||||
let state = AppState {
|
let state = AppState {
|
||||||
jwt_secret: Arc::new(jwt_secret),
|
jwt_secret: Arc::new(jwt_secret),
|
||||||
redis,
|
redis,
|
||||||
|
pg,
|
||||||
|
metrics,
|
||||||
};
|
};
|
||||||
|
|
||||||
let app = Router::new()
|
let app = Router::new()
|
||||||
.route("/healthz", get(healthz))
|
.route("/healthz", get(healthz))
|
||||||
|
.route("/metrics", get(metrics_endpoint))
|
||||||
.route("/v1/token/dev", post(issue_dev_token))
|
.route("/v1/token/dev", post(issue_dev_token))
|
||||||
.route("/v1/token/validate", post(validate_token))
|
.route("/v1/token/validate", post(validate_token))
|
||||||
.route("/v1/stripe/webhook", post(stripe_webhook))
|
|
||||||
.with_state(state);
|
.with_state(state);
|
||||||
|
|
||||||
let listener = tokio::net::TcpListener::bind(&bind)
|
let listener = tokio::net::TcpListener::bind(&bind)
|
||||||
@@ -115,14 +125,36 @@ async fn main() -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn connect_postgres() -> Result<Option<Arc<PgClient>>> {
|
||||||
|
let Some(url) = std::env::var("DATABASE_URL").ok() else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let (client, conn) = tokio_postgres::connect(&url, NoTls)
|
||||||
|
.await
|
||||||
|
.context("connect postgres")?;
|
||||||
|
tokio::spawn(async move {
|
||||||
|
if let Err(e) = conn.await {
|
||||||
|
warn!(error = %e, "postgres connection task ended");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
info!("auth-api connected to postgres");
|
||||||
|
Ok(Some(Arc::new(client)))
|
||||||
|
}
|
||||||
|
|
||||||
async fn healthz() -> &'static str {
|
async fn healthz() -> &'static str {
|
||||||
"ok"
|
"ok"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn metrics_endpoint(State(state): State<AppState>) -> impl IntoResponse {
|
||||||
|
state.metrics.render()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tracing::instrument(skip(state))]
|
||||||
async fn issue_dev_token(
|
async fn issue_dev_token(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Json(req): Json<DevTokenRequest>,
|
Json(req): Json<DevTokenRequest>,
|
||||||
) -> Result<Json<TokenResponse>, ApiError> {
|
) -> Result<Json<TokenResponse>, ApiError> {
|
||||||
|
let started = Instant::now();
|
||||||
let now = Utc::now();
|
let now = Utc::now();
|
||||||
let exp = now + Duration::hours(24);
|
let exp = now + Duration::hours(24);
|
||||||
let claims = Claims {
|
let claims = Claims {
|
||||||
@@ -141,9 +173,68 @@ async fn issue_dev_token(
|
|||||||
)
|
)
|
||||||
.map_err(ApiError::internal)?;
|
.map_err(ApiError::internal)?;
|
||||||
|
|
||||||
|
sync_jwt_cache(&state, &claims).await?;
|
||||||
|
metrics::counter!("auth_jwt_issued_total").increment(1);
|
||||||
|
metrics::histogram!("auth_issue_token_latency_ms")
|
||||||
|
.record(started.elapsed().as_secs_f64() * 1000.0);
|
||||||
|
|
||||||
|
Ok(Json(TokenResponse {
|
||||||
|
token,
|
||||||
|
expires_at: exp.timestamp(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tracing::instrument(skip(state, req))]
|
||||||
|
async fn validate_token(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(req): Json<ValidateRequest>,
|
||||||
|
) -> Result<Json<ValidateResponse>, ApiError> {
|
||||||
|
let started = Instant::now();
|
||||||
|
let decoded = decode::<Claims>(
|
||||||
|
&req.token,
|
||||||
|
&DecodingKey::from_secret(state.jwt_secret.as_bytes()),
|
||||||
|
&Validation::new(Algorithm::HS256),
|
||||||
|
);
|
||||||
|
|
||||||
|
let response = match decoded {
|
||||||
|
Ok(tok) => {
|
||||||
|
let claims = tok.claims;
|
||||||
|
let ent = match load_plan_for_user(&state, &claims.sub).await? {
|
||||||
|
Some(db_plan) => db_plan,
|
||||||
|
None => PlanEntitlement {
|
||||||
|
tier: claims.tier.clone(),
|
||||||
|
max_tunnels: claims.max_tunnels,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
sync_plan_cache(&state, &claims.sub, &ent, "auth-validate").await?;
|
||||||
|
metrics::counter!("auth_token_validate_total", "result" => "valid").increment(1);
|
||||||
|
ValidateResponse {
|
||||||
|
valid: true,
|
||||||
|
user_id: Some(claims.sub),
|
||||||
|
tier: Some(ent.tier),
|
||||||
|
max_tunnels: Some(ent.max_tunnels),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
metrics::counter!("auth_token_validate_total", "result" => "invalid").increment(1);
|
||||||
|
ValidateResponse {
|
||||||
|
valid: false,
|
||||||
|
user_id: None,
|
||||||
|
tier: None,
|
||||||
|
max_tunnels: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
metrics::histogram!("auth_validate_token_latency_ms")
|
||||||
|
.record(started.elapsed().as_secs_f64() * 1000.0);
|
||||||
|
Ok(Json(response))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn sync_jwt_cache(state: &AppState, claims: &Claims) -> Result<(), ApiError> {
|
||||||
if let Some(mut redis) = state.redis.clone() {
|
if let Some(mut redis) = state.redis.clone() {
|
||||||
let key = format!("auth:jwt:jti:{}", claims.jti);
|
let key = format!("auth:jwt:jti:{}", claims.jti);
|
||||||
let ttl = (claims.exp as i64 - now.timestamp()).max(1);
|
let ttl = (claims.exp as i64 - Utc::now().timestamp()).max(1);
|
||||||
let payload = serde_json::json!({
|
let payload = serde_json::json!({
|
||||||
"user_id": claims.sub,
|
"user_id": claims.sub,
|
||||||
"plan_tier": claims.tier,
|
"plan_tier": claims.tier,
|
||||||
@@ -155,69 +246,53 @@ async fn issue_dev_token(
|
|||||||
.await
|
.await
|
||||||
.map_err(ApiError::internal)?;
|
.map_err(ApiError::internal)?;
|
||||||
}
|
}
|
||||||
|
Ok(())
|
||||||
Ok(Json(TokenResponse {
|
|
||||||
token,
|
|
||||||
expires_at: exp.timestamp(),
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn validate_token(
|
async fn sync_plan_cache(
|
||||||
State(state): State<AppState>,
|
state: &AppState,
|
||||||
Json(req): Json<ValidateRequest>,
|
user_id: &str,
|
||||||
) -> Result<Json<ValidateResponse>, ApiError> {
|
ent: &PlanEntitlement,
|
||||||
let decoded = decode::<Claims>(
|
source: &str,
|
||||||
&req.token,
|
) -> Result<(), ApiError> {
|
||||||
&DecodingKey::from_secret(state.jwt_secret.as_bytes()),
|
|
||||||
&Validation::new(Algorithm::HS256),
|
|
||||||
);
|
|
||||||
|
|
||||||
match decoded {
|
|
||||||
Ok(tok) => {
|
|
||||||
let c = tok.claims;
|
|
||||||
if let Some(mut redis) = state.redis.clone() {
|
if let Some(mut redis) = state.redis.clone() {
|
||||||
let key = format!("plan:user:{}", c.sub);
|
let key = format!("plan:user:{user_id}");
|
||||||
let payload = serde_json::json!({
|
let payload = serde_json::json!({
|
||||||
"tier": c.tier,
|
"tier": ent.tier,
|
||||||
"max_tunnels": c.max_tunnels,
|
"max_tunnels": ent.max_tunnels,
|
||||||
"source": "auth-api"
|
"source": source,
|
||||||
|
"updated_at": Utc::now().timestamp()
|
||||||
})
|
})
|
||||||
.to_string();
|
.to_string();
|
||||||
let _: () = redis.set_ex(key, payload, 300).await.map_err(ApiError::internal)?;
|
let _: () = redis.set_ex(key, payload, 300).await.map_err(ApiError::internal)?;
|
||||||
}
|
}
|
||||||
Ok(Json(ValidateResponse {
|
Ok(())
|
||||||
valid: true,
|
|
||||||
user_id: Some(c.sub),
|
|
||||||
tier: Some(c.tier),
|
|
||||||
max_tunnels: Some(c.max_tunnels),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
Err(_) => Ok(Json(ValidateResponse {
|
|
||||||
valid: false,
|
|
||||||
user_id: None,
|
|
||||||
tier: None,
|
|
||||||
max_tunnels: None,
|
|
||||||
})),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn stripe_webhook(
|
async fn load_plan_for_user(state: &AppState, user_id: &str) -> Result<Option<PlanEntitlement>, ApiError> {
|
||||||
State(state): State<AppState>,
|
let Some(pg) = &state.pg else {
|
||||||
Json(event): Json<StripeWebhookEvent>,
|
return Ok(None);
|
||||||
) -> Result<impl IntoResponse, ApiError> {
|
};
|
||||||
if let Some(mut redis) = state.redis.clone() {
|
let _ = Uuid::parse_str(user_id).map_err(ApiError::bad_request)?;
|
||||||
let key = format!("plan:user:{}", event.user_id);
|
let row = pg
|
||||||
let payload = serde_json::json!({
|
.query_opt(
|
||||||
"tier": event.tier,
|
r#"
|
||||||
"max_tunnels": event.max_tunnels,
|
select p.id as plan_id, p.max_tunnels
|
||||||
"source": "stripe_webhook",
|
from subscriptions s
|
||||||
"last_event_type": event.event_type,
|
join plans p on p.id = s.plan_id
|
||||||
"updated_at": Utc::now().timestamp(),
|
where s.user_id = $1::uuid and s.status in ('active', 'trialing')
|
||||||
})
|
order by s.updated_at desc
|
||||||
.to_string();
|
limit 1
|
||||||
let _: () = redis.set_ex(key, payload, 300).await.map_err(ApiError::internal)?;
|
"#,
|
||||||
}
|
&[&user_id],
|
||||||
Ok(StatusCode::NO_CONTENT)
|
)
|
||||||
|
.await
|
||||||
|
.map_err(ApiError::internal)?;
|
||||||
|
|
||||||
|
Ok(row.map(|r| PlanEntitlement {
|
||||||
|
tier: r.get::<_, String>("plan_id"),
|
||||||
|
max_tunnels: r.get::<_, i32>("max_tunnels") as u32,
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
@@ -233,6 +308,14 @@ impl ApiError {
|
|||||||
message: e.to_string(),
|
message: e.to_string(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn bad_request<E: std::fmt::Display>(e: E) -> Self {
|
||||||
|
Self {
|
||||||
|
status: StatusCode::BAD_REQUEST,
|
||||||
|
message: e.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl IntoResponse for ApiError {
|
impl IntoResponse for ApiError {
|
||||||
|
|||||||
@@ -2,6 +2,23 @@
|
|||||||
name = "client"
|
name = "client"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
description = "Play-DVV client"
|
||||||
|
license = "MIT"
|
||||||
|
authors = ["Play-DVV Team <ops@dvv.one>"]
|
||||||
|
|
||||||
|
[package.metadata.deb]
|
||||||
|
maintainer = "Play-DVV Team <ops@dvv.one>"
|
||||||
|
section = "net"
|
||||||
|
priority = "optional"
|
||||||
|
assets = [
|
||||||
|
["target/release/client", "usr/bin/client", "755"]
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.metadata.rpm]
|
||||||
|
package = "client"
|
||||||
|
assets = [
|
||||||
|
{ source = "target/release/client", dest = "/usr/bin/client", mode = "755" }
|
||||||
|
]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
|
|||||||
@@ -57,6 +57,30 @@ pub struct RelayForwardPrelude {
|
|||||||
pub initial_data: Vec<u8>,
|
pub initial_data: Vec<u8>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct R2rStreamData {
|
||||||
|
pub session_id: String,
|
||||||
|
pub stream_id: String,
|
||||||
|
pub data: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct R2rStreamClosed {
|
||||||
|
pub session_id: String,
|
||||||
|
pub stream_id: String,
|
||||||
|
pub reason: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "type", content = "data")]
|
||||||
|
pub enum R2rFrame {
|
||||||
|
Open(RelayForwardPrelude),
|
||||||
|
Data(R2rStreamData),
|
||||||
|
Close(R2rStreamClosed),
|
||||||
|
Ping,
|
||||||
|
Pong,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(tag = "type", content = "data")]
|
#[serde(tag = "type", content = "data")]
|
||||||
pub enum ClientFrame {
|
pub enum ClientFrame {
|
||||||
|
|||||||
@@ -35,6 +35,9 @@ create table if not exists subscriptions (
|
|||||||
);
|
);
|
||||||
|
|
||||||
create index if not exists subscriptions_user_id_idx on subscriptions(user_id);
|
create index if not exists subscriptions_user_id_idx on subscriptions(user_id);
|
||||||
|
create unique index if not exists subscriptions_provider_subscription_id_uidx
|
||||||
|
on subscriptions(provider_subscription_id)
|
||||||
|
where provider_subscription_id is not null;
|
||||||
|
|
||||||
create table if not exists tunnels (
|
create table if not exists tunnels (
|
||||||
id uuid primary key default gen_random_uuid(),
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
|||||||
@@ -2,6 +2,23 @@
|
|||||||
name = "relay"
|
name = "relay"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
description = "Play-DVV relay server"
|
||||||
|
license = "MIT"
|
||||||
|
authors = ["Play-DVV Team <ops@dvv.one>"]
|
||||||
|
|
||||||
|
[package.metadata.deb]
|
||||||
|
maintainer = "Play-DVV Team <ops@dvv.one>"
|
||||||
|
section = "net"
|
||||||
|
priority = "optional"
|
||||||
|
assets = [
|
||||||
|
["target/release/relay", "usr/bin/relay", "755"]
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.metadata.rpm]
|
||||||
|
package = "relay"
|
||||||
|
assets = [
|
||||||
|
{ source = "target/release/relay", dest = "/usr/bin/relay", mode = "755" }
|
||||||
|
]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
@@ -14,4 +31,10 @@ redis.workspace = true
|
|||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
chrono.workspace = true
|
chrono.workspace = true
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
|
metrics.workspace = true
|
||||||
|
metrics-exporter-prometheus.workspace = true
|
||||||
common = { path = "../common" }
|
common = { path = "../common" }
|
||||||
|
quinn.workspace = true
|
||||||
|
rustls.workspace = true
|
||||||
|
rcgen.workspace = true
|
||||||
|
rustls-pemfile.workspace = true
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user