Valkyrian Labs logo

Downloads and configuration backups

Raw (non-JSON) replies with Client::download, size limits, streaming, and handling backups safely.

Downloads and configuration backups

Most FortiOS endpoints answer with JSON, which Client::get and the typed endpoints decode. Some answer with a file instead: the configuration backup (monitor/system/config/backup) is plain FortiOS configuration text. Client::download returns any reply body as raw bytes, text or binary, without decoding it. It uses the client's transport, authentication, TLS verification, timeouts, retry policy and error handling.

1#include <forti_api/monitor.hpp>2using namespace fortiapi;3 4Download backup = Monitor::download("system/config/backup", Query().set("scope", "global"));5// backup.http_status          2006// backup.content_type         "application/octet-stream" (FortiOS 7.4.8)7// backup.content_disposition  as sent by the device8// backup.body                 the configuration, starting with "#config-version="9// backup.bytes                its size

Monitor::download(relative, query, options, client) prefixes monitor/ like Monitor::get; Client::download(api_path, query, options) takes a full API path. A second overload, Client::download(method, api_path, query, body, options), sends any method with an optional JSON body. POST, PUT and DELETE follow the write retry policy: they are never repeated once they may have reached the device.

Limits and streaming

1struct DownloadOptions {2    std::size_t max_bytes = 64 * 1024 * 1024;3    std::function<void(std::string_view)> sink;4};
  • max_bytes caps the body, buffered or streamed. A larger body aborts the transfer with fortiapi::SizeLimitError (limit() returns the cap). Nothing is returned, the partial buffer is overwritten and discarded, and the request is not retried. The limit counts decoded bytes, after HTTP compression.
  • sink, when set, receives the body of a successful reply in chunks as it arrives, and Download::body stays empty (bytes still counts it). Error replies never reach the sink. After any exception the sink may hold part of the body, which the caller must discard.

Errors and retries

  • HTTP outside 2xx throws ApiError, as Client::get does. When the error body is a FortiOS JSON envelope, fortios_error() and the CLI messages are available; any other body is kept as body()["raw"].
  • A 2xx reply is returned as it is. If an endpoint can answer either with a file or with a JSON envelope, check content_type.
  • Retries match the rest of the client: GET is retried on transport errors and HTTP 429, 502, 503 and 504, but not after part of the body has reached a sink, because a sink cannot take data back.
  • Config::on_exchange records a download like any other request, except that the record of a successful reply never carries the body. Error replies are recorded with their body. The API key never appears in a record or in an exception message.

Handling configuration backups

A configuration backup holds the device's whole configuration: password hashes, pre-shared keys, private keys, API tokens and the network layout. Treat it as a credential.

  • Store backups with restrictive permissions (owner read/write only, 0600), created that way rather than tightened afterwards.
  • Never log, print or trace a backup, and never put one in an exception message or a crash report.
  • Never write backups into shared temporary directories such as /tmp; use a private directory owned by the service.
  • Keep backups in memory no longer than needed. body is an ordinary std::string: overwrite it before releasing it if your threat model calls for it.

Streaming straight into a file created with 0600 keeps the backup out of memory:

1#include <fcntl.h>2#include <unistd.h>3 4int fd = ::open("/var/lib/myservice/backups/fw.conf", O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC, 0600);5if (fd < 0) throw std::system_error(errno, std::generic_category(), "backup file");6DownloadOptions options;7options.sink = [fd](std::string_view chunk) {8    while (!chunk.empty()) {9        ssize_t n = ::write(fd, chunk.data(), chunk.size());10        if (n < 0) throw std::system_error(errno, std::generic_category(), "backup write");11        chunk.remove_prefix(static_cast<std::size_t>(n));12    }13};14try {15    Monitor::download("system/config/backup", Query().set("scope", "global"), options);16} catch (...) {17    ::close(fd);18    ::unlink("/var/lib/myservice/backups/fw.conf");   // never keep a partial backup19    throw;20}21::close(fd);

On FortiOS 7.4.8 a GET with scope=global returns the whole device configuration as application/octet-stream; the live test suite validates this. The web GUI sends a POST to the same path instead.

Custom transports

HttpRequest carries two fields for downloads, and HttpResponse one, all defaulted, so existing Transport implementations compile and work unchanged:

FieldMeaning
HttpRequest::max_bodyStop reading once the body exceeds it, and throw SizeLimitError
HttpRequest::on_bodyWhen set, deliver the body of a 2xx reply here in chunks instead of HttpResponse::body
HttpResponse::headersThe final response's headers, as received; header(name) looks one up case-insensitively

fortiapi::detail::BodyWriter implements both body rules, and CurlTransport uses it. A transport that ignores them still works: the client then checks the limit and feeds the sink once the whole body has arrived, so memory is bounded only by the transport.