Haio 0.2.X
Homebrew Asset IO/CDN
Loading...
Searching...
No Matches
server.cpp
Go to the documentation of this file.
1#include <haio_cdn.hpp>
2
3#include <bucket/bucket.hpp>
4
5#include <boost/asio/co_spawn.hpp>
6#include <boost/asio/detached.hpp>
7#include <boost/asio/ip/tcp.hpp>
8#include <boost/asio/signal_set.hpp>
9#include <boost/asio/use_awaitable.hpp>
10#include <boost/beast/core.hpp>
11#include <boost/beast/http.hpp>
12#include <boost/url/parse.hpp>
13
14#include <algorithm>
15#include <cctype>
16#include <iostream>
17#include <algorithm>
18#include <map>
19#include <memory>
20#include <optional>
21#include <numeric>
22#include <string_view>
23
24namespace asio = boost::asio;
25namespace beast = boost::beast;
26namespace http = beast::http;
27using tcp = asio::ip::tcp;
28
29namespace {
30
31std::string joinSegments(const std::vector<std::string>& segments, size_t first) {
32 return std::accumulate(
33 segments.begin() + static_cast<std::ptrdiff_t>(first),
34 segments.end(),
35 std::string{},
36 [](std::string out, const std::string& segment) {
37 if (!out.empty()) out.push_back('/');
38 out += segment;
39 return out;
40 }
41 );
42}
43
44struct Route {
45 std::string bucket;
46 std::string path;
47 std::string query;
48};
49
50Route parseRoute(std::string_view target) {
51 auto parsed = boost::urls::parse_origin_form(target);
52 if (!parsed) throw std::runtime_error("invalid request target: " + parsed.error().message());
53
54 const auto encodedPath = std::string(parsed->encoded_path());
55 constexpr std::string_view prefix = "/cdn/";
56 if (!encodedPath.starts_with(prefix)) throw std::runtime_error("expected /cdn/<bucket>/<path>");
57
58 std::vector<std::string> segments;
59 for (auto segment : parsed->segments()) segments.emplace_back(segment);
60 if (segments.empty() || segments.front() != "cdn") throw std::runtime_error("expected /cdn/<bucket>/<path>");
61
62 const auto tail = std::string_view(encodedPath).substr(prefix.size());
63 const auto query = parsed->has_query() ? std::string(parsed->encoded_query()) : std::string{};
64 if (tail.find('/') == std::string_view::npos) {
65 return Route{"file", segments.size() > 1 ? segments[1] : std::string{}, query};
66 }
67
68 auto bucket = segments.size() > 1 ? segments[1] : std::string{};
69 auto rest = segments.size() > 2 ? joinSegments(segments, 2) : std::string{};
70 if (bucket.empty()) bucket = "file";
71 return Route{std::move(bucket), std::move(rest), query};
72}
73
74std::string downloadName(std::string_view path, Haio::Format format) {
75 const auto slash = path.find_last_of('/');
76 const auto base = slash == std::string_view::npos ? path : path.substr(slash + 1);
77
78 std::string name;
79 for (const char c : base) {
80 if (std::isalnum(static_cast<unsigned char>(c)) || c == '-' || c == '_' || c == '.') name.push_back(c);
81 }
82 if (const auto dot = name.find_last_of('.'); dot != std::string::npos) name.resize(dot);
83 if (name.find_first_not_of('.') == std::string::npos) name = "download";
84
85 if (format == Haio::Format::RAW) return name;
86 return name + '.' + std::string(Haio::extensionFor(format));
87}
88
89bool hasTokenKind(const std::vector<Haio::Token>& tokens, Haio::TokenKind kind) {
90 return std::ranges::any_of(tokens, [kind](const Haio::Token& token) { return token.kind == kind; });
91}
92
93bool hasImageTransform(const std::vector<Haio::Token>& tokens) {
94 return std::ranges::any_of(tokens, [](const Haio::Token& token) {
96 });
97}
98
99/**
100 * asking for the format a file already is, with nothing else to do to it, is asking
101 * for the file.
102 *
103 * the guard is that nothing else may be in the query. a crop or a resize has to be
104 * decoded and written back out even when the container does not change, so this only
105 * applies when the encode is the whole request. where it does apply it spends no cpu
106 * and, more to the point, hands back the original bytes rather than a re-encode that
107 * merely has the same pixels.
108 *
109 * a named colour counts as something else to do. two tga files are both tga and one
110 * of them is sixteen bits a pixel, so "the container it already is" stops being the
111 * whole question the moment -pix_fmt asks the other half of it.
112 */
113bool alreadyWhatWasAsked(const std::vector<Haio::Token>& tokens, Haio::Format format) {
114 if (tokens.empty() || format == Haio::Format::RAW) return false;
115
116 return std::ranges::all_of(tokens, [format](const Haio::Token& token) {
117 return token.kind == Haio::TokenKind::Encode && token.format == format && !token.color;
118 });
119}
120
121http::response<http::vector_body<uint8_t>> makeResponse(http::status status, std::string_view contentType, std::vector<uint8_t> body) {
122 http::response<http::vector_body<uint8_t>> res{status, 11};
123 res.set(http::field::server, "haio-cdn");
124 res.set(http::field::content_type, contentType);
125 res.body() = std::move(body);
126 res.prepare_payload();
127 return res;
128}
129
130http::response<http::vector_body<uint8_t>> makeText(http::status status, std::string text) {
131 return makeResponse(status, "text/plain; charset=utf-8", std::vector<uint8_t>(text.begin(), text.end()));
132}
133
134/** what went wrong decides the status, instead of everything collapsing into 400 */
135constexpr http::status statusFor(Haio::ErrorCode code) {
136 switch (code) {
137 case Haio::ErrorCode::NotFound: return http::status::not_found;
138 case Haio::ErrorCode::UnsupportedFormat: return http::status::unsupported_media_type;
139 case Haio::ErrorCode::Upstream: return http::status::bad_gateway;
140 case Haio::ErrorCode::Timeout: return http::status::gateway_timeout;
141 case Haio::ErrorCode::InvalidInput: return http::status::bad_request;
143 case Haio::ErrorCode::Internal: return http::status::internal_server_error;
144 }
145 return http::status::internal_server_error;
146}
147
148http::response<http::vector_body<uint8_t>> makeError(const Haio::Error& error) {
149 return makeText(statusFor(error.code), error.message + "\n");
150}
151
152/**
153 * requests one address may make per second, as a leaky bucket, the way nginx meters
154 * with limit_req.
155 *
156 * an address holds up to one second of credit and spends one per request, so a client
157 * that has been quiet may burst that much at once and is then held to the rate. a
158 * fixed window would instead let twice the rate through across a boundary, and would
159 * refuse a client that had simply been idle.
160 */
161class RateLimiter {
162public:
163 explicit RateLimiter(size_t perSecond) : rate_(static_cast<double>(perSecond)) {}
164
165 bool allow(const std::string& client) {
166 if (rate_ <= 0 || client.empty()) return true;
167
168 const auto now = std::chrono::steady_clock::now();
169
170 /**
171 * a bucket that has refilled says nothing a new one would not, so those are
172 * the ones dropped when the table grows. a caller with many addresses must
173 * not be able to make this table the thing that runs out.
174 */
175 if (seen_.size() > clientsBeforePrune) {
176 std::erase_if(seen_, [&](const auto& row) { return credit(row.second, now) >= rate_; });
177 if (seen_.size() > clientsBeforePrune) seen_.clear();
178 }
179
180 auto found = seen_.find(client);
181 if (found == seen_.end()) found = seen_.emplace(client, Bucket{rate_, now}).first;
182
183 auto& bucket = found->second;
184 bucket.tokens = credit(bucket, now);
185 bucket.filled = now;
186
187 if (bucket.tokens < 1.0) return false;
188 bucket.tokens -= 1.0;
189 return true;
190 }
191
192private:
193 struct Bucket {
194 double tokens = 0;
195 std::chrono::steady_clock::time_point filled;
196 };
197
198 /** what the bucket holds now, refilled at the rate and never above one second of it */
199 double credit(const Bucket& bucket, std::chrono::steady_clock::time_point now) const {
200 const auto idle = std::chrono::duration<double>(now - bucket.filled).count();
201 return std::min(rate_, bucket.tokens + idle * rate_);
202 }
203
204 static constexpr size_t clientsBeforePrune = 10000;
205 double rate_;
206 std::map<std::string, Bucket> seen_;
207};
208
209/**
210 * a resize allocates width times height times four bytes before anything checks that
211 * the number is sane, so "?resize=99999x99999" is a memory bomb written in a url. the
212 * cap is on each side rather than on the area, the way a gpu states a texture limit.
213 */
214std::optional<Haio::Error> checkSize(const Haio::Cdn::Config& config, const std::vector<Haio::Token>& tokens) {
215 const auto& limits = config.security;
216
217 for (const auto& token : tokens) {
218 if (token.kind != Haio::TokenKind::Resize) continue;
219
220 // a share is checked as a share: the factor is known here even though what it
221 // comes to is not, and bounding the factor bounds the result
222 if (token.percent != 0) {
223 if (limits.maxSizePercent > 0 && token.percent > limits.maxSizePercent) {
224 return Haio::Error{Haio::ErrorCode::InvalidInput,
225 "resize is limited to " + std::to_string(limits.maxSizePercent) + " percent"};
226 }
227 continue;
228 }
229
230 if (limits.maxSizePixel > 0
231 && (token.size.width > limits.maxSizePixel || token.size.height > limits.maxSizePixel)) {
232 return Haio::Error{Haio::ErrorCode::InvalidInput,
233 "resize is limited to " + std::to_string(limits.maxSizePixel) + " on each side"};
234 }
235 }
236 return std::nullopt;
237}
238
239/** the fetch and the conversion behind it, which is exactly what an entry saves */
240asio::awaitable<Haio::Result<Haio::Cdn::CacheEntry>> produce(const Haio::Cdn::Config& config, Haio::Cdn::Bucket::ZipArchives& archives, Route route) {
241 auto fetched = co_await Haio::Cdn::fetchBucket(config, archives, route.bucket, route.path);
242 if (!fetched) co_return std::unexpected(fetched.error());
243 auto blob = *std::move(fetched);
244
245 /**
246 * a bucket is a directory, and a directory holds whatever somebody put there. only
247 * files haio can name are served: without this the cdn hands out anything the
248 * bucket happens to contain, which is a file server with extra steps rather than
249 * an image one.
250 *
251 * naming it is enough, though. a png asked for without a format is handed over
252 * exactly as it was stored, because there is nothing to convert it to.
253 */
254 if (blob.format == Haio::Format::RAW) {
255 co_return std::unexpected(Haio::Error{Haio::ErrorCode::UnsupportedFormat,
256 "haio does not recognise " + route.path});
257 }
258
259 auto tokens = Haio::parseQueryTokens(route.query);
260 if (auto tooBig = checkSize(config, tokens)) co_return std::unexpected(*tooBig);
261
262 if (!tokens.empty() && !alreadyWhatWasAsked(tokens, blob.format)) {
263 Haio::Pipeline pipeline;
264 pipeline |= Haio::Tokens::Source(route.bucket, route.path);
265 for (auto token : tokens) pipeline |= std::move(token);
266
267 if (hasImageTransform(pipeline.tokens()) && !hasTokenKind(pipeline.tokens(), Haio::TokenKind::Encode)) {
268 const auto fallback = blob.format == Haio::Format::RAW ? Haio::Format::PNG : blob.format;
269 pipeline |= Haio::Tokens::Encode(fallback == Haio::Format::PPM ? Haio::Format::PNG : fallback);
270 }
271
272 auto converted = Haio::runPipeline(std::move(blob), pipeline);
273 if (!converted) co_return std::unexpected(converted.error());
274 blob = *std::move(converted);
275 }
276
277 co_return Haio::Cdn::CacheEntry{
278 std::move(blob.data),
279 blob.contentType.empty() ? std::string(Haio::contentTypeFor(blob.format)) : blob.contentType,
280 downloadName(route.path, blob.format),
281 };
282}
283
284asio::awaitable<http::response<http::vector_body<uint8_t>>> handleRequest(const Haio::Cdn::Config& config, Haio::Cdn::Cache cache, std::shared_ptr<RateLimiter> limiter, std::shared_ptr<Haio::Cdn::Bucket::ZipArchives> archives, std::string client, http::request<http::string_body> req) {
285 if (req.method() != http::verb::get && req.method() != http::verb::head) {
286 co_return makeText(http::status::method_not_allowed, "method not allowed\n");
287 }
288
289 // counted before anything is read or fetched, so a flood costs as little as possible
290 if (!limiter->allow(client)) {
291 auto res = makeText(http::status::too_many_requests, "too many requests\n");
292 res.set(http::field::retry_after, "1");
293 co_return res;
294 }
295
296 try {
297 const auto route = parseRoute(req.target());
298
299 // the key leaves the method out on purpose, so a HEAD finds what a GET stored
300 const auto key = Haio::Cdn::cacheKey(route.bucket, route.path, route.query);
301 auto entry = co_await cache.fetch(key, client, [&config, archives, route]() -> asio::awaitable<Haio::Result<Haio::Cdn::CacheEntry>> {
302 return produce(config, *archives, route);
303 });
304 if (!entry) co_return makeError(entry.error());
305
306 auto res = makeResponse(http::status::ok, entry->contentType, std::move(entry->data));
307 res.set(http::field::content_disposition, "inline; filename=\"" + entry->filename + "\"");
308 co_return res;
309 } catch (const std::exception& err) {
310 // only the request parsing above can land here now, and its messages are ours
311 co_return makeText(http::status::bad_request, std::string(err.what()) + "\n");
312 }
313}
314
315asio::awaitable<void> session(tcp::socket socket, Haio::Cdn::Config config, Haio::Cdn::Cache cache, std::shared_ptr<RateLimiter> limiter, std::shared_ptr<Haio::Cdn::Bucket::ZipArchives> archives) {
316 /**
317 * the address is read once, before anything can close the socket under us.
318 *
319 * @todo behind a proxy every request arrives from the proxy, so the per address
320 * quota would see one caller. honouring x-forwarded-for needs a list of proxies
321 * worth believing, since the header is written by whoever sent the request.
322 */
323 std::string client;
324 if (boost::system::error_code ec; true) {
325 const auto peer = socket.remote_endpoint(ec);
326 if (!ec) client = peer.address().to_string();
327 }
328
329 beast::flat_buffer buffer;
330 try {
331 for (;;) {
332 http::request<http::string_body> req;
333 co_await http::async_read(socket, buffer, req, asio::use_awaitable);
334 const bool close = req.need_eof();
335 const bool headOnly = req.method() == http::verb::head;
336 auto res = co_await handleRequest(config, cache, limiter, archives, client, std::move(req));
337 if (headOnly) {
338 const auto size = res.body().size();
339 res.body().clear();
340 res.content_length(size);
341 }
342 res.keep_alive(!close);
343 co_await http::async_write(socket, res, asio::use_awaitable);
344 if (close) break;
345 }
346 } catch (const boost::system::system_error& err) {
347 // a client closing a keep alive connection arrives here as end_of_stream, and
348 // it is the ordinary way a request ends rather than something to report. every
349 // other reason is logged, because a swallowed error leaves nothing to debug
350 if (err.code() != http::error::end_of_stream) {
351 std::cerr << "connection dropped: " << err.code().message() << "\n";
352 }
353 } catch (const std::exception& err) {
354 std::cerr << "connection dropped: " << err.what() << "\n";
355 }
356
357 beast::error_code ec;
358 socket.shutdown(tcp::socket::shutdown_send, ec);
359}
360
361asio::awaitable<void> listener(Haio::Cdn::Config config) {
362 auto executor = co_await asio::this_coro::executor;
363 tcp::resolver resolver(executor);
364 const auto resolved = co_await resolver.async_resolve(config.host, std::to_string(config.port), asio::use_awaitable);
365 tcp::acceptor acceptor(executor, *resolved.begin());
366
367 for (const auto& [name, bucket] : config.buckets) {
368 if (bucket.open) {
369 std::cerr << "warning: bucket \"" << name << "\" is open, so it will fetch whatever host the request names\n";
370 }
371 }
372
373 // one cache for the whole server: a Cache holds a shared impl, so copying into a
374 // session shares the store and the in flight map rather than making new ones
375 Haio::Cdn::Cache cache{config.cache, config.security.maxCacheEntriesByIp, executor};
376 // shared by every session, because a limit counted per connection is no limit
377 auto limiter = std::make_shared<RateLimiter>(config.security.maxRequestsByIp);
378 // shared too: an archive read for one request is there for the next one
379 auto archives = std::make_shared<Haio::Cdn::Bucket::ZipArchives>(config.security.maxUnzip);
380 if (cache.enabled()) {
381 std::cout << "cache: " << (config.cache.url.empty() ? "memory" : config.cache.url)
382 << ", ttl " << config.cache.ttl.count() << "s\n";
383 }
384
385 /**
386 * these three are what stands between the server and somebody deciding to spend
387 * all of its memory, so leaving one out is worth saying out loud. each has a
388 * default and the server is safe without them: the warning is there so nobody
389 * believes they configured a limit they never wrote.
390 */
391 const auto missing = [&config](std::string_view key) { return !config.declared.contains(std::string(key)); };
392
393 if (missing("security.timeout")) {
394 std::cerr << "warning: security.timeout is not set, so a conversion is stopped after "
395 << config.security.timeout.count() << "s by default\n";
396 }
397 if (missing("security.max_size_pixel")) {
398 std::cerr << "warning: security.max_size_pixel is not set, so a resize is capped at "
399 << config.security.maxSizePixel << " on each side by default\n";
400 }
401 if (missing("security.max_size_percent")) {
402 std::cerr << "warning: security.max_size_percent is not set, so a resize is capped at "
403 << config.security.maxSizePercent << " percent by default\n";
404 }
405 if (missing("security.max_requests_by_ip")) {
406 // no default: a number picked blind would shut out a whole office behind one
407 // address, the same reason max_cache_entries_by_ip has none
408 std::cerr << "warning: security.max_requests_by_ip is not set, so one address may "
409 "make as many requests per second as it likes\n";
410 }
411 if (cache.enabled() && missing("cache.max_usage")) {
412 std::cerr << "warning: cache.max_usage is not set, so the cache is capped at "
413 << config.cache.maxUsage / (1024 * 1024) << "mb by default\n";
414 }
415 if (cache.enabled() && missing("security.max_cache_entries_by_ip")) {
416 std::cerr << "warning: security.max_cache_entries_by_ip is not set, so one address "
417 "may fill the cache on its own and evict everybody else\n";
418 }
419 std::cout << "haio cdn listening on http://" << config.host << ':' << config.port << "\n";
420
421 for (;;) {
422 auto socket = co_await acceptor.async_accept(asio::use_awaitable);
423 asio::co_spawn(executor, session(std::move(socket), config, cache, limiter, archives), asio::detached);
424 }
425}
426
427}
428
429namespace Haio::Cdn {
430
431asio::awaitable<void> runServer(Config config) {
432 co_await listener(std::move(config));
433}
434
435}
boost::asio::awaitable< Result< CacheEntry > > fetch(std::string key, std::string client, Producer produce)
Definition cache.cpp:59
bool enabled() const
Definition cache.cpp:55
const std::vector< Token > & tokens() const
Definition pipeline.cpp:16
asio::ip::tcp tcp
Definition http.cpp:21
SecurityConfig security
Definition haio_cdn.hpp:265
CacheConfig cache
Definition haio_cdn.hpp:266
boost::asio::awaitable< void > runServer(Config config)
Definition server.cpp:431
std::map< std::string, BucketConfig > buckets
Definition haio_cdn.hpp:267
std::string cacheKey(std::string_view bucket, std::string_view path, std::string_view query)
Definition key.cpp:34
std::string host
Definition haio_cdn.hpp:262
std::chrono::seconds ttl
unsigned short port
Definition haio_cdn.hpp:263
std::set< std::string > declared
Definition haio_cdn.hpp:276
boost::asio::awaitable< Result< Blob > > fetchBucket(const Config &config, Bucket::ZipArchives &archives, std::string bucket, std::string path)
Definition bucket.cpp:15
std::chrono::seconds timeout
Definition haio_cdn.hpp:231
Token Encode(Format format, std::optional< Color > color=std::nullopt)
Definition pipeline.cpp:73
Token Source(std::string bucket, std::string path)
Definition pipeline.cpp:21
ErrorCode code
std::optional< Color > color
std::vector< Token > parseQueryTokens(std::string_view query)
Definition pipeline.cpp:100
std::string message
Result< Blob > runPipeline(Blob input, const Pipeline &pipeline, std::optional< std::chrono::steady_clock::time_point > deadline=std::nullopt)
Definition convert.cpp:15
TokenKind kind
std::expected< T, Error > Result
std::string_view contentTypeFor(Format format)
Definition format.cpp:62
std::string_view extensionFor(Format format)
Definition format.cpp:54