/*** This file is part of snapcast Copyright (C) 2014-2025 Johannes Pohl This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . ***/ // local headers #include "common/popl.hpp" #include #ifdef HAS_DAEMON #include "common/daemon.hpp" #endif #include "common/snap_exception.hpp" #include "common/utils/string_utils.hpp" #include "common/version.hpp" #include "encoder/encoder_factory.hpp" #include "server.hpp" #include "server_settings.hpp" #if defined(HAS_AVAHI) || defined(HAS_BONJOUR) #include "publishZeroConf/publish_mdns.hpp" #endif #include "common/aixlog.hpp" #include "config.hpp" // 3rd party headers #include // standard headers #include #include #include using namespace std; using namespace popl; static constexpr auto LOG_TAG = "Snapserver"; int main(int argc, char* argv[]) { #ifdef MACOS #pragma message "Warning: the macOS support is experimental and might not be maintained" #endif int exitcode = EXIT_SUCCESS; try { ServerSettings settings; std::string pcmSource = "pipe:///tmp/snapfifo?name=default"; std::string config_file = "/etc/snapserver.conf"; OptionParser op("Allowed options"); auto helpSwitch = op.add("h", "help", "Produce help message, use -hh to show options from config file"); auto groffSwitch = op.add("", "groff", "produce groff message"); auto versionSwitch = op.add("v", "version", "Show version number"); #ifdef HAS_DAEMON int processPriority(0); auto daemonOption = op.add>("d", "daemon", "Daemonize\noptional process priority [-20..19]", 0, &processPriority); #endif op.add>("c", "config", "path to the configuration file", config_file, &config_file); OptionParser conf("Overridable config file options"); // server settings conf.add>("", "server.threads", "number of server threads", settings.server.threads, &settings.server.threads); conf.add>("", "server.pidfile", "pid file when running as daemon", settings.server.pid_file, &settings.server.pid_file); conf.add>("", "server.user", "the user to run as when daemonized", settings.server.user, &settings.server.user); conf.add>("", "server.group", "the group to run as when daemonized", settings.server.group, &settings.server.group); conf.add>("", "server.datadir", "directory where persistent data is stored", settings.server.data_dir, &settings.server.data_dir); // SSL settings conf.add>("", "ssl.certificate", "certificate file (PEM format)", settings.ssl.certificate, &settings.ssl.certificate); conf.add>("", "ssl.certificate_key", "private key file (PEM format)", settings.ssl.certificate_key, &settings.ssl.certificate_key); conf.add>("", "ssl.key_password", "key password (for encrypted private key)", settings.ssl.key_password, &settings.ssl.key_password); #if 0 // feature: users // Users setting auto users_value = conf.add>("", "users.user", "::"); #endif // HTTP RPC settings conf.add>("", "http.enabled", "enable HTTP Json RPC (HTTP POST and websockets)", settings.http.enabled, &settings.http.enabled); conf.add>("", "http.port", "which port the server should listen on", settings.http.port, &settings.http.port); auto http_bind_to_address = conf.add>("", "http.bind_to_address", "address for the server to listen on", settings.http.bind_to_address.front(), &settings.http.bind_to_address[0]); conf.add>("", "http.ssl_enabled", "enable HTTPS Json RPC (HTTPS POST and ssl websockets)", settings.http.ssl_enabled, &settings.http.ssl_enabled); conf.add>("", "http.ssl_port", "which ssl port the server should listen on", settings.http.ssl_port, &settings.http.ssl_port); auto http_ssl_bind_to_address = conf.add>("", "http.ssl_bind_to_address", "ssl address for the server to listen on", settings.http.ssl_bind_to_address.front(), &settings.http.ssl_bind_to_address[0]); conf.add>("", "http.doc_root", "serve a website from the doc_root location", settings.http.doc_root, &settings.http.doc_root); conf.add>("", "http.host", "Hostname or IP under which clients can reach this host", settings.http.host, &settings.http.host); conf.add>("", "http.url_prefix", "URL prefix for generating album art URLs", settings.http.url_prefix, &settings.http.url_prefix); // TCP RPC settings conf.add>("", "tcp.enabled", "enable TCP Json RPC)", settings.tcp.enabled, &settings.tcp.enabled); conf.add>("", "tcp.port", "which port the server should listen on", settings.tcp.port, &settings.tcp.port); auto tcp_bind_to_address = conf.add>("", "tcp.bind_to_address", "address for the server to listen on", settings.tcp.bind_to_address.front(), &settings.tcp.bind_to_address[0]); // stream settings conf.add>("", "stream.plugin_dir", "stream plugin directory", settings.stream.plugin_dir, &settings.stream.plugin_dir); auto stream_bind_to_address = conf.add>("", "stream.bind_to_address", "address for the server to listen on", settings.stream.bind_to_address.front(), &settings.stream.bind_to_address[0]); conf.add>("", "stream.port", "which port the server should listen on", settings.stream.port, &settings.stream.port); // deprecated: stream.stream, use stream.source instead auto streamValue = conf.add>("", "stream.stream", "Deprecated: use stream.source", pcmSource, &pcmSource); auto sourceValue = conf.add>( "", "stream.source", "URI of the PCM input stream.\nFormat: TYPE://host/path?name=NAME\n[&codec=CODEC]\n[&sampleformat=SAMPLEFORMAT]", pcmSource, &pcmSource); conf.add>("", "stream.sampleformat", "Default sample format", settings.stream.sampleFormat, &settings.stream.sampleFormat); conf.add>("", "stream.codec", "Default transport codec\n(flac|ogg|opus|pcm)[:options]\nType codec:? to get codec specific options", settings.stream.codec, &settings.stream.codec); // deprecated: stream_buffer, use chunk_ms instead conf.add>("", "stream.stream_buffer", "Default stream read chunk size [ms], deprecated, use stream.chunk_ms instead", settings.stream.streamChunkMs, &settings.stream.streamChunkMs); conf.add>("", "stream.chunk_ms", "Default stream read chunk size [ms]", settings.stream.streamChunkMs, &settings.stream.streamChunkMs); conf.add>("", "stream.buffer", "Buffer [ms]", settings.stream.bufferMs, &settings.stream.bufferMs); conf.add>("", "stream.send_to_muted", "Send audio to muted clients", settings.stream.sendAudioToMutedClients, &settings.stream.sendAudioToMutedClients); // streaming_client options conf.add>("", "streaming_client.initial_volume", "Volume [percent] assigned to new streaming clients", settings.streamingclient.initialVolume, &settings.streamingclient.initialVolume); // logging settings conf.add>("", "logging.sink", "log sink [null,system,stdout,stderr,file:]", settings.logging.sink, &settings.logging.sink); auto logfilterOption = conf.add>( "", "logging.filter", "log filter :[,:]* with tag = * or and level = [trace,debug,info,notice,warning,error,fatal]", settings.logging.filter); try { op.parse(argc, argv); conf.parse(config_file); conf.parse(argc, argv); if (tcp_bind_to_address->is_set()) { settings.tcp.bind_to_address.clear(); for (size_t n = 0; n < tcp_bind_to_address->count(); ++n) settings.tcp.bind_to_address.push_back(tcp_bind_to_address->value(n)); } if (http_bind_to_address->is_set()) { settings.http.bind_to_address.clear(); for (size_t n = 0; n < http_bind_to_address->count(); ++n) settings.http.bind_to_address.push_back(http_bind_to_address->value(n)); } if (http_ssl_bind_to_address->is_set()) { settings.http.ssl_bind_to_address.clear(); for (size_t n = 0; n < http_ssl_bind_to_address->count(); ++n) settings.http.ssl_bind_to_address.push_back(http_ssl_bind_to_address->value(n)); } if (stream_bind_to_address->is_set()) { settings.stream.bind_to_address.clear(); for (size_t n = 0; n < stream_bind_to_address->count(); ++n) settings.stream.bind_to_address.push_back(stream_bind_to_address->value(n)); } } catch (const std::invalid_argument& e) { cerr << "Exception: " << e.what() << "\n"; cout << "\n" << op << "\n"; exit(EXIT_FAILURE); } if (versionSwitch->is_set()) { cout << "snapserver v" << version::code << (!version::rev().empty() ? (" (rev " + version::rev(8) + ")") : ("")) << "\n" << "Copyright (C) 2014-2025 BadAix (snapcast@badaix.de).\n" << "License GPLv3+: GNU GPL version 3 or later .\n" << "This is free software: you are free to change and redistribute it.\n" << "There is NO WARRANTY, to the extent permitted by law.\n\n" << "Written by Johannes M. Pohl and contributors .\n\n"; exit(EXIT_SUCCESS); } if (helpSwitch->is_set()) { cout << op << "\n"; if (helpSwitch->count() > 1) cout << conf << "\n"; exit(EXIT_SUCCESS); } if (groffSwitch->is_set()) { GroffOptionPrinter option_printer(&op); cout << option_printer.print(); exit(EXIT_SUCCESS); } if (settings.stream.codec.find(":?") != string::npos) { encoder::EncoderFactory encoderFactory; std::unique_ptr encoder(encoderFactory.createEncoder(settings.stream.codec)); if (encoder) { cout << "Options for codec '" << encoder->name() << "':\n" << " " << encoder->getAvailableOptions() << "\n" << " Default: '" << encoder->getDefaultOptions() << "'\n"; } exit(EXIT_SUCCESS); } settings.logging.filter = logfilterOption->value(); if (logfilterOption->is_set()) { for (size_t n = 1; n < logfilterOption->count(); ++n) settings.logging.filter += "," + logfilterOption->value(n); } if (settings.logging.sink.empty()) { settings.logging.sink = "stdout"; #ifdef HAS_DAEMON if (daemonOption->is_set()) settings.logging.sink = "system"; #endif } AixLog::Filter logfilter; auto filters = utils::string::split(settings.logging.filter, ','); for (const auto& filter : filters) logfilter.add_filter(filter); string logformat = "%Y-%m-%d %H-%M-%S.#ms [#severity] (#tag_func)"; if (settings.logging.sink.find("file:") != string::npos) { string logfile = settings.logging.sink.substr(settings.logging.sink.find(':') + 1); AixLog::Log::init(logfilter, logfile, logformat); } else if (settings.logging.sink == "stdout") AixLog::Log::init(logfilter, logformat); else if (settings.logging.sink == "stderr") AixLog::Log::init(logfilter, logformat); else if (settings.logging.sink == "system") AixLog::Log::init("snapserver", logfilter); else if (settings.logging.sink == "null") AixLog::Log::init(); else throw SnapException("Invalid log sink: " + settings.logging.sink); if (!settings.ssl.certificate.empty() && !settings.ssl.certificate_key.empty()) { namespace fs = std::filesystem; auto make_absolute = [](const fs::path& filename) { const fs::path cert_path = "/etc/snapserver/certs/"; if (filename.is_absolute()) return filename; if (fs::exists(filename)) return fs::canonical(filename); return cert_path / filename; }; settings.ssl.certificate = make_absolute(settings.ssl.certificate); if (!fs::exists(settings.ssl.certificate)) throw SnapException("SSL certificate file not found: " + settings.ssl.certificate.native()); settings.ssl.certificate_key = make_absolute(settings.ssl.certificate_key); if (!fs::exists(settings.ssl.certificate_key)) throw SnapException("SSL certificate_key file not found: " + settings.ssl.certificate_key.native()); } else if (settings.ssl.certificate.empty() != settings.ssl.certificate_key.empty()) { throw SnapException("Both SSL 'certificate' and 'certificate_key' must be set or empty"); } if (!settings.ssl.enabled()) { if (settings.http.ssl_enabled) throw SnapException("HTTPS enabled ([http] ssl_enabled), but no certificates specified"); } LOG(INFO, LOG_TAG) << "Version " << version::code << (!version::rev().empty() ? (", revision " + version::rev(8)) : ("")) << "\n"; if (settings.ssl.enabled()) LOG(INFO, LOG_TAG) << "SSL enabled - certificate file: '" << settings.ssl.certificate.native() << "', certificate key file: '" << settings.ssl.certificate_key.native() << "'\n"; if (!streamValue->is_set() && !sourceValue->is_set()) settings.stream.sources.push_back(sourceValue->value()); settings.stream.plugin_dir = std::filesystem::weakly_canonical(settings.stream.plugin_dir); LOG(INFO, LOG_TAG) << "Stream plugin directory: " << settings.stream.plugin_dir << "\n"; for (size_t n = 0; n < streamValue->count(); ++n) { LOG(INFO, LOG_TAG) << "Adding stream: " << streamValue->value(n) << "\n"; settings.stream.sources.push_back(streamValue->value(n)); } for (size_t n = 0; n < sourceValue->count(); ++n) { LOG(INFO, LOG_TAG) << "Adding source: " << sourceValue->value(n) << "\n"; settings.stream.sources.push_back(sourceValue->value(n)); } #if 0 // feature: users for (size_t n = 0; n < users_value->count(); ++n) { settings.users.emplace_back(users_value->value(n)); LOG(DEBUG, LOG_TAG) << "User: " << settings.users.back().name << ", permissions: " << utils::string::container_to_string(settings.users.back().permissions) << ", pw: " << settings.users.back().password << "\n"; } #endif #ifdef HAS_DAEMON std::unique_ptr daemon; if (daemonOption->is_set()) { if (settings.server.user.empty()) throw std::invalid_argument("user must not be empty"); if (settings.server.data_dir.empty()) settings.server.data_dir = "/var/lib/snapserver"; Config::instance().init(settings.server.data_dir, settings.server.user, settings.server.group); daemon = std::make_unique(settings.server.user, settings.server.group, settings.server.pid_file); processPriority = std::min(std::max(-20, processPriority), 19); if (processPriority != 0) setpriority(PRIO_PROCESS, 0, processPriority); LOG(NOTICE, LOG_TAG) << "daemonizing" << "\n"; daemon->daemonize(); LOG(NOTICE, LOG_TAG) << "daemon started" << "\n"; } else Config::instance().init(settings.server.data_dir); #else Config::instance().init(settings.server.data_dir); #endif boost::asio::io_context io_context; #if defined(HAS_AVAHI) || defined(HAS_BONJOUR) auto publishZeroConfg = std::make_unique("Snapcast", io_context); vector dns_services; dns_services.emplace_back("_snapcast._tcp", settings.stream.port); dns_services.emplace_back("_snapcast-stream._tcp", settings.stream.port); if (settings.tcp.enabled) { dns_services.emplace_back("_snapcast-jsonrpc._tcp", settings.tcp.port); dns_services.emplace_back("_snapcast-tcp._tcp", settings.tcp.port); } if (settings.http.enabled) { dns_services.emplace_back("_snapcast-http._tcp", settings.http.port); } if (settings.http.ssl_enabled) { dns_services.emplace_back("_snapcast-https._tcp", settings.http.ssl_port); } publishZeroConfg->publish(dns_services); #endif if (settings.http.enabled || settings.http.ssl_enabled) { if ((settings.http.host == "") || settings.http.host.empty()) { settings.http.host = boost::asio::ip::host_name(); LOG(INFO, LOG_TAG) << "Using HTTP host name: " << settings.http.host << "\n"; } } if (settings.stream.streamChunkMs < 10) { LOG(WARNING, LOG_TAG) << "Stream read chunk size is less than 10ms, changing to 10ms\n"; settings.stream.streamChunkMs = 10; } static constexpr chrono::milliseconds MIN_BUFFER_DURATION = 20ms; if (settings.stream.bufferMs < MIN_BUFFER_DURATION.count()) { LOG(WARNING, LOG_TAG) << "Buffer is less than " << MIN_BUFFER_DURATION.count() << "ms, changing to " << MIN_BUFFER_DURATION.count() << "ms\n"; settings.stream.bufferMs = MIN_BUFFER_DURATION.count(); } auto server = std::make_unique(io_context, settings); server->start(); if (settings.server.threads < 0) settings.server.threads = std::max(2, std::min(4, static_cast(std::thread::hardware_concurrency()))); LOG(INFO, LOG_TAG) << "Number of threads: " << settings.server.threads << ", hw threads: " << std::thread::hardware_concurrency() << "\n"; // Construct a signal set registered for process termination. boost::asio::signal_set signals(io_context, SIGHUP, SIGINT, SIGTERM); signals.async_wait([&io_context](const boost::system::error_code& ec, int signal) { if (!ec) LOG(INFO, LOG_TAG) << "Received signal " << signal << ": " << strsignal(signal) << "\n"; else LOG(INFO, LOG_TAG) << "Failed to wait for signal, error: " << ec.message() << "\n"; io_context.stop(); }); std::vector threads; threads.reserve(settings.server.threads); for (int n = 0; n < settings.server.threads; ++n) threads.emplace_back([&] { io_context.run(); }); io_context.run(); for (auto& t : threads) t.join(); LOG(INFO, LOG_TAG) << "Stopping streamServer\n"; server->stop(); LOG(INFO, LOG_TAG) << "done\n"; } catch (const std::exception& e) { LOG(ERROR, LOG_TAG) << "Exception: " << e.what() << "\n"; exitcode = EXIT_FAILURE; } Config::instance().save(); LOG(NOTICE, LOG_TAG) << "Snapserver terminated.\n"; exit(exitcode); }