Fix Forgejo runner auth: use x-runner-token/x-runner-uuid headers

Forgejo's connect-rpc API uses custom headers for authentication, not
Authorization: Bearer. Registration uses x-runner-token only, while
post-registration calls require both x-runner-token and x-runner-uuid.
This commit is contained in:
Till Wegmueller 2026-04-06 23:43:07 +02:00
parent 70605a3c3a
commit 5dfd9c367b
4 changed files with 84 additions and 25 deletions

View file

@ -7,13 +7,22 @@ use crate::proto::runner::v1::{
RegisterResponse, UpdateLogRequest, UpdateLogResponse, UpdateTaskRequest, UpdateTaskResponse,
};
/// Authentication mode for connect-rpc calls.
/// Forgejo uses custom `x-runner-token` / `x-runner-uuid` headers,
/// NOT `Authorization: Bearer`.
pub enum Auth<'a> {
/// Registration uses only the one-time registration token.
Registration(&'a str),
/// Post-registration uses the runner's UUID + token pair.
Runner { uuid: &'a str, token: &'a str },
}
/// Connect-RPC client for the Forgejo Actions Runner API.
///
/// The Forgejo runner API uses the Connect protocol (HTTP/1.1 POST with raw
/// protobuf bodies), not standard gRPC framing. Each RPC maps to:
/// POST {base_url}/runner.v1.RunnerService/{Method}
/// Content-Type: application/proto
/// Authorization: Bearer {token}
pub struct ConnectClient {
http: reqwest::Client,
/// Base URL for the connect-rpc endpoint, e.g.
@ -34,27 +43,35 @@ impl ConnectClient {
}
/// Execute a unary connect-rpc call.
#[instrument(skip(self, req_msg, token), fields(method = %method))]
#[instrument(skip(self, req_msg, auth), fields(method = %method))]
async fn call<Req: Message, Resp: Message + Default>(
&self,
method: &str,
req_msg: &Req,
token: &str,
auth: Auth<'_>,
) -> Result<Resp> {
let url = format!("{}/runner.v1.RunnerService/{}", self.base_url, method);
debug!(url = %url, "connect-rpc call");
let body = req_msg.encode_to_vec();
let resp = self
let mut req = self
.http
.post(&url)
.header("Content-Type", "application/proto")
.header("Authorization", format!("Bearer {}", token))
.body(body)
.send()
.await
.into_diagnostic()?;
.header("Content-Type", "application/proto");
// Forgejo runner API uses custom auth headers
match auth {
Auth::Registration(token) => {
req = req.header("x-runner-token", token);
}
Auth::Runner { uuid, token } => {
req = req.header("x-runner-token", token);
req = req.header("x-runner-uuid", uuid);
}
}
let resp = req.body(body).send().await.into_diagnostic()?;
let status = resp.status();
if !status.is_success() {
@ -72,48 +89,85 @@ impl ConnectClient {
}
/// Register this runner with the Forgejo instance.
/// Uses the one-time registration token (not the runner token).
/// Uses the one-time registration token.
pub async fn register(
&self,
req: &RegisterRequest,
registration_token: &str,
) -> Result<RegisterResponse> {
self.call("Register", req, registration_token).await
self.call("Register", req, Auth::Registration(registration_token))
.await
}
/// Declare runner version and labels after registration.
pub async fn declare(
&self,
req: &DeclareRequest,
uuid: &str,
runner_token: &str,
) -> Result<DeclareResponse> {
self.call("Declare", req, runner_token).await
self.call(
"Declare",
req,
Auth::Runner {
uuid,
token: runner_token,
},
)
.await
}
/// Long-poll for the next available task.
pub async fn fetch_task(
&self,
req: &FetchTaskRequest,
uuid: &str,
runner_token: &str,
) -> Result<FetchTaskResponse> {
self.call("FetchTask", req, runner_token).await
self.call(
"FetchTask",
req,
Auth::Runner {
uuid,
token: runner_token,
},
)
.await
}
/// Update a task's state (running, success, failure, etc.).
pub async fn update_task(
&self,
req: &UpdateTaskRequest,
uuid: &str,
runner_token: &str,
) -> Result<UpdateTaskResponse> {
self.call("UpdateTask", req, runner_token).await
self.call(
"UpdateTask",
req,
Auth::Runner {
uuid,
token: runner_token,
},
)
.await
}
/// Upload log lines for a task.
pub async fn update_log(
&self,
req: &UpdateLogRequest,
uuid: &str,
runner_token: &str,
) -> Result<UpdateLogResponse> {
self.call("UpdateLog", req, runner_token).await
self.call(
"UpdateLog",
req,
Auth::Runner {
uuid,
token: runner_token,
},
)
.await
}
}

View file

@ -51,7 +51,7 @@ pub async fn run(
// Long-poll for a task
let req = FetchTaskRequest { tasks_version };
let resp = tokio::select! {
r = client.fetch_task(&req, &state.identity.token) => r,
r = client.fetch_task(&req, &state.identity.uuid, &state.identity.token) => r,
_ = shutdown.changed() => {
info!("poller shutting down (fetching task)");
break;
@ -189,7 +189,7 @@ async fn report_running(client: &ConnectClient, state: &RunnerState, task_id: i6
}),
outputs: Default::default(),
};
client.update_task(&req, &state.identity.token).await?;
client.update_task(&req, &state.identity.uuid, &state.identity.token).await?;
Ok(())
}
@ -213,7 +213,7 @@ async fn report_failure(
}),
outputs: Default::default(),
};
client.update_task(&req, &state.identity.token).await?;
client.update_task(&req, &state.identity.uuid, &state.identity.token).await?;
// Also send the error message as a log line
let log_req = crate::proto::runner::v1::UpdateLogRequest {
@ -228,7 +228,7 @@ async fn report_failure(
}],
no_more: true,
};
client.update_log(&log_req, &state.identity.token).await?;
client.update_log(&log_req, &state.identity.uuid, &state.identity.token).await?;
Ok(())
}

View file

@ -25,7 +25,7 @@ pub async fn ensure_registered(
"loaded existing runner registration"
);
// Re-declare labels on every startup so Forgejo stays in sync
declare(client, &identity.token, labels).await?;
declare(client, &identity.uuid, &identity.token, labels).await?;
return Ok(identity);
}
@ -65,17 +65,22 @@ pub async fn ensure_registered(
info!(uuid = %identity.uuid, id = identity.id, "runner registered successfully");
// Declare labels after fresh registration
declare(client, &identity.token, labels).await?;
declare(client, &identity.uuid, &identity.token, labels).await?;
Ok(identity)
}
async fn declare(client: &ConnectClient, runner_token: &str, labels: &[String]) -> Result<()> {
async fn declare(
client: &ConnectClient,
uuid: &str,
runner_token: &str,
labels: &[String],
) -> Result<()> {
let req = DeclareRequest {
version: VERSION.to_string(),
labels: labels.to_vec(),
};
client.declare(&req, runner_token).await?;
client.declare(&req, uuid, runner_token).await?;
info!(labels = ?labels, "declared runner labels");
Ok(())
}

View file

@ -176,7 +176,7 @@ async fn report_to_forgejo(
outputs: Default::default(),
};
client.update_task(&req, &state.identity.token).await?;
client.update_task(&req, &state.identity.uuid, &state.identity.token).await?;
info!(
request_id = %jobres.request_id,