From ba20987ae721f9ffdeb8a81e0116e665e4dbec39 Mon Sep 17 00:00:00 2001 From: Dominik Csapak Date: Tue, 29 Sep 2020 16:18:58 +0200 Subject: [PATCH] client/remote: add support to specify port number this adds the ability to add port numbers in the backup repo spec as well as remotes, so that user that are behind a NAT/Firewall/Reverse proxy can still use it also adds some explanation and examples to the docs to make it clearer for h2 client i left the localhost:8007 part, since it is not configurable where we bind to Signed-off-by: Dominik Csapak --- docs/administration-guide.rst | 17 +++++++++++- src/api2/config/remote.rs | 16 ++++++++++++ src/api2/pull.rs | 4 +-- src/api2/types/mod.rs | 2 +- src/bin/proxmox-backup-client.rs | 30 +++++++++++----------- src/bin/proxmox-backup-manager.rs | 5 ++-- src/bin/proxmox_backup_client/benchmark.rs | 2 +- src/bin/proxmox_backup_client/catalog.rs | 4 +-- src/bin/proxmox_backup_client/mount.rs | 2 +- src/bin/proxmox_backup_client/task.rs | 6 ++--- src/client/backup_reader.rs | 2 +- src/client/backup_repo.rs | 29 +++++++++++++-------- src/client/backup_writer.rs | 2 +- src/client/http_client.rs | 29 +++++++++++++-------- src/client/pull.rs | 2 +- src/config/remote.rs | 7 +++++ 16 files changed, 108 insertions(+), 51 deletions(-) diff --git a/docs/administration-guide.rst b/docs/administration-guide.rst index c270bdb1..9769f3a5 100644 --- a/docs/administration-guide.rst +++ b/docs/administration-guide.rst @@ -732,11 +732,14 @@ Repository Locations The client uses the following notation to specify a datastore repository on the backup server. - [[username@]server:]datastore + [[username@]server[:port]:]datastore The default value for ``username`` ist ``root@pam``. If no server is specified, the default is the local host (``localhost``). +You can specify a port if your backup server is only reachable on a different +port (e.g. with NAT and port forwarding). + Note that if the server is an IPv6 address, you have to write it with square brackets (e.g. [fe80::01]). @@ -744,6 +747,18 @@ You can pass the repository with the ``--repository`` command line option, or by setting the ``PBS_REPOSITORY`` environment variable. +Here some examples of valid repositories and the real values + +================================ ============ ================== =========== +Example User Host:Port Datastore +================================ ============ ================== =========== +mydatastore ``root@pam`` localhost:8007 mydatastore +myhostname:mydatastore ``root@pam`` myhostname:8007 mydatastore +user@pbs@myhostname:mydatastore ``user@pbs`` myhostname:8007 mydatastore +192.168.55.55:1234:mydatastore ``root@pam`` 192.168.55.55:1234 mydatastore +[ff80::51]:mydatastore ``root@pam`` [ff80::51]:8007 mydatastore +[ff80::51]:1234:mydatastore ``root@pam`` [ff80::51]:1234 mydatastore +================================ ============ ================== =========== Environment Variables ~~~~~~~~~~~~~~~~~~~~~ diff --git a/src/api2/config/remote.rs b/src/api2/config/remote.rs index faef51d6..65dedd7e 100644 --- a/src/api2/config/remote.rs +++ b/src/api2/config/remote.rs @@ -60,6 +60,12 @@ pub fn list_remotes( host: { schema: DNS_NAME_OR_IP_SCHEMA, }, + port: { + description: "The (optional) port.", + type: u16, + optional: true, + default: 8007, + }, userid: { type: Userid, }, @@ -136,6 +142,8 @@ pub enum DeletableProperty { comment, /// Delete the fingerprint property. fingerprint, + /// Delete the port property. + port, } #[api( @@ -153,6 +161,11 @@ pub enum DeletableProperty { optional: true, schema: DNS_NAME_OR_IP_SCHEMA, }, + port: { + description: "The (optional) port.", + type: u16, + optional: true, + }, userid: { optional: true, type: Userid, @@ -188,6 +201,7 @@ pub fn update_remote( name: String, comment: Option, host: Option, + port: Option, userid: Option, password: Option, fingerprint: Option, @@ -211,6 +225,7 @@ pub fn update_remote( match delete_prop { DeletableProperty::comment => { data.comment = None; }, DeletableProperty::fingerprint => { data.fingerprint = None; }, + DeletableProperty::port => { data.port = None; }, } } } @@ -224,6 +239,7 @@ pub fn update_remote( } } if let Some(host) = host { data.host = host; } + if port.is_some() { data.port = port; } if let Some(userid) = userid { data.userid = userid; } if let Some(password) = password { data.password = password; } diff --git a/src/api2/pull.rs b/src/api2/pull.rs index 09e27a17..3edbdf2f 100644 --- a/src/api2/pull.rs +++ b/src/api2/pull.rs @@ -55,12 +55,12 @@ pub async fn get_pull_parameters( .password(Some(remote.password.clone())) .fingerprint(remote.fingerprint.clone()); - let client = HttpClient::new(&remote.host, &remote.userid, options)?; + let client = HttpClient::new(&remote.host, remote.port.unwrap_or(8007), &remote.userid, options)?; let _auth_info = client.login() // make sure we can auth .await .map_err(|err| format_err!("remote connection to '{}' failed - {}", remote.host, err))?; - let src_repo = BackupRepository::new(Some(remote.userid), Some(remote.host), remote_store.to_string()); + let src_repo = BackupRepository::new(Some(remote.userid), Some(remote.host), remote.port, remote_store.to_string()); Ok((client, src_repo, tgt_store)) } diff --git a/src/api2/types/mod.rs b/src/api2/types/mod.rs index 6ad67607..a5c8e3e9 100644 --- a/src/api2/types/mod.rs +++ b/src/api2/types/mod.rs @@ -65,7 +65,7 @@ const_regex!{ pub DNS_NAME_OR_IP_REGEX = concat!(r"^", DNS_NAME!(), "|", IPRE!(), r"$"); - pub BACKUP_REPO_URL_REGEX = concat!(r"^^(?:(?:(", USER_ID_REGEX_STR!(), ")@)?(", DNS_NAME!(), "|", IPRE_BRACKET!() ,"):)?(", PROXMOX_SAFE_ID_REGEX_STR!(), r")$"); + pub BACKUP_REPO_URL_REGEX = concat!(r"^^(?:(?:(", USER_ID_REGEX_STR!(), ")@)?(", DNS_NAME!(), "|", IPRE_BRACKET!() ,"):)?(?:([0-9]{1,5}):)?(", PROXMOX_SAFE_ID_REGEX_STR!(), r")$"); pub CERT_FINGERPRINT_SHA256_REGEX = r"^(?:[0-9a-fA-F][0-9a-fA-F])(?::[0-9a-fA-F][0-9a-fA-F]){31}$"; diff --git a/src/bin/proxmox-backup-client.rs b/src/bin/proxmox-backup-client.rs index c0cd86a6..2403bea6 100644 --- a/src/bin/proxmox-backup-client.rs +++ b/src/bin/proxmox-backup-client.rs @@ -192,7 +192,7 @@ pub fn complete_repository(_arg: &str, _param: &HashMap) -> Vec< result } -fn connect(server: &str, userid: &Userid) -> Result { +fn connect(server: &str, port: u16, userid: &Userid) -> Result { let fingerprint = std::env::var(ENV_VAR_PBS_FINGERPRINT).ok(); @@ -211,7 +211,7 @@ fn connect(server: &str, userid: &Userid) -> Result { .fingerprint_cache(true) .ticket_cache(true); - HttpClient::new(server, userid, options) + HttpClient::new(server, port, userid, options) } async fn view_task_result( @@ -365,7 +365,7 @@ async fn list_backup_groups(param: Value) -> Result { let repo = extract_repository_from_value(¶m)?; - let client = connect(repo.host(), repo.user())?; + let client = connect(repo.host(), repo.port(), repo.user())?; let path = format!("api2/json/admin/datastore/{}/groups", repo.store()); @@ -438,7 +438,7 @@ async fn list_snapshots(param: Value) -> Result { let output_format = get_output_format(¶m); - let client = connect(repo.host(), repo.user())?; + let client = connect(repo.host(), repo.port(), repo.user())?; let group: Option = if let Some(path) = param["group"].as_str() { Some(path.parse()?) @@ -503,7 +503,7 @@ async fn forget_snapshots(param: Value) -> Result { let path = tools::required_string_param(¶m, "snapshot")?; let snapshot: BackupDir = path.parse()?; - let mut client = connect(repo.host(), repo.user())?; + let mut client = connect(repo.host(), repo.port(), repo.user())?; let path = format!("api2/json/admin/datastore/{}/snapshots", repo.store()); @@ -533,7 +533,7 @@ async fn api_login(param: Value) -> Result { let repo = extract_repository_from_value(¶m)?; - let client = connect(repo.host(), repo.user())?; + let client = connect(repo.host(), repo.port(), repo.user())?; client.login().await?; record_repository(&repo); @@ -590,7 +590,7 @@ async fn api_version(param: Value) -> Result<(), Error> { let repo = extract_repository_from_value(¶m); if let Ok(repo) = repo { - let client = connect(repo.host(), repo.user())?; + let client = connect(repo.host(), repo.port(), repo.user())?; match client.get("api2/json/version", None).await { Ok(mut result) => version_info["server"] = result["data"].take(), @@ -640,7 +640,7 @@ async fn list_snapshot_files(param: Value) -> Result { let output_format = get_output_format(¶m); - let client = connect(repo.host(), repo.user())?; + let client = connect(repo.host(), repo.port(), repo.user())?; let path = format!("api2/json/admin/datastore/{}/files", repo.store()); @@ -684,7 +684,7 @@ async fn start_garbage_collection(param: Value) -> Result { let output_format = get_output_format(¶m); - let mut client = connect(repo.host(), repo.user())?; + let mut client = connect(repo.host(), repo.port(), repo.user())?; let path = format!("api2/json/admin/datastore/{}/gc", repo.store()); @@ -996,7 +996,7 @@ async fn create_backup( let backup_time = backup_time_opt.unwrap_or_else(|| epoch_i64()); - let client = connect(repo.host(), repo.user())?; + let client = connect(repo.host(), repo.port(), repo.user())?; record_repository(&repo); println!("Starting backup: {}/{}/{}", backup_type, backup_id, BackupDir::backup_time_to_string(backup_time)?); @@ -1299,7 +1299,7 @@ async fn restore(param: Value) -> Result { let archive_name = tools::required_string_param(¶m, "archive-name")?; - let client = connect(repo.host(), repo.user())?; + let client = connect(repo.host(), repo.port(), repo.user())?; record_repository(&repo); @@ -1472,7 +1472,7 @@ async fn upload_log(param: Value) -> Result { let snapshot = tools::required_string_param(¶m, "snapshot")?; let snapshot: BackupDir = snapshot.parse()?; - let mut client = connect(repo.host(), repo.user())?; + let mut client = connect(repo.host(), repo.port(), repo.user())?; let (keydata, crypt_mode) = keyfile_parameters(¶m)?; @@ -1543,7 +1543,7 @@ fn prune<'a>( async fn prune_async(mut param: Value) -> Result { let repo = extract_repository_from_value(¶m)?; - let mut client = connect(repo.host(), repo.user())?; + let mut client = connect(repo.host(), repo.port(), repo.user())?; let path = format!("api2/json/admin/datastore/{}/prune", repo.store()); @@ -1626,7 +1626,7 @@ async fn status(param: Value) -> Result { let output_format = get_output_format(¶m); - let client = connect(repo.host(), repo.user())?; + let client = connect(repo.host(), repo.port(), repo.user())?; let path = format!("api2/json/admin/datastore/{}/status", repo.store()); @@ -1671,7 +1671,7 @@ async fn try_get(repo: &BackupRepository, url: &str) -> Value { .fingerprint_cache(true) .ticket_cache(true); - let client = match HttpClient::new(repo.host(), repo.user(), options) { + let client = match HttpClient::new(repo.host(), repo.port(), repo.user(), options) { Ok(v) => v, _ => return Value::Null, }; diff --git a/src/bin/proxmox-backup-manager.rs b/src/bin/proxmox-backup-manager.rs index 16b8d702..f0dbea93 100644 --- a/src/bin/proxmox-backup-manager.rs +++ b/src/bin/proxmox-backup-manager.rs @@ -62,10 +62,10 @@ fn connect() -> Result { let ticket = Ticket::new("PBS", Userid::root_userid())? .sign(private_auth_key(), None)?; options = options.password(Some(ticket)); - HttpClient::new("localhost", Userid::root_userid(), options)? + HttpClient::new("localhost", 8007, Userid::root_userid(), options)? } else { options = options.ticket_cache(true).interactive(true); - HttpClient::new("localhost", Userid::root_userid(), options)? + HttpClient::new("localhost", 8007, Userid::root_userid(), options)? }; Ok(client) @@ -410,6 +410,7 @@ pub fn complete_remote_datastore_name(_arg: &str, param: &HashMap Result { } }; - let client = connect(repo.host(), repo.user())?; + let client = connect(repo.host(), repo.port(), repo.user())?; let client = BackupReader::start( client, @@ -153,7 +153,7 @@ async fn dump_catalog(param: Value) -> Result { /// Shell to interactively inspect and restore snapshots. async fn catalog_shell(param: Value) -> Result<(), Error> { let repo = extract_repository_from_value(¶m)?; - let client = connect(repo.host(), repo.user())?; + let client = connect(repo.host(), repo.port(), repo.user())?; let path = tools::required_string_param(¶m, "snapshot")?; let archive_name = tools::required_string_param(¶m, "archive-name")?; diff --git a/src/bin/proxmox_backup_client/mount.rs b/src/bin/proxmox_backup_client/mount.rs index 24224499..4f362dd2 100644 --- a/src/bin/proxmox_backup_client/mount.rs +++ b/src/bin/proxmox_backup_client/mount.rs @@ -101,7 +101,7 @@ async fn mount_do(param: Value, pipe: Option) -> Result { let repo = extract_repository_from_value(¶m)?; let archive_name = tools::required_string_param(¶m, "archive-name")?; let target = tools::required_string_param(¶m, "target")?; - let client = connect(repo.host(), repo.user())?; + let client = connect(repo.host(), repo.port(), repo.user())?; record_repository(&repo); diff --git a/src/bin/proxmox_backup_client/task.rs b/src/bin/proxmox_backup_client/task.rs index 96a28be9..72d8095c 100644 --- a/src/bin/proxmox_backup_client/task.rs +++ b/src/bin/proxmox_backup_client/task.rs @@ -48,7 +48,7 @@ async fn task_list(param: Value) -> Result { let output_format = get_output_format(¶m); let repo = extract_repository_from_value(¶m)?; - let client = connect(repo.host(), repo.user())?; + let client = connect(repo.host(), repo.port(), repo.user())?; let limit = param["limit"].as_u64().unwrap_or(50) as usize; let running = !param["all"].as_bool().unwrap_or(false); @@ -96,7 +96,7 @@ async fn task_log(param: Value) -> Result { let repo = extract_repository_from_value(¶m)?; let upid = tools::required_string_param(¶m, "upid")?; - let client = connect(repo.host(), repo.user())?; + let client = connect(repo.host(), repo.port(), repo.user())?; display_task_log(client, upid, true).await?; @@ -122,7 +122,7 @@ async fn task_stop(param: Value) -> Result { let repo = extract_repository_from_value(¶m)?; let upid_str = tools::required_string_param(¶m, "upid")?; - let mut client = connect(repo.host(), repo.user())?; + let mut client = connect(repo.host(), repo.port(), repo.user())?; let path = format!("api2/json/nodes/localhost/tasks/{}", upid_str); let _ = client.delete(&path, None).await?; diff --git a/src/client/backup_reader.rs b/src/client/backup_reader.rs index 8afaa4aa..92200f9e 100644 --- a/src/client/backup_reader.rs +++ b/src/client/backup_reader.rs @@ -54,7 +54,7 @@ impl BackupReader { "store": datastore, "debug": debug, }); - let req = HttpClient::request_builder(client.server(), "GET", "/api2/json/reader", Some(param)).unwrap(); + let req = HttpClient::request_builder(client.server(), client.port(), "GET", "/api2/json/reader", Some(param)).unwrap(); let (h2, abort) = client.start_h2_connection(req, String::from(PROXMOX_BACKUP_READER_PROTOCOL_ID_V1!())).await?; diff --git a/src/client/backup_repo.rs b/src/client/backup_repo.rs index ae40ad2d..862e93af 100644 --- a/src/client/backup_repo.rs +++ b/src/client/backup_repo.rs @@ -19,14 +19,16 @@ pub struct BackupRepository { user: Option, /// The host name or IP address host: Option, + /// The port + port: Option, /// The name of the datastore store: String, } impl BackupRepository { - pub fn new(user: Option, host: Option, store: String) -> Self { - Self { user, host, store } + pub fn new(user: Option, host: Option, port: Option, store: String) -> Self { + Self { user, host, port, store } } pub fn user(&self) -> &Userid { @@ -43,6 +45,13 @@ impl BackupRepository { "localhost" } + pub fn port(&self) -> u16 { + if let Some(port) = self.port { + return port; + } + 8007 + } + pub fn store(&self) -> &str { &self.store } @@ -50,13 +59,12 @@ impl BackupRepository { impl fmt::Display for BackupRepository { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - if let Some(ref user) = self.user { - write!(f, "{}@{}:{}", user, self.host(), self.store) - } else if let Some(ref host) = self.host { - write!(f, "{}:{}", host, self.store) - } else { - write!(f, "{}", self.store) - } + match (&self.user, &self.host, self.port) { + (Some(user), _, _) => write!(f, "{}@{}:{}:{}", user, self.host(), self.port(), self.store), + (None, Some(host), None) => write!(f, "{}:{}", host, self.store), + (None, _, Some(port)) => write!(f, "{}:{}:{}", self.host(), port, self.store), + (None, None, None) => write!(f, "{}", self.store), + } } } @@ -76,7 +84,8 @@ impl std::str::FromStr for BackupRepository { Ok(Self { user: cap.get(1).map(|m| Userid::try_from(m.as_str().to_owned())).transpose()?, host: cap.get(2).map(|m| m.as_str().to_owned()), - store: cap[3].to_owned(), + port: cap.get(3).map(|m| m.as_str().parse::()).transpose()?, + store: cap[4].to_owned(), }) } } diff --git a/src/client/backup_writer.rs b/src/client/backup_writer.rs index e0719115..b3fd3703 100644 --- a/src/client/backup_writer.rs +++ b/src/client/backup_writer.rs @@ -65,7 +65,7 @@ impl BackupWriter { }); let req = HttpClient::request_builder( - client.server(), "GET", "/api2/json/backup", Some(param)).unwrap(); + client.server(), client.port(), "GET", "/api2/json/backup", Some(param)).unwrap(); let (h2, abort) = client.start_h2_connection(req, String::from(PROXMOX_BACKUP_PROTOCOL_ID_V1!())).await?; diff --git a/src/client/http_client.rs b/src/client/http_client.rs index 692b2c3e..66c7e11f 100644 --- a/src/client/http_client.rs +++ b/src/client/http_client.rs @@ -99,6 +99,7 @@ impl HttpClientOptions { pub struct HttpClient { client: Client, server: String, + port: u16, fingerprint: Arc>>, first_auth: BroadcastFuture<()>, auth: Arc>, @@ -250,6 +251,7 @@ fn load_ticket_info(prefix: &str, server: &str, userid: &Userid) -> Option<(Stri impl HttpClient { pub fn new( server: &str, + port: u16, userid: &Userid, mut options: HttpClientOptions, ) -> Result { @@ -338,7 +340,7 @@ impl HttpClient { let authinfo = auth2.read().unwrap().clone(); (authinfo.userid, authinfo.ticket) }; - match Self::credentials(client2.clone(), server2.clone(), userid, ticket).await { + match Self::credentials(client2.clone(), server2.clone(), port, userid, ticket).await { Ok(auth) => { if use_ticket_cache & &prefix2.is_some() { let _ = store_ticket_info(prefix2.as_ref().unwrap(), &server2, &auth.userid.to_string(), &auth.ticket, &auth.token); @@ -358,6 +360,7 @@ impl HttpClient { let login_future = Self::credentials( client.clone(), server.to_owned(), + port, userid.to_owned(), password.to_owned(), ).map_ok({ @@ -377,6 +380,7 @@ impl HttpClient { Ok(Self { client, server: String::from(server), + port, fingerprint: verified_fingerprint, auth, ticket_abort, @@ -486,7 +490,7 @@ impl HttpClient { path: &str, data: Option, ) -> Result { - let req = Self::request_builder(&self.server, "GET", path, data).unwrap(); + let req = Self::request_builder(&self.server, self.port, "GET", path, data)?; self.request(req).await } @@ -495,7 +499,7 @@ impl HttpClient { path: &str, data: Option, ) -> Result { - let req = Self::request_builder(&self.server, "DELETE", path, data).unwrap(); + let req = Self::request_builder(&self.server, self.port, "DELETE", path, data)?; self.request(req).await } @@ -504,7 +508,7 @@ impl HttpClient { path: &str, data: Option, ) -> Result { - let req = Self::request_builder(&self.server, "POST", path, data).unwrap(); + let req = Self::request_builder(&self.server, self.port, "POST", path, data)?; self.request(req).await } @@ -513,7 +517,7 @@ impl HttpClient { path: &str, output: &mut (dyn Write + Send), ) -> Result<(), Error> { - let mut req = Self::request_builder(&self.server, "GET", path, None).unwrap(); + let mut req = Self::request_builder(&self.server, self.port, "GET", path, None)?; let client = self.client.clone(); @@ -549,7 +553,7 @@ impl HttpClient { ) -> Result { let path = path.trim_matches('/'); - let mut url = format!("https://{}:8007/{}", &self.server, path); + let mut url = format!("https://{}:{}/{}", &self.server, self.port, path); if let Some(data) = data { let query = tools::json_object_to_query(data).unwrap(); @@ -624,11 +628,12 @@ impl HttpClient { async fn credentials( client: Client, server: String, + port: u16, username: Userid, password: String, ) -> Result { let data = json!({ "username": username, "password": password }); - let req = Self::request_builder(&server, "POST", "/api2/json/access/ticket", Some(data)).unwrap(); + let req = Self::request_builder(&server, port, "POST", "/api2/json/access/ticket", Some(data))?; let cred = Self::api_request(client, req).await?; let auth = AuthInfo { userid: cred["data"]["username"].as_str().unwrap().parse()?, @@ -672,9 +677,13 @@ impl HttpClient { &self.server } - pub fn request_builder(server: &str, method: &str, path: &str, data: Option) -> Result, Error> { + pub fn port(&self) -> u16 { + self.port + } + + pub fn request_builder(server: &str, port: u16, method: &str, path: &str, data: Option) -> Result, Error> { let path = path.trim_matches('/'); - let url: Uri = format!("https://{}:8007/{}", server, path).parse()?; + let url: Uri = format!("https://{}:{}/{}", server, port, path).parse()?; if let Some(data) = data { if method == "POST" { @@ -687,7 +696,7 @@ impl HttpClient { return Ok(request); } else { let query = tools::json_object_to_query(data)?; - let url: Uri = format!("https://{}:8007/{}?{}", server, path, query).parse()?; + let url: Uri = format!("https://{}:{}/{}?{}", server, port, path, query).parse()?; let request = Request::builder() .method(method) .uri(url) diff --git a/src/client/pull.rs b/src/client/pull.rs index 94143d61..86843b99 100644 --- a/src/client/pull.rs +++ b/src/client/pull.rs @@ -439,7 +439,7 @@ pub async fn pull_group( .password(Some(auth_info.ticket.clone())) .fingerprint(fingerprint.clone()); - let new_client = HttpClient::new(src_repo.host(), src_repo.user(), options)?; + let new_client = HttpClient::new(src_repo.host(), src_repo.port(), src_repo.user(), options)?; let reader = BackupReader::start( new_client, diff --git a/src/config/remote.rs b/src/config/remote.rs index ae2d8957..9e597342 100644 --- a/src/config/remote.rs +++ b/src/config/remote.rs @@ -39,6 +39,11 @@ pub const REMOTE_PASSWORD_SCHEMA: Schema = StringSchema::new("Password or auth t host: { schema: DNS_NAME_OR_IP_SCHEMA, }, + port: { + optional: true, + description: "The (optional) port", + type: u16, + }, userid: { type: Userid, }, @@ -58,6 +63,8 @@ pub struct Remote { #[serde(skip_serializing_if="Option::is_none")] pub comment: Option, pub host: String, + #[serde(skip_serializing_if="Option::is_none")] + pub port: Option, pub userid: Userid, #[serde(skip_serializing_if="String::is_empty")] #[serde(with = "proxmox::tools::serde::string_as_base64")] -- 2.39.2