diff --git a/.github/workflows/ocipkg.yml b/.github/workflows/ocipkg.yml index 41a2b0c..9a9488b 100644 --- a/.github/workflows/ocipkg.yml +++ b/.github/workflows/ocipkg.yml @@ -63,6 +63,21 @@ jobs: ocipkg login -u ${{ github.repository_owner }} -p ${{ github.token }} https://ghcr.io ocipkg get ghcr.io/termoshtt/ocipkg/dynamic/rust:1d23f83 + get-anonymous: + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v1 + - uses: actions-rs/cargo@v1 + with: + command: install + args: --path=ocipkg-cli/ -f + - name: Add path + run: echo "$HOME/.cargo/bin" >> $GITHUB_PATH + + - name: get image from ghcr.io + run: | + ocipkg get ghcr.io/termoshtt/ocipkg/dynamic/rust:1d23f83 + list: runs-on: ubuntu-22.04 steps: diff --git a/ocipkg/src/distribution/auth.rs b/ocipkg/src/distribution/auth.rs index b4b7415..1342d34 100644 --- a/ocipkg/src/distribution/auth.rs +++ b/ocipkg/src/distribution/auth.rs @@ -54,7 +54,7 @@ impl StoredAuth { Ok(()) } - /// Get token for using in API call + /// Get token by trying to access API root `/v2/` /// /// Returns `None` if no authentication is required. pub fn get_token(&self, url: &url::Url) -> Result> { @@ -72,33 +72,31 @@ impl StoredAuth { Err(ureq::Error::Transport(e)) => return Err(Error::NetworkError(e)), }; - let (ty, realm) = parse_www_authenticate_header(&www_auth); - if ty != "Bearer" { - log::warn!("Unsupported authenticate type, fallback: {}", ty); - return Ok(None); - } - let (token_url, query) = parse_bearer_realm(realm)?; + let challenge = AuthChallenge::from_header(&www_auth)?; + self.challenge(&challenge).map(|token| Some(token)) + } + /// Get token based on WWW-Authentication header + pub fn challenge(&self, challenge: &AuthChallenge) -> Result { + let token_url = Url::parse(&challenge.url)?; let domain = token_url .domain() .expect("www-authenticate header returns invalid URL"); + + let mut req = ureq::get(token_url.as_str()).set("Accept", "application/json"); if let Some(auth) = self.auths.get(domain) { - let mut req = ureq::get(token_url.as_str()) - .set("Authorization", &format!("Basic {}", auth.auth)) - .set("Accept", "application/json"); - for (k, v) in query { - req = req.query(k, v); - } - match req.call() { - Ok(res) => { - let token = res.into_json::()?; - Ok(Some(token.token)) - } - Err(ureq::Error::Status(..)) => Err(Error::AuthorizationFailed(url.clone())), - Err(ureq::Error::Transport(e)) => Err(Error::NetworkError(e)), + req = req.set("Authorization", &format!("Basic {}", auth.auth)) + } + req = req + .query("scope", &challenge.scope) + .query("service", &challenge.service); + match req.call() { + Ok(res) => { + let token = res.into_json::()?; + Ok(token.token) } - } else { - Ok(None) + Err(ureq::Error::Status(..)) => Err(Error::AuthorizationFailed(token_url.clone())), + Err(ureq::Error::Transport(e)) => Err(Error::NetworkError(e)), } } @@ -145,44 +143,55 @@ fn podman_auth_path() -> Option { Some(dirs.runtime_dir()?.join("auth.json")) } -/// Parse the header of response. It must be in form: +/// WWW-Authentication challenge /// -/// ```text -/// WWW-Authenticate: realm= /// ``` +/// use ocipkg::distribution::AuthChallenge; /// -/// https://developer.mozilla.org/en-US/docs/Web/HTTP/Authentication#www-authenticate_and_proxy-authenticate_headers -fn parse_www_authenticate_header(header: &str) -> (&str, &str) { - let re = regex::Regex::new(r"(\w+) realm=(.+)").unwrap(); - let cap = re - .captures(header) - .expect("WWW-Authenticate header is invalid"); - let ty = cap.get(1).unwrap().as_str(); - let realm = cap.get(2).unwrap().as_str(); - (ty, realm) -} - -/// Parse realm -/// -/// XXX: Where this format is defined? -/// -/// ghcr.io returns following: +/// let auth = AuthChallenge::from_header( +/// r#"Bearer realm="https://ghcr.io/token",service="ghcr.io",scope="repository:termoshtt/ocipkg/rust-lib:pull""#, +/// ).unwrap(); /// -/// ```text -/// Bearer realm="https://ghcr.io/token",service="ghcr.io",scope="repository:termoshtt/ocipkg/rust-lib:pull" +/// assert_eq!(auth, AuthChallenge { +/// url: "https://ghcr.io/token".to_string(), +/// service: "ghcr.io".to_string(), +/// scope: "repository:termoshtt/ocipkg/rust-lib:pull".to_string(), +/// }); /// ``` -fn parse_bearer_realm(realm: &str) -> Result<(Url, Vec<(&str, &str)>)> { - let realm: Vec<_> = realm.split(',').collect(); - assert!(!realm.is_empty()); - let url = url::Url::parse(realm[0].trim_matches('"'))?; - let query: Vec<_> = realm[1..] - .iter() - .map(|param| { - let q: Vec<_> = param.split('=').collect(); - (q[0].trim_matches('"'), q[1].trim_matches('"')) +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AuthChallenge { + pub url: String, + pub service: String, + pub scope: String, +} + +impl AuthChallenge { + pub fn from_header(header: &str) -> Result { + let err = || Error::UnSupportedAuthHeader(header.to_string()); + let (ty, realm) = header.split_once(' ').ok_or_else(err)?; + if ty != "Bearer" { + return Err(err()); + } + + let mut url = None; + let mut service = None; + let mut scope = None; + for param in realm.split(',') { + let (key, value) = param.split_once('=').ok_or_else(err)?; + let value = value.trim_matches('"').to_string(); + match key { + "realm" => url = Some(value), + "service" => service = Some(value), + "scope" => scope = Some(value), + _ => continue, + } + } + Ok(Self { + url: url.ok_or_else(err)?, + service: service.ok_or_else(err)?, + scope: scope.ok_or_else(err)?, }) - .collect(); - Ok((url, query)) + } } #[derive(Deserialize)] diff --git a/ocipkg/src/distribution/client.rs b/ocipkg/src/distribution/client.rs index 932496b..0ec8e92 100644 --- a/ocipkg/src/distribution/client.rs +++ b/ocipkg/src/distribution/client.rs @@ -10,40 +10,60 @@ pub struct Client { url: Url, /// Name of repository name: Name, - /// Authorization token + /// Loaded authentication info from filesystem + auth: StoredAuth, + /// Cached token token: Option, } impl Client { pub fn new(url: Url, name: Name) -> Result { let auth = StoredAuth::load_all()?; - let token = auth.get_token(&url)?; Ok(Client { agent: ureq::Agent::new(), url, name, - token, + auth, + token: None, }) } - fn add_auth_header(&self, req: ureq::Request) -> ureq::Request { + fn call(&mut self, req: ureq::Request) -> Result { if let Some(token) = &self.token { - req.set("Authorization", &format!("Bearer {}", token)) - } else { - req + return Ok(req + .set("Authorization", &format!("Bearer {}", token)) + .call()?); } + + // Try get token + let try_req = req.clone(); + let www_auth = match try_req.call() { + Ok(res) => return Ok(res), + Err(ureq::Error::Status(status, res)) => { + if status == 401 && res.has("www-authenticate") { + res.header("www-authenticate").unwrap().to_string() + } else { + let err = res.into_json::()?; + return Err(Error::RegistryError(err)); + } + } + Err(ureq::Error::Transport(e)) => return Err(Error::NetworkError(e)), + }; + let challenge = AuthChallenge::from_header(&www_auth)?; + self.token = Some(self.auth.challenge(&challenge)?); + return self.call(req); } fn get(&self, url: &Url) -> ureq::Request { - self.add_auth_header(self.agent.get(url.as_str())) + self.agent.get(url.as_str()) } fn put(&self, url: &Url) -> ureq::Request { - self.add_auth_header(self.agent.put(url.as_str())) + self.agent.put(url.as_str()) } fn post(&self, url: &Url) -> ureq::Request { - self.add_auth_header(self.agent.post(url.as_str())) + self.agent.post(url.as_str()) } /// Get tags of `` repository. @@ -53,9 +73,9 @@ impl Client { /// ``` /// /// See [corresponding OCI distribution spec document](https://github.com/opencontainers/distribution-spec/blob/main/spec.md#content-discovery) for detail. - pub fn get_tags(&self) -> Result> { + pub fn get_tags(&mut self) -> Result> { let url = self.url.join(&format!("/v2/{}/tags/list", self.name))?; - let res = self.get(&url).call().check_response()?; + let res = self.call(self.get(&url))?; let tag_list = res.into_json::()?; Ok(tag_list.tags().to_vec()) } @@ -67,22 +87,18 @@ impl Client { /// ``` /// /// See [corresponding OCI distribution spec document](https://github.com/opencontainers/distribution-spec/blob/main/spec.md#pulling-manifests) for detail. - pub fn get_manifest(&self, reference: &Reference) -> Result { + pub fn get_manifest(&mut self, reference: &Reference) -> Result { let url = self .url .join(&format!("/v2/{}/manifests/{}", self.name, reference))?; - let res = self - .get(&url) - .set( - "Accept", - &format!( - "{}, {}", - MediaType::ImageManifest.to_docker_v2s2().unwrap(), - MediaType::ImageManifest, - ), - ) - .call() - .check_response()?; + let res = self.call(self.get(&url).set( + "Accept", + &format!( + "{}, {}", + MediaType::ImageManifest.to_docker_v2s2().unwrap(), + MediaType::ImageManifest, + ), + ))?; let manifest = ImageManifest::from_reader(res.into_reader())?; Ok(manifest) } @@ -102,11 +118,14 @@ impl Client { let url = self .url .join(&format!("/v2/{}/manifests/{}", self.name, reference))?; - let res = self + let mut req = self .put(&url) - .set("Content-Type", &MediaType::ImageManifest.to_string()) - .send_bytes(&buf) - .check_response()?; + .set("Content-Type", &MediaType::ImageManifest.to_string()); + if let Some(token) = self.token.as_ref() { + // Authorization must be done while blobs push + req = req.set("Authorization", &format!("Bearer {}", token)); + } + let res = req.send_bytes(&buf)?; let loc = res .header("Location") .expect("Location header is lacked in OCI registry response"); @@ -120,11 +139,11 @@ impl Client { /// ``` /// /// See [corresponding OCI distribution spec document](https://github.com/opencontainers/distribution-spec/blob/main/spec.md#pulling-blobs) for detail. - pub fn get_blob(&self, digest: &Digest) -> Result> { + pub fn get_blob(&mut self, digest: &Digest) -> Result> { let url = self .url .join(&format!("/v2/{}/blobs/{}", self.name.as_str(), digest,))?; - let res = self.get(&url).call().check_response()?; + let res = self.call(self.get(&url))?; let mut bytes = Vec::new(); res.into_reader().read_to_end(&mut bytes)?; Ok(bytes) @@ -139,24 +158,27 @@ impl Client { /// and following `PUT` to URL obtained by `POST`. /// /// See [corresponding OCI distribution spec document](https://github.com/opencontainers/distribution-spec/blob/main/spec.md#pushing-manifests) for detail. - pub fn push_blob(&self, blob: &[u8]) -> Result { + pub fn push_blob(&mut self, blob: &[u8]) -> Result { let url = self .url .join(&format!("/v2/{}/blobs/uploads/", self.name))?; - let res = self.post(&url).call().check_response()?; + let res = self.call(self.post(&url))?; let loc = res .header("Location") .expect("Location header is lacked in OCI registry response"); let url = Url::parse(loc).or_else(|_| self.url.join(loc))?; let digest = Digest::from_buf_sha256(blob); - let res = self + let mut req = self .put(&url) .query("digest", &digest.to_string()) .set("Content-Length", &blob.len().to_string()) - .set("Content-Type", "application/octet-stream") - .send_bytes(blob) - .check_response()?; + .set("Content-Type", "application/octet-stream"); + if let Some(token) = self.token.as_ref() { + // Authorization must be done while the first POST + req = req.set("Authorization", &format!("Bearer {}", token)) + } + let res = req.send_bytes(blob)?; let loc = res .header("Location") .expect("Location header is lacked in OCI registry response"); @@ -164,28 +186,6 @@ impl Client { } } -trait CheckResponse { - fn check_response(self) -> Result; -} - -impl CheckResponse for std::result::Result { - fn check_response(self) -> Result { - match self { - Ok(res) => Ok(res), - Err(ureq::Error::Status(status, res)) => { - if status == 401 { - if let Some(msg) = res.header("www-authenticate") { - log::error!("Server returns WWW-Authenticate header: {}", msg); - } - } - let err = res.into_json::()?; - Err(Error::RegistryError(err)) - } - Err(ureq::Error::Transport(e)) => Err(Error::NetworkError(e)), - } - } -} - #[cfg(test)] mod tests { use super::*; @@ -205,7 +205,7 @@ mod tests { #[test] #[ignore] fn get_tags() -> Result<()> { - let client = Client::new(test_url(), test_name())?; + let mut client = Client::new(test_url(), test_name())?; let mut tags = client.get_tags()?; tags.sort_unstable(); assert_eq!( @@ -218,7 +218,7 @@ mod tests { #[test] #[ignore] fn get_images() -> Result<()> { - let client = Client::new(test_url(), test_name())?; + let mut client = Client::new(test_url(), test_name())?; for tag in ["tag1", "tag2", "tag3"] { let manifest = client.get_manifest(&Reference::new(tag)?)?; for layer in manifest.layers() { @@ -232,7 +232,7 @@ mod tests { #[test] #[ignore] fn push_blob() -> Result<()> { - let client = Client::new(test_url(), test_name())?; + let mut client = Client::new(test_url(), test_name())?; let url = client.push_blob("test string".as_bytes())?; dbg!(url); Ok(()) diff --git a/ocipkg/src/distribution/mod.rs b/ocipkg/src/distribution/mod.rs index 41929fe..4b0bdf3 100644 --- a/ocipkg/src/distribution/mod.rs +++ b/ocipkg/src/distribution/mod.rs @@ -22,7 +22,7 @@ pub fn push_image(path: &Path) -> Result<()> { let mut f = fs::File::open(&path)?; let mut ar = crate::image::Archive::new(&mut f); for (image_name, manifest) in ar.get_manifests()? { - let client = Client::new(image_name.registry_url()?, image_name.name)?; + let mut client = Client::new(image_name.registry_url()?, image_name.name)?; for layer in manifest.layers() { let digest = Digest::new(layer.digest())?; let mut entry = ar.get_blob(&digest)?; @@ -45,7 +45,7 @@ pub fn get_image(image_name: &ImageName) -> Result<()> { let ImageName { name, reference, .. } = image_name; - let client = Client::new(image_name.registry_url()?, name.clone())?; + let mut client = Client::new(image_name.registry_url()?, name.clone())?; let manifest = client.get_manifest(reference)?; let dest = crate::local::image_dir(image_name)?; log::info!("Get {} into {}", image_name, dest.display()); diff --git a/ocipkg/src/error.rs b/ocipkg/src/error.rs index d873fdb..5aed0e4 100644 --- a/ocipkg/src/error.rs +++ b/ocipkg/src/error.rs @@ -1,5 +1,5 @@ use crate::Digest; -use oci_spec::OciSpecError; +use oci_spec::{distribution::ErrorResponse, OciSpecError}; use std::path::PathBuf; #[derive(Debug, thiserror::Error)] @@ -46,9 +46,11 @@ pub enum Error { #[error(transparent)] NetworkError(#[from] ureq::Transport), #[error(transparent)] - RegistryError(#[from] oci_spec::distribution::ErrorResponse), + RegistryError(#[from] ErrorResponse), #[error("Authorization failed: {0}")] AuthorizationFailed(url::Url), + #[error("Unsupported WWW-Authentication header: {0}")] + UnSupportedAuthHeader(String), // // System error @@ -79,3 +81,15 @@ impl From for Error { Self::UnknownIo(e.into()) } } + +impl From for Error { + fn from(e: ureq::Error) -> Self { + match e { + ureq::Error::Status(_status, res) => match res.into_json::() { + Ok(err) => Error::RegistryError(err), + Err(e) => Error::UnknownIo(e), + }, + ureq::Error::Transport(e) => Error::NetworkError(e), + } + } +}