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, 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. /// Connect-RPC client for the Forgejo Actions Runner API.
/// ///
/// The Forgejo runner API uses the Connect protocol (HTTP/1.1 POST with raw /// The Forgejo runner API uses the Connect protocol (HTTP/1.1 POST with raw
/// protobuf bodies), not standard gRPC framing. Each RPC maps to: /// protobuf bodies), not standard gRPC framing. Each RPC maps to:
/// POST {base_url}/runner.v1.RunnerService/{Method} /// POST {base_url}/runner.v1.RunnerService/{Method}
/// Content-Type: application/proto /// Content-Type: application/proto
/// Authorization: Bearer {token}
pub struct ConnectClient { pub struct ConnectClient {
http: reqwest::Client, http: reqwest::Client,
/// Base URL for the connect-rpc endpoint, e.g. /// Base URL for the connect-rpc endpoint, e.g.
@ -34,27 +43,35 @@ impl ConnectClient {
} }
/// Execute a unary connect-rpc call. /// 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>( async fn call<Req: Message, Resp: Message + Default>(
&self, &self,
method: &str, method: &str,
req_msg: &Req, req_msg: &Req,
token: &str, auth: Auth<'_>,
) -> Result<Resp> { ) -> Result<Resp> {
let url = format!("{}/runner.v1.RunnerService/{}", self.base_url, method); let url = format!("{}/runner.v1.RunnerService/{}", self.base_url, method);
debug!(url = %url, "connect-rpc call"); debug!(url = %url, "connect-rpc call");
let body = req_msg.encode_to_vec(); let body = req_msg.encode_to_vec();
let resp = self let mut req = self
.http .http
.post(&url) .post(&url)
.header("Content-Type", "application/proto") .header("Content-Type", "application/proto");
.header("Authorization", format!("Bearer {}", token))
.body(body) // Forgejo runner API uses custom auth headers
.send() match auth {
.await Auth::Registration(token) => {
.into_diagnostic()?; 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(); let status = resp.status();
if !status.is_success() { if !status.is_success() {
@ -72,48 +89,85 @@ impl ConnectClient {
} }
/// Register this runner with the Forgejo instance. /// 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( pub async fn register(
&self, &self,
req: &RegisterRequest, req: &RegisterRequest,
registration_token: &str, registration_token: &str,
) -> Result<RegisterResponse> { ) -> 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. /// Declare runner version and labels after registration.
pub async fn declare( pub async fn declare(
&self, &self,
req: &DeclareRequest, req: &DeclareRequest,
uuid: &str,
runner_token: &str, runner_token: &str,
) -> Result<DeclareResponse> { ) -> 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. /// Long-poll for the next available task.
pub async fn fetch_task( pub async fn fetch_task(
&self, &self,
req: &FetchTaskRequest, req: &FetchTaskRequest,
uuid: &str,
runner_token: &str, runner_token: &str,
) -> Result<FetchTaskResponse> { ) -> 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.). /// Update a task's state (running, success, failure, etc.).
pub async fn update_task( pub async fn update_task(
&self, &self,
req: &UpdateTaskRequest, req: &UpdateTaskRequest,
uuid: &str,
runner_token: &str, runner_token: &str,
) -> Result<UpdateTaskResponse> { ) -> 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. /// Upload log lines for a task.
pub async fn update_log( pub async fn update_log(
&self, &self,
req: &UpdateLogRequest, req: &UpdateLogRequest,
uuid: &str,
runner_token: &str, runner_token: &str,
) -> Result<UpdateLogResponse> { ) -> 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 // Long-poll for a task
let req = FetchTaskRequest { tasks_version }; let req = FetchTaskRequest { tasks_version };
let resp = tokio::select! { 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() => { _ = shutdown.changed() => {
info!("poller shutting down (fetching task)"); info!("poller shutting down (fetching task)");
break; break;
@ -189,7 +189,7 @@ async fn report_running(client: &ConnectClient, state: &RunnerState, task_id: i6
}), }),
outputs: Default::default(), outputs: Default::default(),
}; };
client.update_task(&req, &state.identity.token).await?; client.update_task(&req, &state.identity.uuid, &state.identity.token).await?;
Ok(()) Ok(())
} }
@ -213,7 +213,7 @@ async fn report_failure(
}), }),
outputs: Default::default(), 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 // Also send the error message as a log line
let log_req = crate::proto::runner::v1::UpdateLogRequest { let log_req = crate::proto::runner::v1::UpdateLogRequest {
@ -228,7 +228,7 @@ async fn report_failure(
}], }],
no_more: true, 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(()) Ok(())
} }

View file

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

View file

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