diff options
| author | Joel Klinghed <the_jk@spawned.biz> | 2025-10-08 00:58:42 +0200 |
|---|---|---|
| committer | Joel Klinghed <the_jk@spawned.biz> | 2025-10-19 00:13:47 +0200 |
| commit | 86ec0b5386fc2078891a829026844d2ec21ea7db (patch) | |
| tree | 5f3ed650bc2957e06fd3c8c1ecfa7c6e7fc825b6 | |
| parent | 3a002fb9c23fc9a6384bc1b30a8e364924bb574e (diff) | |
Add http module and implement basic http server
| -rw-r--r-- | meson.build | 40 | ||||
| -rw-r--r-- | src/http.cc | 724 | ||||
| -rw-r--r-- | src/http.hh | 177 | ||||
| -rw-r--r-- | src/logger.cc | 145 | ||||
| -rw-r--r-- | src/logger.hh | 41 | ||||
| -rw-r--r-- | src/looper.hh | 46 | ||||
| -rw-r--r-- | src/looper_poll.cc | 238 | ||||
| -rw-r--r-- | src/main.cc | 98 | ||||
| -rw-r--r-- | src/str.cc | 26 | ||||
| -rw-r--r-- | src/str.hh | 7 |
10 files changed, 1541 insertions, 1 deletions
diff --git a/meson.build b/meson.build index 9507b31..6977765 100644 --- a/meson.build +++ b/meson.build @@ -111,6 +111,44 @@ cfg_dep = declare_dependency( dependencies: [io_dep, paths_dep, str_dep], ) +logger_lib = library( + 'logger', + sources: [ + 'src/logger.cc', + 'src/logger.hh', + ], + include_directories: inc, +) +logger_dep = declare_dependency(link_with: logger_lib) + +looper_lib = library( + 'looper', + sources: [ + 'src/looper_poll.cc', + 'src/looper.hh', + ], + include_directories: inc, + dependencies: [logger_dep], +) +looper_dep = declare_dependency( + link_with: looper_lib, + dependencies: [logger_dep], +) + +http_lib = library( + 'http', + sources: [ + 'src/http.cc', + 'src/http.hh', + ], + include_directories: inc, + dependencies: [buffer_dep, logger_dep, looper_dep], +) +http_dep = declare_dependency( + link_with: http_lib, + dependencies: [buffer_dep, logger_dep, looper_dep], +) + bluetooth_jukebox = executable( 'bluetooth-jukebox', sources: [ @@ -121,6 +159,8 @@ bluetooth_jukebox = executable( dependencies : [ args_dep, cfg_dep, + http_dep, + looper_dep, ], ) diff --git a/src/http.cc b/src/http.cc new file mode 100644 index 0000000..8a0a1e6 --- /dev/null +++ b/src/http.cc @@ -0,0 +1,724 @@ +#include "http.hh" + +#include "buffer.hh" +#include "cfg.hh" +#include "logger.hh" +#include "looper.hh" +#include "str.hh" +#include "unique_fd.hh" + +#include <algorithm> +#include <cassert> +#include <cerrno> +#include <charconv> +#include <chrono> +#include <cstdint> +#include <cstring> +#include <fcntl.h> +#include <format> +#include <map> +#include <memory> +#include <netdb.h> +#include <optional> +#include <string> +#include <string_view> +#include <sys/socket.h> +#include <sys/types.h> +#include <unistd.h> +#include <utility> +#include <vector> + +namespace http { + +namespace { + +std::string_view ascii_lowercase(std::string_view input, std::string& tmp) { + auto it = input.begin(); + for (; it != input.end(); ++it) { + if (*it >= 'A' && *it <= 'Z') { + tmp.resize(input.size()); + auto out = std::copy(input.begin(), it, tmp.begin()); + // NOLINTNEXTLINE(bugprone-narrowing-conversions) + *out++ = *it++ | 0x20; + for (; it != input.end(); ++it) { + if (*it >= 'A' && *it <= 'Z') { + // NOLINTNEXTLINE(bugprone-narrowing-conversions) + *out++ = *it | 0x20; + } else { + *out++ = *it; + } + } + return tmp; + } + } + return input; +} + +std::string standard_header(std::string_view input) { + bool capitalize = true; + std::string ret; + for (auto c : input) { + if (c >= 'A' && c <= 'Z') { + if (capitalize) { + ret.push_back(c); + capitalize = false; + } else { + // NOLINTNEXTLINE(bugprone-narrowing-conversions) + ret.push_back(c | 0x20); + } + } else if (c >= 'a' && c <= 'z') { + if (capitalize) { + // NOLINTNEXTLINE(bugprone-narrowing-conversions) + ret.push_back(c & 0x20); + capitalize = false; + } else { + ret.push_back(c); + } + } else if (c == '-') { + ret.push_back(c); + capitalize = true; + } else { + ret.push_back(c); + } + } + return ret; +} + +void make_nonblock(int fd) { + int flags = fcntl(fd, F_GETFL); + if (flags & O_NONBLOCK) + return; + fcntl(fd, F_SETFL, flags | O_NONBLOCK); +} + +void clear(Buffer& buffer) { + if (buffer.empty()) + return; + while (true) { + size_t avail; + buffer.rptr(avail); + if (avail == 0) + break; + buffer.consume(avail); + } +} + +class MimeTypeImpl : public MimeType { + public: + MimeTypeImpl(std::string str, size_t slash, size_t subtype_len, + std::map<std::string_view, std::string_view> params = {}) + : str_(std::move(str)), + slash_(slash), + subtype_len_(subtype_len), + params_(std::move(params)) { + assert(slash_ < str_.size()); + assert(slash_ + 1 + subtype_len_ <= str_.size()); + } + + [[nodiscard]] + std::string_view type() const override { + return std::string_view(str_).substr(0, slash_); + } + + [[nodiscard]] + std::string_view subtype() const override { + return std::string_view(str_).substr(slash_ + 1, subtype_len_); + } + + [[nodiscard]] + std::optional<std::string_view> parameter( + std::string_view name) const override { + std::optional<std::string_view> ret; + std::string tmp; + auto it = params_.find(ascii_lowercase(name, tmp)); + if (it != params_.end()) + ret = it->second; + return ret; + } + + [[nodiscard]] + std::string const& string() const override { + return str_; + } + + private: + std::string const str_; + size_t const slash_; + size_t const subtype_len_; + std::map<std::string_view, std::string_view> const params_; +}; + +class MimeTypeBuilderImpl : public MimeType::Builder { + public: + MimeTypeBuilderImpl(std::string_view type, std::string_view subtype) + : slash_(type.size()), subtype_len_(subtype.size()) { + str_.reserve(type.size() + 1 + subtype.size()); + str_.append(type); + str_.push_back('/'); + str_.append(subtype); + } + + MimeType::Builder& parameter(std::string_view name, + std::string_view value) override { + // TODO: make value a quoted string if it contains ; + std::string tmp; + auto ret = params_.emplace(ascii_lowercase(name, tmp), value); + if (!ret.second) { + ret.first->second = value; + } + return *this; + } + + [[nodiscard]] + std::unique_ptr<MimeType> build() const override { + if (params_.empty()) { + return std::make_unique<MimeTypeImpl>(str_, slash_, subtype_len_); + } + + std::string tmp(str_); + std::map<std::pair<size_t, size_t>, size_t> params1; + for (auto const& pair : params_) { + tmp.append("; "); + params1.emplace(std::make_pair(tmp.size(), pair.first.size()), + pair.second.size()); + tmp.append(pair.first); + tmp.push_back('='); + tmp.append(pair.second); + } + std::map<std::string_view, std::string_view> params2; + std::string_view tmp_view(tmp); + for (auto const& pair : params1) { + params2.emplace(tmp_view.substr(pair.first.first, pair.first.second), + tmp_view.substr(pair.first.first + pair.first.second + 1, + pair.second)); + } + return std::make_unique<MimeTypeImpl>(std::move(tmp), slash_, subtype_len_, + std::move(params2)); + } + + private: + std::string str_; + size_t const slash_; + size_t const subtype_len_; + std::map<std::string, std::string> params_; +}; + +class OpenPortImpl : public OpenPort { + public: + explicit OpenPortImpl(std::vector<unique_fd> fd) : fd_(std::move(fd)) {} + + std::vector<unique_fd> release() { return std::move(fd_); } + + private: + std::vector<unique_fd> fd_; +}; + +class ResponseImpl : public Response { + public: + explicit ResponseImpl(std::string data) : data_(std::move(data)) {} + + bool write(Buffer& buffer) override { + if (offset_ >= data_.size()) + return false; + + size_t avail; + auto* ptr = buffer.wptr(avail); + if (avail == 0) + return true; + + avail = std::min(data_.size() - offset_, avail); + + std::copy_n(data_.data() + offset_, avail, reinterpret_cast<char*>(ptr)); + offset_ += avail; + buffer.commit(avail); + + return offset_ < data_.size(); + } + + private: + std::string const data_; + size_t offset_{0}; +}; + +class ResponseBuilderImpl : public Response::Builder { + public: + explicit ResponseBuilderImpl(StatusCode status) : status_(status) {} + + Response::Builder& content(std::string_view content) override { + content_ = content; + return *this; + } + + Response::Builder& add_header(std::string_view name, + std::string_view value) override { + // TODO: Make sure name or value doesn't contain invalid chars + headers_.emplace_back(standard_header(name), value); + return *this; + } + + [[nodiscard]] + std::unique_ptr<Response> build() const override { + std::string ret; + ret.reserve(100 + content_.size()); + ret.append("HTTP/1.1 "); + { + char tmp[4]; + auto [ptr, ec] = + std::to_chars(tmp, tmp + sizeof(tmp), std::to_underlying(status_)); + ret.append(tmp, ptr - tmp); + } + ret.append(" "); + switch (status_) { + case StatusCode::kOK: + ret.append("OK"); + break; + case StatusCode::kNoContent: + ret.append("No Content"); + break; + case StatusCode::kNotFound: + ret.append("Not Found"); + break; + case StatusCode::kMethodNotAllowed: + ret.append("Method Not Allowed"); + break; + } + ret.append("\r\n"); + bool have_content_len = false; + for (auto const& pair : headers_) { + if (!have_content_len && pair.first == "Content-Length") + have_content_len = true; + ret.append(pair.first); + ret.append(": "); + ret.append(pair.second); + ret.append("\r\n"); + } + if (!have_content_len && status_ != StatusCode::kNoContent) { + char tmp[20]; + auto [ptr, ec] = std::to_chars(tmp, tmp + sizeof(tmp), content_.size()); + ret.append("Content-Length"); + ret.append(": "); + ret.append(tmp, ptr - tmp); + ret.append("\r\n"); + } + ret.append("\r\n"); + if (status_ != StatusCode::kNoContent) + ret.append(content_); + return std::make_unique<ResponseImpl>(std::move(ret)); + } + + private: + StatusCode const status_; + std::string content_; + std::vector<std::pair<std::string, std::string>> headers_; +}; + +class RequestImpl : public Request { + public: + RequestImpl(std::string_view method, std::string_view path) + : method_(method), path_(path) {} + + [[nodiscard]] + std::string_view method() const override { + return method_; + } + + [[nodiscard]] + std::string_view path() const override { + return path_; + } + + private: + std::string_view method_; + std::string_view path_; +}; + +// TODO: What is a good value? +const int kListenBacklog = 10; + +class ServerImpl : public Server { + public: + ServerImpl(logger::Logger& logger, cfg::Config const& cfg, + looper::Looper& looper, std::unique_ptr<OpenPort> open_port, + Server::Delegate& delegate) + : logger_(logger), + cfg_(cfg), + looper_(looper), + delegate_(delegate), + listens_(static_cast<OpenPortImpl*>(open_port.get())->release()) { + client_timeout_ = std::chrono::duration<double>( + cfg_.get_uint64("http.client.timeout.seconds").value_or(60)); + client_.resize(cfg_.get_uint64("http.max.clients").value_or(100)); + start_to_listen(); + } + + private: + static const size_t kInputMinSize = 1024; + static const size_t kInputMaxSize = static_cast<size_t>(1) * 1024 * 1024; + static const size_t kOutputSize = static_cast<size_t>(512) * 1024; + + struct Client { + unique_fd fd; + std::unique_ptr<Buffer> in{Buffer::dynamic(kInputMinSize, kInputMaxSize)}; + std::unique_ptr<Buffer> out{Buffer::fixed(kOutputSize)}; + std::unique_ptr<Response> resp; + std::chrono::steady_clock::time_point last; + uint32_t timeout{0}; + bool read_closed_{false}; + }; + + void start_to_listen() { + auto it = listens_.begin(); + while (it != listens_.end()) { + if (listen(it->get(), + static_cast<int>(cfg_.get_uint64("http.listen.backlog") + .value_or(kListenBacklog)))) { + logger_.warn( + std::format("Error listening to socket: {}", strerror(errno))); + it = listens_.erase(it); + } else { + make_nonblock(it->get()); + + looper_.add( + it->get(), looper::EVENT_READ, + [this, fd = it->get()](auto events) { accept_client(fd, events); }); + ++it; + } + } + + if (listens_.empty()) { + logger_.err("No ports left to listen to, exit."); + looper_.quit(); + } + } + + void accept_client(int fd, uint8_t event) { + if (event & looper::EVENT_ERROR) { + logger_.warn("Listening port returned error, closing."); + looper_.remove(fd); + auto it = std::ranges::find_if( + listens_, [&fd](auto& ufd) { return ufd.get() == fd; }); + if (it != listens_.end()) { + listens_.erase(it); + if (listens_.empty()) { + logger_.err("No ports left to listen to, exit."); + looper_.quit(); + } + } + return; + } + + unique_fd client_fd(accept4(fd, nullptr, nullptr, SOCK_NONBLOCK)); + if (!client_fd) { + logger_.info(std::format("Accept returned error: {}", strerror(errno))); + return; + } + + if (active_clients_ == client_.size()) { + logger_.warn("Max number of clients already."); + return; + } + + for (; client_[next_client_].fd; next_client_++) { + if (next_client_ == client_.size()) + next_client_ = 0; + } + + logger_.dbg(std::format("New client: {}", next_client_)); + + auto& client = client_[next_client_]; + client.fd = std::move(client_fd); + client.last = std::chrono::steady_clock::now(); + + looper_.add( + client.fd.get(), looper::EVENT_READ, + [this, id = next_client_](auto events) { client_event(id, events); }); + client.timeout = looper_.schedule( + client_timeout_.count(), + [this, id = next_client_](auto handle) { client_timeout(id, handle); }); + + ++active_clients_; + ++next_client_; + // TODO: Stopping listening if active_clients_ == client_.size() + } + + void client_event(size_t client_id, uint8_t event) { + if (event & looper::EVENT_ERROR) { + logger_.info(std::format("Client socket error: {}", client_id)); + close_client(client_id); + return; + } + + auto& client = client_[client_id]; + if (event & looper::EVENT_READ) { + size_t avail; + auto* ptr = client.in->wptr(avail); + if (avail == 0) { + logger_.info(std::format("Client too large request: {}", client_id)); + close_client(client_id); + return; + } + + ssize_t got; + while (true) { + got = read(client.fd.get(), ptr, avail); + if (got < 0 && errno == EINTR) + continue; + break; + } + if (got < 0) { + if (errno != EWOULDBLOCK && errno != EAGAIN) { + logger_.info(std::format("Client read error: {}: {}", client_id, + strerror(errno))); + close_client(client_id); + return; + } + } else if (got == 0) { + client.read_closed_ = true; + } else { + client.in->commit(got); + + if (!client_read(client_id)) + return; + } + } + + while (true) { + size_t avail; + auto* ptr = client.out->rptr(avail); + if (avail == 0) { + if (client.resp) { + if (!client.resp->write(*client.out)) { + client.resp.reset(); + + if (!client_read(client_id)) + return; + } + continue; + } + break; + } + ssize_t got; + while (true) { + got = write(client.fd.get(), ptr, avail); + if (got < 0 && errno == EINTR) + continue; + break; + } + if (got < 0) { + if (errno != EWOULDBLOCK && errno != EAGAIN) { + logger_.info(std::format("Client write error: {}: {}", client_id, + strerror(errno))); + close_client(client_id); + return; + } + break; + } + client.out->consume(got); + + if (std::cmp_less(got, avail)) + break; + } + + if (client.read_closed_ && client.in->empty() && client.out->empty() && + !client.resp) { + close_client(client_id); + return; + } + + bool want_read = !client.read_closed_ && !client.resp; + bool want_write = !client.out->empty(); + + looper_.update(client.fd.get(), (want_read ? looper::EVENT_READ : 0) | + (want_write ? looper::EVENT_WRITE : 0)); + } + + bool client_read(size_t client_id) { + auto& client = client_[client_id]; + size_t avail; + auto* ptr = client.in->rptr(avail); + if (avail == 0) + return true; + std::string_view data(reinterpret_cast<char const*>(ptr), avail); + // TODO: Cache last search to not have to restart all the time + auto end = data.find("\r\n\r\n"); + if (end == std::string_view::npos) + return true; + + auto lines = str::split(data.substr(0, end), "\r\n", /* keep_empty */ true); + std::string_view method; + std::string_view path; + { + auto parts = str::split(lines[0], ' ', false); + if (parts.size() != 3) { + logger_.info(std::format("Client invalid request: {}", client_id)); + close_client(client_id); + return false; + } + method = parts[0]; + path = parts[1]; + if (!parts[2].starts_with("HTTP/")) { + logger_.info(std::format("Client invalid request: {}", client_id)); + close_client(client_id); + return false; + } + } + RequestImpl request(method, path); + client.resp = delegate_.handle(request); + client.in->consume(end + 4); + return true; + } + + void client_timeout(size_t client_id, uint32_t id) { + auto now = std::chrono::steady_clock::now(); + auto& client = client_[client_id]; + assert(client.timeout == id); + if (now - client.last < + std::chrono::duration_cast<std::chrono::steady_clock::duration>( + client_timeout_)) { + // TODO: Reschedule for delay left, not the full one + client.timeout = looper_.schedule(client_timeout_.count(), + [this, client_id](auto handle) { + client_timeout(client_id, handle); + }); + return; + } + + logger_.dbg(std::format("Client timeout: {}", client_id)); + + client.timeout = 0; + close_client(client_id); + } + + void close_client(size_t client_id) { + auto& client = client_[client_id]; + if (!client.fd) { + assert(false); + return; + } + + logger_.dbg(std::format("Drop client: {}", client_id)); + + looper_.remove(client.fd.get()); + if (client.timeout) + looper_.cancel(client.timeout); + client.fd.reset(); + clear(*client.in); + clear(*client.out); + client.resp.reset(); + client.read_closed_ = false; + assert(active_clients_ > 0); + --active_clients_; + if (next_client_ == client_id + 1) + next_client_ = client_id; + } + + logger::Logger& logger_; + cfg::Config const& cfg_; + looper::Looper& looper_; + Server::Delegate& delegate_; + std::chrono::duration<double> client_timeout_; + std::vector<unique_fd> listens_; + std::vector<Client> client_; + size_t next_client_{0}; + size_t active_clients_{0}; +}; + +} // namespace + +std::unique_ptr<MimeType> MimeType::create(std::string_view type, + std::string_view subtype) { + std::string tmp; + tmp.reserve(type.size() + 1 + subtype.size()); + tmp.append(type); + tmp.push_back('/'); + tmp.append(subtype); + return std::make_unique<MimeTypeImpl>(std::move(tmp), type.size(), + subtype.size()); +} + +std::unique_ptr<MimeType::Builder> MimeType::Builder::create( + std::string_view type, std::string_view subtype) { + return std::make_unique<MimeTypeBuilderImpl>(type, subtype); +} + +std::unique_ptr<OpenPort> open_port(std::string_view host_port, + logger::Logger& logger) { + auto colon = host_port.find(':'); + std::string host; + std::string port; + if (colon == std::string_view::npos) { + host = "localhost"; + port = host_port; + } else { + host = host_port.substr(0, colon); + port = host_port.substr(colon + 1); + } + + if (host == "*") + host = ""; + + struct addrinfo hints = {}; + hints.ai_flags = + AI_PASSIVE // Use wildcard if host is nullptr + | + AI_ADDRCONFIG; // Only use IPv4 or IPv6 if there is at least one interface of that type + hints.ai_family = AF_UNSPEC; // Allow IPv4 or IPv6 + hints.ai_socktype = SOCK_STREAM; // TCP + struct addrinfo* res; + auto ret = getaddrinfo(host.empty() ? nullptr : host.c_str(), port.c_str(), + &hints, &res); + if (ret) { + logger.err( + std::format("Unable to bind to {}: {}", host_port, gai_strerror(ret))); + return nullptr; + } + + struct addrinfo* addr; + std::vector<unique_fd> fds; + for (addr = res; addr != nullptr; addr = addr->ai_next) { + unique_fd fd{socket(addr->ai_family, addr->ai_socktype, addr->ai_protocol)}; + if (!fd) + continue; + if (bind(fd.get(), addr->ai_addr, addr->ai_addrlen) == 0) { + fds.push_back(std::move(fd)); + } + } + freeaddrinfo(res); + + if (fds.empty()) { + // Assume that the last errno will be bind or socket failing + logger.err( + std::format("Unable to bind to {}: {}", host_port, strerror(errno))); + return nullptr; + } + + return std::make_unique<OpenPortImpl>(std::move(fds)); +} + +std::unique_ptr<Response::Builder> Response::Builder::create( + StatusCode status_code) { + return std::make_unique<ResponseBuilderImpl>(status_code); +} + +std::unique_ptr<Response> Response::status(StatusCode status_code) { + return Response::Builder::create(status_code)->build(); +} + +std::unique_ptr<Response> Response::content(std::string_view content, + MimeType const& mime_type) { + return Response::Builder::create(StatusCode::kOK) + ->content(content) + .add_header("Content-Type", mime_type.string()) + .build(); +} + +std::unique_ptr<Server> create_server(logger::Logger& logger, + cfg::Config const& cfg, + looper::Looper& looper, + std::unique_ptr<OpenPort> open_port, + Server::Delegate& delegate) { + return std::make_unique<ServerImpl>(logger, cfg, looper, std::move(open_port), + delegate); +} + +} // namespace http diff --git a/src/http.hh b/src/http.hh new file mode 100644 index 0000000..ca3f7d4 --- /dev/null +++ b/src/http.hh @@ -0,0 +1,177 @@ +#ifndef HTTP_HH +#define HTTP_HH + +#include <cstdint> +#include <memory> +#include <optional> +#include <string_view> + +class Buffer; + +namespace logger { +class Logger; +} // namespace logger + +namespace looper { +class Looper; +} // namespace looper + +namespace cfg { +class Config; +} // namespace cfg + +namespace http { + +enum class StatusCode : uint16_t { + kOK = 200, + kNoContent = 204, + kNotFound = 404, + kMethodNotAllowed = 405, +}; + +class OpenPort { + public: + virtual ~OpenPort() = default; + + protected: + OpenPort() = default; + OpenPort(OpenPort const&) = delete; + OpenPort& operator=(OpenPort const&) = delete; +}; + +// Returns nullptr in case of error, the error is written to logger. +std::unique_ptr<OpenPort> open_port(std::string_view host_port, + logger::Logger& logger); + +class MimeType { + public: + virtual ~MimeType() = default; + + [[nodiscard]] + virtual std::string_view type() const = 0; + [[nodiscard]] + virtual std::string_view subtype() const = 0; + [[nodiscard]] + virtual std::optional<std::string_view> parameter( + std::string_view name) const = 0; + [[nodiscard]] + virtual std::string const& string() const = 0; + + class Builder { + public: + virtual ~Builder() = default; + + virtual Builder& parameter(std::string_view name, + std::string_view value) = 0; + + [[nodiscard]] + virtual std::unique_ptr<MimeType> build() const = 0; + + [[nodiscard]] + static std::unique_ptr<Builder> create(std::string_view type, + std::string_view subtype); + + protected: + Builder() = default; + Builder(Builder const&) = delete; + Builder& operator=(Builder const&) = delete; + }; + + // Short version of Builder::create(type, subtype).build() + [[nodiscard]] + static std::unique_ptr<MimeType> create(std::string_view type, + std::string_view subtype); + + protected: + MimeType() = default; + MimeType(MimeType const&) = delete; + MimeType& operator=(MimeType const&) = delete; +}; + +class Request { + public: + virtual ~Request() = default; + + [[nodiscard]] + virtual std::string_view method() const = 0; + [[nodiscard]] + virtual std::string_view path() const = 0; + + protected: + Request() = default; + Request(Request const&) = delete; + Request& operator=(Request const&) = delete; +}; + +class Response { + public: + virtual ~Response() = default; + + class Builder { + public: + virtual ~Builder() = default; + + virtual Builder& content(std::string_view content) = 0; + virtual Builder& add_header(std::string_view name, + std::string_view value) = 0; + + [[nodiscard]] + virtual std::unique_ptr<Response> build() const = 0; + + [[nodiscard]] + static std::unique_ptr<Builder> create(StatusCode status_code); + + protected: + Builder() = default; + Builder(Builder const&) = delete; + Builder& operator=(Builder const&) = delete; + }; + + // Short version of Builder::create(status_code).build() + [[nodiscard]] + static std::unique_ptr<Response> status(StatusCode status_code); + + // Short version of Builder::create(StatusCode::kOK).content(content) + // .add_header("Content-Type", mime_type).build() + [[nodiscard]] + static std::unique_ptr<Response> content(std::string_view content, + MimeType const& mime_type); + + // Returns true while there is more data to write. + virtual bool write(Buffer& buffer) = 0; + + protected: + Response() = default; + Response(Response const&) = delete; + Response& operator=(Response const&) = delete; +}; + +class Server { + public: + virtual ~Server() = default; + + class Delegate { + public: + virtual ~Delegate() = default; + + virtual std::unique_ptr<Response> handle(Request const& request) = 0; + + protected: + Delegate() = default; + }; + + protected: + Server() = default; + Server(Server const&) = delete; + Server& operator=(Server const&) = delete; +}; + +std::unique_ptr<Server> create_server(logger::Logger& logger, + cfg::Config const& cfg, + looper::Looper& looper, + std::unique_ptr<OpenPort> open_port, + Server::Delegate& delegate); + +} // namespace http + +#endif // HTTP_HH diff --git a/src/logger.cc b/src/logger.cc new file mode 100644 index 0000000..21effff --- /dev/null +++ b/src/logger.cc @@ -0,0 +1,145 @@ +#include "logger.hh" + +#include <cstdint> +#include <iostream> +#include <memory> +#include <string> +#include <string_view> +#include <syslog.h> +#include <utility> + +namespace logger { + +namespace { + +class BaseLogger : public Logger { + protected: + enum class Level : uint8_t { + kError, + kWarning, + kInfo, + kDebug, + }; + + public: + void err(std::string_view message) final { write(Level::kError, message); } + + void warn(std::string_view message) final { write(Level::kWarning, message); } + + void info(std::string_view message) final { write(Level::kInfo, message); } + +#if !defined(NDEBUG) + void dbg(std::string_view message) final { write(Level::kDebug, message); } +#endif + + protected: + BaseLogger() = default; + + virtual void write(Level level, std::string_view message) = 0; +}; + +class NoopLogger : public BaseLogger { + public: + NoopLogger() = default; + + protected: + void write(Level /* level */, std::string_view /* message */) override {} +}; + +class SyslogLogger : public BaseLogger { + public: + SyslogLogger(std::string ident, bool verbose) + : ident_(std::move(ident)), verbose_(verbose) { + // NOLINTNEXTLINE(misc-include-cleaner) + openlog(ident_.c_str(), LOG_PID | LOG_CONS, LOG_DAEMON); + } + + ~SyslogLogger() override { + // NOLINTNEXTLINE(misc-include-cleaner) + closelog(); + } + + protected: + void write(Level level, std::string_view message) override { + // NOLINTNEXTLINE(misc-include-cleaner) + int prio = LOG_ERR; + switch (level) { + case Level::kError: + // NOLINTNEXTLINE(misc-include-cleaner) + prio = LOG_ERR; + break; + case Level::kWarning: + // NOLINTNEXTLINE(misc-include-cleaner) + prio = LOG_WARNING; + break; + case Level::kInfo: + if (!verbose_) + return; + // NOLINTNEXTLINE(misc-include-cleaner) + prio = LOG_INFO; + break; + case Level::kDebug: + if (!verbose_) + return; + // NOLINTNEXTLINE(misc-include-cleaner) + prio = LOG_DEBUG; + break; + } + // NOLINTNEXTLINE(misc-include-cleaner) + ::syslog(prio, "%*s", static_cast<int>(message.size()), message.data()); + } + + private: + std::string const ident_; + bool const verbose_; +}; + +class StderrLogger : public BaseLogger { + public: + explicit StderrLogger(bool verbose) : verbose_(verbose) {} + + protected: + void write(Level level, std::string_view message) override { + switch (level) { + case Level::kError: + std::cerr << "Error: "; + break; + case Level::kWarning: + std::cerr << "Warning: "; + break; + case Level::kInfo: + if (!verbose_) + return; + std::cerr << "Info: "; + break; + case Level::kDebug: + if (!verbose_) + return; + std::cerr << "Debug: "; + break; + } + std::cerr << message << '\n'; + } + + private: + bool const verbose_; +}; + +} // namespace + +[[nodiscard]] +std::unique_ptr<Logger> noop() { + return std::make_unique<NoopLogger>(); +} + +[[nodiscard]] +std::unique_ptr<Logger> syslog(std::string ident, bool verbose) { + return std::make_unique<SyslogLogger>(std::move(ident), verbose); +} + +[[nodiscard]] +std::unique_ptr<Logger> stderr(bool verbose) { + return std::make_unique<StderrLogger>(verbose); +} + +} // namespace logger diff --git a/src/logger.hh b/src/logger.hh new file mode 100644 index 0000000..5c1e599 --- /dev/null +++ b/src/logger.hh @@ -0,0 +1,41 @@ +#ifndef LOGGER_HH +#define LOGGER_HH + +#include <memory> +#include <string> +#include <string_view> + +namespace logger { + +class Logger { + public: + virtual ~Logger() = default; + + virtual void err(std::string_view message) = 0; + virtual void warn(std::string_view message) = 0; + virtual void info(std::string_view message) = 0; + +#if defined(NDEBUG) + void dbg(std::string_view) {} +#else + virtual void dbg(std::string_view message) = 0; +#endif + + protected: + Logger() = default; + Logger(Logger const&) = delete; + Logger& operator=(Logger const&) = delete; +}; + +[[nodiscard]] +std::unique_ptr<Logger> noop(); + +[[nodiscard]] +std::unique_ptr<Logger> syslog(std::string ident, bool verbose = false); + +[[nodiscard]] +std::unique_ptr<Logger> stderr(bool verbose = false); + +} // namespace logger + +#endif // LOGGER_HH diff --git a/src/looper.hh b/src/looper.hh new file mode 100644 index 0000000..2f4c672 --- /dev/null +++ b/src/looper.hh @@ -0,0 +1,46 @@ +#ifndef LOOPER_HH +#define LOOPER_HH + +#include <cstdint> +#include <functional> +#include <memory> + +namespace logger { +class Logger; +} // namespace logger + +namespace looper { + +constexpr static uint8_t EVENT_READ = 1; +constexpr static uint8_t EVENT_WRITE = 2; +constexpr static uint8_t EVENT_ERROR = 4; + +class Looper { + public: + virtual ~Looper() = default; + + virtual void add(int fd, uint8_t events, + std::function<void(uint8_t)> callback) = 0; + virtual void update(int fd, uint8_t events) = 0; + virtual void remove(int fd) = 0; + + // Returned id is never 0 + virtual uint32_t schedule(double delay, + std::function<void(uint32_t)> callback) = 0; + virtual void cancel(uint32_t id) = 0; + + virtual bool run(logger::Logger& logger) = 0; + virtual void quit() = 0; + + protected: + Looper() = default; + Looper(Looper const&) = delete; + Looper& operator=(Looper const&) = delete; +}; + +[[nodiscard]] +std::unique_ptr<Looper> create(); + +} // namespace looper + +#endif // LOOPER_HH diff --git a/src/looper_poll.cc b/src/looper_poll.cc new file mode 100644 index 0000000..2fed7d2 --- /dev/null +++ b/src/looper_poll.cc @@ -0,0 +1,238 @@ +#include "logger.hh" +#include "looper.hh" + +#include <algorithm> +#include <cassert> +#include <cerrno> +#include <chrono> +#include <cstdint> +#include <cstring> +#include <deque> +#include <format> +#include <functional> +#include <limits> +#include <memory> +#include <poll.h> +#include <unordered_map> +#include <utility> +#include <vector> + +namespace looper { + +namespace { + +class LooperPoll : public Looper { + public: + LooperPoll() = default; + + void add(int fd, uint8_t events, + std::function<void(uint8_t)> callback) override { + if (fd < 0) + return; + auto ret = entry_.emplace(fd, Entry(events)); + if (!ret.second) { + assert(ret.first->second.delete_); + ret.first->second.delete_ = false; + ret.first->second.events_ = events; + } + ret.first->second.callback_ = std::move(callback); + } + + void update(int fd, uint8_t events) override { + if (fd < 0) + return; + auto it = entry_.find(fd); + if (it == entry_.end() || it->second.delete_) { + assert(false); + return; + } + it->second.events_ = events; + } + + void remove(int fd) override { + if (fd < 0) + return; + auto it = entry_.find(fd); + if (it == entry_.end()) + return; + it->second.delete_ = true; + } + + bool run(logger::Logger& logger) override { + while (!quit_) { + int timeout; + if (scheduled_.empty()) { + timeout = -1; + } else { + auto now = std::chrono::steady_clock::now(); + while (true) { + if (now < scheduled_.front().target_) { + auto delay = std::chrono::duration_cast<std::chrono::milliseconds>( + scheduled_.front().target_ - now); + if (delay.count() <= std::numeric_limits<int>::max()) + timeout = static_cast<int>(delay.count()); + else + timeout = std::numeric_limits<int>::max(); + break; + } + auto id = scheduled_.front().id_; + auto callback = std::move(scheduled_.front().callback_); + scheduled_.pop_front(); + callback(id); + if (scheduled_.empty()) { + timeout = -1; + break; + } + } + // Scheduled callbacks might call quit(). + if (quit_) + break; + } + // NOLINTNEXTLINE(misc-include-cleaner) + std::vector<struct pollfd> pollfd; + pollfd.reserve(entry_.size()); + auto it = entry_.begin(); + while (it != entry_.end()) { + if (it->second.delete_) { + it = entry_.erase(it); + } else { + // NOLINTNEXTLINE(misc-include-cleaner) + struct pollfd tmp; + tmp.fd = it->first; + tmp.events = events_looper2poll(it->second.events_); + pollfd.push_back(tmp); + ++it; + } + } + // NOLINTNEXTLINE(misc-include-cleaner) + int active = poll(pollfd.data(), pollfd.size(), timeout); + if (active < 0) { + if (errno == EINTR) + continue; + logger.err(std::format("Poll failed: {}", strerror(errno))); + return false; + } + for (auto it2 = pollfd.begin(); active; ++it2) { + if (it2->revents == 0) + continue; + --active; + auto events = events_poll2looper(it2->revents); + if (events) { + it = entry_.find(it2->fd); + if (!it->second.delete_) { + events &= (it->second.events_ | EVENT_ERROR); + if (events) { + it->second.callback_(events); + } + } + } + } + } + // Reset quit_ so run() can be called again + quit_ = false; + return true; + } + + void quit() override { quit_ = true; } + + uint32_t schedule(double delay, + std::function<void(uint32_t)> callback) override { + assert(delay >= 0.0); + uint32_t id = next_schedule_id(); + auto target = + std::chrono::steady_clock::now() + + std::chrono::duration_cast<std::chrono::steady_clock::duration>( + std::chrono::duration<double>(delay)); + auto insert = scheduled_.end(); + while (insert != scheduled_.begin()) { + auto prev = insert - 1; + if (prev->target_ < target) + break; + insert = prev; + } + scheduled_.emplace(insert, std::move(callback), id, target); + return id; + } + + void cancel(uint32_t id) override { + auto it = std::ranges::find_if(scheduled_, [id](auto const& scheduled) { + return scheduled.id_ == id; + }); + if (it != scheduled_.end()) { + scheduled_.erase(it); + } else { + assert(false); + } + } + + private: + struct Entry { + uint8_t events_; + std::function<void(uint8_t)> callback_; + bool delete_{false}; + + explicit Entry(uint8_t events) : events_(events) {} + }; + + struct Scheduled { + std::function<void(uint32_t)> callback_; + uint32_t id_; + std::chrono::steady_clock::time_point target_; + + Scheduled(std::function<void(uint32_t)> callback, uint32_t id, + std::chrono::steady_clock::time_point target) + : callback_(std::move(callback)), id_(id), target_(target) {} + }; + + static short events_looper2poll(uint8_t events) { + short ret = 0; + if (events & EVENT_READ) + // NOLINTNEXTLINE(misc-include-cleaner) + ret |= POLLIN | POLLPRI; + if (events & EVENT_WRITE) + // NOLINTNEXTLINE(misc-include-cleaner) + ret |= POLLOUT; + return ret; + } + + static uint8_t events_poll2looper(short events) { + uint8_t ret = 0; + // NOLINTNEXTLINE(misc-include-cleaner) + if (events & (POLLIN | POLLPRI | POLLHUP)) + ret |= EVENT_READ; + // NOLINTNEXTLINE(misc-include-cleaner) + if (events & POLLOUT) + ret |= EVENT_WRITE; + // NOLINTNEXTLINE(misc-include-cleaner) + if (events & (POLLERR | POLLNVAL)) + ret |= EVENT_ERROR; + return ret; + } + + uint32_t next_schedule_id() { + while (true) { + uint32_t ret = next_schedule_id_++; + if (ret) { + bool found = std::ranges::any_of( + scheduled_, + [ret](auto const& scheduled) { return scheduled.id_ == ret; }); + if (!found) + return ret; + } + } + } + + bool quit_{false}; + std::unordered_map<int, Entry> entry_; + uint32_t next_schedule_id_{1}; + std::deque<Scheduled> scheduled_; +}; + +} // namespace + +[[nodiscard]] +std::unique_ptr<Looper> create() { + return std::make_unique<LooperPoll>(); +} + +} // namespace looper diff --git a/src/main.cc b/src/main.cc index e66f95a..54f3d0a 100644 --- a/src/main.cc +++ b/src/main.cc @@ -1,16 +1,91 @@ #include "args.hh" +#include "cfg.hh" #include "config.h" +#include "http.hh" +#include "logger.hh" +#include "looper.hh" +#include <cerrno> +#include <cstring> +#include <format> #include <iostream> +#include <memory> +#include <unistd.h> +#include <utility> +#include <vector> #ifndef VERSION # define VERSION "unknown" #endif +namespace { + +class HttpServerDelegate : public http::Server::Delegate { + public: + HttpServerDelegate() = default; + + std::unique_ptr<http::Response> handle( + http::Request const& request) override { + if (request.method() != "GET") { + return http::Response::status(http::StatusCode::kMethodNotAllowed); + } + + if (request.path() == "/api/v1/status") { + return http::Response::content( + R"({ status: "OK" })", + *http::MimeType::create("application", "json")); + } + + return http::Response::status(http::StatusCode::kNotFound); + } +}; + +bool run(logger::Logger& logger, cfg::Config const& cfg, + std::unique_ptr<http::OpenPort> port) { + auto looper = looper::create(); + HttpServerDelegate delegate; + auto server = + http::create_server(logger, cfg, *looper, std::move(port), delegate); + return looper->run(logger); +} + +bool start_daemon(logger::Logger& logger, cfg::Config const& cfg, + std::unique_ptr<http::OpenPort> port, bool verbose) { + auto pid = fork(); + if (pid == 0) { + // Child process + // Move out of current workind directory to not keep it alive + chdir("/"); + // Create a new process group + setpgid(0, 0); + // Close stdin, stdout and stderr + close(0); + close(1); + close(2); + // Use syslog logger + auto logger2 = logger::syslog("bluetooth-jukebox", verbose); + _exit(run(*logger2, cfg, std::move(port)) ? 0 : 1); + } else if (pid == -1) { + // Error + logger.err(std::format("Unable to fork: {}", strerror(errno))); + return false; + } else { + // Parent process + logger.info(std::format("Forked into pid {}", pid)); + return true; + } +} + +} // namespace + int main(int argc, char** argv) { auto args = Args::create(); auto opt_help = args->option('h', "help", "display this text and exit."); auto opt_version = args->option('V', "version", "display version and exit."); + auto opt_config = args->option_argument( + 'C', "config", "FILE", "load config from FILE instead of default."); + auto opt_verbose = args->option('v', "verbose", "be more verbose."); + auto opt_daemon = args->option('d', "daemon", "fork into background."); if (!args->run(argc, argv)) { args->print_error(std::cerr); std::cerr << "Try 'bluetooth-jukebox --help' for more information.\n"; @@ -27,5 +102,26 @@ int main(int argc, char** argv) { << " written by Joel Klinghed <the_jk@spawned.biz>.\n"; return 0; } - return 0; + bool verbose = opt_verbose->is_set(); + bool daemon = opt_daemon->is_set(); + std::unique_ptr<cfg::Config> cfg; + std::vector<std::string> errors; + if (opt_config->is_set()) { + cfg = cfg::load_one(opt_config->argument(), errors); + } else { + cfg = cfg::load_all("bluetooth-jukebox", errors); + } + + auto logger = logger::stderr(verbose); + + // Open the port here, in case we fork() later, to fail early. + auto port = http::open_port(cfg->get("http.bind").value_or("localhost:5555"), + *logger); + if (!port) + return 1; + + if (daemon) + return start_daemon(*logger, *cfg, std::move(port), verbose) ? 0 : 1; + + return run(*logger, *cfg, std::move(port)) ? 0 : 1; } @@ -40,6 +40,32 @@ std::vector<std::string_view> split(std::string_view str, char separator, return vec; } +void split(std::string_view str, std::vector<std::string_view>& out, + std::string_view separator, bool keep_empty) { + out.clear(); + + size_t offset = 0; + while (true) { + auto next = str.find(separator, offset); + if (next == std::string_view::npos) { + if (keep_empty || offset < str.size()) + out.push_back(str.substr(offset)); + break; + } + if (keep_empty || offset < next) + out.push_back(str.substr(offset, next - offset)); + offset = next + separator.size(); + } +} + +std::vector<std::string_view> split(std::string_view str, + std::string_view separator, + bool keep_empty) { + std::vector<std::string_view> vec; + split(str, vec, separator, keep_empty); + return vec; +} + std::string_view trim(std::string_view str) { size_t s = 0; size_t e = str.size(); @@ -13,6 +13,13 @@ void split(std::string_view str, std::vector<std::string_view>& out, char separator = ' ', bool keep_empty = false); +void split(std::string_view str, std::vector<std::string_view>& out, + std::string_view separator, bool keep_empty = false); + +[[nodiscard]] std::vector<std::string_view> split(std::string_view str, + std::string_view separator, + bool keep_empty = false); + [[nodiscard]] std::string_view trim(std::string_view str); |
