Projects
Multimedia
libdatachannel
Sign Up
Log In
Username
Password
Overview
Repositories
Revisions
Requests
Users
Attributes
Meta
Expand all
Collapse all
Changes of Revision 11
View file
_service
Changed
@@ -1,7 +1,7 @@ <services> <service name="obs_scm"> <param name="filename">libdatachannel</param> - <param name="revision">443f6934d9007eb7076ab7825ba330f355fcbead</param> + <param name="revision">6b1e2e620f1e37f0eafeee702eaea0043cb305fd</param> <param name="scm">git</param> <param name="submodules">disable</param> <param name="url">https://github.com/paullouisageneau/libdatachannel.git</param>
View file
_service:obs_scm:libdatachannel-0.24.5.obscpio/.github/workflows/build-mbedtls.yml -> _service:obs_scm:libdatachannel-0.24.6.obscpio/.github/workflows/build-mbedtls.yml
Changed
@@ -10,7 +10,7 @@ steps: - uses: actions/checkout@v6 - name: Set up Homebrew - uses: Homebrew/actions/setup-homebrew@master + uses: Homebrew/actions/setup-homebrew@main - name: Install Mbed TLS run: brew update && brew install mbedtls@3 - name: submodules
View file
_service:obs_scm:libdatachannel-0.24.5.obscpio/.github/workflows/build-openssl.yml -> _service:obs_scm:libdatachannel-0.24.6.obscpio/.github/workflows/build-openssl.yml
Changed
@@ -28,7 +28,7 @@ - name: submodules run: git submodule update --init --recursive --depth 1 - name: cmake - run: cmake -B build -DUSE_GNUTLS=0 -WARNINGS_AS_ERRORS=1 -DENABLE_LOCAL_ADDRESS_TRANSLATION=1 + run: cmake -B build -DUSE_GNUTLS=0 -DWARNINGS_AS_ERRORS=1 -DENABLE_LOCAL_ADDRESS_TRANSLATION=1 env: OPENSSL_ROOT_DIR: /usr/local/opt/openssl@3 - name: make
View file
_service:obs_scm:libdatachannel-0.24.5.obscpio/CMakeLists.txt -> _service:obs_scm:libdatachannel-0.24.6.obscpio/CMakeLists.txt
Changed
@@ -1,6 +1,6 @@ cmake_minimum_required(VERSION 3.13) project(libdatachannel - VERSION 0.24.5 + VERSION 0.24.6 LANGUAGES CXX) set(PROJECT_DESCRIPTION "C/C++ WebRTC network library featuring Data Channels, Media Transport, and WebSockets") @@ -214,6 +214,7 @@ set(TESTS_SOURCES ${CMAKE_CURRENT_SOURCE_DIR}/test/main.cpp ${CMAKE_CURRENT_SOURCE_DIR}/test/connectivity.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/test/dtls.cpp ${CMAKE_CURRENT_SOURCE_DIR}/test/negotiated.cpp ${CMAKE_CURRENT_SOURCE_DIR}/test/reliability.cpp ${CMAKE_CURRENT_SOURCE_DIR}/test/turn_connectivity.cpp
View file
_service:obs_scm:libdatachannel-0.24.5.obscpio/include/rtc/rtp.hpp -> _service:obs_scm:libdatachannel-0.24.6.obscpio/include/rtc/rtp.hpp
Changed
@@ -98,7 +98,7 @@ nodiscard uint32_t delaySinceSR() const; nodiscard SSRC getSSRC() const; - nodiscard uint32_t getNTPOfSR() const; + nodiscard uint64_t getNTPOfSR() const; nodiscard uint8_t getFractionLost() const; nodiscard unsigned int getPacketsLostCount() const;
View file
_service:obs_scm:libdatachannel-0.24.5.obscpio/include/rtc/version.h -> _service:obs_scm:libdatachannel-0.24.6.obscpio/include/rtc/version.h
Changed
@@ -3,7 +3,7 @@ #define RTC_VERSION_MAJOR 0 #define RTC_VERSION_MINOR 24 -#define RTC_VERSION_PATCH 5 -#define RTC_VERSION "0.24.5" +#define RTC_VERSION_PATCH 6 +#define RTC_VERSION "0.24.6" #endif
View file
_service:obs_scm:libdatachannel-0.24.5.obscpio/src/description.cpp -> _service:obs_scm:libdatachannel-0.24.6.obscpio/src/description.cpp
Changed
@@ -157,7 +157,7 @@ // RFC 8839: The "ice-options" attribute is a session-level and media-level // attribute. if (mIceOptions.empty()) - mIceOptions = utils::explode(string(value), ','); + mIceOptions = utils::explode(string(value), ' '); } else if (key == "candidate") { addCandidate(Candidate(attr, bundleMid())); } else if (key == "end-of-candidates") { @@ -318,7 +318,7 @@ // Session-level attributes sdp << "a=msid-semantic:WMS *" << eol; if (!mIceOptions.empty()) - sdp << "a=ice-options:" << utils::implode(mIceOptions, ',') << eol; + sdp << "a=ice-options:" << utils::implode(mIceOptions, ' ') << eol; if (mFingerprint) sdp << "a=fingerprint:" << CertificateFingerprint::AlgorithmIdentifier(mFingerprint->algorithm) << " " @@ -381,7 +381,7 @@ // Session-level attributes sdp << "a=msid-semantic:WMS *" << eol; if (!mIceOptions.empty()) - sdp << "a=ice-options:" << utils::implode(mIceOptions, ',') << eol; + sdp << "a=ice-options:" << utils::implode(mIceOptions, ' ') << eol; for (const auto &attr : mAttributes) sdp << "a=" << attr << eol; @@ -667,7 +667,15 @@ if (mDirection != Direction::Unknown) sdp << "a=" << mDirection << eol; + // Do not reciprocate unknown transport attributes + const std::array ignoredPrefixes{"ice-", "dtls-", "sctp-"}; + for (const auto &attr : mAttributes) { + if (std::any_of(ignoredPrefixes.begin(), ignoredPrefixes.end(), + &(string_view prefix) { return match_prefix(attr, prefix); })) { + continue; + } + if (mRids.size() != 0 && match_prefix(attr, "ssrc:")) { continue; }
View file
_service:obs_scm:libdatachannel-0.24.5.obscpio/src/impl/dtlstransport.cpp -> _service:obs_scm:libdatachannel-0.24.6.obscpio/src/impl/dtlstransport.cpp
Changed
@@ -85,6 +85,8 @@ "Failed to set SRTP profile"); gnutls::check(gnutls_credentials_set(mSession, GNUTLS_CRD_CERTIFICATE, creds)); + if (!mIsClient) + gnutls_certificate_server_set_request(mSession, GNUTLS_CERT_REQUIRE); gnutls_dtls_set_timeouts(mSession, 1000, // 1s retransmission timeout recommended by RFC 6347 @@ -162,8 +164,11 @@ } PLOG_VERBOSE << "Incoming size=" << message->size(); - mIncomingQueue.push(message); - enqueueRecv(); + if (mIncomingQueue.tryPush(std::move(message))) { + enqueueRecv(); + } else { + PLOG_VERBOSE << "DTLS incoming queue is full, dropping"; + } } bool DtlsTransport::outgoing(message_ptr message) { @@ -501,8 +506,11 @@ } PLOG_VERBOSE << "Incoming size=" << message->size(); - mIncomingQueue.push(message); - enqueueRecv(); + if (mIncomingQueue.tryPush(std::move(message))) { + enqueueRecv(); + } else { + PLOG_VERBOSE << "DTLS incoming queue is full, dropping"; + } } bool DtlsTransport::outgoing(message_ptr message) { @@ -543,7 +551,9 @@ } if (ret == MBEDTLS_ERR_SSL_WANT_READ) { - ThreadPool::Instance().schedule(mTimerSetAt + milliseconds(mFinMs), + auto timeout = mFinMs != 0 ? mTimerSetAt + milliseconds(mFinMs) + : std::chrono::steady_clock::now() + milliseconds(MBEDTLS_SSL_DTLS_TIMEOUT_DFL_MIN); + ThreadPool::Instance().schedule(timeout, weak_this = weak_from_this()() { if (auto locked = weak_this.lock()) locked->doRecv(); @@ -610,12 +620,18 @@ } int DtlsTransport::CertificateCallback(void *ctx, mbedtls_x509_crt *crt, int /*depth*/, - uint32_t * /*flags*/) { + uint32_t *flags) { auto this_ = static_cast<DtlsTransport *>(ctx); string fingerprint = make_fingerprint(crt, this_->mFingerprintAlgorithm); std::transform(fingerprint.begin(), fingerprint.end(), fingerprint.begin(), (char c) { return char(std::toupper(c)); }); - return this_->mVerifierCallback(fingerprint) ? 0 : 1; + + if (!this_->mVerifierCallback(fingerprint)) { + if (flags) *flags |= MBEDTLS_X509_BADCERT_NOT_TRUSTED; + return MBEDTLS_ERR_X509_CERT_VERIFY_FAILED; + } + + return 0; } void DtlsTransport::ExportKeysCallback(void *ctx, mbedtls_ssl_key_export_type /*type*/, @@ -858,7 +874,13 @@ openssl::check_error(err, "Handshake failed"); - handleTimeout(); + // start() can run on the libjuice poll thread via the ICE state-change + // callback, while juice still holds its registry mutex. Calling + // handleTimeout() inline then waits on mSslMutex, which a thread pool + // recv thread may hold while blocked in juice_send (conn_lock) on the + // TURN relay path, forming an ABBA deadlock. Defer the initial timeout + // handling to the thread pool, as the other TLS backends do. + enqueueRecv(); // to initiate the handshake } void DtlsTransport::stop() { @@ -896,7 +918,7 @@ } PLOG_VERBOSE << "Incoming size=" << message->size(); - if(mIncomingQueue.tryPush(message)) { + if (mIncomingQueue.tryPush(std::move(message))) { enqueueRecv(); } else { PLOG_VERBOSE << "DTLS incoming queue is full, dropping";
View file
_service:obs_scm:libdatachannel-0.24.5.obscpio/src/impl/icetransport.cpp -> _service:obs_scm:libdatachannel-0.24.6.obscpio/src/impl/icetransport.cpp
Changed
@@ -667,28 +667,32 @@ nice_agent_attach_recv(mNiceAgent.get(), mStreamId, 1, g_main_loop_get_context(MainLoop->get()), NULL, NULL); - // Synchronize with the libnice main loop thread BEFORE freeing the - // stream's rx buffer (nice_agent_remove_stream below) or `this`. The - // detach above prevents new dispatches, but a callback (e.g. - // RecvCallback) may already be running on the main loop thread with - // `this` as userData and `buf` pointing into libnice's stream rx buffer. - // Post a barrier task to the main context and wait for it to run; GLib - // dispatches sources serially on that thread, so once our task runs no - // callback can still be using `this` or the rx buffer. + // Remove the stream on the main loop thread. The attach_recv above only + // clears the io callback: the socket sources stay attached, and libnice's + // component_io_cb dereferences its component before taking the agent lock, + // so removing the stream from here would free it under a live dispatch. + // GLib dispatches serially on that thread, so the removal cannot overlap + // one; waiting for it also ensures no callback still uses `this` or the + // stream's rx buffer. { - std::promise<void> barrier; - auto future = barrier.get_future(); + struct RemoveStreamData { + NiceAgent *agent; + guint streamId; + std::promise<void> done; + } data{mNiceAgent.get(), mStreamId, {}}; + auto future = data.done.get_future(); g_main_context_invoke_full( g_main_loop_get_context(MainLoop->get()), G_PRIORITY_HIGH, - (gpointer data) -> gboolean { - static_cast<std::promise<void> *>(data)->set_value(); + (gpointer ptr) -> gboolean { + auto *data = static_cast<RemoveStreamData *>(ptr); + nice_agent_remove_stream(data->agent, data->streamId); + data->done.set_value(); return G_SOURCE_REMOVE; }, - &barrier, NULL); + &data, NULL); future.wait(); } - nice_agent_remove_stream(mNiceAgent.get(), mStreamId); mNiceAgent.reset(); if (mTimeoutId)
View file
_service:obs_scm:libdatachannel-0.24.5.obscpio/src/impl/init.cpp -> _service:obs_scm:libdatachannel-0.24.6.obscpio/src/impl/init.cpp
Changed
@@ -110,7 +110,7 @@ mCurrentSctpSettings = std::move(s); // store for next init } -void Init::doInit() { +void Init::doInit() try { // mMutex needs to be locked if (std::exchange(mInitialized, true)) @@ -151,6 +151,12 @@ DtlsSrtpTransport::Init(); #endif IceTransport::Init(); +} catch (...) { + PLOG_ERROR << "Global initialization failed, resetting"; + ThreadPool::Instance().join(); + ThreadPool::Instance().clear(); + mInitialized = false; + throw; } void Init::doCleanup() {
View file
_service:obs_scm:libdatachannel-0.24.5.obscpio/src/impl/peerconnection.cpp -> _service:obs_scm:libdatachannel-0.24.6.obscpio/src/impl/peerconnection.cpp
Changed
@@ -171,7 +171,8 @@ break; case IceTransport::State::Connected: changeIceState(IceState::Connected); - initDtlsTransport(); + if (remoteDescription()) + initDtlsTransport(); break; case IceTransport::State::Completed: changeIceState(IceState::Completed); @@ -221,6 +222,7 @@ shared_ptr<DtlsTransport> PeerConnection::initDtlsTransport() { try { + std::lock_guard lock(mDtlsTransportInitMutex); if (auto transport = std::atomic_load(&mDtlsTransport)) return transport; @@ -1162,6 +1164,14 @@ } auto dtlsTransport = std::atomic_load(&mDtlsTransport); + if (!dtlsTransport) { + // ICE might have connected before the remote fingerprint was committed. + auto iceTransport = std::atomic_load(&mIceTransport); + if (iceTransport && (iceTransport->state() == Transport::State::Connected || + iceTransport->state() == Transport::State::Completed)) + dtlsTransport = initDtlsTransport(); + } + if (description.hasApplication()) { auto sctpTransport = std::atomic_load(&mSctpTransport); if (!sctpTransport && dtlsTransport &&
View file
_service:obs_scm:libdatachannel-0.24.5.obscpio/src/impl/peerconnection.hpp -> _service:obs_scm:libdatachannel-0.24.6.obscpio/src/impl/peerconnection.hpp
Changed
@@ -153,6 +153,7 @@ shared_ptr<IceTransport> mIceTransport; shared_ptr<DtlsTransport> mDtlsTransport; + std::mutex mDtlsTransportInitMutex; shared_ptr<SctpTransport> mSctpTransport; std::unordered_map<uint16_t, weak_ptr<DataChannel>> mDataChannels; // by stream ID
View file
_service:obs_scm:libdatachannel-0.24.5.obscpio/src/impl/tlstransport.cpp -> _service:obs_scm:libdatachannel-0.24.6.obscpio/src/impl/tlstransport.cpp
Changed
@@ -339,7 +339,7 @@ MBEDTLS_SSL_TRANSPORT_STREAM, MBEDTLS_SSL_PRESET_DEFAULT)); mbedtls_ssl_conf_max_version(&mConf, MBEDTLS_SSL_MAJOR_VERSION_3, MBEDTLS_SSL_MINOR_VERSION_3); // TLS 1.2 - mbedtls_ssl_conf_authmode(&mConf, MBEDTLS_SSL_VERIFY_OPTIONAL); + mbedtls_ssl_conf_authmode(&mConf, MBEDTLS_SSL_VERIFY_NONE); mbedtls_ssl_conf_rng(&mConf, mbedtls_ctr_drbg_random, &mDrbg); if (certificate) {
View file
_service:obs_scm:libdatachannel-0.24.5.obscpio/src/rtp.cpp -> _service:obs_scm:libdatachannel-0.24.6.obscpio/src/rtp.cpp
Changed
@@ -229,7 +229,7 @@ void RtcpReportBlock::setNTPOfSR(uint64_t ntp) { _lastReport = htonl((uint32_t)(ntp >> 16)); } -uint32_t RtcpReportBlock::getNTPOfSR() const { return ntohl(_lastReport) << 16u; } +uint64_t RtcpReportBlock::getNTPOfSR() const { return (uint64_t)ntohl(_lastReport) << 16u; } void RtcpReportBlock::setDelaySinceSR(uint32_t sr) { // The delay, expressed in units of 1/65536 seconds @@ -735,7 +735,10 @@ return totalSize - (getBody() - reinterpret_cast<const char *>(this)); } -size_t RtpRtx::getSize() const { return header.getSize() + sizeof(uint16_t); } +size_t RtpRtx::getSize() const { + return static_cast<size_t>(header.getBody() - reinterpret_cast<const char *>(this)) + + sizeof(uint16_t); +} size_t RtpRtx::normalizePacket(size_t totalSize, SSRC originalSSRC, uint8_t originalPayloadType) { if (totalSize < getSize()) @@ -744,12 +747,13 @@ header.setSeqNumber(getOriginalSeqNo()); header.setSsrc(originalSSRC); header.setPayloadType(originalPayloadType); - memmove(header.getBody(), getBody(), totalSize - getSize()); + memmove(header.getBody(), getBody(), getBodySize(totalSize)); return totalSize - 2; } size_t RtpRtx::copyTo(RtpHeader *dest, size_t totalSize, uint8_t originalPayloadType) { - memmove((char *)dest, (char *)this, header.getSize()); + auto headerSize = static_cast<size_t>(header.getBody() - reinterpret_cast<const char *>(this)); + memmove(dest, this, headerSize); dest->setSeqNumber(getOriginalSeqNo()); dest->setPayloadType(originalPayloadType); memmove(dest->getBody(), getBody(), getBodySize(totalSize));
View file
_service:obs_scm:libdatachannel-0.24.6.obscpio/test/dtls.cpp
Added
@@ -0,0 +1,191 @@ +/** + * Copyright (c) 2026 mertushka + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +#include "rtc/rtc.hpp" +#include "test.hpp" + +#include <chrono> +#include <condition_variable> +#include <future> +#include <iostream> +#include <memory> +#include <mutex> +#include <stdexcept> +#include <string> +#include <thread> + +using namespace rtc; +using namespace std; +using namespace chrono_literals; + +namespace { + +struct ConnectionState { + mutex lock; + condition_variable changed; + PeerConnection::IceState ice = PeerConnection::IceState::New; + PeerConnection::GatheringState gathering = PeerConnection::GatheringState::New; + bool channelOpen = false; + bool closed = false; + bool connected = false; + bool failed = false; +}; + +template <typename Predicate> +bool waitFor(const shared_ptr<ConnectionState> &state, chrono::milliseconds timeout, + Predicate predicate) { + unique_lock lock(state->lock); + return state->changed.wait_for(lock, timeout, & { return predicate(*state); }); +} + +void observe(PeerConnection &pc, const shared_ptr<ConnectionState> &state) { + pc.onStateChange(state(PeerConnection::State value) { + { + lock_guard lock(state->lock); + state->closed = state->closed || value == PeerConnection::State::Closed; + state->connected = state->connected || value == PeerConnection::State::Connected; + state->failed = state->failed || value == PeerConnection::State::Failed; + } + state->changed.notify_all(); + }); + pc.onIceStateChange(state(PeerConnection::IceState value) { + { + lock_guard lock(state->lock); + state->ice = value; + } + state->changed.notify_all(); + }); + pc.onGatheringStateChange(state(PeerConnection::GatheringState value) { + { + lock_guard lock(state->lock); + state->gathering = value; + } + state->changed.notify_all(); + }); +} + +Description corruptFingerprint(Description description) { + auto fingerprint = description.fingerprint(); + if (!fingerprint || fingerprint->value.empty()) + throw runtime_error("Answer has no fingerprint"); + + fingerprint->value.front() = fingerprint->value.front() == '0' ? '1' : '0'; + description.setFingerprint(*fingerprint); + return description; +} + +void runDelayedAnswer(bool invalidFingerprint) { + Configuration config; + config.disableAutoNegotiation = true; +#if RTC_ENABLE_MEDIA + config.forceMediaTransport = true; +#endif + + PeerConnection offerer(config); + PeerConnection answerer(config); + auto offererState = make_shared<ConnectionState>(); + auto answererState = make_shared<ConnectionState>(); + observe(offerer, offererState); + observe(answerer, answererState); + + auto channel = offerer.createDataChannel("dtls-startup-order"); + channel->onOpen(offererState { + { + lock_guard lock(offererState->lock); + offererState->channelOpen = true; + } + offererState->changed.notify_all(); + }); + offerer.setLocalDescription(Description::Type::Offer); + if (!waitFor(offererState, 5s, (const ConnectionState &state) { + return state.gathering == PeerConnection::GatheringState::Complete; + })) + throw runtime_error("Offer gathering timed out"); + + auto offer = offerer.localDescription(); + if (!offer || offer->candidates().empty()) + throw runtime_error("Full offer has no inline candidates"); + + answerer.setRemoteDescription(*offer); + answerer.setLocalDescription(Description::Type::Answer); + if (!waitFor(answererState, 5s, (const ConnectionState &state) { + return state.gathering == PeerConnection::GatheringState::Complete; + })) + throw runtime_error("Answer gathering timed out"); + + auto answer = answerer.localDescription(); + if (!answer || answer->candidates().empty()) + throw runtime_error("Full answer has no inline candidates"); + + if (!waitFor(answererState, 5s, (const ConnectionState &state) { + return state.ice == PeerConnection::IceState::Checking || + state.ice == PeerConnection::IceState::Connected || + state.ice == PeerConnection::IceState::Completed; + })) + throw runtime_error("Answerer did not start ICE checks before the delayed answer"); + + this_thread::sleep_for(50ms); + offerer.setRemoteDescription(invalidFingerprint ? corruptFingerprint(*answer) : *answer); + + if (!waitFor(offererState, 10s, invalidFingerprint(const ConnectionState &state) { + return state.failed || state.closed || + (state.connected && (invalidFingerprint || state.channelOpen)); + })) + throw runtime_error("Offerer did not finish DTLS startup"); + + { + lock_guard lock(offererState->lock); + if (invalidFingerprint && + (offererState->connected || (!offererState->failed && !offererState->closed))) + throw runtime_error("Invalid fingerprint did not fail authentication"); + if (!invalidFingerprint && !offererState->connected) + throw runtime_error("Valid delayed answer did not connect"); + } + + if (!invalidFingerprint) { + if (!waitFor(answererState, 10s, (const ConnectionState &state) { + return state.connected || state.failed || state.closed; + })) + throw runtime_error("Answerer did not finish DTLS startup"); + + lock_guard lock(answererState->lock); + if (!answererState->connected) + throw runtime_error("Answerer did not connect"); + } else { + this_thread::sleep_for(100ms); + } + + offerer.close(); + answerer.close(); +} + +void runIterations(int iterations, bool invalidFingerprint) { + for (int i = 0; i < iterations; ++i) { + try { + runDelayedAnswer(invalidFingerprint); + if (Cleanup().wait_for(10s) == future_status::timeout) + throw runtime_error("Cleanup timed out"); + } catch (const exception &e) { + const string message = string(invalidFingerprint ? "Invalid" : "Valid") + + " fingerprint iteration " + to_string(i + 1) + ": " + e.what(); + cerr << message << endl; + throw runtime_error(message); + } + } +} + +} // namespace + +TestResult test_dtls_startup_after_remote_description() { + InitLogger(LogLevel::Warning); + + runIterations(100, false); + runIterations(10, true); + + return TestResult(true); +}
View file
_service:obs_scm:libdatachannel-0.24.5.obscpio/test/main.cpp -> _service:obs_scm:libdatachannel-0.24.6.obscpio/test/main.cpp
Changed
@@ -23,6 +23,7 @@ TestResult test_connectivity(); TestResult test_connectivity_fail_on_wrong_fingerprint(); +TestResult test_dtls_startup_after_remote_description(); TestResult test_pem(); TestResult test_negotiated(); TestResult test_reliability(); @@ -68,6 +69,7 @@ static const vector<Test> tests = { // C++ API tests + Test("DTLS startup after remote description", test_dtls_startup_after_remote_description), Test("WebRTC connectivity", test_connectivity), Test("WebRTC broken fingerprint", test_connectivity_fail_on_wrong_fingerprint), Test("pem", test_pem),
View file
_service:obs_scm:libdatachannel.obsinfo
Changed
@@ -1,4 +1,4 @@ name: libdatachannel -version: 0.24.5 -mtime: 1781267114 -commit: 443f6934d9007eb7076ab7825ba330f355fcbead +version: 0.24.6 +mtime: 1790460966 +commit: 6b1e2e620f1e37f0eafeee702eaea0043cb305fd
Locations
Projects
Search
Status Monitor
Help
Open Build Service
OBS Manuals
API Documentation
OBS Portal
Reporting a Bug
Contact
Mailing List
Forums
Chat (IRC)
Twitter
Open Build Service (OBS)
is an
openSUSE project
.