diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..dd16792e --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Preserve official Mbed TLS source bytes, including upstream trailing blank lines. +lib/CzcPeerTls/vendor/** whitespace=-blank-at-eof diff --git a/.gitignore b/.gitignore index 37ef75b4..852503ef 100644 --- a/.gitignore +++ b/.gitignore @@ -17,4 +17,11 @@ gzipped/ */tools/* min/ node_modules -package-lock.json \ No newline at end of file +package-lock.json +/.cache/ +/.backhaul-tests/ +!/tools/backhaul/ +!/tools/backhaul/** +/bin/czc_fw_*.bin +**/__pycache__/ +*.pyc diff --git a/.gitmodules b/.gitmodules index 6eec8447..cd376ab0 100644 --- a/.gitmodules +++ b/.gitmodules @@ -5,3 +5,6 @@ [submodule "lib/ESPAsyncTCP"] path = lib/ESPAsyncTCP url = https://github.com/me-no-dev/ESPAsyncTCP +[submodule "lib/CzcPeerTls/mbedtls"] + path = lib/CzcPeerTls/mbedtls + url = https://github.com/Mbed-TLS/mbedtls.git diff --git a/README.md b/README.md index ee7130b5..212c0731 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,9 @@ https://github.com/xyzroe/XZG/assets/6440415/ae312626-f1d9-41c4-b982-11a1f9ba9ed Visit [Wiki page](https://xzg.xyzroe.cc/quick-start/) to get information +[Optional Master/Satellite packet forwarding over IP](docs/backhaul.md) +includes configuration, radio requirements, builds and acceptance tests. + ## 🚀 Installation For a quick setup, use [XZG Web Flasher](https://xzg.xyzroe.cc/install) for an easy plug-and-flash experience. diff --git a/docs/backhaul.md b/docs/backhaul.md new file mode 100644 index 00000000..282dfcd8 --- /dev/null +++ b/docs/backhaul.md @@ -0,0 +1,80 @@ +# Zigbee packet forwarding over IP + +Master and Satellite modes let CZC gateways in separate radio coverage areas +carry Zigbee frames over an encrypted IP connection. Zigbee software connects to +Master using its ZNP TCP interface. Satellites provide remote radio +coverage in the same Zigbee network. One Master supports up to eight Satellite +connections. + +## Configuration + +1. Install radio firmware with the optional extensions from + [Z-Stack-firmware PR #609](https://github.com/Koenkk/Z-Stack-firmware/pull/609) + on each device. +2. In **Role**, select **Master** on the gateway used by your Zigbee software. + Generate a shared key. +3. Select **Satellite** on the other gateway, enter Master's IP address or + hostname and the same key. IEEE identities are exchanged automatically. +4. Start or restore the Zigbee network on Master through your Zigbee software. + Satellite receives the initial network profile through the encrypted channel and + initializes its radio without an initial join within Master's radio range. + +The modes are disabled by default. Select **Off** to use the ordinary CZC role. +The controls are available for the Zigbee Coordinator firmware role only. +Selecting Router or OpenThread hides the controls; applying that firmware role +disables forwarding and restarts ESP32 with the ordinary UART path. The PSK and +Master address remain saved. Returning to Coordinator leaves forwarding off. +A Satellite previously using another network is migrated to the configured +Master's network. + +Role controls the peer port and shows each peer's IEEE, IP and connection state. +Network lists current addresses of both interfaces. An IPv6 Master address +uses IPv6; IPv4 uses IPv4; hostnames use their resolved addresses. +Peer TLS defaults to port `7443`. Zigbee applications use the configured ZNP +port (`6638` by default). + +The radio update dialog accepts a URL or local BIN file. ESP32 updates report +completion across the device's restart. + +## Transport and authentication + +Peer and administrative channels use TLS 1.3, a 256-bit external PSK, X25519 +key exchange and `TLS_AES_128_GCM_SHA256`. The services use distinct PSK identities. +Nodes sharing a key belong to the same trust group. + +The key is returned to the browser by the configuration API so it can be shown +and copied, using the web authentication policy. The HTTP UI +and ZNP TCP connection do not gain TLS from this feature. + +See the [TLS engine documentation](../lib/CzcPeerTls/README.md) for dependencies, +build configuration and memory profiles. + +`lib/Backhaul` provides framing, epochs, a bounded transaction window, heartbeats +and deadlines. `src/backhaul.cpp` maps admission results to radio tickets, +renews virtual-neighbor leases and handles reconnection. Admission means the +destination radio queued the frame; it does not replace APS acknowledgements. +`lib/CzcBackhaul` owns UART arbitration, bootstrap, sockets and recovery state. + +## Build and test + +Initialize dependencies with `git submodule update --init --recursive` after cloning. +Master/Satellite support is part of the main firmware: build with `pio run` +or `bash tools/backhaul/build.sh -e prod` for the container build. Forwarding +is enabled by selecting the mode in Role. + +The `DEBUG` build flag selects the acceptance service. `pio run -e debug` +includes administrative TLS on fixed port `7444`, diagnostic commands and +extended logs. These diagnostics are available only in `debug` builds. +The API's `debug_mode` reports this build capability. Updating between variants +preserves the PSK, mode, Master address and peer port. Both builds use LTO to +keep the firmware and localized UI within the original OTA slot. + +The build generates TLS wrappers and the symbol header inside `.pio/build/`. +The `prepare-web-server.py` step applies a bounded idle-client queue to +Arduino WebServer, preventing idle browser connections from delaying ready +API requests. + +See [host and hardware tests](../tests/backhaul/README.md). Images remain close +to the original OTA slot size, checked by PlatformIO on each build. Eight TLS +peer pairs are covered by native tests; hardware acceptance has covered one +Master and one Satellite, not eight physical nodes. diff --git a/lib/Backhaul/README.md b/lib/Backhaul/README.md new file mode 100644 index 00000000..5a9f1448 --- /dev/null +++ b/lib/Backhaul/README.md @@ -0,0 +1,14 @@ +# Framed peer transport + +Portable C++11 framing and bounded session state used by `src/backhaul.cpp`. +The caller supplies an authenticated byte stream, radio admission callback and +monotonic clock. This library allocates no heap memory and has no Arduino or +socket dependency. + +Sessions use epochs and sequence numbers to reject stale confirmations, a +four-message window, admission results, heartbeats and transaction deadlines. +Admission means the remote radio queued the frame; APS delivery is confirmed +separately by the Zigbee stack. + +See [configuration and protocol](../../docs/backhaul.md) and +[tests](../../tests/backhaul/README.md). diff --git a/lib/Backhaul/src/Backhaul.cpp b/lib/Backhaul/src/Backhaul.cpp new file mode 100644 index 00000000..83638ff1 --- /dev/null +++ b/lib/Backhaul/src/Backhaul.cpp @@ -0,0 +1,312 @@ +#include "Backhaul.h" + +#include +#include +#include + +namespace backhaul { +namespace { +void put(uint8_t *p, uint64_t value, std::size_t count) { + while (count) { p[--count] = static_cast(value); value >>= 8; } +} +uint64_t get(const uint8_t *p, std::size_t count) { + uint64_t value = 0; + for (std::size_t i = 0; i < count; ++i) value = (value << 8) | p[i]; + return value; +} +uint32_t crc32(const uint8_t *p, std::size_t count) { + uint32_t crc = 0xffffffffU; + for (std::size_t i = 0; i < count; ++i) { + crc ^= p[i]; + for (unsigned bit = 0; bit < 8; ++bit) + crc = (crc >> 1) ^ ((crc & 1) ? 0xedb88320U : 0U); + } + return ~crc; +} +bool valid(const Frame &f) { + if (!f.node || !f.epoch || f.length > kMaxPayload) return false; + if (f.type == Type::Hello) + return !f.targetEpoch && !f.sequence && f.length == 4 && + get(f.payload.data(), 2) == kWindow && + get(f.payload.data() + 2, 2) == kMaxPayload; + if (!f.targetEpoch) return false; + switch (f.type) { + case Type::HelloAck: + case Type::Heartbeat: return !f.sequence && !f.length; + case Type::Data: return f.sequence && f.length; + case Type::Confirm: return f.sequence && f.length == 1 && f.payload[0] <= 1; + default: return false; + } +} +} // namespace + +std::size_t encode(const Frame &f, uint8_t *out, std::size_t capacity) { + const std::size_t total = kHeaderSize + f.length + 4; + if (!valid(f) || !out || capacity < total) return 0; + out[0] = 'C'; out[1] = 'B'; out[2] = 1; + out[3] = static_cast(f.type); + put(out + 4, f.length, 2); + out[6] = out[7] = 0; + put(out + 8, f.node, 4); + put(out + 12, f.epoch, 8); + put(out + 20, f.targetEpoch, 8); + put(out + 28, f.sequence, 4); + std::copy_n(f.payload.begin(), f.length, out + kHeaderSize); + put(out + total - 4, crc32(out, total - 4), 4); + return total; +} + +void Decoder::reset() { bytes_.fill(0); size_ = expected_ = 0; failed_ = false; } +Decoder::Step Decoder::feed(uint8_t byte, Frame &frame) { + if (failed_) return Step::Error; + bytes_[size_++] = byte; + if ((size_ == 1 && bytes_[0] != 'C') || + (size_ == 2 && bytes_[1] != 'B') || + (size_ == 3 && bytes_[2] != 1) || + (size_ == 4 && (bytes_[3] < 1 || bytes_[3] > 5)) || + (size_ == 6 && get(bytes_.data() + 4, 2) > kMaxPayload) || + (size_ == 8 && (bytes_[6] || bytes_[7]))) { + failed_ = true; + return Step::Error; + } + if (size_ == 6) expected_ = kHeaderSize + get(bytes_.data() + 4, 2) + 4; + if (!expected_ || size_ < expected_) return Step::More; + const uint8_t *p = bytes_.data(); + if (get(p + size_ - 4, 4) != crc32(p, size_ - 4)) { + failed_ = true; + return Step::Error; + } + frame = Frame{}; + frame.type = static_cast(p[3]); + frame.length = static_cast(get(p + 4, 2)); + frame.node = static_cast(get(p + 8, 4)); + frame.epoch = get(p + 12, 8); + frame.targetEpoch = get(p + 20, 8); + frame.sequence = static_cast(get(p + 28, 4)); + std::copy_n(p + kHeaderSize, frame.length, frame.payload.begin()); + if (!valid(frame)) { failed_ = true; return Step::Error; } + reset(); + return Step::Complete; +} + +Endpoint::Endpoint(uint32_t node, uint32_t expectedPeer) + : node_(node), expectedPeer_(expectedPeer) {} + +bool Endpoint::open(uint64_t epoch, uint64_t now) { + if (state_ != State::Closed || results_.size() || !node_ || !expectedPeer_ || + node_ == expectedPeer_ || epoch <= epoch_) return false; + epoch_ = epoch; + peerEpoch_ = 0; + openedAt_ = lastRx_ = lastHeartbeat_ = now; + nextSequence_ = 1; + highestReceived_ = 0; + helloAckReceived_ = false; + decoder_.reset(); + controls_.clear(); data_.clear(); incoming_.clear(); + pending_.fill(Pending{}); history_.fill(History{}); historyNext_ = 0; + wire_.fill(0); decoder_.reset(); + wireSize_ = wireOffset_ = 0; + state_ = State::Handshaking; + reason_ = Reason::None; + Frame hello = makeFrame(Type::Hello); + hello.length = 4; + put(hello.payload.data(), kWindow, 2); + put(hello.payload.data() + 2, kMaxPayload, 2); + return control(hello); +} + +void Endpoint::close(Reason reason) { + if (state_ == State::Closed) return; + state_ = State::Closed; + reason_ = reason; + for (auto &p : pending_) { + if (p.sequence) { + Result r; + r.sequence = p.sequence; r.reason = reason; r.delivery = Delivery::Unknown; + results_.push(r); // Reserved by submit(): pending + results <= window. + p = Pending{}; + } + } + controls_.clear(); data_.clear(); incoming_.clear(); + wire_.fill(0); decoder_.reset(); + wireSize_ = wireOffset_ = 0; +} + +Frame Endpoint::makeFrame(Type type) const { + Frame f; + f.type = type; f.node = node_; f.epoch = epoch_; f.targetEpoch = peerEpoch_; + return f; +} +bool Endpoint::control(const Frame &frame) { + Control small; + small.length=encode(frame,small.wire.data(),small.wire.size()); + if (small.length && controls_.push(small)) return true; + close(Reason::ControlOverflow); + return false; +} +std::size_t Endpoint::pending() const { + std::size_t count = 0; + for (const auto &p : pending_) if (p.sequence) ++count; + return count; +} +Submit Endpoint::submit(const uint8_t *bytes, std::size_t length, uint64_t now, + uint32_t &sequence) { + sequence = 0; + tick(now); + if (state_ != State::Ready) return Submit::NotReady; + if (!bytes || !length || length > kMaxPayload) return Submit::Invalid; + if (pending() + results_.size() >= kWindow) return Submit::Backpressure; + if (nextSequence_ == std::numeric_limits::max()) { + close(Reason::SequenceExhausted); + return Submit::NotReady; + } + Frame f = makeFrame(Type::Data); + f.sequence = nextSequence_; + f.length = static_cast(length); + std::copy_n(bytes, length, f.payload.begin()); + if (!data_.push(f)) return Submit::Backpressure; + for (auto &p : pending_) if (!p.sequence) { + p.sequence = nextSequence_; p.since = now; break; + } + sequence = nextSequence_++; + statistics_.peakPending = std::max(statistics_.peakPending, pending()); + return Submit::Queued; +} + +bool Endpoint::input(const uint8_t *bytes, std::size_t length, uint64_t now) { + // A delayed caller may already have read a valid heartbeat from TLS. + // Check liveness after parsing it, but never accept a late confirmation + // or extend the handshake/transaction deadlines because bytes arrived. + expireTransactions(now); + if (state_ == State::Closed) return false; + if (!bytes && length) { close(Reason::Protocol); return false; } + for (std::size_t i = 0; i < length; ++i) { + Frame f; + const Decoder::Step step = decoder_.feed(bytes[i], f); + if (step == Decoder::Step::Error) { close(Reason::Protocol); return false; } + if (step == Decoder::Step::Complete) receive(f, now); + if (state_ == State::Closed) return false; + } + tick(now); + return state_ != State::Closed; +} + +const uint8_t *Endpoint::output(std::size_t &length) { + length = 0; + if (state_ == State::Closed) return nullptr; + if (wireOffset_ == wireSize_) { + Frame f; Control small; + if (controls_.pop(small)) { wireSize_=small.length; std::copy_n(small.wire.begin(),wireSize_,wire_.begin()); } + else { + if (!data_.pop(f)) return nullptr; + wireSize_ = encode(f, wire_.data(), wire_.size()); + } + wireOffset_ = 0; + if (!wireSize_) { close(Reason::Protocol); return nullptr; } + } + length = wireSize_ - wireOffset_; + return wire_.data() + wireOffset_; +} +bool Endpoint::consumeOutput(std::size_t length) { + if (length > wireSize_ - wireOffset_) return false; + wireOffset_ += length; + if(wireOffset_==wireSize_) wire_.fill(0); + return true; +} + +void Endpoint::remember(uint32_t sequence, bool accepted) { + history_[historyNext_].sequence = sequence; + history_[historyNext_].accepted = accepted; + historyNext_ = (historyNext_ + 1) % history_.size(); +} +void Endpoint::confirm(uint32_t sequence, bool accepted) { + Frame f = makeFrame(Type::Confirm); + f.sequence = sequence; f.length = 1; f.payload[0] = accepted ? 0 : 1; + control(f); +} +bool Endpoint::serviceIncoming(Admission admit, void *context, uint64_t now) { + tick(now); + if (state_ != State::Ready || !incoming_.front() || !admit) return false; + const bool accepted = admit(incoming_.front()->frame, context); + return resolveIncoming(accepted); +} +bool Endpoint::resolveIncoming(bool accepted) { + Incoming item; + if (state_ != State::Ready || !incoming_.pop(item)) return false; + remember(item.frame.sequence, accepted); + confirm(item.frame.sequence, accepted); + return true; +} + +void Endpoint::receive(const Frame &f, uint64_t now) { + if (f.node != expectedPeer_) { close(Reason::Protocol); return; } + if (f.type == Type::Hello) { + if (peerEpoch_ && peerEpoch_ != f.epoch) { close(Reason::Protocol); return; } + peerEpoch_ = f.epoch; + control(makeFrame(Type::HelloAck)); + lastRx_ = now; + return; + } + if (!peerEpoch_ || f.epoch != peerEpoch_ || f.targetEpoch != epoch_) { + ++statistics_.staleFrames; + return; + } + if (f.type == Type::HelloAck) { + helloAckReceived_ = true; + state_ = State::Ready; + lastRx_ = now; + return; + } + if (!helloAckReceived_) { close(Reason::Protocol); return; } + lastRx_ = now; + if (f.type == Type::Data) { + if (f.sequence <= highestReceived_) { + ++statistics_.duplicateData; + for (const auto &h : history_) if (h.sequence == f.sequence) { + confirm(f.sequence, h.accepted); + break; + } + return; // Pending/evicted sequences are never admitted twice. + } + highestReceived_ = f.sequence; + Incoming item; item.frame = f; item.since = now; + if (!incoming_.push(item)) { + remember(f.sequence, false); + confirm(f.sequence, false); + } + statistics_.peakIncoming = std::max(statistics_.peakIncoming, incoming_.size()); + } else if (f.type == Type::Confirm) { + for (auto &p : pending_) if (p.sequence == f.sequence) { + Result result; + result.sequence = f.sequence; + result.delivery = f.payload[0] == 0 ? Delivery::AcceptedIntoRadioQueue : Delivery::Rejected; + results_.push(result); + p = Pending{}; + return; + } + ++statistics_.unexpectedConfirmations; + } +} + +void Endpoint::expireTransactions(uint64_t now) { + if (state_ == State::Closed) return; + if (state_ == State::Handshaking && now - openedAt_ >= kHandshakeMs) { + close(Reason::HandshakeTimeout); return; + } + for (const auto &p : pending_) if (p.sequence && now - p.since >= kTransactionMs) { + close(Reason::TransactionTimeout); return; + } + while (incoming_.front() && now - incoming_.front()->since >= kTransactionMs) + if (!resolveIncoming(false)) break; +} + +void Endpoint::tick(uint64_t now) { + expireTransactions(now); + if (state_ == State::Closed) return; + if (now - lastRx_ >= kIdleMs) { close(Reason::IdleTimeout); return; } + if (state_ == State::Ready && now - lastHeartbeat_ >= kHeartbeatMs) { + control(makeFrame(Type::Heartbeat)); + lastHeartbeat_ = now; + } +} +} // namespace backhaul diff --git a/lib/Backhaul/src/Backhaul.h b/lib/Backhaul/src/Backhaul.h new file mode 100644 index 00000000..d80bd666 --- /dev/null +++ b/lib/Backhaul/src/Backhaul.h @@ -0,0 +1,158 @@ +#ifndef CZC_BACKHAUL_H +#define CZC_BACKHAUL_H + +#include +#include +#include + +// Portable framed transport. No Arduino, sockets, Zigbee, or heap allocation. +namespace backhaul { +constexpr std::size_t kMaxPayload = 256; +constexpr std::size_t kHeaderSize = 32; +constexpr std::size_t kMaxWireSize = kHeaderSize + kMaxPayload + 4; +constexpr std::size_t kWindow = 4; +constexpr uint64_t kHandshakeMs = 500; +constexpr uint64_t kTransactionMs = 500; +constexpr uint64_t kHeartbeatMs = 100; +constexpr uint64_t kIdleMs = 1000; + +enum class Type : uint8_t { Hello = 1, HelloAck, Data, Confirm, Heartbeat }; +enum class State { Closed, Handshaking, Ready }; +enum class Reason { None, Disconnect, Protocol, HandshakeTimeout, IdleTimeout, + TransactionTimeout, ControlOverflow, SequenceExhausted }; +enum class Delivery { AcceptedIntoRadioQueue, Rejected, Unknown }; +enum class Submit { Queued, NotReady, Backpressure, Invalid }; + +struct Frame { + Type type = Type::Hello; + uint32_t node = 0; + uint64_t epoch = 0; + uint64_t targetEpoch = 0; + uint32_t sequence = 0; + uint16_t length = 0; + std::array payload{}; +}; + +struct Result { + uint32_t sequence = 0; + Delivery delivery = Delivery::Unknown; + Reason reason = Reason::None; +}; + +template class Queue { +public: + bool push(const T &value) { + if (size_ == N) return false; + items_[(head_ + size_) % N] = value; + ++size_; + return true; + } + const T *front() const { return size_ ? &items_[head_] : nullptr; } + bool pop(T &value) { + if (!size_) return false; + value = items_[head_]; + items_[head_] = T{}; + head_ = (head_ + 1) % N; + --size_; + return true; + } + void clear() { items_.fill(T{}); head_ = size_ = 0; } + std::size_t size() const { return size_; } +private: + std::array items_{}; + std::size_t head_ = 0; + std::size_t size_ = 0; +}; + +// Zero means invalid frame/capacity. All integer fields use network byte order. +std::size_t encode(const Frame &frame, uint8_t *output, std::size_t capacity); + +class Decoder { +public: + enum class Step { More, Complete, Error }; + Step feed(uint8_t byte, Frame &frame); + void reset(); +private: + std::array bytes_{}; + std::size_t size_ = 0; + std::size_t expected_ = 0; + bool failed_ = false; +}; + +struct Statistics { + uint64_t duplicateData = 0; + uint64_t staleFrames = 0; + uint64_t unexpectedConfirmations = 0; + std::size_t peakIncoming = 0; + std::size_t peakPending = 0; +}; + +class Endpoint { +public: + Endpoint(uint32_t node, uint32_t expectedPeer); + // Call for a NEW byte stream. Epoch must increase, including across reboot + // (persistence/provisioning belongs to the future firmware integration). + // Drain results before opening another session. + bool open(uint64_t epoch, uint64_t now); + void close(Reason reason = Reason::Disconnect); + void tick(uint64_t now); + // Feed available input before tick(): a complete valid peer frame refreshes + // liveness; handshake and transaction expiry still precede input handling. + bool input(const uint8_t *bytes, std::size_t length, uint64_t now); + // Keep sending the returned frame until consumed. Control cannot interleave + // with a partially written data frame. Consume only bytes actually written. + const uint8_t *output(std::size_t &length); + bool consumeOutput(std::size_t length); + Submit submit(const uint8_t *bytes, std::size_t length, uint64_t now, + uint32_t &sequence); + // Synchronous, non-reentrant adapter callback returns true only after its + // bounded radio queue accepts the frame. Expired input never reaches it. + using Admission = bool (*)(const Frame &, void *); + bool serviceIncoming(Admission admit, void *context, uint64_t now); + bool takeResult(Result &result) { return results_.pop(result); } + State state() const { return state_; } + Reason reason() const { return reason_; } + std::size_t pending() const; + std::size_t incomingCount() const { return incoming_.size(); } + const Statistics &statistics() const { return statistics_; } + +private: + struct Pending { uint32_t sequence = 0; uint64_t since = 0; }; + struct Incoming { Frame frame; uint64_t since = 0; }; + struct History { uint32_t sequence = 0; bool accepted = false; }; + Frame makeFrame(Type type) const; + bool control(const Frame &frame); + void receive(const Frame &frame, uint64_t now); + void confirm(uint32_t sequence, bool accepted); + void remember(uint32_t sequence, bool accepted); + bool resolveIncoming(bool accepted); + void expireTransactions(uint64_t now); + + uint32_t node_; + uint32_t expectedPeer_; + State state_ = State::Closed; + Reason reason_ = Reason::None; + uint64_t epoch_ = 0; + uint64_t peerEpoch_ = 0; + uint64_t openedAt_ = 0; + uint64_t lastRx_ = 0; + uint64_t lastHeartbeat_ = 0; + uint32_t nextSequence_ = 1; + uint32_t highestReceived_ = 0; + bool helloAckReceived_ = false; + Decoder decoder_; + struct Control { std::array wire{}; std::size_t length=0; }; + Queue controls_; + Queue data_; + Queue incoming_; + Queue results_; + std::array pending_{}; + std::array history_{}; + std::size_t historyNext_ = 0; + std::array wire_{}; + std::size_t wireSize_ = 0; + std::size_t wireOffset_ = 0; + Statistics statistics_; +}; +} // namespace backhaul +#endif diff --git a/lib/CCTools/src/CCTools.cpp b/lib/CCTools/src/CCTools.cpp index 08f3e824..9ac12255 100644 --- a/lib/CCTools/src/CCTools.cpp +++ b/lib/CCTools/src/CCTools.cpp @@ -2,6 +2,8 @@ #include "CCTools.h" #include #include +#include +#include #ifndef DEBUG_PRINT #ifdef DEBUG @@ -52,28 +54,18 @@ bool CommandInterface::_sendSynch() bool CommandInterface::_wait_for_ack(unsigned long timeout = 1) { - unsigned long startMillis = millis(); - while (millis() - startMillis < timeout * 1000) - { - if (_stream.available() >= 1) - { - uint8_t received = _stream.read(); - if (received == ACK_BYTE) - { - // DEBUG_PRINTLN("ACK received"); - return true; - } - else if (received == NACK_BYTE) - { - // DEBUG_PRINTLN("NACK received"); - return false; - } - } + unsigned long start=millis(); + while (millis()-start data(_receivePacket()); + if (data && _checkLastCmd()) { - return data; + return data.release(); } } return 0; } -byte *CommandInterface::_receivePacket() +bool CommandInterface::_receivePacket(byte *data,size_t expected,unsigned long timeout) { - byte got[2]; - // Read the initial 2 bytes which contain size and checksum - // The _read function should be defined elsewhere to read bytes from a communication interface. - _stream.readBytes(got, 2); - - byte size = got[0]; // rcv size - byte chks = got[1]; // rcv checksum - byte *data = new byte[size - 2]; // Allocate buffer for the data - - // Now read the rest of the packet - if (!_stream.readBytes(data, size - 2)) - { - // Handle read error - delete[] data; // Remember to free the memory if an error occurs - return nullptr; - } - - // Debugging output, might use DEBUG_PRINT in Arduino - // DEBUG_PRINT(F("*** received ")); - // DEBUG_PRINT(size, HEX); - // DEBUG_PRINTLN(F(" bytes")); - - // Calculate checksum - byte calculatedChks = 0; - for (int i = 0; i < size - 2; i++) - { - calculatedChks += data[i]; + // A whole response has one deadline. No uninitialised length, short reads, + // per-byte timeout extension, or heap allocation on the flashing path. + if(!data || !expected || expected>253) { bslError="bsl_packet_length"; return false; } + byte packet[255]; size_t used=0; unsigned long start=millis(); + while(used data(new(std::nothrow) byte[4]); + if(!data) { bslError="bsl_memory"; return nullptr; } + if(!_receivePacket(data.get(),4)) return nullptr; + return data.release(); } + void CommandInterface::_encodeAddr(unsigned long addr, byte encodedAddr[4]) { encodedAddr[3] = (byte)((addr >> 0) & 0xFF); @@ -698,6 +606,7 @@ void CCTools::enterBSL() // DEBUG_PRINTLN(F("Zigbee BSL pin OFF")); delay(500); } + _cleanBuffer(); bslActive = 1; } @@ -739,7 +648,8 @@ bool CCTools::detectChipInfo() // DEBUG_PRINTLN(chip_id, HEX); - byte *device_id = _cmdMemRead(ICEPICK_DEVICE_ID); + std::unique_ptr device_id(_cmdMemRead(ICEPICK_DEVICE_ID)); + if(!device_id) return false; // DEBUG_PRINTLN(sizeof(device_id)); uint32_t wafer_id = (((device_id[3] & 0x0F) << 16) + @@ -755,7 +665,8 @@ bool CCTools::detectChipInfo() // DEBUG_PRINT("pg_rev: "); // DEBUG_PRINTLN(pg_rev, HEX); - byte *user_id = _cmdMemRead(FCFG_USER_ID); + std::unique_ptr user_id(_cmdMemRead(FCFG_USER_ID)); + if(!user_id) return false; // DEBUG_PRINTLN("Package: " + _getPackageString(user_id[2])); @@ -763,16 +674,19 @@ bool CCTools::detectChipInfo() // DEBUG_PRINT("protocols: "); // DEBUG_PRINTLN(protocols, HEX); - byte *flash_size = _cmdMemRead(FLASH_SIZE); + std::unique_ptr flash_size(_cmdMemRead(FLASH_SIZE)); + if(!flash_size) return false; // DEBUG_PRINT("flash_size: "); // DEBUG_PRINTLN(flash_size[0], HEX); - // byte *ram_size = _cmdMemRead(PRCM_RAMHWOPT); + // RAM size is not needed for flashing. // DEBUG_PRINT("ram_size: "); // DEBUG_PRINTLN(ram_size[0], HEX); - byte *ieee_b1 = _cmdMemRead(addr_ieee_address_primary + 4); - byte *ieee_b2 = _cmdMemRead(addr_ieee_address_primary); + std::unique_ptr ieee_b1(_cmdMemRead(addr_ieee_address_primary + 4)); + if(!ieee_b1) return false; + std::unique_ptr ieee_b2(_cmdMemRead(addr_ieee_address_primary)); + if(!ieee_b2) return false; if (ieee_b1 == nullptr || ieee_b2 == nullptr) { @@ -794,19 +708,19 @@ bool CCTools::detectChipInfo() chip.ieee = ieeeAddr; - delete[] ieee_b1; - delete[] ieee_b2; + String chip_str; - if (protocols & PROTO_MASK_IEEE == PROTO_MASK_IEEE) + if ((protocols & PROTO_MASK_IEEE) == PROTO_MASK_IEEE) { uint32_t test = 360372; // DEBUG_PRINT(test, HEX); - byte *b_val = _cmdMemRead(test); + std::unique_ptr b_val(_cmdMemRead(test)); + if(!b_val) return false; chip.hwRev = _getChipDescription(chip_id, wafer_id, pg_rev, b_val[1]); int page_size = 4096; - if (chip.hwRev.indexOf("P7")) + if (chip.hwRev.indexOf("P7") >= 0) { page_size = page_size * 2; } @@ -814,13 +728,15 @@ bool CCTools::detectChipInfo() test = chip.flashSize - 88 + 0xC; //DEBUG_PRINT(test, HEX); - b_val = _cmdMemRead(test); + b_val.reset(_cmdMemRead(test)); + if(!b_val) return false; chip.modeCfg = _decodeAddr(b_val[3], b_val[2], b_val[1], b_val[0]); uint32_t bsl_adr = chip.flashSize - 88 + 0x30; //DEBUG_PRINT(bsl_adr, HEX); - byte *bsl_val = _cmdMemRead(bsl_adr); + std::unique_ptr bsl_val(_cmdMemRead(bsl_adr)); + if(!bsl_val) return false; chip.bslCfg = _decodeAddr(bsl_val[3], bsl_val[2], bsl_val[1], bsl_val[0]); @@ -831,6 +747,7 @@ bool CCTools::detectChipInfo() bool CCTools::eraseFlash() { + bslError="none"; bslStatus=0; if (!bslActive) { enterBSL(); @@ -847,23 +764,39 @@ bool CCTools::eraseFlash() bool CCTools::beginFlash(uint32_t startAddr, int totalSize) { - this->currentAddr = startAddr; - return _cmdDownload(startAddr, totalSize); + flashRemaining=0; bslError="none"; bslStatus=0; + if(totalSize<=0 || (startAddr&3) || (totalSize&3) || !chip.flashSize || + startAddr>chip.flashSize || uint32_t(totalSize)>chip.flashSize-startAddr) { bslError="bsl_flash_range"; return false; } + if(!_cmdDownload(startAddr,totalSize)) return false; + currentAddr=startAddr; flashRemaining=totalSize; return true; } + bool CCTools::processFlash(byte *data, int size) { - if (memcmp(data, this->emptyPacket, size) != 0) - { - if (!_cmdSendData(data, size)) - { - return false; - } - } - this->currentAddr += size; + if(!data || size<=0 || size>TRANSFER_SIZE || (size&3) || uint32_t(size)>flashRemaining) { bslError="bsl_flash_range"; return false; } + // SEND_DATA advances the ROM's address. Skipping an FF block here would + // shift all subsequent data, including CCFG, to the wrong address. + if(!_cmdSendData(data,size)) { flashRemaining=0; return false; } + currentAddr+=size; flashRemaining-=size; return true; +} + +bool CCTools::verifyFlash(uint32_t startAddr,uint32_t size,uint32_t expectedCrc) +{ + if(flashRemaining || !size || startAddr>chip.flashSize || size>chip.flashSize-startAddr) { bslError="bsl_flash_range"; return false; } + // CC13xx/CC26xx CMD_CRC32: address, byte count, zero read-repeat count. + byte request[15]={15,0,0x27}; + _encodeAddr(startAddr,request+3); _encodeAddr(size,request+7); + for(unsigned i=2;i + +// The admin stream accepts full 16 KiB TLS records from ordinary PSK clients. +// Outgoing records are split at 1024 bytes; no change to the ZNP byte stream. +class AdminTls { + CzcTls *tls=nullptr; + static int send(void *ctx,const unsigned char *p,size_t n) { + int r=::send(*static_cast(ctx),p,n,0); + return r<0 && (errno==EAGAIN || errno==EWOULDBLOCK || errno==EINTR) ? 0 : r; + } + static int recv(void *ctx,unsigned char *p,size_t n) { + int r=::recv(*static_cast(ctx),p,n,0); + if(!r) return -1; + return r<0 && (errno==EAGAIN || errno==EWOULDBLOCK || errno==EINTR) ? 0 : r; + } + void failed(const char *phase,int code) { + errorPhase=phase; errorCode=code; ++failures; close(); + } +public: + int fd=-1,family=0; + bool ready=false; + uint32_t since=0,failures=0; + const char *errorPhase="none"; + int errorCode=0; + int (*randomSource)(void *,unsigned char *,size_t)=tlsRandom; + AdminTls()=default; + AdminTls(const AdminTls &)=delete; + AdminTls &operator=(const AdminTls &)=delete; + ~AdminTls() { close(); } + void close() { + ready=false; family=0; + if(fd>=0) { ::shutdown(fd,SHUT_RDWR); ::close(fd); fd=-1; } + czc_tls_delete(tls); tls=nullptr; + } + bool start(int socket,bool server,const uint8_t key[32],const char *identity) { + close(); fd=socket; since=millis(); + sockaddr_storage address{}; socklen_t size=sizeof(address); + if(!getpeername(fd,reinterpret_cast(&address),&size)) { + family=address.ss_family; + static const uint8_t prefix[12]={0,0,0,0,0,0,0,0,0,0,255,255}; + if(family==AF_INET6 && !memcmp(&reinterpret_cast(&address)->sin6_addr,prefix,12)) family=AF_INET; + } + fcntl(fd,F_SETFL,fcntl(fd,F_GETFL,0)|O_NONBLOCK); + int one=1; setsockopt(fd,IPPROTO_TCP,TCP_NODELAY,&one,sizeof(one)); + setsockopt(fd,SOL_SOCKET,SO_KEEPALIVE,&one,sizeof(one)); + int idle=10,interval=5,count=3; + setsockopt(fd,IPPROTO_TCP,TCP_KEEPIDLE,&idle,sizeof(idle)); + setsockopt(fd,IPPROTO_TCP,TCP_KEEPINTVL,&interval,sizeof(interval)); + setsockopt(fd,IPPROTO_TCP,TCP_KEEPCNT,&count,sizeof(count)); + int error=0; + tls=czc_tls_new_admin(server,key,identity,randomSource,send,recv,&fd,&error); + if(!tls) { failed("setup",error); return false; } + return true; + } + void tick() { + if(fd<0 || ready) return; + int r=czc_tls_handshake(tls); + if(r==1) { ready=true; return; } + if(r<0) failed("handshake",czc_tls_error(tls)); + else if(uint32_t(millis()-since)>3000) failed("handshake_timeout",-0x6800); + } + int read(uint8_t *p,size_t n) { + if(!ready) return 0; + int r=czc_tls_read(tls,p,n); + if(r<0) { + int code=czc_tls_error(tls); + // Peer close/close_notify is normal controller teardown. + if(code==0 || code==-1 || code==-0x7880) close(); + else failed("read",code); + } + return r; + } + int write(const uint8_t *p,size_t n) { + if(!ready) return 0; + int r=czc_tls_write(tls,p,n); + if(r<0) failed("write",czc_tls_error(tls)); + return r; + } +}; diff --git a/lib/CzcBackhaul/src/MemoryBudget.h b/lib/CzcBackhaul/src/MemoryBudget.h new file mode 100644 index 00000000..070d3a5d --- /dev/null +++ b/lib/CzcBackhaul/src/MemoryBudget.h @@ -0,0 +1,11 @@ +#pragma once +#include + +namespace czc { +struct MemoryBudget { + uint32_t free=0,largest=0,minimum=0; + // Stock HTTPS uses two 16 KiB TLS records in addition to its task stack, + // certificates, HTTP body and Wi-Fi buffers. It is optional background work. + bool backgroundHttps() const { return free>=80*1024 && largest>=24*1024; } +}; +} diff --git a/lib/CzcBackhaul/src/NetconnStream.h b/lib/CzcBackhaul/src/NetconnStream.h new file mode 100644 index 00000000..1427cc8d --- /dev/null +++ b/lib/CzcBackhaul/src/NetconnStream.h @@ -0,0 +1,92 @@ +#pragma once +// Use lwIP's sequential API directly: eight peers must not consume the SDK's +// sixteen BSD socket descriptors needed by the stock UI/MQTT/WireGuard/services. +#include +#include +#include +#include +class NetconnStream { + netconn *connection=nullptr; + pbuf *received=nullptr; + uint16_t offset=0; + struct CoreCall { tcpip_api_call_data base; netconn *connection; bool abort,inspect; int state; }; + static err_t core(tcpip_api_call_data *base) { + auto &call=*reinterpret_cast(base); + if(call.inspect) { + auto *pcb=call.connection->pcb.tcp; + call.state=!pcb ? -1 : pcb->state==ESTABLISHED ? 1 : + (pcb->state==SYN_SENT || pcb->state==SYN_RCVD) ? 0 : -1; + return ERR_OK; + } + if(call.connection->pcb.tcp) { + if(call.abort) tcp_abort(call.connection->pcb.tcp); + else tcp_nagle_disable(call.connection->pcb.tcp); + } + return ERR_OK; + } +public: + int family=0; + NetconnStream()=default; + NetconnStream(const NetconnStream &)=delete; + ~NetconnStream() { close(); } + int connectState() { + if(!connection) return -1; + CoreCall call{}; call.connection=connection; call.inspect=true; + return tcpip_api_call(core,&call.base)==ERR_OK ? call.state : -1; + } + int addressFamily() { + ip_addr_t address{}; uint16_t port=0; + if(connection && netconn_peer(connection,&address,&port)==ERR_OK) + family=IP_IS_V6(&address) && !ip6_addr_isipv4mappedipv6(ip_2_ip6(&address)) ? AF_INET6 : AF_INET; + return family; + } + bool peerAddress(char *out,size_t size) { + if(!size) return false; + out[0]=0; ip_addr_t address{}; uint16_t port=0; + return connection && netconn_peer(connection,&address,&port)==ERR_OK && + ipaddr_ntoa_r(&address,out,size)!=nullptr; + } + static void threadInit() { netconn_thread_init(); } + bool start(netconn *handle) { + close(); connection=handle; if(!connection) return false; + netconn_set_nonblocking(connection,1); + CoreCall call{}; call.connection=connection; + if(tcpip_api_call(core,&call.base)!=ERR_OK) { close(); return false; } + addressFamily(); + return true; + } + void close() { + if(received) { pbuf_free(received); received=nullptr; } offset=0; + if(connection) { + // Abort on the TCP/IP thread, before deletion: never wait for a dead + // peer to ACK buffered data while other Satellite leases expire. + CoreCall call{}; call.connection=connection; call.abort=true; + tcpip_api_call(core,&call.base); netconn_delete(connection); connection=nullptr; + } + family=0; + } + int write(const unsigned char *data,size_t length) { + size_t sent=0; + err_t r=netconn_write_partly(connection,data,length,NETCONN_COPY|NETCONN_DONTBLOCK,&sent); + if(sent) return sent; + return r==ERR_OK || r==ERR_WOULDBLOCK || r==ERR_INPROGRESS || r==ERR_MEM ? 0 : -1; + } + int read(unsigned char *data,size_t length) { + if(!received) { + err_t r=netconn_recv_tcp_pbuf_flags(connection,&received,NETCONN_DONTBLOCK); + if(r==ERR_WOULDBLOCK || r==ERR_TIMEOUT || r==ERR_INPROGRESS) return 0; + if(r!=ERR_OK || !received) return -1; + } + size_t count=std::min(length,static_cast(received->tot_len-offset)); + uint16_t n=pbuf_copy_partial(received,data,count,offset); offset+=n; + if(offset==received->tot_len) { pbuf_free(received); received=nullptr; offset=0; } + return n ? n : -1; + } + static netconn *listen(uint16_t port,bool ipv6) { + threadInit(); netconn *server=netconn_new(ipv6 ? NETCONN_TCP_IPV6 : NETCONN_TCP); + if(!server) return nullptr; + netconn_set_nonblocking(server,1); + if(netconn_bind(server,ipv6 ? IP6_ADDR_ANY : IP4_ADDR_ANY,port)!=ERR_OK || netconn_listen_with_backlog(server,8)!=ERR_OK) { netconn_delete(server); return nullptr; } + return server; + } +}; diff --git a/lib/CzcBackhaul/src/PeerRetry.h b/lib/CzcBackhaul/src/PeerRetry.h new file mode 100644 index 00000000..67a8613f --- /dev/null +++ b/lib/CzcBackhaul/src/PeerRetry.h @@ -0,0 +1,37 @@ +#pragma once +#include + +namespace czc { +// The quiet interval starts AFTER an attempt ends, including DNS/TCP/TLS time. +// Only a stable authenticated connection resets backoff; flapping is not success. +class PeerRetry { + bool attempted=false,waiting=false,healthy=false; + uint32_t ended=0,delay=0,nextDelay=2000,healthySince=0; +public: + uint32_t attempts=0; + bool due(uint32_t now,bool active,bool online,uint32_t jitter) { + if(active) { + if(!online) healthy=false; + else if(!healthy) { healthy=true; healthySince=now; } + else if(uint32_t(now-healthySince)>=30000) nextDelay=2000; + return false; + } + healthy=false; + if(attempted) { + attempted=false; waiting=true; ended=now; + delay=nextDelay+jitter%501; + nextDelay=nextDelay<16000 ? nextDelay*2 : 30000; + } + return !waiting || uint32_t(now-ended)>=delay; + } + void started() { attempted=true; waiting=false; ++attempts; } + // Authenticated maintenance/test actions explicitly request a fresh session. + void retrySoon(uint32_t now) { + attempted=healthy=false; waiting=true; ended=now; delay=nextDelay=2000; + } + uint32_t remaining(uint32_t now) const { + const uint32_t elapsed=now-ended; + return waiting && elapsed +#ifdef ARDUINO_ARCH_ESP32 +#include "NetconnStream.h" +using PeerHandle=netconn *; +#else +#include +using PeerHandle=int; +#endif +// PSK-only TLS engine with bounded records. +class PeerTls { + CzcTls *tls=nullptr; +#ifdef ARDUINO_ARCH_ESP32 + NetconnStream stream; + bool connecting=false; + uint32_t connectPoll=0; +#endif + uint32_t handshakePoll=0; + static int send(void *ctx,const unsigned char *p,size_t n) { +#ifdef ARDUINO_ARCH_ESP32 + return static_cast(ctx)->write(p,n); +#else + int r=::send(*static_cast(ctx),p,n,0); + return r<0 && (errno==EAGAIN || errno==EWOULDBLOCK || errno==EINTR) ? 0 : r; +#endif + } + static int recv(void *ctx,unsigned char *p,size_t n) { +#ifdef ARDUINO_ARCH_ESP32 + return static_cast(ctx)->read(p,n); +#else + int r=::recv(*static_cast(ctx),p,n,0); + if(!r) return -1; + return r<0 && (errno==EAGAIN || errno==EWOULDBLOCK || errno==EINTR) ? 0 : r; +#endif + } +public: + int fd=-1,family=0; bool ready=false; uint32_t since=0,failures=0; + const char *error="tls_handshake"; + int (*randomSource)(void *,unsigned char *,size_t)=tlsRandom; + PeerTls()=default; + PeerTls(const PeerTls &)=delete; + ~PeerTls() { close(); } + bool peerAddress(char *out,size_t size) { + if(!size) return false; + out[0]=0; +#ifdef ARDUINO_ARCH_ESP32 + return stream.peerAddress(out,size); +#else + sockaddr_storage address{}; socklen_t n=sizeof(address); + if(fd<0 || getpeername(fd,reinterpret_cast(&address),&n)) return false; + const void *bytes=address.ss_family==AF_INET ? + static_cast(&reinterpret_cast(&address)->sin_addr) : + static_cast(&reinterpret_cast(&address)->sin6_addr); + return inet_ntop(address.ss_family,bytes,out,size)!=nullptr; +#endif + } + void close() { + ready=false; family=0; +#ifdef ARDUINO_ARCH_ESP32 + stream.close(); +#else + if(fd>=0) { ::shutdown(fd,SHUT_RDWR); ::close(fd); } +#endif + fd=-1; czc_tls_delete(tls); tls=nullptr; + } + bool start(PeerHandle socket,bool server,const uint8_t key[32],const char *identity) { + close(); since=millis(); handshakePoll=since-10; error="tls_handshake"; +#ifdef ARDUINO_ARCH_ESP32 + if(!stream.start(socket)) { ++failures; return false; } + connecting=true; connectPoll=since-50; + fd=1; // Active marker only; peer TCP does not use a BSD descriptor on ESP32. + family=stream.family; + void *io=&stream; +#else + fd=socket; + sockaddr_storage address{}; socklen_t size=sizeof(address); + if(!getpeername(fd,reinterpret_cast(&address),&size)) { + family=address.ss_family; + static const uint8_t prefix[12]={0,0,0,0,0,0,0,0,0,0,255,255}; + if(family==AF_INET6 && !memcmp(&reinterpret_cast(&address)->sin6_addr,prefix,12)) family=AF_INET; + } + fcntl(fd,F_SETFL,fcntl(fd,F_GETFL,0)|O_NONBLOCK); + int one=1; setsockopt(fd,IPPROTO_TCP,TCP_NODELAY,&one,sizeof(one)); + void *io=&fd; +#endif + tls=czc_tls_new(server,key,identity,randomSource,send,recv,io); + if(!tls) { ++failures; close(); return false; } return true; + } + void tick() { + if(fd<0 || ready) return; + uint32_t now=millis(); +#ifdef ARDUINO_ARCH_ESP32 + // Do not send TLS records into SYN_SENT on every FreeRTOS tick. The + // TCP/IP task also serves the UI; poll establishment at most 20 Hz. + if(connecting) { + if(uint32_t(now-connectPoll)<50) return; + connectPoll=now; + int state=stream.connectState(); + if(state<0 || (state==0 && uint32_t(now-since)>=4000)) { + error=state<0 ? "peer_connect" : "peer_connect_timeout"; ++failures; close(); return; + } + if(!state) return; + connecting=false; since=now; + } +#endif + if(uint32_t(now-handshakePoll)<10) return; + handshakePoll=now; + int r=czc_tls_handshake(tls); if(r==1) { + ready=true; +#ifdef ARDUINO_ARCH_ESP32 + family=stream.addressFamily(); +#endif + } + else if(r<0 || millis()-since>3000) { ++failures; close(); } + } + int read(uint8_t *p,size_t n) { if(!ready) return 0; int r=czc_tls_read(tls,p,n); if(r<0) close(); return r; } + int write(const uint8_t *p,size_t n) { if(!ready) return 0; int r=czc_tls_write(tls,p,n); if(r<0) close(); return r; } +}; diff --git a/lib/CzcBackhaul/src/RadioImage.h b/lib/CzcBackhaul/src/RadioImage.h new file mode 100644 index 00000000..a7dfd07c --- /dev/null +++ b/lib/CzcBackhaul/src/RadioImage.h @@ -0,0 +1,20 @@ +#pragma once +#include +#include +namespace czc { +// IEEE CRC-32 accumulator, initial 0xFFFFFFFF and final XOR 0xFFFFFFFF. +inline uint32_t radioCrcUpdate(uint32_t crc,const uint8_t *data,size_t size) { + for(size_t i=0;i>1)^(0xEDB88320u & (0u-(crc&1u))); + } + return crc; +} + +inline uint32_t imageWord(const uint8_t *p) { return uint32_t(p[0])|(uint32_t(p[1])<<8)|(uint32_t(p[2])<<16)|(uint32_t(p[3])<<24); } +inline bool radioImageHeader(const uint8_t *p,size_t bytes,size_t size,uint32_t flashSize) { + if(bytes<8 || size<4096 || !flashSize || size>flashSize || (size&3)) return false; + uint32_t stack=imageWord(p),entry=imageWord(p+4); + return stack>=0x20000000 && stack<=0x20080000 && !(stack&3) && (entry&1) && (entry&~1U) + +namespace czc { +// Three UART restart attempts, with increasing delay. Only a full minute of +// authenticated, healthy peer traffic replenishes the allowance. +class Recovery { + bool failing=false,healthy=false; + uint32_t failureSince=0,healthySince=0; +public: + unsigned attempts=0; + uint32_t total=0; + bool due(uint32_t now,bool fatal,bool online) { + if(!fatal) { + failing=false; + if(!online) healthy=false; + else if(!healthy) { healthy=true; healthySince=now; } + else if(uint32_t(now-healthySince)>=60000) attempts=0; + return false; + } + healthy=false; + if(!failing) { failing=true; failureSince=now; } + return attempts<3 && uint32_t(now-failureSince)>=(5000u< +#include + +namespace czc { +// The radio applies pending bootstrap profiles (1) at BDB startup. Committed +// profiles (2) identify the managing Master; migration records (3) persist the +// intent to clear the Satellite network and request that Master's current profile. +class SatelliteMigration { +public: + enum : uint16_t { Boot=0x03f0, Nib=0x0021, Startup=3, OnNetwork=0x55, RecordSize=88 }; + enum Result { Unmanaged, StorageError, Prepared, ResetRequired, Cleared }; + struct Nv { + virtual bool length(uint16_t id,uint16_t &size)=0; + virtual bool read(uint16_t id,uint8_t *data,unsigned size)=0; + virtual bool write(uint16_t id,const uint8_t *data,unsigned size)=0; + virtual bool remove(uint16_t id,uint16_t size)=0; + virtual ~Nv() {} + }; + static bool networkMatches(const uint8_t *a,const uint8_t *b) { + return !memcmp(a+8,b+8,3) && !memcmp(a+20,b+20,8); + } + static Result prepare(Nv &nv,const uint8_t local[32],const uint8_t master[32]) { + // Never adopt an arbitrary running coordinator/router just because it + // knows the group PSK. It must have a committed profile from this IEEE. + if(local[11]!=7 || master[11]!=9 || master[6] || master[7] || + master[10]<11 || master[10]>26 || networkMatches(local,master)) return Unmanaged; + uint8_t record[RecordSize]{}; uint16_t size=0; + if(!nv.length(Boot,size)) return StorageError; + if(size!=RecordSize) return Unmanaged; + if(!nv.read(Boot,record,sizeof(record))) return StorageError; + const uint8_t *p=record+5; + bool managed=valid(record) && record[4]==2 && p[0]==1 && + !memcmp(p+8,master+12,8) && !memcmp(p+16,local+12,8) && + p[1]==local[10] && !memcmp(p+2,local+8,2) && !memcmp(p+24,local+20,8); + if(!managed) { wipe(record,sizeof(record)); return Unmanaged; } + // Committed profiles have already had their keys wiped by the radio. + // Persist the intent BEFORE setting startup flags or removing any NV. + record[4]=3; + bool ok=put(nv,Boot,record,sizeof(record)); wipe(record,sizeof(record)); + return ok ? Prepared : StorageError; + } + static Result advance(Nv &nv,const uint8_t local[32]) { + uint8_t record[RecordSize]{}; uint16_t size=0; + if(!nv.length(Boot,size)) return StorageError; + if(size!=RecordSize) return Unmanaged; + if(!nv.read(Boot,record,sizeof(record))) return StorageError; + bool pending=valid(record) && record[4]==3 && record[5]==1 && + !memcmp(record+21,local+12,8); + wipe(record,sizeof(record)); + if(!pending || local[11]==9) return Unmanaged; + uint8_t on=0,options=0; + if(!nv.read(OnNetwork,&on,1) || !nv.read(Startup,&options,1)) return StorageError; + if(on || local[11]==7 || (options&1)) { + // TI zgInit clears configuration, BDB membership and old link keys. + // Deliberately exclude 0x80: retain NWK frame-counter high-water marks. + uint8_t reset=3; + return put(nv,Startup,&reset,1) ? ResetRequired : StorageError; + } + // Only after the reset: a live network could otherwise save its NIB + // again between delete and reset. BDB initialization is held by caller. + if(!eraseItem(nv,Nib) || !eraseItem(nv,Boot)) return StorageError; + return Cleared; + } +private: + static bool valid(const uint8_t *r) { return r[0]==0x32 && r[1]==0x43 && r[2]==0x48 && r[3]==0x42; } + static void wipe(void *data,unsigned n) { volatile uint8_t *p=static_cast(data); while(n--) *p++=0; } + static bool put(Nv &nv,uint16_t id,const uint8_t *data,unsigned size) { + uint8_t verify[RecordSize]{}; + bool ok=nv.write(id,data,size) && nv.read(id,verify,size) && !memcmp(data,verify,size); + wipe(verify,sizeof(verify)); return ok; + } + static bool eraseItem(Nv &nv,uint16_t id) { + uint16_t size=0,after=0; + return nv.length(id,size) && (!size || (nv.remove(id,size) && nv.length(id,after) && !after)); + } +}; +} diff --git a/lib/CzcBackhaul/src/Settings.h b/lib/CzcBackhaul/src/Settings.h new file mode 100644 index 00000000..6f497023 --- /dev/null +++ b/lib/CzcBackhaul/src/Settings.h @@ -0,0 +1,59 @@ +#pragma once +#include +#include +#include +#include + +namespace czc { +#ifdef DEBUG +constexpr bool debugBuild=true; +#else +constexpr bool debugBuild=false; +#endif +constexpr uint16_t debugAdminPort=7444; +// Stored as a single versioned NVS blob. +struct Settings { + uint32_t magic=0x42484332; + uint8_t mode=0; // 0: ordinary CZC, 1: Master, 2: Satellite + uint8_t legacyIpv6=1, legacyRawTcp=1, hasKey=0; // Reserved NVS bytes; both transports are always available. + uint8_t key[32]{}; + uint8_t peerIEEE[8]{}; // Reserved NVS bytes; peers exchange radio identity inside TLS. + uint16_t peerPort=7443, legacyAdminPort=7444; + char host[128]{}; + uint8_t legacyDiagnostics=0; // Ignored; debug capabilities are selected at build time. + uint8_t reserved[3]{}; +}; +static_assert(offsetof(Settings,legacyDiagnostics)==180, "Settings NVS record layout"); +inline bool privateRadioCommand(uint8_t cmd) { return (cmd>=0xc1 && cmd<=0xc5) || (cmd>=0xc7 && cmd<=0xcb) || cmd>=0xd0; } +inline bool decodeHex(const char *s,uint8_t *out,size_t n,bool reverse=false) { + if(!s) return false; + if(s[0]=='0' && (s[1]=='x'||s[1]=='X')) s+=2; + if(strlen(s)!=2*n) return false; + uint8_t temp[32]; if(n>sizeof(temp)) return false; + for(size_t i=0;i2 || s.hasKey>1) return "invalid_mode"; + if(!memchr(s.host,0,sizeof(s.host))) return "invalid_host"; + if(s.peerPort<1024 || s.peerPort==controllerPort || + (debugBuild && (s.peerPort==debugAdminPort || controllerPort==debugAdminPort))) return "port_conflict"; + for(const char *p=s.host;*p;p++) if(!isalnum(static_cast(*p)) && !strchr(".-:%_",*p)) return "invalid_host"; + if(s.mode && !s.hasKey) return "key_required"; + if(s.mode==2 && !s.host[0]) return "master_address_required"; + return nullptr; +} +} diff --git a/lib/CzcBackhaul/src/SocketHelpers.h b/lib/CzcBackhaul/src/SocketHelpers.h new file mode 100644 index 00000000..685f1af9 --- /dev/null +++ b/lib/CzcBackhaul/src/SocketHelpers.h @@ -0,0 +1,22 @@ +#pragma once +#include +#include +#include +#include +#include +#include +#include + +inline int tlsRandom(void *, unsigned char *p, size_t n) { esp_fill_random(p,n); return 0; } + +inline int listener(uint16_t port, bool ipv6=true) { + int fd=socket(ipv6 ? AF_INET6 : AF_INET,SOCK_STREAM,IPPROTO_TCP); + if(fd<0) return -1; + int zero=0, one=1; + setsockopt(fd,IPPROTO_IPV6,IPV6_V6ONLY,&zero,sizeof(zero)); + setsockopt(fd,SOL_SOCKET,SO_REUSEADDR,&one,sizeof(one)); + sockaddr_in6 address{}; address.sin6_family=AF_INET6; address.sin6_port=htons(port); + sockaddr_in address4{}; address4.sin_family=AF_INET; address4.sin_port=htons(port); + if(bind(fd,ipv6 ? reinterpret_cast(&address) : reinterpret_cast(&address4),ipv6 ? sizeof(address) : sizeof(address4)) || listen(fd,8)) { ::close(fd); return -1; } + fcntl(fd,F_SETFL,fcntl(fd,F_GETFL,0)|O_NONBLOCK); return fd; +} diff --git a/lib/CzcBackhaul/src/StatusSnapshot.h b/lib/CzcBackhaul/src/StatusSnapshot.h new file mode 100644 index 00000000..eca37837 --- /dev/null +++ b/lib/CzcBackhaul/src/StatusSnapshot.h @@ -0,0 +1,58 @@ +#pragma once +#include +#include +#include + +namespace czc { +struct RadioVersion { + static constexpr uint32_t required=20260917; + uint32_t revision=0; + uint8_t protocol=0; + bool compatible=false; + const char *inspect(bool received,const uint8_t *body,size_t length) { + compatible=false; + if(!received || length<6 || body[0]) return "radio_info"; + protocol=body[1]; revision=0; + for(unsigned i=0;i<4;i++) revision|=uint32_t(body[2+i])<<(8*i); + if(length!=32 || protocol!=2 || revision!=required) return "radio_revision"; + compatible=true; return nullptr; + } +}; + +inline bool copyStatusSnapshot(JsonObject destination,const char *json) { + // The const input forces owned strings. Both JSON keys and nested values + // must survive the source buffer AND this temporary document. + DynamicJsonDocument snapshot(4096); + if(deserializeJson(snapshot,json)) return false; + for(JsonPair pair:snapshot.as()) destination[pair.key()]=pair.value(); + return true; +} + +// Deserialize the cached status directly into the web response document. +// A Reader forces owned strings, without a second 4 KiB document or a String +// containing a copy of the entire JSON response. The peer task owns the cache; +// its caller supplies a stable local snapshot for this short operation. +class StatusReader { + const char *body; + size_t prefix=0; + bool ended=false; +public: + explicit StatusReader(const char *json):body(json) {} + int read() { + static const char begin[]="{\"status\":"; + if(prefix(*body++); + if(!ended) { ended=true; return '}'; } + return -1; + } + size_t readBytes(char *out,size_t count) { + size_t n=0; int ch; + while(n=0) out[n++]=ch; + return n; + } +}; +inline bool loadStatusSnapshot(JsonDocument &destination,const char *json) { + StatusReader reader(json); + return !deserializeJson(destination,reader) && destination["status"].is(); +} +} diff --git a/lib/CzcBackhaul/src/Znp.h b/lib/CzcBackhaul/src/Znp.h new file mode 100644 index 00000000..c46fe75e --- /dev/null +++ b/lib/CzcBackhaul/src/Znp.h @@ -0,0 +1,207 @@ +#pragma once +#include +#include +#include +#include + +struct ZnpFrame { + uint8_t data[260]{}; + uint16_t size=0; + void build(uint8_t c0, uint8_t c1, const uint8_t *body, size_t len) { + size=0; if(len>250) return; + data[0]=0xfe; data[1]=len; data[2]=c0; data[3]=c1; + if(len) memcpy(data+4,body,len); + uint8_t fcs=0; for(size_t i=1;i250) { + lengthError=true; errorCmd0=errorCmd1=0; + frame.size=0; ++errors; return false; + } + if(frame.size<2 || frame.size!=frame.data[1]+5) return false; + uint8_t sum=0; for(unsigned i=1;i=2 ? frame.data[1]+5 : 0)<<8); } +}; +class Radio { + SemaphoreHandle_t mutex=nullptr; + QueueHandle_t replies=nullptr; + volatile bool waiting=false, suspendRequested=false, suspended=false; + TaskHandle_t receiver=nullptr; + volatile uint8_t expect0=0,expect1=0; + uint32_t resetStarted=0; + volatile uint32_t rxBytes=0,rxFrames=0,rxLoopAt=0; + volatile uint16_t rxPartial=0; + volatile uint32_t requestStarted=0; + void fail(const char *reason,uint8_t c0=0,uint8_t c1=0) { + if(fatal) return; + error=reason; errorCmd0=c0; errorCmd1=c1; fatal=true; + } + void checkRxError() { + if(rxError && !resetWaiting) fail(rxError,waiting ? expect0&0x3f : 0,waiting ? expect1 : 0); + } + static void task(void *arg) { static_cast(arg)->run(); } + void run() { + ZnpParser parser; ZnpFrame frame; + for(;;) { + if(suspendRequested) { suspended=true; vTaskDelay(1); continue; } + if(suspended) { parser.reset(); parser.errors=0; rxPartial=0; suspended=false; } + rxLoopAt=millis(); + checkRxError(); + while(Serial2.available()) { + int byte=Serial2.read(); if(byte<0) break; + ++rxBytes; + bool complete=parser.feed(byte,frame); rxPartial=parser.progress(); + if(!complete) continue; + ++rxFrames; + if(frame.data[2]==0x41 && frame.data[3]==0x80 && frame.data[1]>=6) { + parser.errors=0; + if(waiting) fail("reset_during_sreq",expect0&0x3f,expect1); + if(!resetWaiting) ++resetGeneration; + // Queue the real indication before releasing the reset barrier. + if(adminActive && xQueueSend(events,&frame,0)!=pdTRUE) { adminOverflow=true; ++overflows; } + resetWaiting=false; + } else if(resetWaiting) { + // Boot/BSL residue is not a reply to a new UART transaction. + } else if(waiting && frame.data[2]==expect0 && frame.data[3]==expect1) { + if(replyMs==UINT32_MAX) replyMs=millis()-requestStarted; + // After a timeout only observe the outstanding response. No + // replay or new RPC is allowed before maintenance clears it. + if(!timedOut && xQueueSend(replies,&frame,0)!=pdTRUE) fail("reply_overflow",frame.data[2],frame.data[3]); + } else if((frame.data[2]&0xe0)==0x60) { + // An unsolicited SRSP cannot safely be assigned to a later RPC. + fail("unexpected_srsp",frame.data[2],frame.data[3]); + } else if(adminActive && xQueueSend(events,&frame,0)!=pdTRUE) { + adminOverflow=true; ++overflows; + } + } + if(parser.errors) { + if(!resetWaiting) fail(parser.lengthError ? "frame_length" : "frame_fcs",parser.errorCmd0,parser.errorCmd1); + parser.errors=0; + } + checkRxError(); + vTaskDelay(1); + } + } +public: + QueueHandle_t events=nullptr; + volatile bool fatal=false,adminActive=false,adminOverflow=false; + volatile bool resetWaiting=false; + volatile uint32_t resetGeneration=0; + const char *volatile error="none"; + const char *volatile rxError=nullptr; + volatile uint8_t errorCmd0=0,errorCmd1=0; + uint32_t timeouts=0,overflows=0; + volatile bool timedOut=false; + uint32_t timeoutWait=0,timeoutRx=0,timeoutFrames=0,timeoutGap=0; + uint16_t timeoutPartial=0; + volatile uint32_t replyMs=UINT32_MAX; + // BDB initialization can apply a staged profile and restore NV before its + // SRSP. Its deadline depends on the command, including on a Satellite + // without a controller. Ordinary SRSPs share the radio task with stack/NV + // work: allow 750 ms for UART, independently of frame/lease expiry. + static uint32_t requestTimeout(uint8_t c0,uint8_t cmd,uint32_t normal=750) { + return (c0==0x2f && cmd==5) || (c0==0x25 && cmd==0x40) ? 40000 : normal; + } + static uint32_t controllerTimeout(uint8_t c0,uint8_t cmd) { + return requestTimeout(c0,cmd,6000); + } + void expectReset() { + resetStarted=millis(); ++resetGeneration; resetWaiting=true; + } + void tick(uint32_t now=millis()) { + if(resetWaiting && uint32_t(now-resetStarted)>=30000) fail("reset_timeout",0x41,0); + } + bool begin() { + mutex=xSemaphoreCreateMutex(); replies=xQueueCreate(1,sizeof(ZnpFrame)); + events=xQueueCreate(24,sizeof(ZnpFrame)); + if(!mutex || !replies || !events) { fatal=true; return false; } + // Serial2 is already initialized with the hardware table by stock CZC. + // At 115200 baud the Arduino default (120/128 bytes) leaves less than + // 1 ms to service the FIFO. A larger ring buffer cannot prevent that overrun. + if(!Serial2.setRxFIFOFull(32)) { fail("uart_config"); return false; } + Serial2.eventQueueReset(); + Serial2.onReceiveError([this](hardwareSerial_error_t e) { + // Bootloader traffic belongs to CCTools during maintenance. + if(suspendRequested || resetWaiting || rxError) return; + switch(e) { + case UART_FIFO_OVF_ERROR: rxError="rx_fifo_overflow"; break; + case UART_BUFFER_FULL_ERROR: rxError="rx_buffer_full"; break; + case UART_BREAK_ERROR: rxError="rx_break"; break; + case UART_PARITY_ERROR: rxError="rx_parity"; break; + case UART_FRAME_ERROR: rxError="rx_frame"; break; + default: break; + } + }); + if(xTaskCreatePinnedToCore(task,"znp-rx",4096,this,3,&receiver,1)!=pdPASS) { fatal=true; return false; } + return true; + } + void stop() { + Serial2.onReceiveError(nullptr); + if(receiver) { vTaskDelete(receiver); receiver=nullptr; } + if(replies) { vQueueDelete(replies); replies=nullptr; } + if(events) { vQueueDelete(events); events=nullptr; } + if(mutex) { vSemaphoreDelete(mutex); mutex=nullptr; } + fatal=true; + } + bool suspend() { + if(xSemaphoreTake(mutex,pdMS_TO_TICKS(1000))!=pdTRUE) return false; + suspendRequested=true; + uint32_t start=millis(); + while(!suspended && millis()-start<1000) vTaskDelay(1); + if(!suspended) { suspendRequested=false; xSemaphoreGive(mutex); return false; } + return true; // The network task retains the RPC mutex until resume(). + } + void resume() { + // A maintenance operation may have reset or re-flashed the radio. + // Recovery may already have a resetInd buffered while CCTools held UART. + if(!resetWaiting) while(Serial2.available()) Serial2.read(); + Serial2.eventQueueReset(); rxError=nullptr; + xQueueReset(replies); xQueueReset(events); fatal=false; waiting=false; adminOverflow=false; + timedOut=false; replyMs=UINT32_MAX; + error="none"; errorCmd0=errorCmd1=0; + suspendRequested=false; + while(suspended) vTaskDelay(1); + xSemaphoreGive(mutex); + } + bool rpc(uint8_t c0, uint8_t c1, const uint8_t *in, size_t len, ZnpFrame &out,uint32_t timeout=0) { + out.size=0; + if(!timeout) timeout=requestTimeout(c0,c1); + if(fatal || resetWaiting || suspendRequested || len>250 || xSemaphoreTake(mutex,pdMS_TO_TICKS(500))!=pdTRUE) return false; + if(fatal || resetWaiting || suspendRequested) { xSemaphoreGive(mutex); return false; } + ZnpFrame frame; frame.build(c0,c1,in,len); + uint32_t startBytes=rxBytes,startFrames=rxFrames; + requestStarted=millis(); replyMs=UINT32_MAX; timedOut=false; + expect0=(c0&31)|0x60; expect1=c1; waiting=(c0&0xe0)==0x20; + if(c0==0x41 && c1==0 && len==1) expectReset(); + Serial2.write(frame.data,frame.size); + bool ok=true; + if(waiting) ok=xQueueReceive(replies,&out,pdMS_TO_TICKS(timeout))==pdTRUE; + checkRxError(); + if(ok) waiting=false; + else { + uint32_t loopAt=rxLoopAt,now=millis(); + timeoutWait=now-requestStarted; timeoutRx=rxBytes-startBytes; timeoutFrames=rxFrames-startFrames; + timeoutPartial=rxPartial; timeoutGap=now-loopAt; timedOut=true; + ++timeouts; fail("srsp_timeout",c0,c1); + } + xSemaphoreGive(mutex); return ok && !fatal; + } + bool command(uint8_t cmd, const uint8_t *in, size_t len, ZnpFrame &out) { + return rpc(0x21,cmd,in,len,out) && out.size>=6 && out.data[4]==0; + } +}; diff --git a/lib/CzcPeerTls/LICENSE b/lib/CzcPeerTls/LICENSE new file mode 100644 index 00000000..776ac77e --- /dev/null +++ b/lib/CzcPeerTls/LICENSE @@ -0,0 +1,553 @@ +Mbed TLS files are provided under a dual [Apache-2.0](https://spdx.org/licenses/Apache-2.0.html) +OR [GPL-2.0-or-later](https://spdx.org/licenses/GPL-2.0-or-later.html) license. +This means that users may choose which of these licenses they take the code +under. + +The full text of each of these licenses is given below. + + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + +=============================================================================== + + + GNU GENERAL PUBLIC LICENSE + Version 2, June 1991 + + Copyright (C) 1989, 1991 Free Software Foundation, Inc., + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +License is intended to guarantee your freedom to share and change free +software--to make sure the software is free for all its users. This +General Public License applies to most of the Free Software +Foundation's software and to any other program whose authors commit to +using it. (Some other Free Software Foundation software is covered by +the GNU Lesser General Public License instead.) You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +this service if you wish), that you receive source code or can get it +if you want it, that you can change the software or use pieces of it +in new free programs; and that you know you can do these things. + + To protect your rights, we need to make restrictions that forbid +anyone to deny you these rights or to ask you to surrender the rights. +These restrictions translate to certain responsibilities for you if you +distribute copies of the software, or if you modify it. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must give the recipients all the rights that +you have. You must make sure that they, too, receive or can get the +source code. And you must show them these terms so they know their +rights. + + We protect your rights with two steps: (1) copyright the software, and +(2) offer you this license which gives you legal permission to copy, +distribute and/or modify the software. + + Also, for each author's protection and ours, we want to make certain +that everyone understands that there is no warranty for this free +software. If the software is modified by someone else and passed on, we +want its recipients to know that what they have is not the original, so +that any problems introduced by others will not reflect on the original +authors' reputations. + + Finally, any free program is threatened constantly by software +patents. We wish to avoid the danger that redistributors of a free +program will individually obtain patent licenses, in effect making the +program proprietary. To prevent this, we have made it clear that any +patent must be licensed for everyone's free use or not licensed at all. + + The precise terms and conditions for copying, distribution and +modification follow. + + GNU GENERAL PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. This License applies to any program or other work which contains +a notice placed by the copyright holder saying it may be distributed +under the terms of this General Public License. The "Program", below, +refers to any such program or work, and a "work based on the Program" +means either the Program or any derivative work under copyright law: +that is to say, a work containing the Program or a portion of it, +either verbatim or with modifications and/or translated into another +language. (Hereinafter, translation is included without limitation in +the term "modification".) Each licensee is addressed as "you". + +Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running the Program is not restricted, and the output from the Program +is covered only if its contents constitute a work based on the +Program (independent of having been made by running the Program). +Whether that is true depends on what the Program does. + + 1. You may copy and distribute verbatim copies of the Program's +source code as you receive it, in any medium, provided that you +conspicuously and appropriately publish on each copy an appropriate +copyright notice and disclaimer of warranty; keep intact all the +notices that refer to this License and to the absence of any warranty; +and give any other recipients of the Program a copy of this License +along with the Program. + +You may charge a fee for the physical act of transferring a copy, and +you may at your option offer warranty protection in exchange for a fee. + + 2. You may modify your copy or copies of the Program or any portion +of it, thus forming a work based on the Program, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: + + a) You must cause the modified files to carry prominent notices + stating that you changed the files and the date of any change. + + b) You must cause any work that you distribute or publish, that in + whole or in part contains or is derived from the Program or any + part thereof, to be licensed as a whole at no charge to all third + parties under the terms of this License. + + c) If the modified program normally reads commands interactively + when run, you must cause it, when started running for such + interactive use in the most ordinary way, to print or display an + announcement including an appropriate copyright notice and a + notice that there is no warranty (or else, saying that you provide + a warranty) and that users may redistribute the program under + these conditions, and telling the user how to view a copy of this + License. (Exception: if the Program itself is interactive but + does not normally print such an announcement, your work based on + the Program is not required to print an announcement.) + +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Program, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Program, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote it. + +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Program. + +In addition, mere aggregation of another work not based on the Program +with the Program (or with a work based on the Program) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. + + 3. You may copy and distribute the Program (or a work based on it, +under Section 2) in object code or executable form under the terms of +Sections 1 and 2 above provided that you also do one of the following: + + a) Accompany it with the complete corresponding machine-readable + source code, which must be distributed under the terms of Sections + 1 and 2 above on a medium customarily used for software interchange; or, + + b) Accompany it with a written offer, valid for at least three + years, to give any third party, for a charge no more than your + cost of physically performing source distribution, a complete + machine-readable copy of the corresponding source code, to be + distributed under the terms of Sections 1 and 2 above on a medium + customarily used for software interchange; or, + + c) Accompany it with the information you received as to the offer + to distribute corresponding source code. (This alternative is + allowed only for noncommercial distribution and only if you + received the program in object code or executable form with such + an offer, in accord with Subsection b above.) + +The source code for a work means the preferred form of the work for +making modifications to it. For an executable work, complete source +code means all the source code for all modules it contains, plus any +associated interface definition files, plus the scripts used to +control compilation and installation of the executable. However, as a +special exception, the source code distributed need not include +anything that is normally distributed (in either source or binary +form) with the major components (compiler, kernel, and so on) of the +operating system on which the executable runs, unless that component +itself accompanies the executable. + +If distribution of executable or object code is made by offering +access to copy from a designated place, then offering equivalent +access to copy the source code from the same place counts as +distribution of the source code, even though third parties are not +compelled to copy the source along with the object code. + + 4. You may not copy, modify, sublicense, or distribute the Program +except as expressly provided under this License. Any attempt +otherwise to copy, modify, sublicense or distribute the Program is +void, and will automatically terminate your rights under this License. +However, parties who have received copies, or rights, from you under +this License will not have their licenses terminated so long as such +parties remain in full compliance. + + 5. You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Program or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Program (or any work based on the +Program), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Program or works based on it. + + 6. Each time you redistribute the Program (or any work based on the +Program), the recipient automatically receives a license from the +original licensor to copy, distribute or modify the Program subject to +these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties to +this License. + + 7. If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Program at all. For example, if a patent +license would not permit royalty-free redistribution of the Program by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Program. + +If any portion of this section is held invalid or unenforceable under +any particular circumstance, the balance of the section is intended to +apply and the section as a whole is intended to apply in other +circumstances. + +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system, which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. + +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. + + 8. If the distribution and/or use of the Program is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Program under this License +may add an explicit geographical distribution limitation excluding +those countries, so that distribution is permitted only in or among +countries not thus excluded. In such case, this License incorporates +the limitation as if written in the body of this License. + + 9. The Free Software Foundation may publish revised and/or new versions +of the General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + +Each version is given a distinguishing version number. If the Program +specifies a version number of this License which applies to it and "any +later version", you have the option of following the terms and conditions +either of that version or of any later version published by the Free +Software Foundation. If the Program does not specify a version number of +this License, you may choose any version ever published by the Free Software +Foundation. + + 10. If you wish to incorporate parts of the Program into other free +programs whose distribution conditions are different, write to the author +to ask for permission. For software which is copyrighted by the Free +Software Foundation, write to the Free Software Foundation; we sometimes +make exceptions for this. Our decision will be guided by the two goals +of preserving the free status of all derivatives of our free software and +of promoting the sharing and reuse of software generally. + + NO WARRANTY + + 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY +FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN +OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES +PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED +OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS +TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE +PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, +REPAIR OR CORRECTION. + + 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR +REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, +INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING +OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED +TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY +YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER +PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE +POSSIBILITY OF SUCH DAMAGES. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +convey the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + 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 2 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, write to the Free Software Foundation, Inc., + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +Also add information on how to contact you by electronic and paper mail. + +If the program is interactive, make it output a short notice like this +when it starts in an interactive mode: + + Gnomovision version 69, Copyright (C) year name of author + Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, the commands you use may +be called something other than `show w' and `show c'; they could even be +mouse-clicks or menu items--whatever suits your program. + +You should also get your employer (if you work as a programmer) or your +school, if any, to sign a "copyright disclaimer" for the program, if +necessary. Here is a sample; alter the names: + + Yoyodyne, Inc., hereby disclaims all copyright interest in the program + `Gnomovision' (which makes passes at compilers) written by James Hacker. + + , 1 April 1989 + Ty Coon, President of Vice + +This General Public License does not permit incorporating your program into +proprietary programs. If your program is a subroutine library, you may +consider it more useful to permit linking proprietary applications with the +library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. diff --git a/lib/CzcPeerTls/README.md b/lib/CzcPeerTls/README.md new file mode 100644 index 00000000..441e1883 --- /dev/null +++ b/lib/CzcPeerTls/README.md @@ -0,0 +1,51 @@ +# CZC TLS engine + +The peer and debug-only administrative PSK channels use Mbed TLS 3.6.7. +The `mbedtls/` submodule's release and commit are recorded in `upstream.json`. + +`CzcTlsConfig.h` selects TLS 1.3, external PSK with X25519, and +TLS_AES_128_GCM_SHA256. It uses two buffer profiles: + +| Profile | Input | Output | Record Size Limit extension | +| --- | ---: | ---: | --- | +| Radio peers, client and server | 1024 | 1024 | 1024 bytes | +| Administrative server | 16384 | 1024 | Disabled | + +Both TLS profiles share one crypto implementation. The administrative profile +accepts standard 16 KiB records from clients without a record size limit. +Only builds with `DEBUG` compile that profile and expose `czc_tls_new_admin`. +Production builds generate and compile the peer profile and shared crypto. +`CzcTlsEngine.inc` adapts the Mbed TLS API to the opaque +`CzcPeerTls.h` interface. `prepare-peer-tls.py` generates the C wrappers and +`CzcTlsSymbols.h` into `.pio/build//czc-peer-tls/generated/`. +PlatformIO compiles them and the adapter into one private archive with LTO. +Symbol prefixes prevent collisions with the SDK's Mbed TLS. Headers and +compiler settings are scoped to this archive. +Generation runs automatically with `pio run` and preserves unchanged files for +incremental builds. `pio run -t clean` removes the generated inputs. + +The adapter records TLS-owned PSA key imports and destroys leftover keys when +their session closes. This covers the Mbed TLS 3.6.7 error path that frees an +incomplete handshake transform without destroying its first imported key when +the second import fails. All engines and their PSA state belong to one task +and use the application's DRBG. + +From the repository root: + +```sh +# Initialize dependencies. +git submodule update --init --recursive + +# Check the Mbed TLS dependency. +python3 tools/backhaul/prepare-peer-tls.py --check + +bash tools/backhaul/test-tls.sh +bash tools/backhaul/test-admin-tls.sh +bash tools/backhaul/build.sh -e prod -e debug +``` + +Tests cover protocol version enforcement, eight TLS 1.3 peer pairs, +OpenSSL interoperability, malformed authentication, standard admin records, +allocation failures with another live session, +reconnects and complete cleanup under ASan/UBSan. Native memory measurements +are not measurements of the complete application on ESP32 hardware. diff --git a/lib/CzcPeerTls/build.py b/lib/CzcPeerTls/build.py new file mode 100644 index 00000000..74bcda89 --- /dev/null +++ b/lib/CzcPeerTls/build.py @@ -0,0 +1,32 @@ +"""Build private TLS profiles from generated inputs in PlatformIO's build directory.""" +import importlib.util +from pathlib import Path + +Import('env', 'pio_lib_builder') +root = Path(pio_lib_builder.path) +spec = importlib.util.spec_from_file_location('czc_tls_generator', root.parents[1] / 'tools/backhaul/prepare-peer-tls.py') +generator = importlib.util.module_from_spec(spec) +spec.loader.exec_module(generator) + +def define_name(item): + return item[0] if isinstance(item, (tuple, list)) else item + +debug = any(define_name(item) == 'DEBUG' for item in env.get('CPPDEFINES', [])) +work = Path(env.subst('$BUILD_DIR')) / 'czc-peer-tls' +sources = generator.prepare(work / 'generated', debug) +# This environment owns both compiler include paths and dependency scanning. +# Only the resulting archive is exported to other PlatformIO libraries. +private = env.Clone() +private.Replace(CPPDEFINES=[item for item in private.get('CPPDEFINES', []) + if define_name(item) != 'MBEDTLS_CONFIG_FILE']) +private.Append(CPPDEFINES=[('MBEDTLS_CONFIG_FILE', '\\"CzcTlsConfig.h\\"')]) +private.Prepend(CPPPATH=[str(root / 'src'), str(sources), str(root / 'mbedtls'), + str(root / 'mbedtls/include'), str(root / 'mbedtls/library')]) +private.Append(CCFLAGS=['-flto']) +nodes = private.CollectBuildFiles(str(work / 'adapter'), str(root / 'src'), ['+<*.c>']) +nodes += private.CollectBuildFiles(str(work / 'profiles'), str(sources), ['+<*>']) +archive = private.BuildLibrary(str(work / 'CzcPeerTls'), str(sources), nodes=nodes) +env.Append(LIBS=[archive], LINKFLAGS=['-flto']) +# The adapter sources are compiled into the private archive above. +env.Replace(SRC_FILTER=['-<*>']) +env.Clean(archive, str(sources)) diff --git a/lib/CzcPeerTls/library.json b/lib/CzcPeerTls/library.json new file mode 100644 index 00000000..2a412264 --- /dev/null +++ b/lib/CzcPeerTls/library.json @@ -0,0 +1,7 @@ +{ + "name": "CzcPeerTls", + "version": "3.6.7", + "build": { + "extraScript": "build.py" + } +} diff --git a/lib/CzcPeerTls/mbedtls b/lib/CzcPeerTls/mbedtls new file mode 160000 index 00000000..068ff080 --- /dev/null +++ b/lib/CzcPeerTls/mbedtls @@ -0,0 +1 @@ +Subproject commit 068ff080b369adfac81509f9b57b2afabaf82dc5 diff --git a/lib/CzcPeerTls/src/CzcPeerTls.c b/lib/CzcPeerTls/src/CzcPeerTls.c new file mode 100644 index 00000000..16b1df7f --- /dev/null +++ b/lib/CzcPeerTls/src/CzcPeerTls.c @@ -0,0 +1,32 @@ +#include "CzcTlsInternal.h" +#include +static size_t allocated,peak; +typedef union { size_t size; long double alignment; } Allocation; +void *czc_tls_calloc(size_t n,size_t s) { + if(s && n>(SIZE_MAX-sizeof(Allocation))/s) return NULL; + size_t bytes=n*s+sizeof(Allocation); + Allocation *p=calloc(1,bytes); if(!p) return NULL; + p->size=bytes; allocated+=bytes; if(allocated>peak) peak=allocated; return p+1; +} +void czc_tls_free(void *ptr) { + if(ptr) { Allocation *p=(Allocation *)ptr-1; allocated-=p->size; free(p); } +} +size_t czc_tls_allocated(void) { return allocated; } +size_t czc_tls_peak(void) { return peak; } +CzcTls *czc_tls_new(int server,const uint8_t key[32],const char *identity, + int (*rng)(void *,unsigned char *,size_t),int (*send)(void *,const unsigned char *,size_t), + int (*recv)(void *,unsigned char *,size_t),void *io) { + return czc_tls_peer_create(server,key,identity,rng,send,recv,io,NULL); +} +#ifdef DEBUG +CzcTls *czc_tls_new_admin(int server,const uint8_t key[32],const char *identity, + int (*rng)(void *,unsigned char *,size_t),int (*send)(void *,const unsigned char *,size_t), + int (*recv)(void *,unsigned char *,size_t),void *io,int *error) { + return czc_tls_admin_create(server,key,identity,rng,send,recv,io,error); +} +#endif +void czc_tls_delete(CzcTls *t) { if(t) t->ops->destroy(t); } +int czc_tls_error(const CzcTls *t) { return t ? t->error : -0x7f00; } +int czc_tls_handshake(CzcTls *t) { return t->ops->handshake(t); } +int czc_tls_read(CzcTls *t,uint8_t *p,size_t n) { return t->ops->read(t,p,n); } +int czc_tls_write(CzcTls *t,const uint8_t *p,size_t n) { return t->ops->write(t,p,n); } diff --git a/lib/CzcPeerTls/src/CzcPeerTls.h b/lib/CzcPeerTls/src/CzcPeerTls.h new file mode 100644 index 00000000..e9dbbade --- /dev/null +++ b/lib/CzcPeerTls/src/CzcPeerTls.h @@ -0,0 +1,28 @@ +#pragma once +#include +#include +#ifdef __cplusplus +extern "C" { +#endif +typedef struct CzcTls CzcTls; +CzcTls *czc_tls_new(int server, const uint8_t key[32], const char *identity, + int (*rng)(void *, unsigned char *, size_t), + int (*send)(void *, const unsigned char *, size_t), + int (*recv)(void *, unsigned char *, size_t), void *io); +#ifdef DEBUG +CzcTls *czc_tls_new_admin(int server, const uint8_t key[32], const char *identity, + int (*rng)(void *, unsigned char *, size_t), + int (*send)(void *, const unsigned char *, size_t), + int (*recv)(void *, unsigned char *, size_t), void *io, int *error); +#endif +int czc_tls_error(const CzcTls *tls); +void czc_tls_delete(CzcTls *tls); +// 0: pending, 1: handshake complete, -1: error. IO uses positive bytes or 0 for WANT. +int czc_tls_handshake(CzcTls *tls); +int czc_tls_read(CzcTls *tls, uint8_t *buffer, size_t size); +int czc_tls_write(CzcTls *tls, const uint8_t *buffer, size_t size); +size_t czc_tls_allocated(void); +size_t czc_tls_peak(void); +#ifdef __cplusplus +} +#endif diff --git a/lib/CzcPeerTls/src/CzcTlsConfig.h b/lib/CzcPeerTls/src/CzcTlsConfig.h new file mode 100644 index 00000000..b4ad87f3 --- /dev/null +++ b/lib/CzcPeerTls/src/CzcTlsConfig.h @@ -0,0 +1,42 @@ +#pragma once +#include +#include "CzcTlsSymbols.h" +void *czc_tls_calloc(size_t count, size_t size); +void czc_tls_free(void *pointer); +#define MBEDTLS_PLATFORM_C +#define MBEDTLS_PLATFORM_MEMORY +#define MBEDTLS_PLATFORM_CALLOC_MACRO czc_tls_calloc +#define MBEDTLS_PLATFORM_FREE_MACRO czc_tls_free +#define MBEDTLS_AES_C +#define MBEDTLS_AES_ONLY_128_BIT_KEY_LENGTH +#define MBEDTLS_GCM_C +#define MBEDTLS_CIPHER_C +#define MBEDTLS_MD_C +#define MBEDTLS_SHA256_C +#define MBEDTLS_HKDF_C +#define MBEDTLS_PSA_CRYPTO_C +#define MBEDTLS_PSA_CRYPTO_EXTERNAL_RNG +#define MBEDTLS_USE_PSA_CRYPTO +#define MBEDTLS_PSA_KEY_SLOT_COUNT 128 +// These buffers belong to the single TLS task; no untrusted concurrent writer. +#define MBEDTLS_PSA_ASSUME_EXCLUSIVE_BUFFERS +#define MBEDTLS_BIGNUM_C +#define MBEDTLS_ECP_C +#define MBEDTLS_ECDH_C +#define MBEDTLS_ECP_DP_CURVE25519_ENABLED +#define MBEDTLS_SSL_TLS_C +#define MBEDTLS_SSL_SRV_C +#define MBEDTLS_SSL_PROTO_TLS1_3 +#define MBEDTLS_SSL_TLS1_3_KEY_EXCHANGE_MODE_PSK_EPHEMERAL_ENABLED +#define MBEDTLS_SSL_KEEP_PEER_CERTIFICATE +#define MBEDTLS_SSL_CIPHERSUITES MBEDTLS_TLS1_3_AES_128_GCM_SHA256 +#define MBEDTLS_SSL_OUT_CONTENT_LEN 1024 +#define MBEDTLS_PSK_MAX_LEN 32 +#ifdef CZC_TLS_ADMIN +// Standard PSK clients may send full-size records. +#define MBEDTLS_SSL_IN_CONTENT_LEN 16384 +#else +#define MBEDTLS_SSL_CLI_C +#define MBEDTLS_SSL_RECORD_SIZE_LIMIT +#define MBEDTLS_SSL_IN_CONTENT_LEN 1024 +#endif diff --git a/lib/CzcPeerTls/src/CzcTlsCrypto.c b/lib/CzcPeerTls/src/CzcTlsCrypto.c new file mode 100644 index 00000000..ad318d26 --- /dev/null +++ b/lib/CzcPeerTls/src/CzcTlsCrypto.c @@ -0,0 +1,52 @@ +#include "CzcTlsConfig.h" +#include "CzcTlsInternal.h" +#include "psa/crypto.h" +#include "psa/crypto_extra.h" + +// The gateway owns all TLS engines on one task. PSA uses the same application +// DRBG callback as TLS; it never opens a second hardware entropy source. +static unsigned users; +static int (*random_source)(void *,unsigned char *,size_t); +static CzcTls *owner; +void czc_tls_crypto_enter(CzcTls *tls) { owner=tls; } +// Track TLS imports through the PSA API. Mbed TLS 3.6.7 can abandon an +// already imported encryption key if importing the decryption key fails. +// Reclaim this session's leftover keys without disturbing other live sessions. +psa_status_t czc_tls_guard_psa_import_key(const psa_key_attributes_t *attributes, + const uint8_t *data,size_t size,mbedtls_svc_key_id_t *key) { + if(!owner) return PSA_ERROR_BAD_STATE; + for(size_t i=0;iimported_keys)/sizeof(owner->imported_keys[0]);++i) { + if(owner->imported_keys[i]) continue; + psa_status_t status=psa_import_key(attributes,data,size,key); + if(status==PSA_SUCCESS) owner->imported_keys[i]=*key; + return status; + } + return PSA_ERROR_INSUFFICIENT_MEMORY; +} +psa_status_t czc_tls_guard_psa_destroy_key(mbedtls_svc_key_id_t key) { + psa_status_t status=psa_destroy_key(key); + if(owner && (status==PSA_SUCCESS || status==PSA_ERROR_INVALID_HANDLE)) { + for(size_t i=0;iimported_keys)/sizeof(owner->imported_keys[0]);++i) + if(owner->imported_keys[i]==key) owner->imported_keys[i]=0; + } + return status; +} +void czc_tls_crypto_cleanup(CzcTls *tls) { + for(size_t i=0;iimported_keys)/sizeof(tls->imported_keys[0]);++i) { + if(tls->imported_keys[i]) psa_destroy_key(tls->imported_keys[i]); + tls->imported_keys[i]=0; + } +} +int czc_tls_crypto_acquire(int (*rng)(void *,unsigned char *,size_t)) { + if(!rng || (users && rng!=random_source)) return -1; + random_source=rng; ++users; return 0; +} +void czc_tls_crypto_release(void) { + if(--users==0) { mbedtls_psa_crypto_free(); random_source=NULL; } +} +psa_status_t mbedtls_psa_external_get_random(mbedtls_psa_external_random_context_t *context, + uint8_t *output,size_t size,size_t *length) { + (void)context; *length=0; + if(!random_source || random_source(NULL,output,size)) return PSA_ERROR_INSUFFICIENT_ENTROPY; + *length=size; return PSA_SUCCESS; +} diff --git a/lib/CzcPeerTls/src/CzcTlsEngine.inc b/lib/CzcPeerTls/src/CzcTlsEngine.inc new file mode 100644 index 00000000..4083347a --- /dev/null +++ b/lib/CzcPeerTls/src/CzcTlsEngine.inc @@ -0,0 +1,91 @@ +// TLS adapter; compiled once per buffer profile. +#include "CzcTlsConfig.h" +#include "CzcTlsInternal.h" +#include "mbedtls/ssl.h" +#include "mbedtls/platform_util.h" +#include + +struct Engine { + CzcTls base; + mbedtls_ssl_context ssl; + mbedtls_ssl_config conf; + int (*send)(void *,const unsigned char *,size_t); + int (*recv)(void *,unsigned char *,size_t); + void *io; + int crypto_acquired; +}; +static int send_bio(void *ctx,const unsigned char *p,size_t n) { + struct Engine *t=ctx; int r=t->send(t->io,p,n); + return r ? r : MBEDTLS_ERR_SSL_WANT_WRITE; +} +static int recv_bio(void *ctx,unsigned char *p,size_t n) { + struct Engine *t=ctx; int r=t->recv(t->io,p,n); + return r ? r : MBEDTLS_ERR_SSL_WANT_READ; +} +static void destroy(CzcTls *base) { + struct Engine *t=(struct Engine *)base; + czc_tls_crypto_enter(base); + mbedtls_ssl_free(&t->ssl); mbedtls_ssl_config_free(&t->conf); + czc_tls_crypto_cleanup(base); czc_tls_crypto_enter(NULL); + if(t->crypto_acquired) czc_tls_crypto_release(); + mbedtls_platform_zeroize(t,sizeof(*t)); czc_tls_free(t); +} +static int handshake(CzcTls *base) { + struct Engine *t=(struct Engine *)base; + czc_tls_crypto_enter(base); + int r=mbedtls_ssl_handshake(&t->ssl); + czc_tls_crypto_enter(NULL); + if(r && r!=MBEDTLS_ERR_SSL_WANT_READ && r!=MBEDTLS_ERR_SSL_WANT_WRITE) base->error=r; + return !r ? 1 : (r==MBEDTLS_ERR_SSL_WANT_READ || r==MBEDTLS_ERR_SSL_WANT_WRITE) ? 0 : -1; +} +static int io_result(CzcTls *base,int r) { + if(r==MBEDTLS_ERR_SSL_WANT_READ || r==MBEDTLS_ERR_SSL_WANT_WRITE) return 0; + if(r<=0) { base->error=r; return -1; } + return r; +} +static int read_data(CzcTls *base,uint8_t *p,size_t n) { + czc_tls_crypto_enter(base); + int r=mbedtls_ssl_read(&((struct Engine *)base)->ssl,p,n); + czc_tls_crypto_enter(NULL); return io_result(base,r); +} +static int write_data(CzcTls *base,const uint8_t *p,size_t n) { + czc_tls_crypto_enter(base); + int r=mbedtls_ssl_write(&((struct Engine *)base)->ssl,p,n>1024?1024:n); + czc_tls_crypto_enter(NULL); return io_result(base,r); +} +static const struct CzcTlsOps ops={destroy,handshake,read_data,write_data}; +#ifdef CZC_TLS_ADMIN +#define CREATE czc_tls_admin_create +#else +#define CREATE czc_tls_peer_create +#endif +CzcTls *CREATE(int server,const uint8_t key[32],const char *identity, + int (*rng)(void *,unsigned char *,size_t),int (*send)(void *,const unsigned char *,size_t), + int (*recv)(void *,unsigned char *,size_t),void *io,int *error) { + if(error) *error=MBEDTLS_ERR_SSL_ALLOC_FAILED; + struct Engine *t=czc_tls_calloc(1,sizeof(*t)); if(!t) return NULL; + t->base.ops=&ops; t->send=send; t->recv=recv; t->io=io; + mbedtls_ssl_init(&t->ssl); mbedtls_ssl_config_init(&t->conf); + if(czc_tls_crypto_acquire(rng)) { + if(error) *error=MBEDTLS_ERR_SSL_NO_RNG; + destroy(&t->base); return NULL; + } + t->crypto_acquired=1; + czc_tls_crypto_enter(&t->base); + int result=mbedtls_ssl_config_defaults(&t->conf,server?MBEDTLS_SSL_IS_SERVER:MBEDTLS_SSL_IS_CLIENT, + MBEDTLS_SSL_TRANSPORT_STREAM,MBEDTLS_SSL_PRESET_DEFAULT); + if(result) goto fail; + mbedtls_ssl_conf_rng(&t->conf,rng,NULL); + result=mbedtls_ssl_conf_psk(&t->conf,key,32,(const unsigned char *)identity,strlen(identity)); + if(result) goto fail; + result=mbedtls_ssl_setup(&t->ssl,&t->conf); + if(result) goto fail; + mbedtls_ssl_set_bio(&t->ssl,t,send_bio,recv_bio,NULL); + czc_tls_crypto_enter(NULL); + if(error) *error=0; + return &t->base; +fail: + if(error) *error=result; + destroy(&t->base); return NULL; +} +#undef CREATE diff --git a/lib/CzcPeerTls/src/CzcTlsInternal.h b/lib/CzcPeerTls/src/CzcTlsInternal.h new file mode 100644 index 00000000..fe7446ed --- /dev/null +++ b/lib/CzcPeerTls/src/CzcTlsInternal.h @@ -0,0 +1,23 @@ +#pragma once +#include "CzcPeerTls.h" +struct CzcTlsOps { + void (*destroy)(CzcTls *); + int (*handshake)(CzcTls *); + int (*read)(CzcTls *,uint8_t *,size_t); + int (*write)(CzcTls *,const uint8_t *,size_t); +}; +struct CzcTls { const struct CzcTlsOps *ops; int error; uint32_t imported_keys[16]; }; +int czc_tls_crypto_acquire(int (*rng)(void *,unsigned char *,size_t)); +void czc_tls_crypto_release(void); +void czc_tls_crypto_enter(CzcTls *); +void czc_tls_crypto_cleanup(CzcTls *); +CzcTls *czc_tls_peer_create(int server,const uint8_t key[32],const char *identity, + int (*rng)(void *,unsigned char *,size_t), + int (*send)(void *,const unsigned char *,size_t), + int (*recv)(void *,unsigned char *,size_t),void *io,int *error); +#ifdef DEBUG +CzcTls *czc_tls_admin_create(int server,const uint8_t key[32],const char *identity, + int (*rng)(void *,unsigned char *,size_t), + int (*send)(void *,const unsigned char *,size_t), + int (*recv)(void *,unsigned char *,size_t),void *io,int *error); +#endif diff --git a/lib/CzcPeerTls/upstream.json b/lib/CzcPeerTls/upstream.json new file mode 100644 index 00000000..e24e907c --- /dev/null +++ b/lib/CzcPeerTls/upstream.json @@ -0,0 +1,9 @@ +{ + "url": "https://github.com/Mbed-TLS/mbedtls.git", + "version": "3.6.7", + "tag": "mbedtls-3.6.7", + "commit": "068ff080b369adfac81509f9b57b2afabaf82dc5", + "submodule": "mbedtls", + "source_changes": [], + "build": "Peer/admin TLS configurations with shared crypto; compiler macros isolate symbols from SDK Mbed TLS" +} diff --git a/platformio.ini b/platformio.ini index 6be00203..d1376b24 100644 --- a/platformio.ini +++ b/platformio.ini @@ -15,24 +15,27 @@ default_envs = prod platform = https://github.com/tasmota/platform-espressif32/releases/download/2023.07.00/platform-espressif32.zip board = esp32dev framework = arduino +build_unflags = -fno-lto lib_deps = bblanchon/ArduinoJson@6.21.3 - rlogiacco/CircularBuffer@>=1.4.0 - sstaub/Ticker@>=4.4.0 - Martin-Laclaustra/CronAlarms - marvinroger/AsyncMqttClient @ ^0.9.0 + rlogiacco/CircularBuffer@1.4.0 + sstaub/Ticker@4.4.0 + Martin-Laclaustra/CronAlarms@0.1.0 + marvinroger/AsyncMqttClient@0.9.0 me-no-dev/AsyncTCP@1.1.1 - milesburton/DallasTemperature @ ^3.11.0 + milesburton/DallasTemperature@3.11.0 monitor_filters = direct monitor_speed = 115200 upload_speed = 460800 extra_scripts = + pre:tools/backhaul/prepare-web-server.py pre:tools/build/pre_build.py pre:tools/webfilesbuilder/build_html.py pre:tools/export_compile_commands.py post:tools/build/build.py build_flags = -DBUILD_ENV_NAME=$PIOENV + -flto [env:prod] build_flags = diff --git a/src/backhaul.cpp b/src/backhaul.cpp new file mode 100644 index 00000000..ef923adc --- /dev/null +++ b/src/backhaul.cpp @@ -0,0 +1,994 @@ +#include "czc_backhaul.h" +#include "config.h" +#include "etc.h" +#include "web.h" +#include "backhaul_random.h" +#include "memory_status.h" +#include +#include +#include +#include +#include +#include +#ifdef DEBUG +#include +#endif +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +extern SystemConfigStruct systemCfg; +extern NetworkConfigStruct networkCfg; +extern SysVarsStruct vars; +extern CCTools CCTool; +extern WiFiServer server; +extern void socketClientConnected(int client,IPAddress ip); +extern void socketClientDisconnected(int client); + +static czc::Settings cfg; +static czc::Recovery recovery; +static uint8_t ownIEEE[8]{}; +static WiFiClient rawClient; +static uint32_t controllerReset=0; +static bool running=false,bootSelectionMade=false,uartSelected=false; +static volatile bool maintenanceRequested=false,maintenanceActive=false,bslHold=false; +static SemaphoreHandle_t maintenanceMutex=nullptr; +static unsigned maintenanceDepth=0; +static portMUX_TYPE statusLock=portMUX_INITIALIZER_UNLOCKED; +static char cachedStatus[3072]="{}"; +static char cachedLog[256]{}; +static czc::RadioVersion radioVersion; +static char csrf[33]{}; +static WebServer *web=nullptr; +static volatile bool rejoinRequested=false; +static const char *joinResult="idle"; +static void rawClose(); +static void publishStatus(bool force=false); +static Radio radio; +#ifdef DEBUG +static AdminTls adminTls; +static czc::MemoryBudget adminOpenMemory; +#endif +static constexpr unsigned PeerLimit=8; +#ifdef DEBUG +static bool paused=false,holdTx=false; +#else +static constexpr bool paused=false,holdTx=false; +#endif +static uint8_t localInfo[32]{}; +static uint32_t bootEpoch=0,connectionEpoch=0,lastInfo=0; +static czc::PeerRetry peerRetry; +static uint32_t reconnects=0,peerMismatch=0; +static netconn *peerListen=nullptr; +#ifdef DEBUG +static int adminListen=-1; +#endif +static const char *fault="unconfigured"; +static const char *peerError="none"; +static volatile uint32_t restartAt=0; +static bool roleRestart=false; +static bool bootstrapRestart=false,autoJoinChecked=false,satelliteInitialized=false; +static bool masterInitialized=false; +static uint32_t satelliteReadyAt=0; +static uint32_t bootstrapAt=0,joinStarted=0; +static uint8_t bootstrapState=0; +struct Mapping { uint32_t sequence=0,ticket=0; }; +struct Peer { + PeerTls tls; + backhaul::Endpoint *endpoint=nullptr; + Mapping mapping[4]; + uint8_t remote[32]{},session[8]{}; + char remoteIp[64]{}; + bool known=false,infoSent=false,sessionOpen=false,bound=false,identified=false,profileNeeded=false,profileSent=false; + uint32_t lastLease=0,lastPoll=0; + const char *fault="offline"; +}; +static Peer peers[PeerLimit]; +static unsigned peerCount() { unsigned n=0; for(auto &p:peers) n+=p.known; return n; } +static uint64_t nowMs() { return esp_timer_get_time()/1000; } +static uint16_t r16(const uint8_t *p) { return p[0]|uint16_t(p[1])<<8; } +static uint32_t r32(const uint8_t *p) { return r16(p)|uint32_t(r16(p+2))<<16; } +static void w32(uint8_t *p,uint32_t v) { for(unsigned i=0;i<4;i++) p[i]=v>>(8*i); } +static void erase(void *buffer,size_t n) { volatile uint8_t *p=static_cast(buffer); while(n--) *p++=0; } +static bool info() { + ZnpFrame response; + bool received=radio.command(0xc0,nullptr,0,response); + const char *error=radioVersion.inspect(received,response.data+4,response.size>=6 ? response.data[1] : 0); + if(error) { fault=radio.fatal ? "uart_desync" : error; return false; } + memcpy(localInfo,response.data+4,32); memcpy(ownIEEE,localInfo+12,8); + if(!czc::validIEEE(ownIEEE)) { radioVersion.compatible=false; fault="own_ieee"; return false; } + if(cfg.mode==2) { ZnpFrame state; if(radio.command(0xca,nullptr,0,state) && state.data[1]==2) bootstrapState=state.data[5]; } + return true; +} +static bool bindRadio(Peer &p) { + uint8_t in[28]; memcpy(in,p.session,8); memcpy(in+8,p.remote+6,2); + memcpy(in+10,localInfo+8,2); memcpy(in+12,localInfo+20,8); memcpy(in+20,p.remote+12,8); + ZnpFrame out; p.bound=radio.command(0xc1,in,sizeof(in),out); + if(!p.bound) p.fault="radio_busy"; + return p.bound; +} +static bool leaseRadio(Peer &p,bool enable) { + uint8_t in[9]; memcpy(in,p.session,8); in[8]=enable; + ZnpFrame out; p.lastLease=millis(); + return p.bound && radio.command(0xc5,in,sizeof(in),out); +} +static void complete(Peer &p,uint32_t ticket,bool accepted) { + uint8_t in[13]; memcpy(in,p.session,8); w32(in+8,ticket); in[12]=accepted ? 0 : 1; + ZnpFrame out; radio.command(0xc4,in,sizeof(in),out); +} +static void results(Peer &p) { + if(!p.endpoint) return; + backhaul::Result result; + while(p.endpoint->takeResult(result)) { + for(auto &m:p.mapping) if(m.sequence==result.sequence) { + complete(p,m.ticket,result.delivery==backhaul::Delivery::AcceptedIntoRadioQueue); m={}; break; + } + } +} +static void disconnectPeer(Peer &p,const char *why) { + p.fault=why; fault=why; + if(p.bound) leaseRadio(p,false); + if(p.endpoint) { p.endpoint->close(); results(p); delete p.endpoint; p.endpoint=nullptr; } + p.tls.close(); p.known=p.infoSent=p.sessionOpen=p.bound=p.identified=p.profileNeeded=p.profileSent=false; +} +static void disconnectPeers(const char *why) { for(auto &p:peers) if(p.tls.fd>=0 || p.endpoint) disconnectPeer(p,why); fault=why; } +static bool sameNetwork(const uint8_t *a,const uint8_t *b) { + return !memcmp(a+8,b+8,3) && !memcmp(a+20,b+20,8); +} +static uint8_t bootstrapDiagnostic[8]{}; +class MigrationNv final : public czc::SatelliteMigration::Nv { + bool rpc(uint8_t cmd,const uint8_t *in,unsigned len,ZnpFrame &out) { + return radio.rpc(0x21,cmd,in,len,out,6000); + } +public: + bool length(uint16_t id,uint16_t &size) override { + uint8_t in[2]={uint8_t(id),uint8_t(id>>8)}; ZnpFrame out; + if(!rpc(0x13,in,2,out) || out.data[1]!=2) return false; + size=r16(out.data+4); return true; + } + bool read(uint16_t id,uint8_t *data,unsigned size) override { + uint8_t in[3]={uint8_t(id),uint8_t(id>>8),0}; ZnpFrame out; + bool ok=rpc(8,in,3,out) && out.data[1]==size+2 && !out.data[4] && out.data[5]==size; + if(ok) memcpy(data,out.data+6,size); + erase(&out,sizeof(out)); return ok; + } + bool write(uint16_t id,const uint8_t *data,unsigned size) override { + if(size>czc::SatelliteMigration::RecordSize || (id!=czc::SatelliteMigration::Boot && id!=czc::SatelliteMigration::Startup)) return false; + uint8_t in[czc::SatelliteMigration::RecordSize+4]={uint8_t(id),uint8_t(id>>8),0,uint8_t(size)}; + memcpy(in+4,data,size); ZnpFrame out; + bool ok=rpc(9,in,size+4,out) && out.data[1]==1 && !out.data[4]; + erase(in,sizeof(in)); return ok; + } + bool remove(uint16_t id,uint16_t size) override { + if(id!=czc::SatelliteMigration::Boot && id!=czc::SatelliteMigration::Nib) return false; + uint8_t in[4]={uint8_t(id),uint8_t(id>>8),uint8_t(size),uint8_t(size>>8)}; ZnpFrame out; + return rpc(0x12,in,4,out) && out.data[1]==1 && !out.data[4]; + } +}; +static uint32_t migrationRetryAt=0,migrationGeneration=0; +static bool migrationResetSent=false; +static void migrateSatellite() { + if(static_cast(millis()-migrationRetryAt)<0) return; + if(migrationResetSent && radio.resetGeneration==migrationGeneration) return; + if(!info()) return; + disconnectPeers("network_migrating"); publishStatus(true); + MigrationNv nv; + auto result=czc::SatelliteMigration::advance(nv,localInfo); + if(result==czc::SatelliteMigration::ResetRequired) { + rawClose(); +#ifdef DEBUG + adminTls.close(); +#endif + ++controllerReset; + migrationResetSent=true; migrationGeneration=radio.resetGeneration; + ZnpFrame out; uint8_t reset=1; + radio.rpc(0x41,0,&reset,1,out); + satelliteInitialized=false; lastInfo=0; + } else if(result==czc::SatelliteMigration::Cleared) { + bootstrapState=0; migrationResetSent=false; migrationRetryAt=0; + satelliteInitialized=false; lastInfo=0; rejoinRequested=true; + joinResult="joining"; joinStarted=millis(); fault="awaiting_master"; + } else { + fault=result==czc::SatelliteMigration::StorageError ? "bootstrap_storage" : "bootstrap_identity"; + joinResult=fault; migrationRetryAt=millis()+10000; + publishStatus(true); + } +} +static void readBootstrapFailure(uint8_t status) { + memset(bootstrapDiagnostic,0,sizeof(bootstrapDiagnostic)); + if(status!=11) return; + ZnpFrame out; + if(radio.command(0xcb,nullptr,0,out) && out.data[1]==9) + memcpy(bootstrapDiagnostic,out.data+5,sizeof(bootstrapDiagnostic)); + erase(&out,sizeof(out)); +} +static const char *provisionFailure(uint8_t status) { + switch(status) { + case 11: return "bootstrap_storage"; + case 12: return "already_joined"; + case 13: return "counter_exhausted"; + case 14: return "peer_capacity"; + case 15: return "master_network"; + case 16: return "bootstrap_identity"; + default: return "bootstrap_radio"; + } +} +static bool admit(const backhaul::Frame &frame,void *context) { + Peer &peer=*static_cast(context); + const uint8_t *p=frame.payload.data(); + if(frame.length==34 && p[0]==1 && !peer.identified) { + memcpy(peer.remote,p+1,32); const uint8_t *remote=peer.remote; + if(remote[0] || remote[1]!=2 || r32(remote+2)!=20260917 || p[33]>1 || + !czc::validIEEE(remote+12) || !memcmp(remote+12,ownIEEE,8)) { + ++peerMismatch; peer.fault="peer_identity"; return false; + } + // A group PSK authenticates group membership. Each live IEEE/short address is unique. + for(auto &other:peers) if(&other!=&peer && other.identified && + (!memcmp(other.remote+12,remote+12,8) || + (other.known && r16(remote+6)<0xfff8 && r16(other.remote+6)==r16(remote+6)))) { + ++peerMismatch; peer.fault="duplicate_peer"; return false; + } + // A disconnected slot is history, not another device. Retire it when + // the same authenticated IEEE returns through a different free slot. + for(auto &other:peers) if(&other!=&peer && other.tls.fd<0 && !other.endpoint && + !memcmp(other.remote+12,remote+12,8)) { + memset(other.remote,0,sizeof(other.remote)); other.remoteIp[0]=0; + } + const uint8_t *master=cfg.mode==1 ? localInfo : remote; + const uint8_t *satellite=cfg.mode==1 ? remote : localInfo; + if(master[11]!=9 || r16(master+6)!=0 || master[10]<11 || master[10]>26) { + peer.fault="master_network"; return false; + } + if(satellite[11]==7 && r16(satellite+6)>0 && r16(satellite+6)<0xfff8 && sameNetwork(master,satellite)) { + if(!bindRadio(peer) || !leaseRadio(peer,true)) return false; + peer.identified=peer.known=true; peer.fault=fault="none"; + if(cfg.mode==2 && rejoinRequested) { rejoinRequested=false; joinResult="joined"; } + return true; + } + if(satellite[11]==7 || satellite[11]==9) { + if(cfg.mode==2 && static_cast(millis()-migrationRetryAt)>=0) { + MigrationNv nv; + auto migration=czc::SatelliteMigration::prepare(nv,localInfo,master); + if(migration==czc::SatelliteMigration::Prepared) { + bootstrapState=3; satelliteInitialized=false; + joinResult=peer.fault=fault="network_migrating"; + return false; + } + if(migration==czc::SatelliteMigration::StorageError) { + migrationRetryAt=millis()+10000; peer.fault="bootstrap_storage"; return false; + } + } + peer.fault="already_joined"; return false; + } + if(!(cfg.mode==1 ? p[33] : rejoinRequested)) { peer.fault="bootstrap_required"; return false; } + peer.identified=true; peer.profileNeeded=cfg.mode==1; peer.fault="provisioning"; + return true; + } + if(frame.length==81 && p[0]==3 && cfg.mode==2 && peer.identified && !peer.known && rejoinRequested && !bootstrapRestart) { + // Only this TLS peer may supply the initial network. Keys never enter status/AF queues. + const uint8_t *profile=p+1; + if(profile[0]!=1 || memcmp(profile+8,peer.remote+12,8) || memcmp(profile+16,ownIEEE,8) || + profile[1]!=peer.remote[10] || memcmp(profile+2,peer.remote+8,2) || memcmp(profile+24,peer.remote+20,8)) { + peer.fault="bootstrap_identity"; return false; + } + ZnpFrame out; + bool ok=radio.command(0xc9,profile,80,out); + if(!ok) { + uint8_t status=out.size>=6 ? out.data[4] : 255; + readBootstrapFailure(status); joinResult=peer.fault=provisionFailure(status); rejoinRequested=false; + } + erase(&out,sizeof(out)); + if(ok) { bootstrapRestart=true; bootstrapAt=millis()+100; joinResult="joining"; } + return ok; + } + if(p[0]!=2 || !peer.known || paused || frame.length<39 || frame.length>140 || + p[12]>127 || frame.length!=13+p[12]) return false; + uint8_t in[147]; memcpy(in,peer.session,8); + memcpy(in+8,p+1,frame.length-1); w32(in+8,frame.sequence); + ZnpFrame out; bool ok=radio.command(0xc3,in,frame.length+7,out); + return ok || (out.size>=6 && out.data[4]==8 && p[5]==1); +} +static netconn *dial(uint16_t targetPort) { + char host[sizeof(cfg.host)]; strlcpy(host,cfg.host,sizeof(host)); + unsigned scope=0; char *zone=strchr(host,'%'); + if(zone) { + *zone++=0; + esp_netif_t *netif=esp_netif_get_handle_from_ifkey(!strcmp(zone,"wifi") ? "WIFI_STA_DEF" : "ETH_DEF"); + if(!netif) return nullptr; + scope=esp_netif_get_netif_impl_index(netif); + } + addrinfo hints{},*addresses=nullptr; hints.ai_socktype=SOCK_STREAM; hints.ai_family=strchr(host,':') ? AF_INET6 : AF_UNSPEC; + char port[6]; snprintf(port,sizeof(port),"%u",targetPort); + if(getaddrinfo(host,port,&hints,&addresses)) return nullptr; + unsigned count=0; for(auto *a=addresses;a;a=a->ai_next) ++count; + static unsigned next=0; + unsigned selected=count ? next++%count : 0,ordinal=0; + netconn *connected=nullptr; + for(auto *a=addresses;a;a=a->ai_next,++ordinal) { + if(ordinal!=selected) continue; // Rotate DNS alternatives after an unsuccessful connection. + ip_addr_t ip{}; + if(a->ai_family==AF_INET6) { + IP_SET_TYPE_VAL(ip,IPADDR_TYPE_V6); + memcpy(ip_2_ip6(&ip)->addr,&reinterpret_cast(a->ai_addr)->sin6_addr,16); + ip6_addr_set_zone(ip_2_ip6(&ip),scope); + } else { IP_SET_TYPE_VAL(ip,IPADDR_TYPE_V4); ip_2_ip4(&ip)->addr=reinterpret_cast(a->ai_addr)->sin_addr.s_addr; } + connected=netconn_new(a->ai_family==AF_INET6 ? NETCONN_TCP_IPV6 : NETCONN_TCP); + if(!connected) break; + netconn_set_nonblocking(connected,1); + err_t r=netconn_connect(connected,&ip,targetPort); + if(r!=ERR_OK && r!=ERR_INPROGRESS) { netconn_delete(connected); connected=nullptr; } + } + freeaddrinfo(addresses); return connected; +} +static void endpointFailed(Peer &p) { + auto reason=p.endpoint->reason(); + switch(reason) { + case backhaul::Reason::HandshakeTimeout: peerError="handshake_timeout"; break; + case backhaul::Reason::IdleTimeout: peerError="idle_timeout"; break; + case backhaul::Reason::TransactionTimeout: peerError="confirmation_timeout"; break; + case backhaul::Reason::ControlOverflow: peerError="control_overflow"; break; + case backhaul::Reason::SequenceExhausted: peerError="sequence_exhausted"; break; + default: peerError="protocol"; break; + } + bool deadline=reason==backhaul::Reason::HandshakeTimeout || reason==backhaul::Reason::IdleTimeout || reason==backhaul::Reason::TransactionTimeout; + disconnectPeer(p,deadline ? "peer_deadline" : "peer_protocol"); +} +static void workPeer(Peer &p) { + uint32_t failedBefore=p.tls.failures; p.tls.tick(); + if(p.tls.failures!=failedBefore) fault=p.fault=p.tls.error; + if(p.sessionOpen && p.tls.fd<0) { disconnectPeer(p,"peer_disconnected"); return; } + if(!p.tls.ready) return; + if(!p.sessionOpen) { + if(++connectionEpoch==0) { disconnectPeer(p,"epoch"); return; } + uint64_t epoch=(uint64_t(bootEpoch)<<32)|connectionEpoch; + memset(p.remote,0,sizeof(p.remote)); p.remoteIp[0]=0; + p.tls.peerAddress(p.remoteIp,sizeof(p.remoteIp)); + for(unsigned i=0;i<8;i++) p.session[i]=epoch>>(8*i); + p.endpoint=new(std::nothrow) backhaul::Endpoint(cfg.mode==1 ? 1 : 2,cfg.mode==1 ? 2 : 1); + if(!p.endpoint || !p.endpoint->open(epoch,nowMs())) { disconnectPeer(p,"memory_or_epoch"); return; } + p.sessionOpen=true; ++reconnects; + } + auto &ep=*p.endpoint; + uint8_t input[512]; int n=p.tls.read(input,sizeof(input)); + bool valid=n>=0 && (!n || ep.input(input,n,nowMs())); erase(input,sizeof(input)); + if(!valid) { + if(n<0) disconnectPeer(p,"peer_disconnected"); else endpointFailed(p); + return; + } + ep.tick(nowMs()); + if(ep.state()==backhaul::State::Closed) { endpointFailed(p); return; } + if(ep.state()==backhaul::State::Ready && !p.infoSent) { + uint8_t payload[34]; payload[0]=1; memcpy(payload+1,localInfo,32); payload[33]=cfg.mode==2 && rejoinRequested; uint32_t seq; + p.infoSent=ep.submit(payload,sizeof(payload),nowMs(),seq)==backhaul::Submit::Queued; + } + // A Confirm and its radio reply can arrive in the same TLS read. Queue + // the local MAC confirm first: TI only matches APS ACKs after that confirm. + results(p); + ep.serviceIncoming(admit,&p,nowMs()); + if(p.profileNeeded && !p.profileSent) { + ZnpFrame out; uint8_t profile[81]{}; uint32_t seq; + bool ok=radio.command(0xc8,p.remote+12,8,out) && out.data[1]==81; + if(ok) { + profile[0]=3; memcpy(profile+1,out.data+5,80); + p.profileSent=ep.submit(profile,sizeof(profile),nowMs(),seq)==backhaul::Submit::Queued; + } else { + uint8_t status=out.size>=6 ? out.data[4] : 255; + readBootstrapFailure(status); p.fault=provisionFailure(status); + } + erase(profile,sizeof(profile)); erase(&out,sizeof(out)); + if(!ok) { disconnectPeer(p,p.fault); return; } + } + if(p.known) { + if(millis()-p.lastLease>350 && !leaseRadio(p,true)) { disconnectPeer(p,"radio_lease"); return; } + if(!holdTx && millis()-p.lastPoll>=10 && ep.pending()<4) { + unsigned slot=0; while(slot<4 && p.mapping[slot].sequence) ++slot; + if(slot<4) { + p.lastPoll=millis(); ZnpFrame out; + if(radio.command(0xc2,p.session,8,out)) { + if(out.data[1]<47 || out.data[1]>148 || out.data[24]>127 || out.data[1]!=21+out.data[24]) { disconnectPeer(p,"radio_frame"); return; } + uint8_t payload[140]; payload[0]=2; memcpy(payload+1,out.data+13,out.data[1]-9); + uint32_t seq=0,ticket=r32(out.data+13); + if(ep.submit(payload,out.data[1]-8,nowMs(),seq)==backhaul::Submit::Queued) { p.mapping[slot].sequence=seq; p.mapping[slot].ticket=ticket; } + else complete(p,ticket,false); + } + } + } + } else if(millis()-p.tls.since>5000) { disconnectPeer(p,p.fault); return; } + size_t length=0; const uint8_t *data=ep.output(length); + if(data && length) { int sent=p.tls.write(data,length); if(sent>0) ep.consumeOutput(sent); } +} +static void serviceRadioLifecycle() { + static uint32_t seenReset=0; + radio.tick(); + if(seenReset==radio.resetGeneration) return; + seenReset=radio.resetGeneration; + // A reset destroys radio sessions and tickets. Do not issue cleanup RPCs + // against a booting radio, or apply old completions to its new sessions. + for(auto &p:peers) { p.bound=false; for(auto &m:p.mapping) m={}; } + disconnectPeers("radio_reset"); + memset(localInfo,0,sizeof(localInfo)); radioVersion.compatible=false; + satelliteInitialized=false; masterInitialized=false; lastInfo=0; + // Keep the controller TCP/TLS session and queued resetInd intact. +} +static void initializeMaster() { + // A controller owns its entire clear/restore/commission sequence. Background + // BDB is only for unattended reboot with an already saved coordinator network. + if(cfg.mode!=1 || masterInitialized || rawClient.connected() || server.hasClient() +#ifdef DEBUG + || adminTls.fd>=0 +#endif + ) return; + if(localInfo[11]==9) { masterInitialized=true; return; } + MigrationNv nv; uint8_t on=0,type=0,options=0; uint16_t nibSize=0; + if(!nv.read(czc::SatelliteMigration::OnNetwork,&on,1) || !nv.read(0x87,&type,1) || + !nv.read(czc::SatelliteMigration::Startup,&options,1) || !nv.length(czc::SatelliteMigration::Nib,nibSize)) return; + if(on!=1 || type!=0 || (options&3) || !nibSize) return; + fault="radio_initializing"; publishStatus(true); + ZnpFrame out; uint8_t initialize=0; + if(radio.rpc(0x2f,5,&initialize,1,out) && out.size>=6 && !out.data[4]) { + masterInitialized=true; lastInfo=0; + } else fault=radio.fatal ? "uart_desync" : "bootstrap_radio"; +} +static void peerWork() { + if(paused || radio.fatal) { if(paused || radio.fatal) disconnectPeers(paused ? "paused" : "uart_desync"); return; } + if(radio.resetWaiting) { fault="radio_reset"; return; } + if(cfg.mode==2 && bootstrapState==3) { migrateSatellite(); return; } + if(cfg.mode==2 && !satelliteInitialized) { + // ZNP routers need BDB initialization after every radio reset; no controller drives Satellite. + if(lastInfo && millis()-lastInfo<2000) return; + lastInfo=millis(); + if(!info()) return; + // Resume durable migration before BDB can restore the previous network. + if(bootstrapState==3) return; + ZnpFrame out; uint8_t initialize=0; + fault="radio_initializing"; publishStatus(true); + if(!radio.rpc(0x2f,5,&initialize,1,out) || out.size<6 || out.data[4]) { fault="bootstrap_radio"; return; } + satelliteInitialized=true; satelliteReadyAt=millis()+1200; lastInfo=0; + // The post-initialization router deadline begins after NV/BDB work, + // which has its own bounded command deadline. + if(!strcmp(joinResult,"restoring")) joinStarted=millis(); + fault="awaiting_master"; + return; + } + if(cfg.mode==2 && static_cast(millis()-satelliteReadyAt)<0) return; + if(!lastInfo || millis()-lastInfo>2000) { + uint8_t old[32]; memcpy(old,localInfo,32); lastInfo=millis(); + if(!info()) { disconnectPeers(fault); return; } + initializeMaster(); + if(radio.fatal) return; + if(memcmp(old+6,localInfo+6,22)) disconnectPeers("network_changed"); + else if(cfg.mode==1 && localInfo[11]==9 && r16(localInfo+6)==0 && !strcmp(fault,"network_changed")) + fault="awaiting_peer"; // Initial BDB startup is not a permanent network-change error. + if(cfg.mode==2 && !autoJoinChecked) { + autoJoinChecked=true; + if(localInfo[11]!=7 && localInfo[11]!=9) { rejoinRequested=true; joinResult="joining"; joinStarted=millis(); } + } + } + if(!radioVersion.compatible) return; + if(cfg.mode==1) { + if(localInfo[11]!=9 || r16(localInfo+6)!=0) fault="master_network"; + netconn *fd=nullptr; + if(peerListen && netconn_accept(peerListen,&fd)==ERR_OK && fd) { + Peer *free=nullptr; for(auto &p:peers) if(p.tls.fd<0 && !p.endpoint) { free=&p; break; } + if(free) free->tls.start(fd,true,cfg.key,"czc-peer-v2"); else { NetconnStream rejected; rejected.start(fd); rejected.close(); fault="peer_capacity"; } + } + } else if(peerRetry.due(millis(),peers[0].tls.fd>=0 || peers[0].endpoint,peers[0].known,r32(ownIEEE)^bootEpoch^connectionEpoch)) { + peerRetry.started(); + fault=peers[0].fault="peer_connect"; + netconn *fd=dial(cfg.peerPort); + if(fd) peers[0].tls.start(fd,false,cfg.key,"czc-peer-v2"); + } + // Rotate the first serviced peer, so a busy Satellite cannot monopolize UART exports. + static unsigned first=0; + for(unsigned i=0;i=0 || p.endpoint) workPeer(p); } + first=(first+1)%PeerLimit; +} +static void ieeeText(const uint8_t *ieee,char *out) { for(unsigned i=0;i<8;i++) snprintf(out+2*i,3,"%02x",ieee[7-i]); } +static void statusJson(char *out,size_t size,bool compact=false) { + StaticJsonDocument<4096> doc; + unsigned connected=peerCount(); + doc["rev"]=20260917; doc["role"]=cfg.mode==1 ? "master" : "satellite"; + doc["peer"]=connected>0; doc["peers_online"]=connected; doc["peer_limit"]=cfg.mode==1 ? PeerLimit : 1; + doc["paused"]=paused; doc["uart_ok"]=!radio.fatal; doc["debug"]=czc::debugBuild; + doc["fault"]=connected ? "none" : fault; doc["join"]=joinResult; doc["bootstrap"]=bootstrapState; + doc["af"]=peers[0].tls.family==AF_INET6 ? 6 : (peers[0].tls.family==AF_INET ? 4 : 0); + if(!compact) { + doc["last_peer_error"]=peerError; + doc["uart_error"]=radio.rxError ? radio.rxError : radio.error; + doc["uart_cmd0"]=radio.errorCmd0; doc["uart_cmd1"]=radio.errorCmd1; + doc["radio_reset_pending"]=radio.resetWaiting; + doc["radio_revision"]=radioVersion.revision; doc["radio_required"]=uint32_t(czc::RadioVersion::required); + doc["radio_protocol"]=radioVersion.protocol; doc["radio_compatible"]=radioVersion.compatible; + char own[17]; ieeeText(ownIEEE,own); doc["own_ieee"]=own; + doc["psk_tls_bytes"]=czc_tls_allocated(); +#ifdef DEBUG + auto admin=doc.createNestedObject("admin_tls"); + admin["state"]=adminTls.ready ? "connected" : adminTls.fd>=0 ? "handshake" : "listening"; + admin["failures"]=adminTls.failures; admin["last_stage"]=adminTls.errorPhase; admin["last_code"]=adminTls.errorCode; + admin["open_free"]=adminOpenMemory.free; admin["open_largest"]=adminOpenMemory.largest; + admin["raw_client"]=bool(rawClient.connected()); +#endif + if(cfg.mode==2) { + doc["connect_attempts"]=peerRetry.attempts; + doc["retry_ms"]=peerRetry.remaining(millis()); + doc["master_host"]=cfg.host; + } + JsonArray list=doc.createNestedArray("peers"); + for(auto &p:peers) if(p.identified || czc::validIEEE(p.remote+12)) { + auto item=list.createNestedObject(); char ieee[17]; ieeeText(p.remote+12,ieee); + item["ieee"]=ieee; item["address"]=r16(p.remote+6); item["online"]=p.known; item["fault"]=p.fault; + item["ip"]=p.remoteIp; + item["af"]=p.tls.family==AF_INET6 ? 6 : (p.tls.family==AF_INET ? 4 : 0); + } + } + if(compact) { doc.remove("peer_limit"); } + serializeJson(doc,out,size); +} +#ifdef DEBUG +static void adminWork() { + static ZnpParser parser; + static ZnpFrame outgoing; + static size_t offset=0; + static bool had=false; + static uint32_t seenReset=0,lastProgress=0; + if(seenReset!=controllerReset) { seenReset=controllerReset; had=false; parser.reset(); parser.errors=0; outgoing.size=0; offset=0; } + if(adminTls.fd<0) { + if(had) { + had=false; parser.reset(); outgoing.size=0; offset=0; radio.adminActive=false; if(!rawClient.connected()) xQueueReset(radio.events); + // Test controls belong to the authenticated connection, not to NVS. + // A crashed acceptance client must not leave the pair paused forever. + paused=holdTx=false; + } + int fd=accept(adminListen,nullptr,nullptr); + if(fd>=0) { if(rawClient.connected()) { + ::close(fd); adminTls.errorPhase="controller_busy"; adminTls.errorCode=0; ++adminTls.failures; + } else { + adminOpenMemory=memoryBudget(); + had=adminTls.start(fd,true,cfg.key,"czc-admin-v1"); + } } + } + adminTls.tick(); + radio.adminActive=adminTls.ready || rawClient.connected(); + if(radio.adminOverflow && adminTls.ready) { radio.adminOverflow=false; adminTls.close(); return; } + if(!adminTls.ready) return; + if(!outgoing.size && xQueueReceive(radio.events,&outgoing,0)==pdTRUE) lastProgress=millis(); + if(outgoing.size) { + int n=adminTls.write(outgoing.data+offset,outgoing.size-offset); + if(n>0) { lastProgress=millis(); offset+=n; if(offset==outgoing.size) { offset=0; outgoing.size=0; } } + else if(millis()-lastProgress>3000) adminTls.close(); + return; + } + if(radio.resetWaiting) return; + // One request at a time. Read a byte at a time so coalesced requests remain + // in TLS until their previous SRSP has actually been sent. + ZnpFrame request; uint8_t byte; + for(unsigned i=0;i<260;i++) { + int n=adminTls.read(&byte,1); if(n<=0) break; + if(!parser.feed(byte,request)) continue; + uint8_t c0=request.data[2],cmd=request.data[3],len=request.data[1]; const uint8_t *in=request.data+4; + if(c0==0x3f) { + uint8_t result=0; + if(cmd==2 && len==1 && in[0]<=1) { + paused=in[0]; if(paused) disconnectPeers("paused"); + else peerRetry.retrySoon(millis()); + outgoing.build(0x7f,cmd,&result,1); + } else if(cmd==1 && !len) { + char json[251]; statusJson(json,sizeof(json),true); outgoing.build(0x7f,cmd,reinterpret_cast(json),strlen(json)); + } else if(cmd==3 && !len && paused) { + // Explicit acceptance/commissioning action, authenticated admin only. + ZnpFrame reply; + for(auto &p:peers) if(r32(p.session) || r32(p.session+4)) radio.command(0xc7,p.session,8,reply); + outgoing.build(0x7f,cmd,&result,1); + } else if(cmd==4 && len==1 && in[0]<=1) { + holdTx=in[0]; outgoing.build(0x7f,cmd,&result,1); + } else if(cmd==5 && !len) { + disconnectPeers("admin_reconnect"); peerRetry.retrySoon(millis()); outgoing.build(0x7f,cmd,&result,1); + } else if(cmd==6 && !len && cfg.mode==2) { rejoinRequested=true; joinResult="requested"; outgoing.build(0x7f,cmd,&result,1); } + else { result=1; outgoing.build(0x7f,cmd,&result,1); } + } else if((c0&0xe0)!=0x20 && (c0&0xe0)!=0x40) { adminTls.close(); } + else if(c0==0x21 && czc::privateRadioCommand(cmd)) { + uint8_t denied=0x1a; outgoing.build(0x61,cmd,&denied,1); + } else { + outgoing.size=0; + if(!radio.rpc(c0,cmd,in,len,outgoing,Radio::controllerTimeout(c0,cmd))) adminTls.close(); + } + lastProgress=millis(); break; + } + if(parser.errors) { parser.reset(); parser.errors=0; adminTls.close(); } +} + +#endif // DEBUG + +static void rawClose() { + if(rawClient) rawClient.stop(); + if(vars.connectedClients) socketClientDisconnected(0); + radio.adminActive=false; radio.adminOverflow=false; + if(radio.events) xQueueReset(radio.events); +} +static void rawWork() { + static ZnpParser parser; + static ZnpFrame outgoing; + static size_t offset=0; + static uint32_t seenReset=0,lastProgress=0; + if(seenReset!=controllerReset || !rawClient.connected()) { + seenReset=controllerReset; parser.reset(); parser.errors=0; outgoing.size=0; offset=0; + if(vars.connectedClients) rawClose(); + } + if(server.hasClient()) { + WiFiClient candidate=server.available(); + if(rawClient.connected() || +#ifdef DEBUG + adminTls.fd>=0 || +#endif + (systemCfg.fwEnabled && candidate.remoteIP()!=systemCfg.fwIp)) candidate.stop(); + else { + rawClient=candidate; rawClient.setNoDelay(true); lastProgress=millis(); + xQueueReset(radio.events); radio.adminActive=true; + socketClientConnected(0,rawClient.remoteIP()); + } + } + if(!rawClient.connected()) return; + if(radio.fatal || radio.adminOverflow) { rawClose(); return; } + if(!outgoing.size) { xQueueReceive(radio.events,&outgoing,0); if(outgoing.size) lastProgress=millis(); } + if(outgoing.size) { + // Arduino ESP32 2.0.11 inherits Print::availableForWrite() == 0. + // A bounded socket send also avoids WiFiClient::write() blocking peer leases. + int n=::send(rawClient.fd(),outgoing.data+offset,outgoing.size-offset,MSG_DONTWAIT); + if(n>0) { + offset+=n; lastProgress=millis(); + if(offset==outgoing.size) { outgoing.size=0; offset=0; } + } else if(!n || (errno!=EAGAIN && errno!=EWOULDBLOCK && errno!=EINTR)) { rawClose(); return; } + if(millis()-lastProgress>3000) rawClose(); + return; + } + if(radio.resetWaiting) return; + ZnpFrame request; + for(unsigned i=0;i<260 && rawClient.available();i++) { + if(!parser.feed(rawClient.read(),request)) continue; + uint8_t c0=request.data[2],cmd=request.data[3],len=request.data[1]; + if((c0&0xe0)!=0x20 && (c0&0xe0)!=0x40) { rawClose(); break; } + // Peer/session ownership and diagnostics are never exposed to ordinary ZNP clients. + if((c0&31)==31 || (c0==0x21 && czc::privateRadioCommand(cmd))) { + if((c0&0xe0)==0x20) { uint8_t denied=0x1a; outgoing.build((c0&31)|0x60,cmd,&denied,1); } + } else if(!radio.rpc(c0,cmd,request.data+4,len,outgoing,Radio::controllerTimeout(c0,cmd))) rawClose(); + lastProgress=millis(); break; + } + if(parser.errors) rawClose(); +} + +static void statusLogLine(char *out,size_t size) { + snprintf(out,size,"[BH] %s state=%s radio=%lu/protocol-%u required=%lu/protocol-2 peers=%u/%u debug=%u", + cfg.mode==1 ? "Master" : cfg.mode==2 ? "Satellite" : "disabled",peerCount() ? "connected" : fault, + static_cast(radioVersion.revision),radioVersion.protocol, + static_cast(czc::RadioVersion::required),peerCount(),cfg.mode==1 ? PeerLimit : 1,czc::debugBuild); + if(!strcmp(fault,"peer_deadline") || !strcmp(fault,"peer_protocol")) { + size_t used=strlen(out); + if(used(radio.timeoutWait),static_cast(radio.timeoutRx), + static_cast(radio.timeoutFrames),radio.timeoutPartial&255,radio.timeoutPartial>>8, + static_cast(radio.timeoutGap),static_cast(static_cast(radio.replyMs))); + } + } +#ifdef DEBUG + if(adminTls.failures) { + size_t used=strlen(out); + if(used(adminTls.failures)); + } +#endif +} +static void publishStatus(bool force) { + static uint32_t last=0; if(!force && millis()-last<250) return; last=millis(); + char json[sizeof(cachedStatus)]{}; statusJson(json,sizeof(json)); + char line[256]{}; statusLogLine(line,sizeof(line)); + portENTER_CRITICAL(&statusLock); + memcpy(cachedStatus,json,sizeof(cachedStatus)); memcpy(cachedLog,line,sizeof(cachedLog)); + portEXIT_CRITICAL(&statusLock); +} +// Apply the staged profile only on this fresh Satellite. Master and other peers stay live. +static void commissionSatellite() { + if(bootstrapRestart && static_cast(millis()-bootstrapAt)>=0) { + bootstrapRestart=false; rejoinRequested=false; disconnectPeers("joining"); + rawClose(); +#ifdef DEBUG + adminTls.close(); +#endif + ++controllerReset; + ZnpFrame out; uint8_t reset=1; + bool ok=radio.rpc(0x41,0,&reset,1,out); + // peerWork initializes BDB only after the real resetInd arrives. + joinResult=ok ? "restoring" : "bootstrap_radio"; joinStarted=millis(); lastInfo=0; + satelliteInitialized=false; + } + if(rejoinRequested && strcmp(joinResult,"joining") && strcmp(joinResult,"awaiting_master")) { + joinResult="joining"; joinStarted=millis(); disconnectPeers("joining"); lastInfo=0; + } + if(!strcmp(joinResult,"restoring") && localInfo[11]==7) joinResult="joined"; + if(!strcmp(joinResult,"restoring") && millis()-joinStarted>30000) { + rejoinRequested=false; joinResult="join_timeout"; disconnectPeers(joinResult); + } else if(rejoinRequested && !strcmp(joinResult,"joining") && millis()-joinStarted>30000) { + // Waiting for a restored Master does not revoke the initial bootstrap + // request. Keep advertising it on retries, without tearing down TLS. + joinResult="awaiting_master"; + } +} + +static void networkTask(void *) { + NetconnStream::threadInit(); + for(;;) { + if(maintenanceRequested || bslHold) { + if(!maintenanceActive) { + disconnectPeers("maintenance"); +#ifdef DEBUG + adminTls.close(); +#endif + rawClose(); ++controllerReset; + // Let the radio finish ownership cleanup before giving its UART to CCTools. + uint32_t start=millis(); ZnpFrame out; + while(!radio.fatal && millis()-start<1600) { + for(auto &p:peers) if(r32(p.session) || r32(p.session+4)) radio.command(0xc7,p.session,8,out); + if(info() && localInfo[28]==0) break; + vTaskDelay(pdMS_TO_TICKS(50)); + } + if(radio.suspend()) maintenanceActive=true; + } + publishStatus(); vTaskDelay(pdMS_TO_TICKS(10)); continue; + } + if(maintenanceActive) { + radio.resume(); maintenanceActive=false; satelliteInitialized=false; masterInitialized=false; lastInfo=0; radioVersion.compatible=false; fault="awaiting_peer"; + } + commissionSatellite(); + serviceRadioLifecycle(); + peerWork(); +#ifdef DEBUG + adminWork(); +#endif + rawWork(); + publishStatus(); vTaskDelay(1); + } +} + +static bool backhaulRoleAllowed() { return systemCfg.zbRole==COORDINATOR; } +bool backhaulConfigured() { return cfg.mode!=0 && backhaulRoleAllowed(); } +bool backhaulEnabled() { return bootSelectionMade ? uartSelected : backhaulConfigured() && systemCfg.workMode==WORK_MODE_NETWORK; } +void backhaulLoad() { + czc_random::begin(); + maintenanceMutex=xSemaphoreCreateRecursiveMutex(); + Preferences store; + if(store.begin("czc-peer",true)) { + czc::Settings saved; + size_t length=store.getBytesLength("settings"); + if((length==sizeof(saved) || length==offsetof(czc::Settings,legacyDiagnostics)) && store.getBytes("settings",&saved,length)==length && + !czc::validate(saved,systemCfg.socketPort)) cfg=saved; + memset(&saved,0,sizeof(saved)); store.end(); + } + uint8_t token[16]; + if(!czc_random::fill(nullptr,token,sizeof(token))) { + for(unsigned i=0;i=1000) { + logAt=millis(); char line[256]{}; + if(running) { portENTER_CRITICAL(&statusLock); memcpy(line,cachedLog,sizeof(line)); portEXIT_CRITICAL(&statusLock); } + else if(cfg.mode) statusLogLine(line,sizeof(line)); + if(line[0] && strcmp(line,previous)) { + // Compare the stable state first: changing heap/RSSI must not turn + // the console into a once-per-second telemetry dump. + memcpy(previous,line,sizeof(previous)); + if(czc::debugBuild) { + auto memory=memoryBudget(); char detail[128]; + snprintf(detail,sizeof(detail)," heap=%lu max=%lu min=%lu wifi=%d esp=%s", + static_cast(memory.free),static_cast(memory.largest), + static_cast(memory.minimum),WiFi.status()==WL_CONNECTED ? WiFi.RSSI() : 0,VERSION); + printLogMsg(String(line)+detail); + } else printLogMsg(String(line)); + } + } + if(restartAt && !vars.zbFlashing && (!maintenanceActive || roleRestart) && static_cast(millis()-restartAt)>=0) ESP.restart(); + if(!running || restartAt || vars.zbFlashing || maintenanceRequested || maintenanceActive || bslHold || + paused || bootstrapRestart || !strcmp(joinResult,"restoring")) return; + if(recovery.due(millis(),radio.fatal,peerCount()>0)) { + recovery.attempted(millis()); + BackhaulMaintenance maintenance; + if(maintenance) { radio.expectReset(); CCTool.restart(); } + } +} +bool backhaulMaintenanceBegin() { + if(!running) return true; + if(xSemaphoreTakeRecursive(maintenanceMutex,pdMS_TO_TICKS(5000))!=pdTRUE) return false; + if(maintenanceDepth++) return true; + maintenanceRequested=true; + uint32_t start=millis(); + while(!maintenanceActive && millis()-start<5000) vTaskDelay(1); + if(maintenanceActive) return true; + maintenanceRequested=false; --maintenanceDepth; xSemaphoreGiveRecursive(maintenanceMutex); return false; +} +void backhaulMaintenanceEnd() { + if(!running) return; + if(!--maintenanceDepth) { + maintenanceRequested=false; + while(maintenanceActive && !bslHold) vTaskDelay(1); + } + xSemaphoreGiveRecursive(maintenanceMutex); +} +void backhaulBslHold(bool hold) { bslHold=hold; } +void backhaulRoleChanged() { + // This is the installed firmware role, not the router state of a Satellite ZNP. + if(backhaulRoleAllowed() || !cfg.mode) return; + czc::Settings disabled=cfg; disabled.mode=0; + Preferences store; + bool saved=store.begin("czc-peer",false) && store.putBytes("settings",&disabled,sizeof(disabled))==sizeof(disabled); + store.end(); memset(&disabled,0,sizeof(disabled)); + if(!saved) printLogMsg("[BH] Cannot save disabled mode; incompatible radio role remains blocked"); + if(running) { + BackhaulMaintenance maintenance; + // Keep the broker suspended until boot selects the ordinary UART path. + bslHold=true; roleRestart=true; restartAt=millis()+1500; + } else cfg.mode=0; +} +bool backhaulStatus(JsonDocument &document) { + char json[sizeof(cachedStatus)]; + portENTER_CRITICAL(&statusLock); memcpy(json,cachedStatus,sizeof(json)); portEXIT_CRITICAL(&statusLock); + if(!czc::loadStatusSnapshot(document,json)) { + return false; + } + JsonObject obj=document["status"].as(); + if(!running) { obj["fault"]=fault; obj["peer"]=false; } + obj["mode"]=cfg.mode; + if(!running) obj["own_ieee"]=CCTool.chip.ieee; + obj["join"]=joinResult; + obj["maintenance"]=maintenanceActive; + obj["uart_restarts"]=recovery.total; + if(radio.fatal && recovery.attempts>=3) obj["fault"]="uart_recovery_exhausted"; + if(maintenanceActive) { obj["peer"]=false; obj["fault"]="maintenance"; } + else if(!strcmp(joinResult,"joining")) { + const char *reason=obj["fault"] | "unconfigured"; + if(!strcmp(reason,"none") || !strcmp(reason,"awaiting_peer") || !strcmp(reason,"network_changed") || !strcmp(reason,"provisioning")) { + obj["peer"]=false; obj["fault"]="joining"; + } + } + return !document.overflowed(); +} +static void webReply(int code,const char *result) { + StaticJsonDocument<160> doc; doc["result"]=result; + String body; serializeJson(doc,body); web->send(code,"application/json",body); +} +static bool webAuthorize(bool mutation=false) { + web->sendHeader("Cache-Control","no-store"); + if(!is_authenticated()) { webReply(401,"authentication_required"); return false; } + if(mutation && (!csrf[0] || web->header("X-Backhaul-Token")!=csrf)) { webReply(403,"reload_page"); return false; } + return true; +} +bool backhaulAuthorizeWebMutation() { return webAuthorize(true); } +static void webConfig() { + if(!webAuthorize()) return; + DynamicJsonDocument doc(4096); + if(!doc.capacity() || !backhaulStatus(doc)) { webReply(503,"status_unavailable"); return; } + doc["mode"]=backhaulRoleAllowed() ? cfg.mode : 0; + doc["radio_role"]=systemCfg.zbRole; doc["role_supported"]=backhaulRoleAllowed(); + doc["peer_host"]=cfg.host; doc["peer_port"]=cfg.peerPort; +#ifdef DEBUG + doc["admin_port"]=czc::debugAdminPort; +#endif + doc["debug_mode"]=czc::debugBuild; + doc["key_set"]=bool(cfg.hasKey); doc["token"]=csrf; + char savedKey[65]{}; + if(cfg.hasKey) for(unsigned i=0;i<32;i++) snprintf(savedKey+2*i,3,"%02x",cfg.key[i]); + doc["psk"]=savedKey; // Available through the existing web authentication policy. + + memoryStatus(doc["status"]["heap"].to()); + if(doc.overflowed()) { webReply(503,"status_unavailable"); return; } + String body; serializeJson(doc,body); + if(body.length()!=measureJson(doc)) { webReply(503,"status_unavailable"); return; } + web->send(200,"application/json",body); +} +static void webSave() { + if(!webAuthorize(true)) return; + if(vars.zbFlashing || maintenanceRequested || maintenanceActive || bootstrapRestart || !strcmp(joinResult,"restoring")) { webReply(409,"busy"); return; } + String body=web->arg("plain"); + if(body.length()>1024) { webReply(413,"invalid_settings"); return; } + DynamicJsonDocument doc(1536); + if(deserializeJson(doc,body) || !doc["mode"].is() || !doc["peer_port"].is() || + !doc["peer_host"].is() || !doc["psk"].is()) { + webReply(400,"invalid_settings"); return; + } + czc::Settings fresh=cfg; + unsigned mode=doc["mode"],peerPort=doc["peer_port"]; + if(mode>2 || peerPort>65535 || strlen(doc["peer_host"])>=sizeof(fresh.host)) { + webReply(400,"invalid_settings"); return; + } + fresh.mode=mode; fresh.legacyIpv6=1; fresh.legacyRawTcp=1; + // Initialize reserved NVS bytes; diagnostics are selected at build time. + fresh.legacyDiagnostics=0; fresh.legacyAdminPort=czc::debugAdminPort; + fresh.peerPort=peerPort; strlcpy(fresh.host,doc["peer_host"],sizeof(fresh.host)); + const char *key=doc["psk"]; + bool valid=true; + if(*key) { valid=czc::decodeHex(key,fresh.key,32); if(valid) fresh.hasKey=1; } + memset(fresh.peerIEEE,0,sizeof(fresh.peerIEEE)); // Reserved NVS bytes. + const char *error=valid ? czc::validate(fresh,systemCfg.socketPort) : "invalid_key"; + if(!error && fresh.mode && !backhaulRoleAllowed()) error="coordinator_required"; + if(!error && fresh.mode && (!networkCfg.ethEnable && !networkCfg.wifiEnable)) error="network_required"; + if(!error && fresh.mode && systemCfg.workMode!=WORK_MODE_NETWORK) error="select_network_mode"; + if(!error && fresh.mode && systemCfg.serialSpeed!=115200) error="radio_baud_115200_required"; + if(error) { memset(&fresh,0,sizeof(fresh)); webReply(400,error); return; } + Preferences store; + bool saved=store.begin("czc-peer",false) && store.putBytes("settings",&fresh,sizeof(fresh))==sizeof(fresh); + store.end(); memset(&fresh,0,sizeof(fresh)); doc.clear(); + if(saved) { webReply(200,"saved_restarting"); restartAt=millis()+1500; } + else webReply(500,"storage_error"); +} +static void webKey() { + if(!webAuthorize(true)) return; + uint8_t key[32]; char encoded[65]; + if(czc_random::fill(nullptr,key,sizeof(key))) { webReply(503,"entropy_unavailable"); return; } + for(unsigned i=0;i<32;i++) snprintf(encoded+2*i,3,"%02x",key[i]); + StaticJsonDocument<128> doc; doc["psk"]=encoded; + String body; serializeJson(doc,body); web->send(200,"application/json",body); + memset(key,0,sizeof(key)); memset(encoded,0,sizeof(encoded)); doc.clear(); +} +static void webJoin() { + if(!webAuthorize(true)) return; + if(!backhaulRoleAllowed()) { webReply(409,"coordinator_required"); return; } + if(!running || cfg.mode!=2) { webReply(409,"satellite_required"); return; } + if(rejoinRequested || !strcmp(joinResult,"joining") || maintenanceRequested) { webReply(409,"busy"); return; } + rejoinRequested=true; joinResult="requested"; webReply(202,"joining"); +} +void backhaulWebRoutes(WebServer &serverWeb) { + web=&serverWeb; + web->on("/api/backhaul",HTTP_GET,webConfig); + web->on("/api/backhaul",HTTP_POST,webSave); + web->on("/api/backhaul/key",HTTP_POST,webKey); + web->on("/api/backhaul/join",HTTP_POST,webJoin); +} diff --git a/src/backhaul_random.h b/src/backhaul_random.h new file mode 100644 index 00000000..f196dc74 --- /dev/null +++ b/src/backhaul_random.h @@ -0,0 +1,37 @@ +#pragma once +#include +#include +#include +#include +#include + +// Seed before Wi-Fi/ADC initialization; immediately release the SAR entropy source. +// TLS then uses a locked DRBG, so the existing Wi-Fi, temperature and VPN functions +// may keep their normal hardware ownership. Never reseed from an inactive RF RNG. +namespace czc_random { +static mbedtls_ctr_drbg_context drbg; +static SemaphoreHandle_t mutex=nullptr; +static bool ready=false; +struct Seed { uint8_t bytes[96]; size_t used=0; bool available=true; }; +static Seed seed; +static int entropy(void *arg,unsigned char *out,size_t n) { + Seed &s=*static_cast(arg); + if(!s.available || s.used+n>sizeof(s.bytes)) return -1; + memcpy(out,s.bytes+s.used,n); s.used+=n; return 0; +} +static void begin() { + mutex=xSemaphoreCreateMutex(); + bootloader_random_enable(); esp_fill_random(seed.bytes,sizeof(seed.bytes)); bootloader_random_disable(); + mbedtls_ctr_drbg_init(&drbg); + const unsigned char personal[]="CZC full firmware peer TLS v1"; + ready=mutex && mbedtls_ctr_drbg_seed(&drbg,entropy,&seed,personal,sizeof(personal)-1)==0; + seed.available=false; memset(seed.bytes,0,sizeof(seed.bytes)); + // One million requests, then fail closed until reboot rather than silently + // reseeding with low-entropy bytes after Wi-Fi has been disabled. + mbedtls_ctr_drbg_set_reseed_interval(&drbg,1000000); +} +static int fill(void *,unsigned char *out,size_t n) { + if(!ready || xSemaphoreTake(mutex,pdMS_TO_TICKS(1000))!=pdTRUE) return -1; + int result=mbedtls_ctr_drbg_random(&drbg,out,n); xSemaphoreGive(mutex); return result; +} +} diff --git a/src/config.cpp b/src/config.cpp index 68b7d2f4..7f949f18 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -10,6 +10,7 @@ #include "config.h" #include "etc.h" #include "web.h" +#include "web_refresh.h" #include "const/keys.h" Preferences preferences; @@ -366,7 +367,7 @@ void loadSystemConfig(SystemConfigStruct &config) config.tempOffset = preferences.getInt(tempOffsetKey, 0); config.disableLedUSB = preferences.getBool(disableLedUSBKey, false); config.disableLedPwr = preferences.getBool(disableLedPwrKey, false); - config.refreshLogs = preferences.getInt(refreshLogsKey, 1); + config.refreshLogs = webRefreshSeconds(preferences.getInt(refreshLogsKey, 1)); strlcpy(config.timeZone, preferences.getString(timeZoneKey, NTP_TIME_ZONE).c_str(), sizeof(config.timeZone)); strlcpy(config.ntpServ1, preferences.getString(ntpServ1Key, NTP_SERV_1).c_str(), sizeof(config.ntpServ1)); strlcpy(config.ntpServ2, preferences.getString(ntpServ2Key, NTP_SERV_2).c_str(), sizeof(config.ntpServ2)); @@ -435,7 +436,7 @@ void updateConfiguration(WebServer &serverWeb, SystemConfigStruct &configSys, Ne if (serverWeb.hasArg(refreshLogsKey)) { - configSys.refreshLogs = serverWeb.arg(refreshLogsKey).toInt(); + configSys.refreshLogs = webRefreshSeconds(serverWeb.arg(refreshLogsKey).toInt()); } if (serverWeb.hasArg(hostnameKey)) @@ -1200,7 +1201,7 @@ bool loadFileConfigGeneral() } else { - systemCfg.refreshLogs = (int)doc[refreshLogsKey] / 1000; + systemCfg.refreshLogs = webRefreshSeconds((int)doc[refreshLogsKey] / 1000); } strlcpy(systemCfg.hostname, doc[hostnameKey] | "", sizeof(systemCfg.hostname)); diff --git a/src/czc_backhaul.h b/src/czc_backhaul.h new file mode 100644 index 00000000..ccd05dcf --- /dev/null +++ b/src/czc_backhaul.h @@ -0,0 +1,27 @@ +#pragma once +#include +#include +#include +#include + +void backhaulLoad(); +void backhaulBegin(); +void backhaulLoop(); +bool backhaulEnabled(); +bool backhaulConfigured(); +bool backhaulAuthorizeWebMutation(); +void backhaulWebRoutes(WebServer &server); +bool backhaulStatus(JsonDocument &document); +bool backhaulMaintenanceBegin(); +void backhaulMaintenanceEnd(); +void backhaulBslHold(bool hold); +void backhaulRoleChanged(); + +class BackhaulMaintenance { + bool acquired; +public: + BackhaulMaintenance():acquired(backhaulMaintenanceBegin()) {} + ~BackhaulMaintenance() { if(acquired) backhaulMaintenanceEnd(); } + explicit operator bool() const { return acquired; } + BackhaulMaintenance(const BackhaulMaintenance &)=delete; +}; diff --git a/src/etc.cpp b/src/etc.cpp index e57ff17f..46e8026c 100644 --- a/src/etc.cpp +++ b/src/etc.cpp @@ -7,9 +7,15 @@ #include #include #include +#include +#include +#include +#include "memory_status.h" +#include "network_ipv4.h" // #include //not available now #include "config.h" +#include "czc_backhaul.h" #include "web.h" #include "log.h" #include "etc.h" @@ -220,6 +226,8 @@ float getCPUtemp(bool clear) void zigbeeRouterRejoin() { + BackhaulMaintenance maintenance; + if (!maintenance) { printLogMsg("[ZB] UART busy; operation cancelled"); return; } printLogMsg("Router rejoin begin"); CCTool.routerRejoin(); printLogMsg("Router in join mode!"); @@ -227,8 +235,11 @@ void zigbeeRouterRejoin() void zigbeeEnableBSL() { + BackhaulMaintenance maintenance; + if (!maintenance) { printLogMsg("[ZB] UART busy; operation cancelled"); return; } printLogMsg("ZB enable BSL"); CCTool.enterBSL(); + backhaulBslHold(true); printLogMsg("Now you can flash CC2652!"); if (systemCfg.workMode == WORK_MODE_USB) { @@ -239,8 +250,11 @@ void zigbeeEnableBSL() void zigbeeRestart() { + BackhaulMaintenance maintenance; + if (!maintenance) { printLogMsg("[ZB] UART busy; operation cancelled"); return; } printLogMsg("ZB RST begin"); CCTool.restart(); + backhaulBslHold(false); printLogMsg("ZB restart was done"); if (systemCfg.workMode == WORK_MODE_USB) { @@ -445,88 +459,38 @@ void nmDeactivate() setLedsDisable(); } +// DNS repair must not call WiFi/ETH.config: those also stop DHCP and set IP. bool checkDNS(bool setup) { - const char *wifiKey = "WiFi"; - const char *ethKey = "ETH"; - const char *savedKey = "Saved"; - const char *restoredKey = "Restored"; - const char *dnsTagKey = "[DNS]"; - char buffer[100]; - - if (networkCfg.wifiEnable) - { - IPAddress currentWifiDNS = WiFi.dnsIP(); - if (currentWifiDNS != vars.savedWifiDNS) - { - char dnsStrW[16]; - snprintf(dnsStrW, sizeof(dnsStrW), "%u.%u.%u.%u", currentWifiDNS[0], currentWifiDNS[1], currentWifiDNS[2], currentWifiDNS[3]); - - int lastDot = -1; - for (int i = 0; dnsStrW[i] != '\0'; i++) - { - if (dnsStrW[i] == '.') - { - lastDot = i; - } - } - - int fourthPartW = atoi(dnsStrW + lastDot + 1); - - if (setup && fourthPartW != 0) - { - vars.savedWifiDNS = currentWifiDNS; - snprintf(buffer, sizeof(buffer), "%s %s %s - %s", dnsTagKey, savedKey, wifiKey, dnsStrW); - printLogMsg(buffer); - } - else - { - if (vars.savedWifiDNS) - { - WiFi.config(WiFi.localIP(), WiFi.gatewayIP(), WiFi.subnetMask(), vars.savedWifiDNS); - snprintf(buffer, sizeof(buffer), "%s %s %s - %s", dnsTagKey, restoredKey, wifiKey, vars.savedWifiDNS.toString().c_str()); - printLogMsg(buffer); - } - } + auto check=[&](const char *key, const char *label, bool connected, IPAddress &saved) { + if (!connected) return; + esp_netif_t *netif=esp_netif_get_handle_from_ifkey(key); + esp_netif_dns_info_t dns{}; + // Arduino dnsIP() reads the IPv4 union member even for an IPv6 resolver. + // Leave IPv6 DNS untouched; this cache holds IPv4 addresses only. + if (!netif || !esp_netif_is_netif_up(netif) || + esp_netif_get_dns_info(netif,ESP_NETIF_DNS_MAIN,&dns)!=ESP_OK || dns.ip.type!=ESP_IPADDR_TYPE_V4) return; + IPAddress current(dns.ip.u_addr.ip4.addr); + if (current==saved) return; + const char *action=nullptr; + if (ipv4Assigned(uint32_t(current))) { + // A new valid DHCP resolver wins over the cached one, including when + // Ethernet and Wi-Fi share the SDK's DNS table. Do not rewrite it. + if (setup) { saved=current; action="Saved"; } + } else if (ipv4Assigned(uint32_t(saved))) { + dns.ip.u_addr.ip4.addr=uint32_t(saved); + if (esp_netif_set_dns_info(netif,ESP_NETIF_DNS_MAIN,&dns)==ESP_OK) action="Restored"; } - } - - if (networkCfg.ethEnable) - { - IPAddress currentEthDNS = ETH.dnsIP(); - if (currentEthDNS != vars.savedEthDNS) - { - char dnsStrE[16]; - snprintf(dnsStrE, sizeof(dnsStrE), "%u.%u.%u.%u", currentEthDNS[0], currentEthDNS[1], currentEthDNS[2], currentEthDNS[3]); - - int lastDot = -1; - for (int i = 0; dnsStrE[i] != '\0'; i++) - { - if (dnsStrE[i] == '.') - { - lastDot = i; - } - } - - int fourthPartE = atoi(dnsStrE + lastDot + 1); - - if (setup && fourthPartE != 0) - { - vars.savedEthDNS = currentEthDNS; - snprintf(buffer, sizeof(buffer), "%s %s %s - %s", dnsTagKey, savedKey, ethKey, dnsStrE); - printLogMsg(buffer); - } - else - { - if (vars.savedEthDNS) - { - ETH.config(ETH.localIP(), ETH.gatewayIP(), ETH.subnetMask(), vars.savedEthDNS); - snprintf(buffer, sizeof(buffer), "%s %s %s - %s", dnsTagKey, restoredKey, ethKey, vars.savedEthDNS.toString().c_str()); - printLogMsg(buffer); - } - } + if (action) { + char buffer[100]; + snprintf(buffer,sizeof(buffer),"[DNS] %s %s - %s",action,label,saved.toString().c_str()); + printLogMsg(buffer); } - } + }; + if (networkCfg.wifiEnable) + check("WIFI_STA_DEF","WiFi",WiFi.status()==WL_CONNECTED,vars.savedWifiDNS); + if (networkCfg.ethEnable) + check("ETH_DEF","ETH",ETH.linkUp(),vars.savedEthDNS); return true; } @@ -892,8 +856,63 @@ int compareVersions(String v1, String v2) return v1_suffix.compareTo(v2_suffix); } +// Only the worker performs internet I/O. Flags, logs and automatic installation +// stay on the original UI task, so unavailable DNS/HTTPS cannot stall WebServer. +struct UpdateCheckResult { String esp,zb; }; +static QueueHandle_t updateCheckResults=nullptr; +static bool updateCheckRunning=false; +static bool updateCheckDeferred=false; +static uint32_t updateCheckDeferredAt=0; +static void updateCheckTask(void *arg) +{ + auto *result=static_cast(arg); + result->esp=fetchLatestEspFw(false); + result->zb=fetchLatestZbFw(false); + xQueueSend(updateCheckResults,&result,portMAX_DELAY); + vTaskDelete(nullptr); +} + void checkUpdateAvail() { + if(vars.apStarted || vars.zbFlashing || updateCheckRunning) return; + auto memory=memoryBudget(); + if(!memory.backgroundHttps()) { + if(!updateCheckDeferred) { + char line[128]; + snprintf(line,sizeof(line),"[UPD_CHK] Deferred: heap=%lu largest=%lu; keeping RAM for UI/TLS", + static_cast(memory.free),static_cast(memory.largest)); + printLogMsg(line); + } + updateCheckDeferred=true; updateCheckDeferredAt=millis(); return; + } + updateCheckDeferred=false; + if(!updateCheckResults) updateCheckResults=xQueueCreate(1,sizeof(UpdateCheckResult *)); + if(!updateCheckResults) return; + auto *result=new(std::nothrow) UpdateCheckResult; + if(!result) return; + checkDNS(); + updateCheckRunning=true; + if(xTaskCreatePinnedToCore(updateCheckTask,"update-check",8192,result,1,nullptr,0)!=pdPASS) { + updateCheckRunning=false; delete result; + updateCheckDeferred=true; updateCheckDeferredAt=millis(); + memory=memoryBudget(); char line[112]; + snprintf(line,sizeof(line),"[UPD_CHK] Cannot start background check: heap=%lu largest=%lu", + static_cast(memory.free),static_cast(memory.largest)); + printLogMsg(line); + } +} + +void updateCheckLoop() +{ + if(updateCheckDeferred && uint32_t(millis()-updateCheckDeferredAt)>=60000) { + updateCheckDeferredAt=millis(); checkUpdateAvail(); + } + if(!updateCheckResults || vars.zbFlashing) return; + UpdateCheckResult *result=nullptr; + if(xQueueReceive(updateCheckResults,&result,0)!=pdTRUE) return; + updateCheckRunning=false; + String latestReleaseUrlEsp=result->esp, latestReleaseUrlZb=result->zb; + delete result; if (!vars.apStarted) { const char *ESPkey = "ESP"; @@ -901,8 +920,6 @@ void checkUpdateAvail() const char *FoundKey = "Found "; const char *NewFwKey = " new fw: "; const char *TryKey = "try to install"; - String latestReleaseUrlEsp = fetchLatestEspFw(); - String latestReleaseUrlZb = fetchLatestZbFw(); String latestVersionEsp = extractVersionFromURL(latestReleaseUrlEsp); String latestVersionZb = extractVersionFromURL(latestReleaseUrlZb); @@ -918,7 +935,7 @@ void checkUpdateAvail() { vars.updateEspAvail = true; printLogMsg(String(FoundKey) + String(ESPkey) + String(NewFwKey) + latestVersionEsp); - if (systemCfg.updAutoInst) + if (systemCfg.updAutoInst && !backhaulConfigured()) { printLogMsg(String(TryKey)); getEspUpdate(latestReleaseUrlEsp); @@ -933,7 +950,7 @@ void checkUpdateAvail() { vars.updateZbAvail = true; printLogMsg(String(FoundKey) + String(ZBkey) + String(NewFwKey) + latestVersionZb); - if (systemCfg.updAutoInst) + if (systemCfg.updAutoInst && !backhaulConfigured()) { printLogMsg(String(TryKey)); const char* zigbee_firmware_path= "/zigbee/firmware.bin"; diff --git a/src/etc.h b/src/etc.h index fc524ed0..a3b70a9d 100644 --- a/src/etc.h +++ b/src/etc.h @@ -49,5 +49,6 @@ void ledTask(void *parameter); String getTime(); void checkUpdateAvail(); +void updateCheckLoop(); int numOfConnectedClients(); diff --git a/src/main.cpp b/src/main.cpp index 89e39650..187a69c3 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -19,6 +19,8 @@ // #define ASYNC_TCP_SSL_ENABLED 1 #include "config.h" +#include "czc_backhaul.h" +#include "network_status.h" #include "esp_eth_mac.h" #include "web.h" #include "log.h" @@ -101,43 +103,30 @@ void startSocketServer() void startServers(bool usb = false) { - - if (!vars.apStarted) + // AP fallback can call this again while Ethernet is still obtaining a lease. + // Keep one NTP task, cron setup, controller listener and MQTT/VPN instance. + static bool servicesStarted = false; + if (servicesStarted) return; + servicesStarted = true; + xTaskCreate(setClock, "setClock", 2048, NULL, 9, NULL); + if (!usb) { - xTaskCreate(setClock, "setClock", 2048, NULL, 9, NULL); - if (!usb) - { - startSocketServer(); - } - if (vpnCfg.wgEnable) - { - wgBegin(); - } - if (mqttCfg.enable) - { - mqttConnectSetup(); - } + startSocketServer(); + } + if (vpnCfg.wgEnable) + { + wgBegin(); + } + if (mqttCfg.enable) + { + mqttConnectSetup(); } initWebServer(); startAP(false); - /*if (!vars.apStarted) - { - if (vpnCfg.wgEnable) - { - wgBegin(); - } - }*/ - mDNS_start(); - /* //not available now - if (vpnCfg.hnEnable) - { - hnBegin(); - } - */ } void handleTmrNetworkOverseer() @@ -250,6 +239,7 @@ void NetworkEvent(WiFiEvent_t event) break; case ARDUINO_EVENT_ETH_CONNECTED: // 20: // SYSTEM_EVENT_ETH_CONNECTED: LOGD("%s Connected", ethKey); + ETH.enableIpV6(); break; case ARDUINO_EVENT_ETH_GOT_IP: // 22: // SYSTEM_EVENT_ETH_GOT_IP: // startServers(); @@ -284,6 +274,12 @@ void NetworkEvent(WiFiEvent_t event) tmrNetworkOverseer.start(); } break; + case ARDUINO_EVENT_WIFI_STA_CONNECTED: + // Start Wi-Fi IPv6 later, after IPv4 is stable; leave SDK DNS policy alone. + break; + case ARDUINO_EVENT_WIFI_AP_START: + WiFi.softAPenableIpV6(); + break; case ARDUINO_EVENT_WIFI_STA_GOT_IP: // SYSTEM_EVENT_STA_GOT_IP: // startServers(); LOGI("%s MAC: %s, IP: %s, Mask: %s, Gw: %s, DNS: %s", wifiKey, @@ -366,7 +362,7 @@ void startAP(const bool start) WiFi.setSleep(false); // ConfigSettings.wifiAPenblTime = millis(); LOGD("startServers()"); - startServers(); + startServers(systemCfg.workMode == WORK_MODE_USB); vars.apStarted = true; } } @@ -414,16 +410,23 @@ void connectWifi() WiFi.setSleep(false); + bool addressConfigured; if (!networkCfg.wifiDhcp) { - WiFi.config(networkCfg.wifiIp, networkCfg.wifiGate, networkCfg.wifiMask, networkCfg.wifiDns1, networkCfg.wifiDns2); + addressConfigured=WiFi.config(networkCfg.wifiIp, networkCfg.wifiGate, networkCfg.wifiMask, networkCfg.wifiDns1, networkCfg.wifiDns2); LOGD("WiFi.config"); } else { - WiFi.config(INADDR_NONE, INADDR_NONE, INADDR_NONE, INADDR_NONE); + // This SDK starts DHCP only for local_ip == 0. INADDR_NONE is the + // broadcast address and would instead stop DHCP and set a static IP. + addressConfigured=WiFi.config(IPAddress(0U), IPAddress(0U), IPAddress(0U)); LOGD("Try DHCP"); } + if (!addressConfigured) { + printLogMsg("[WiFi] IPv4 configuration failed"); + return; + } WiFi.setScanMethod(WIFI_ALL_CHANNEL_SCAN); WiFi.setSortMethod(WIFI_CONNECT_AP_BY_SIGNAL); WiFi.begin(networkCfg.wifiSsid, networkCfg.wifiPass); @@ -504,12 +507,12 @@ void setupCoordinatorMode() WiFi.onEvent(NetworkEvent); if (networkCfg.ethEnable) initLan(); - if (networkCfg.wifiEnable) - connectWifi(); - //} writeDefaultDeviceID (vars.deviceId); // need for mqtt, vpn, mdns, wifi ap and so on writeDeviceId (systemCfg, vpnCfg, mqttCfg); + if (networkCfg.wifiEnable) + connectWifi(); + //} switch (systemCfg.workMode) { @@ -531,7 +534,7 @@ void setupCoordinatorMode() // if (!systemCfg.disableWeb && ((systemCfg.workMode != WORK_MODE_USB) || systemCfg.keepWeb)) // updWeb = true; // handle web server - if (!systemCfg.disableWeb) + if (!systemCfg.disableWeb || backhaulEnabled()) updWeb = true; // handle web server // if (systemCfg.workMode == WORK_MODE_USB && systemCfg.keepWeb) // connectWifi(); // try 2 connect wifi @@ -547,6 +550,7 @@ void setup() loadNetworkConfig (networkCfg); loadVpnConfig (vpnCfg); loadMqttConfig (mqttCfg); + backhaulLoad(); // Seed TLS before Wi-Fi initialization and load optional peer settings. if (!LittleFS.begin(FORMAT_LITTLEFS_IF_FAILED, "/lfs2", 10)) { @@ -607,6 +611,7 @@ void setup() if ((hwConfig.zb.txPin > 0) && (hwConfig.zb.rxPin > 0) && (hwConfig.zb.rstPin > 0) && (hwConfig.zb.bslPin > 0)) { + if (backhaulEnabled()) Serial2.setRxBufferSize(4096); Serial2.begin(systemCfg.serialSpeed, SERIAL_8N1, hwConfig.zb.rxPin, hwConfig.zb.txPin); // start zigbee serial int BSL_PIN_MODE = 0; if (CCTool.begin(hwConfig.zb.rstPin, hwConfig.zb.bslPin, BSL_PIN_MODE)) @@ -634,7 +639,9 @@ void setup() vars.connectedClients = 0; - xTaskCreate(updateWebTask, "update Web Task", 2048, NULL, 8, NULL); + // Live page values must not preempt the UI, peer transport or UART workers. + if(xTaskCreate(updateWebTask, "update Web Task", 2048, NULL, 1, NULL)!=pdPASS) + printLogMsg("[WEB] Live updates unavailable: cannot create task"); printNVSFreeSpace(); @@ -657,12 +664,13 @@ void setup() { LOGI("[ZB] role: %s", String(systemCfg.zbRole)); } - LOGI("[ESP] FW: %s", String(VERSION)); + printLogMsg(String("[ESP] FW: ") + VERSION); LOGI("Load cfg %s", hwConfig.board); setup1wire(check1wire()); + backhaulBegin(); // All stock boot-time CCTools probes have finished. LOGI("done"); } @@ -700,9 +708,20 @@ void socketClientDisconnected(int client) } } +// Called by the web radio flasher after validation, before entering BSL. +// Drop the old controller sessions so buffered ZNP commands cannot survive a flash. +void socketClientsDisconnect() +{ + for (byte i = 0; i < MAX_SOCKET_CLIENTS; ++i) + { + if (client[i]) client[i].stop(); + socketClientDisconnected(i); + } +} + void printRecvSocket(size_t bytes_read, uint8_t net_buf[BUFFER_SIZE]) { - char output_sprintf[2]; + char output_sprintf[3]; // two hex digits plus the terminating NUL if (bytes_read > 0) { String tmpTime; @@ -732,7 +751,7 @@ void printRecvSocket(size_t bytes_read, uint8_t net_buf[BUFFER_SIZE]) void printSendSocket(size_t bytes_read, uint8_t serial_buf[BUFFER_SIZE]) { - char output_sprintf[2]; + char output_sprintf[3]; // two hex digits plus the terminating NUL String tmpTime; String buff = ""; unsigned long timeLog = millis(); @@ -765,13 +784,17 @@ void printSendSocket(size_t bytes_read, uint8_t serial_buf[BUFFER_SIZE]) void loop(void) { + espUpdateLoop(); + backhaulLoop(); + updateCheckLoop(); + networkIpv6Loop(); if (btnFlag && vars.hwBtnIs) { buttonLoop(); } tmrNetworkOverseer.update(); - if (updWeb) + if (updWeb || backhaulEnabled()) { webServerHandleClient(); } @@ -786,7 +809,7 @@ void loop(void) if (!vars.zbFlashing) { - if (systemCfg.workMode == WORK_MODE_USB) + if (!backhaulEnabled() && systemCfg.workMode == WORK_MODE_USB) { if (Serial2.available()) { @@ -801,7 +824,7 @@ void loop(void) return; } - else if (systemCfg.workMode == WORK_MODE_NETWORK) + else if (systemCfg.workMode == WORK_MODE_NETWORK && !backhaulEnabled()) { uint16_t net_bytes_read = 0; uint8_t net_buf[BUFFER_SIZE]; @@ -848,11 +871,10 @@ void loop(void) if (client[cln]) { socketClientConnected(cln, client[cln].remoteIP()); - while (client[cln].available()) + while (net_bytes_read < BUFFER_SIZE && client[cln].available()) { // read from LAN net_buf[net_bytes_read] = client[cln].read(); - if (net_bytes_read < BUFFER_SIZE - 1) - net_bytes_read++; + net_bytes_read++; } // send to Zigbee Serial2.write(net_buf, net_bytes_read); // print to web console @@ -867,11 +889,10 @@ void loop(void) if (Serial2.available()) { - while (Serial2.available()) + while (serial_bytes_read < BUFFER_SIZE && Serial2.available()) { // read from Zigbee serial_buf[serial_bytes_read] = Serial2.read(); - if (serial_bytes_read < BUFFER_SIZE - 1) - serial_bytes_read++; + serial_bytes_read++; } // send to LAN for (byte cln = 0; cln < MAX_SOCKET_CLIENTS; cln++) diff --git a/src/memory_status.h b/src/memory_status.h new file mode 100644 index 00000000..1995f554 --- /dev/null +++ b/src/memory_status.h @@ -0,0 +1,17 @@ +#pragma once +#include +#include +#include + +inline czc::MemoryBudget memoryBudget() { + const uint32_t caps=MALLOC_CAP_INTERNAL|MALLOC_CAP_8BIT; + czc::MemoryBudget value; + value.free=heap_caps_get_free_size(caps); + value.largest=heap_caps_get_largest_free_block(caps); + value.minimum=heap_caps_get_minimum_free_size(caps); + return value; +} +inline void memoryStatus(JsonObject obj) { + auto value=memoryBudget(); + obj["free"]=value.free; obj["largest"]=value.largest; obj["minimum"]=value.minimum; +} diff --git a/src/network_ipv4.h b/src/network_ipv4.h new file mode 100644 index 00000000..981c77db --- /dev/null +++ b/src/network_ipv4.h @@ -0,0 +1,7 @@ +#pragma once +#include + +// Reject the unset and broadcast sentinels. This is not subnet validation. +inline bool ipv4Assigned(uint32_t address) { + return address!=0 && address!=UINT32_MAX; +} diff --git a/src/network_ipv6.h b/src/network_ipv6.h new file mode 100644 index 00000000..4a7451fc --- /dev/null +++ b/src/network_ipv6.h @@ -0,0 +1,44 @@ +#pragma once +#include +#include +#include +#include "network_ipv4.h" + +// Check IPv6 after the IPv4 connection has been stable for 5 s, then every 5 s. +// Do not set WIFI_WANT_IP6_BIT: in the pinned SDK that also switches every +// stock WiFiClient hostname connection to a different DNS/address policy. +class NetworkIpv6 { + esp_netif_t *active=nullptr; + uint32_t ipv4=0; + unsigned tries=0; + uint32_t next=0; +public: + int last=ESP_OK; + unsigned attempts=0; + bool ensure(const char *key, bool connected, uint32_t now) { + esp_netif_t *netif=connected ? esp_netif_get_handle_from_ifkey(key) : nullptr; + esp_netif_ip_info_t info{}; + if(!netif || !esp_netif_is_netif_up(netif) || + esp_netif_get_ip_info(netif,&info)!=ESP_OK || !ipv4Assigned(info.ip.addr)) { + active=nullptr; return false; + } + if(active!=netif || ipv4!=info.ip.addr) { + active=netif; ipv4=info.ip.addr; tries=0; next=now+5000; + return false; + } + if(static_cast(now-next)<0) return false; + next=now+5000; + esp_ip6_addr_t addresses[CONFIG_LWIP_IPV6_NUM_ADDRESSES]{}; + int count=esp_netif_get_all_ip6(netif,addresses); + if(count<0) return false; + for(int i=0;i=3) return false; + last=esp_netif_create_ip6_linklocal(netif); ++attempts; ++tries; + return true; + } +}; diff --git a/src/network_status.cpp b/src/network_status.cpp new file mode 100644 index 00000000..46c419ca --- /dev/null +++ b/src/network_status.cpp @@ -0,0 +1,38 @@ +#include "network_status.h" +#include "config.h" +#include "network_ipv6.h" +#include "web.h" +#include +#include + +extern NetworkConfigStruct networkCfg; +extern SysVarsStruct vars; + +static NetworkIpv6 ipv6Wifi,ipv6Eth; + +void networkIpv6Loop() { + static uint32_t last=0; + uint32_t now=millis(); + if(now-last<1000) return; + last=now; + auto start=[&](NetworkIpv6 &state,const char *key,const char *label,bool enabled) { + unsigned before=state.attempts; int previous=state.last; + if(state.ensure(key,enabled,now) && (!before || previous!=state.last)) { + printLogMsg(String("[IPv6] ")+label+(state.last==ESP_OK ? " link-local started" : " init failed: "+String(state.last))); + } + }; + start(ipv6Wifi,"WIFI_STA_DEF","WiFi",networkCfg.wifiEnable && WiFi.status()==WL_CONNECTED); + // Ethernet's connect event still starts IPv6 immediately. This also covers + // address loss without a new event, or an event before the netif is ready. + start(ipv6Eth,"ETH_DEF","ETH",networkCfg.ethEnable && ETH.linkUp()); +} + +void networkStatus(JsonObject obj) { + JsonArray interfaces=obj.createNestedArray("interfaces"); + networkInterfaceStatus(interfaces.createNestedObject(),"eth","ETH_DEF",networkCfg.ethEnable,ETH.linkUp()); + JsonObject wifi=interfaces.createNestedObject(); + networkInterfaceStatus(wifi,"wifi","WIFI_STA_DEF",networkCfg.wifiEnable,WiFi.status()==WL_CONNECTED); + wifi["ipv6_init_attempts"]=ipv6Wifi.attempts; + wifi["ipv6_init_status"]=ipv6Wifi.last; + if(vars.apStarted) networkInterfaceStatus(interfaces.createNestedObject(),"ap","WIFI_AP_DEF",true,true); +} diff --git a/src/network_status.h b/src/network_status.h new file mode 100644 index 00000000..db392d89 --- /dev/null +++ b/src/network_status.h @@ -0,0 +1,32 @@ +#pragma once +#include +#include +#include +#include +#include + +// Read the active interface, never the saved static configuration or peer-task cache. +inline void networkInterfaceStatus(JsonObject obj, const char *id, const char *key, + bool enabled, bool connected) { + obj["id"]=id; obj["enabled"]=enabled; + esp_netif_t *netif=esp_netif_get_handle_from_ifkey(key); + connected=enabled && connected && netif && esp_netif_is_netif_up(netif); + obj["connected"]=connected; obj["ipv4"]=""; + JsonArray ipv6=obj.createNestedArray("ipv6"); + if(!connected) return; + esp_netif_ip_info_t info{}; + char address[INET6_ADDRSTRLEN]{}; + if(esp_netif_get_ip_info(netif,&info)==ESP_OK && info.ip.addr && esp_ip4addr_ntoa(&info.ip,address,sizeof(address))) + obj["ipv4"]=address; + esp_ip6_addr_t addresses[CONFIG_LWIP_IPV6_NUM_ADDRESSES]{}; + int count=esp_netif_get_all_ip6(netif,addresses); + for(int i=0;i +#include +#include +#include +#include "config.h" +#include "web.h" +#include "zb.h" +#include "czc_backhaul.h" +#include + +extern CCTools CCTool; +extern SysVarsStruct vars; +static WebServer *uploadServer; +static File imageFile; +static const char *imagePath="/zigbee/local-upload.bin"; +static const char *uploadError=nullptr; +static bool received=false,started=false; +static uint32_t expectedBytes=0,uploadedBytes=0,storedBytes=0; +static void uploadFailure(const char *code) { + if(uploadError) return; + uploadError=code; + printLogMsg(String("[ZB upload] ")+code+" received="+String(uploadedBytes)+" expected="+String(expectedBytes)+" stored="+String(storedBytes)); +} +static void upload() { + HTTPUpload &part=uploadServer->upload(); + if(part.status==UPLOAD_FILE_START) { + if(!backhaulAuthorizeWebMutation()) { uploadFailure("authentication_required"); return; } + if(started) { uploadFailure("one_file_required"); return; } + started=true; received=false; uploadError=nullptr; + expectedBytes=uploadedBytes=storedBytes=0; + if(!part.filename.endsWith(".bin")) { uploadFailure("radio_bin_required"); return; } + if(vars.zbFlashing) { uploadFailure("radio_busy"); return; } + if(!CCTool.chip.flashSize) { uploadFailure("radio_flash_unknown"); return; } + LittleFS.mkdir("/zigbee"); LittleFS.remove(imagePath); + imageFile=LittleFS.open(imagePath,"w"); + if(!imageFile) uploadFailure("storage_error"); + } else if(part.status==UPLOAD_FILE_WRITE && !uploadError && started) { + if(part.currentSize>CCTool.chip.flashSize-uploadedBytes) { uploadFailure("radio_image_too_large"); return; } + if(!imageFile) { uploadFailure("storage_error"); return; } + size_t written=imageFile.write(part.buf,part.currentSize); + uploadedBytes+=written; + if(written!=part.currentSize) uploadFailure("storage_write_failed"); + } else if(part.status==UPLOAD_FILE_END && started) { + expectedBytes=part.totalSize; + if(uploadError) { imageFile.close(); return; } + // Arduino VFS File.size() uses stat(path), which may lag buffered writes. + // Closing commits the stream; reopening also discards the cached stat. + imageFile.close(); + File stored=LittleFS.open(imagePath,"r"); + storedBytes=stored ? stored.size() : 0; + received=stored && uploadedBytes==expectedBytes && storedBytes==expectedBytes; + stored.close(); + if(!received) uploadFailure("incomplete_upload"); + } else if(part.status==UPLOAD_FILE_ABORTED) { + imageFile.close(); LittleFS.remove(imagePath); uploadFailure("upload_aborted"); started=false; received=false; + } +} +static void finish() { + if(!backhaulAuthorizeWebMutation()) { imageFile.close(); LittleFS.remove(imagePath); started=received=false; return; } + imageFile.close(); + bool ok=false; + ZbFlashReport flash; + if(!uploadError && received) { + BackhaulMaintenance maintenance; + if(!maintenance) uploadFailure("radio_busy"); + else { + vars.zbFlashing=true; + ok=eraseWriteZbFile(imagePath,CCTool,&flash); + const String role=uploadServer->arg("fwMode"); + if(ok && (role=="coordinator" || role=="router" || role=="thread")) changeZbMode(role); + // Release the broker even after failure; UI remains usable for retry. + if(!ok && flash.eraseStarted) { CCTool.restart(); backhaulBslHold(false); } + vars.zbFlashing=false; + } + } + LittleFS.remove(imagePath); + uploadServer->sendHeader("Cache-Control","no-store"); + StaticJsonDocument<512> response; + response["result"]=ok ? "radio_updated" : uploadError ? uploadError : flash.error ? flash.error : "missing_file"; + if(!ok) { + response["stage"]=uploadError || !received ? "upload" : flash.stage; + response["expected_bytes"]=expectedBytes; response["received_bytes"]=uploadedBytes; response["stored_bytes"]=storedBytes; + response["written_bytes"]=flash.writtenBytes; response["radio_flash_bytes"]=CCTool.chip.flashSize; + response["erase_started"]=flash.eraseStarted; + if(flash.error) { response["bsl_error"]=flash.bslError; response["bsl_status"]=flash.bslStatus; } + } + String body; serializeJson(response,body); + if(!ok) printLogMsg(String("[ZB upload] ")+body); + uploadServer->send(ok ? 200 : 400,"application/json",body); + started=received=false; uploadError=nullptr; + expectedBytes=uploadedBytes=storedBytes=0; +} +void registerRadioUpload(WebServer &server) { + uploadServer=&server; + server.on("/updateZB",HTTP_POST,finish,upload); +} diff --git a/src/version.h b/src/version.h index fb4fbea0..bdb79ddd 100644 --- a/src/version.h +++ b/src/version.h @@ -1,4 +1,4 @@ // AUTO GENERATED FILE #ifndef VERSION - #define VERSION "20241127" + #define VERSION "20260925.1" #endif diff --git a/src/web.cpp b/src/web.cpp index 14771ac6..92eeb850 100644 --- a/src/web.cpp +++ b/src/web.cpp @@ -10,9 +10,15 @@ #include #include #include +#include +#include #include "config.h" +#include "czc_backhaul.h" +#include "network_status.h" +#include "memory_status.h" #include "web.h" +#include "web_refresh.h" #include "log.h" #include "etc.h" #include "zb.h" @@ -83,6 +89,27 @@ bool wifiWebSetupInProgress = false; bool eventOK = false; SemaphoreHandle_t sendEventMutex; +static std::atomic eventDrops{0}; +static const char *espUpdateState="idle",*espUpdateError=""; +static uint32_t espUpdateRestartAt=0; +static bool espUploadHeld=false,espUploadSeen=false,espUploadReady=false,espUploadStarted=false; +static size_t espUploadExpected=0,espUploadReceived=0; +static void failEspUpdate(const char *reason) { + Update.abort(); espUpdateState="failed"; espUpdateError=reason; + sendEventSafe("esp.fi","failed"); +} +static void completeEspUpdate() { + espUpdateState="restarting"; espUpdateError=""; + sendEventSafe(tagESP_FW_prgs,"100"); sendEventSafe("esp.fi","restarting"); + espUpdateRestartAt=millis()+2000; +} +void espUpdateLoop() { + // Give the HTTP result time to reach the browser; never reboot inside the + // multipart callback, before WebServer can send its completion response. + if(espUpdateRestartAt && static_cast(millis()-espUpdateRestartAt)>=0) { + espUpdateRestartAt=0; ESP.restart(); + } +} // API strings const char *argAction = "action"; @@ -207,8 +234,13 @@ void handleLoader() sendGzip(contTypeTextHtml, PAGE_LOADER_html_gz, PAGE_LOADER_html_gz_len); } +void registerRadioUpload(WebServer &server); void initWebServer() { + // Network/AP recovery can call startServers() again. Keep the existing + // route list and mutex instead of leaking handlers or replacing a live lock. + static bool initialized=false; + if(initialized) return; /* ----- LANG FILES | START -----*/ serverWeb.on("/lg/en.json", []() { sendGzip(contTypeJson, en_json_gz, en_json_gz_len); }); @@ -288,6 +320,14 @@ void initWebServer() serverWeb.on("/saveFile", handleSavefile); serverWeb.on("/api", handleApi); + serverWeb.on("/api/network/status", HTTP_GET, []() { + if(!is_authenticated()) { serverWeb.send(401); return; } + serverWeb.sendHeader("Cache-Control", "no-store"); + DynamicJsonDocument doc(2048); + networkStatus(doc.to()); + String body; serializeJson(doc,body); + serverWeb.send(200,"application/json",body); + }); serverWeb.on("/events", handleEvents); /* ----- MIST CMDs | END -----*/ @@ -295,11 +335,26 @@ void initWebServer() /* ----- OTA | START -----*/ serverWeb.on("/update", HTTP_POST, handleUpdateRequest, handleEspUpdateUpload); + serverWeb.on("/api/esp-update",HTTP_GET,[]() { + if(!is_authenticated()) { serverWeb.send(401); return; } + static const String boot=String(esp_random(),HEX)+"-"+String(esp_random(),HEX); + serverWeb.sendHeader("Cache-Control","no-store"); + StaticJsonDocument<512> doc; doc["boot"]=boot; doc["version"]=VERSION; + doc["state"]=espUpdateState; doc["error"]=espUpdateError; + memoryStatus(doc.createNestedObject("heap")); + doc["wifi_connected"]=WiFi.status()==WL_CONNECTED; + if(WiFi.status()==WL_CONNECTED) doc["wifi_rssi"]=WiFi.RSSI(); + doc["ui_refresh_s"]=webRefreshSeconds(systemCfg.refreshLogs); + doc["ui_events_dropped"]=eventDrops.load(); + String body; serializeJson(doc,body); serverWeb.send(200,contTypeJson,body); + }); // serverWeb.on("/updateZB", HTTP_POST, handleUpdateRequest, handleZbUpdateUpload); /* ----- OTA | END -----*/ - const char *headerkeys[] = {"Content-Length", "Cookie"}; + backhaulWebRoutes(serverWeb); + registerRadioUpload(serverWeb); + const char *headerkeys[] = {"Content-Length", "Cookie", "X-Backhaul-Token"}; constexpr size_t headerkeyssize = sizeof(headerkeys) / sizeof(char *); sendEventMutex = xSemaphoreCreateMutex(); if(sendEventMutex == NULL) { @@ -307,6 +362,7 @@ void initWebServer() } serverWeb.collectHeaders(headerkeys, headerkeyssize); serverWeb.begin(); + initialized=true; LOGD("done"); } @@ -374,102 +430,120 @@ void handleNotFound() void handleUpdateRequest() { - serverWeb.sendHeader ("Connection", - "close"); - serverWeb.send (HTTP_CODE_OK, - contTypeText, - "Upload OK. Try to flash..."); + if(!is_authenticated()) { serverWeb.send(401); return; } + // Validate only after the complete multipart request, not its first file. + bool complete=espUploadStarted && espUploadReady && espUploadReceived && + (!espUploadExpected || espUploadReceived==espUploadExpected) && Update.end(!espUploadExpected); + if(!complete && espUploadSeen && !*espUpdateError) failEspUpdate("ota_incomplete_or_invalid"); + serverWeb.sendHeader("Connection","close"); + StaticJsonDocument<192> doc; + doc["result"]=complete ? "esp_updated" : *espUpdateError ? espUpdateError : "missing_file"; + String body; serializeJson(doc,body); + serverWeb.send(complete ? 200 : 400,contTypeJson,body); + if(complete) completeEspUpdate(); + else if(espUploadHeld) { backhaulMaintenanceEnd(); espUploadHeld=false; } + espUploadSeen=espUploadReady=espUploadStarted=false; } void handleEspUpdateUpload() { - if (!is_authenticated()) + if(!is_authenticated()) return; + HTTPUpload &upload=serverWeb.upload(); + if(upload.status==UPLOAD_FILE_START) { - return; - } - - HTTPUpload &upload = serverWeb.upload(); - static long contentLength = 0; - if (upload.status == UPLOAD_FILE_START) - { - contentLength = serverWeb.header("Content-Length").toInt(); - LOGD ("hostHeader: %s", - serverWeb.hostHeader()); - LOGD ("contentLength: %s", - String(contentLength)); - LOGD ("Update ESP from file %s size: %s", - String(upload.filename.c_str()), - String(upload.totalSize)); - LOGD ("upload.currentSize %s", - String(upload.currentSize)); - if (!Update.begin(contentLength)) - { - Update.printError(Serial); + if(espUpdateRestartAt) { espUpdateError="restart_pending"; return; } + if(espUploadSeen) { + failEspUpdate("unexpected_file"); espUploadStarted=false; return; } + espUploadSeen=true; espUploadReady=false; espUploadReceived=0; + espUpdateState="uploading"; espUpdateError=""; + espUploadExpected=0; + if(serverWeb.hasArg("size")) { + String size=serverWeb.arg("size"); + bool valid=size.length()>0 && size.length()<=7; + for(size_t i=0;i='0' && size[i]<='9'; + if(!valid || size.toInt()<=0) { failEspUpdate("invalid_size"); return; } + espUploadExpected=size.toInt(); + } + espUploadHeld=backhaulMaintenanceBegin(); + if(!espUploadHeld) { failEspUpdate("radio_busy"); return; } Update.onProgress(progressFunc); + espUploadStarted=Update.begin(espUploadExpected ? espUploadExpected : UPDATE_SIZE_UNKNOWN); + if(!espUploadStarted) failEspUpdate("ota_begin_failed"); } - else if (upload.status == UPLOAD_FILE_WRITE) + else if(upload.status==UPLOAD_FILE_WRITE && espUploadStarted) { - if (Update.write(upload.buf, upload.currentSize) != upload.currentSize) - { - Update.printError(Serial); + if(Update.write(upload.buf,upload.currentSize)!=upload.currentSize) { + failEspUpdate("ota_write_failed"); espUploadStarted=false; } + espUploadReceived+=upload.currentSize; } - else if (upload.status == UPLOAD_FILE_END) - { - if (Update.end(true)) - { - LOGD("Update success. Rebooting..."); - ESP.restart(); - } - else - { - LOGD("Update error: "); - Update.printError(Serial); - } + else if(upload.status==UPLOAD_FILE_END) espUploadReady=espUploadStarted; + else if(upload.status==UPLOAD_FILE_ABORTED) { + failEspUpdate("upload_aborted"); + if(espUploadHeld) backhaulMaintenanceEnd(); + espUploadHeld=espUploadSeen=espUploadReady=espUploadStarted=false; } } -void handleEvents() -{ - if (is_authenticated()) - { - if (eventsClient) - { - eventsClient.stop(); - } - eventsClient = serverWeb.client(); - if (eventsClient) - { - eventsClient.println ("HTTP/1.1 200 OK"); - eventsClient.println ("Content-Type: text/event-stream;"); - eventsClient.println ("Connection: close"); - eventsClient.println ("Access-Control-Allow-Origin: *"); - eventsClient.println ("Cache-Control: no-cache"); - eventsClient.println (); - eventsClient.flush (); - } - } +// SSE transport: this also runs with backhaul Off. Arduino WiFiClient::write +// retries select() for seconds; it must never run in an OTA callback or while +// holding the lock shared with the HTTP task. SSE is recoverable telemetry: +// close a congested/partial stream and let EventSource reconnect for a snapshot. +static void dropEvents() { + ++eventDrops; + eventsClient.stop(); } -void sendEventSafe(const char *event, const String data) { - if(xSemaphoreTake(sendEventMutex, pdMS_TO_TICKS(250))) { - sendEvent(event, data); - xSemaphoreGive(sendEventMutex); +static void writeEvents(const char *data, size_t length) { + if(eventsClient.fd()<0) return; + if(::send(eventsClient.fd(),data,length,MSG_DONTWAIT)!=static_cast(length)) + dropEvents(); +} + +static bool eventsConnected() { + if(!sendEventMutex || xSemaphoreTake(sendEventMutex,0)!=pdTRUE) return false; + bool connected=eventsClient.connected(); + xSemaphoreGive(sendEventMutex); + return connected; +} + +void handleEvents() { + if(!is_authenticated()) { serverWeb.send(401); return; } + // No wait on a worker and no unsynchronised reassignment of its WiFiClient. + if(!sendEventMutex || xSemaphoreTake(sendEventMutex,0)!=pdTRUE) { + serverWeb.client().stop(); + return; } - else { - DEBUG_PRINTLN("Could not send event because method was locked!"); + eventsClient.stop(); + eventsClient=serverWeb.client(); + eventsClient.setNoDelay(true); + static const char headers[]="HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\n" + "Connection: close\r\nCache-Control: no-cache\r\n\r\nretry: 1500\n\n"; + writeEvents(headers,sizeof(headers)-1); + xSemaphoreGive(sendEventMutex); +} + +static void sendEventRecord(const char *event, const String &data, bool rootFinish) { + if(!sendEventMutex || xSemaphoreTake(sendEventMutex,0)!=pdTRUE) return; + if(eventsClient.fd()>=0) { + String record; + size_t expected=strlen(event)+data.length()+16; + static const char finish[]="event: root_update\ndata: finish\n\n"; + if(rootFinish) expected+=sizeof(finish)-1; + if(!record.reserve(expected)) dropEvents(); + else { + record+="event: "; record+=event; record+="\ndata: "; record+=data; record+="\n\n"; + if(rootFinish) record+=finish; + if(record.length()!=expected) dropEvents(); + else writeEvents(record.c_str(),record.length()); + } } + xSemaphoreGive(sendEventMutex); } -void sendEvent(const char *event, const String data) -{ - if (eventsClient) - { - eventsClient.print(String("event: ") + event + "\n"); - eventsClient.print(String("data: ") + data + "\n\n"); - eventsClient.flush(); - } +void sendEventSafe(const char *event, const String data) { + sendEventRecord(event,data,false); } void sendGzip(const char *contentType, @@ -520,18 +594,19 @@ static void apiCmdUpdateUrl(String &result) { if (serverWeb.hasArg(argUrl)) { - getEspUpdate(serverWeb.arg(argUrl)); + result=getEspUpdate(serverWeb.arg(argUrl)) ? "esp_updated" : espUpdateError; } else { String link = fetchLatestEspFw(); if (link) { - getEspUpdate(link); + result=getEspUpdate(link) ? "esp_updated" : espUpdateError; } else { LOGW("%s", String(errLink)); + failEspUpdate("download_failed"); result=espUpdateError; } } } @@ -614,6 +689,9 @@ static void apiCmdLedAct(String &result) static void apiCmdZbFlash(String &result) { + BackhaulMaintenance maintenance; + if(!maintenance) { serverWeb.send(409,contTypeText,"radio_busy"); return; } + bool updated=false; const char* zigbee_firmware_path = "/zigbee/firmware.bin"; DEBUG_PRINT("[WEB] There are currently "); @@ -625,7 +703,8 @@ static void apiCmdZbFlash(String &result) if (serverWeb.hasArg(argUrl)) { - if(!flashZigbeefromURL(serverWeb.arg(argUrl).c_str(), zigbee_firmware_path, CCTool)) { + updated=flashZigbeefromURL(serverWeb.arg(argUrl).c_str(), zigbee_firmware_path, CCTool); + if(!updated) { DEBUG_PRINTLN("[WEB] Error while downloading and flashing Zigbee firmware"); } } @@ -633,7 +712,8 @@ static void apiCmdZbFlash(String &result) String link = fetchLatestZbFw(); if (link) { - if(!flashZigbeefromURL(link.c_str(), zigbee_firmware_path, CCTool)) { + updated=flashZigbeefromURL(link.c_str(), zigbee_firmware_path, CCTool); + if(!updated) { DEBUG_PRINTLN("[WEB] Error while downloading and flashing ZigBee firmware from link"); } } @@ -642,7 +722,7 @@ static void apiCmdZbFlash(String &result) LOGW("%s", String(errLink)); } } - changeZbMode(serverWeb.arg(argFwMode)); + if(updated) changeZbMode(serverWeb.arg(argFwMode)); } @@ -654,8 +734,8 @@ void changeZbMode(String fwMode) { // Only here because the other FWs dont respond to FW checks DEBUG_PRINTLN("[WEB] Checking ZigBee firmware on the CC"); - String result = ""; - apiCmdZbCheckFirmware(result); + // The upload handler sends the final response after role persistence and cleanup. + zbFwCheck(); } else if (fwMode == "router") { @@ -669,6 +749,7 @@ void changeZbMode(String fwMode) { DEBUG_PRINTLN("[WEB] Changed ZbRole to OPENTHREAD"); } saveSystemConfig(systemCfg); + backhaulRoleChanged(); } static void apiCmdDefault(String &result) @@ -1054,39 +1135,18 @@ void handleSaveParams() updateConfiguration(serverWeb, systemCfg, networkCfg, vpnCfg, mqttCfg); } -void printEachKeyValuePair(const String &jsonString) -{ - DynamicJsonDocument doc(1024); - DeserializationError error = deserializeJson(doc, jsonString); - - if (error) - { - - return; - } - - for (JsonPair kv : doc.as()) - { - DynamicJsonDocument pairDoc(256); - pairDoc[kv.key().c_str()] = kv.value(); - - String output; - serializeJson(pairDoc, output); - - sendEventSafe("root_update", String(output)); - } - sendEventSafe("root_update", String("finish")); -} - void updateWebTask(void *parameter) { - TickType_t lastWakeTime = xTaskGetTickCount(); while (1) { - String root_data = getRootData(true); - printEachKeyValuePair(root_data); - root_data = String(); // free memory - vTaskDelayUntil(&lastWakeTime, pdMS_TO_TICKS(systemCfg.refreshLogs * 1000)); + if(eventsConnected()) { + // The browser already merges a JSON object. Send the whole snapshot + // and finish in one write, not two writes for every individual key. + String root_data=getRootData(true); + sendEventRecord("root_update",root_data,true); + } + // Always yield after work; no catch-up bursts after a slow sensor/read. + vTaskDelay(pdMS_TO_TICKS(webRefreshSeconds(systemCfg.refreshLogs)*1000)); } } @@ -1873,12 +1933,14 @@ void handleSavefile() /* ----- Multi-tool support | START -----*/ void handleZigbeeBSL() { + if (!is_authenticated()) { serverWeb.send(401); return; } zigbeeEnableBSL(); serverWeb.send(HTTP_CODE_OK, contTypeText, "Zigbee BSL"); } void handleZigbeeRestart() { + if (!is_authenticated()) { serverWeb.send(401); return; } zigbeeRestart(); serverWeb.send(HTTP_CODE_OK, contTypeText, "Zigbee Restart"); } @@ -1913,7 +1975,7 @@ void printLogMsg(String msg) void progressFunc(unsigned int progress, unsigned int total) { - float percent = ((float)progress / total) * 100.0; + float percent = total ? std::min(99.0f, ((float)progress / total) * 100.0f) : 0; sendEventSafe(tagESP_FW_prgs, String(percent)); // printLogMsg(String(percent)); @@ -1926,84 +1988,53 @@ void progressFunc(unsigned int progress, unsigned int total) #endif }; -int totalLength; // total size of firmware -int currentLength = 0; // current size of written firmware - -void getEspUpdate(String esp_fw_url) +bool getEspUpdate(String esp_fw_url) { - LOGI("getEspUpdate: %s", esp_fw_url.c_str()); - + if(espUpdateRestartAt) return false; + espUpdateState="uploading"; espUpdateError=""; + BackhaulMaintenance maintenance; + if(!maintenance) { failEspUpdate("radio_busy"); return false; } checkDNS(); HTTPClient http; WiFiClientSecure client; - client.setInsecure(); // the magic line, use with caution + client.setInsecure(); // Preserve the stock URL updater's HTTPS policy. http.setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS); - http.begin(client, esp_fw_url); - http.addHeader("Content-Type", "application/octet-stream"); - - // Get file, just to check if each reachable - int resp = http.GET(); - LOGD("Response: %s", String(resp)); - // If file is reachable, start downloading - if (resp == HTTP_CODE_OK) - { - // get length of document (is -1 when Server sends no Content-Length header) - totalLength = http.getSize(); - // transfer to local variable - int len = totalLength; - // this is required to start firmware update process - Update.begin(totalLength); - Update.onProgress(progressFunc); - LOGI("FW Size: %s", String(totalLength)); - // create buffer for read - uint8_t buff[128] = {0}; - // get tcp stream - WiFiClient *stream = http.getStreamPtr(); - // read all data from server - LOGI("Updating firmware..."); - while (http.connected() && (len > 0 || len == -1)) - { - // get available data size - size_t size = stream->available(); - if (size) - { - // read up to 128 byte - int c = stream->readBytes(buff, ((size > sizeof(buff)) ? sizeof(buff) : size)); - // pass to function - runEspUpdateFirmware(buff, c); - if (len > 0) - { - len -= c; - } - } - } - } - else - { - LOGI("Cannot download firmware file."); + http.begin(client,esp_fw_url); + http.addHeader("Content-Type","application/octet-stream"); + if(http.GET()!=HTTP_CODE_OK) { http.end(); failEspUpdate("download_failed"); return false; } + int remaining=http.getSize(); + if(remaining<=0 || !Update.begin(remaining)) { + http.end(); failEspUpdate("ota_begin_failed"); return false; + } + Update.onProgress(progressFunc); + WiFiClient *stream=http.getStreamPtr(); + uint8_t buffer[512]; + uint32_t lastProgress=millis(); + bool ok=true; + while(remaining>0) { + size_t available=stream->available(); + if(available) { + size_t count=std::min(available,std::min(sizeof(buffer),static_cast(remaining))); + int received=stream->readBytes(buffer,count); + if(received<=0 || Update.write(buffer,received)!=static_cast(received)) { ok=false; break; } + remaining-=received; lastProgress=millis(); + } else if(!http.connected() || millis()-lastProgress>10000) { ok=false; break; } + delay(1); } http.end(); + if(ok && remaining==0 && Update.end()) { + printLogMsg("[ESP] Update complete; restarting"); completeEspUpdate(); return true; + } else { + failEspUpdate("ota_incomplete_or_invalid"); return false; + } } -void runEspUpdateFirmware(uint8_t *data, size_t len) -{ - Update.write(data, len); - currentLength += len; - - // if current length of written firmware is not equal to total firmware size, repeat - if (currentLength != totalLength) - return; - // only if currentLength == totalLength - Update.end(true); - LOGD("Update success. Rebooting..."); - // Restart ESP32 to see changes - ESP.restart(); -} - -String fetchLatestEspFw() +String fetchLatestEspFw(bool refreshDns) { - checkDNS(); + if(refreshDns) checkDNS(); HTTPClient http; + http.setConnectTimeout(2500); + http.setTimeout(3000); http.begin("https://docs.codm.de/tools/releases.php"); int httpCode = http.GET(); @@ -2032,10 +2063,12 @@ String fetchLatestEspFw() return browser_download_url; } -String fetchLatestZbFw() +String fetchLatestZbFw(bool refreshDns) { - checkDNS(); + if(refreshDns) checkDNS(); HTTPClient http; + http.setConnectTimeout(2500); + http.setTimeout(3000); http.begin("https://raw.githubusercontent.com/codm/CZC/refs/heads/zb_fws/ti/manifest.json"); int httpCode = http.GET(); diff --git a/src/web.h b/src/web.h index baaa2cc9..9e4caeda 100644 --- a/src/web.h +++ b/src/web.h @@ -31,13 +31,12 @@ void handleSaveParams(); String getRootData(bool update = false); void sendEventSafe(const char *event, const String data); -void sendEvent(const char *event, const String data); void progressFunc(unsigned int progress, unsigned int total); +void espUpdateLoop(); -void getEspUpdate(String esp_fw_url); -void runEspUpdateFirmware(uint8_t *data, size_t len); -String fetchLatestEspFw(); -String fetchLatestZbFw(); +bool getEspUpdate(String esp_fw_url); +String fetchLatestEspFw(bool refreshDns = true); +String fetchLatestZbFw(bool refreshDns = true); String extractVersionFromURL(String url); void updateWebTask(void *parameter); diff --git a/src/web_refresh.h b/src/web_refresh.h new file mode 100644 index 00000000..3241a74a --- /dev/null +++ b/src/web_refresh.h @@ -0,0 +1,7 @@ +#pragma once + +// The UI accepts 1..30 seconds. Apply the same bounds to NVS, old JSON +// migrations and POSTs; never let an invalid interval create a busy task. +inline unsigned webRefreshSeconds(long seconds) { + return seconds < 1 ? 1 : seconds > 30 ? 30 : static_cast(seconds); +} diff --git a/src/websrc/html/PAGE_NETWORK.html b/src/websrc/html/PAGE_NETWORK.html index ad02ae6b..1e689a49 100644 --- a/src/websrc/html/PAGE_NETWORK.html +++ b/src/websrc/html/PAGE_NETWORK.html @@ -31,6 +31,22 @@
+
+
+
Current addresses
+
+
+ + + +
InterfaceConnectionIPv4IPv6
+
+

IPv6 addresses appear automatically. A link-local address works only within the local network.

+

+ +
+
+
@@ -350,4 +366,4 @@
-
\ No newline at end of file +
diff --git a/src/websrc/html/PAGE_TOOLS.html b/src/websrc/html/PAGE_TOOLS.html index 73737b0f..638b1d6c 100644 --- a/src/websrc/html/PAGE_TOOLS.html +++ b/src/websrc/html/PAGE_TOOLS.html @@ -280,45 +280,28 @@ - diff --git a/src/websrc/html/PAGE_ZIGBEE.html b/src/websrc/html/PAGE_ZIGBEE.html index b7174542..807b17ef 100644 --- a/src/websrc/html/PAGE_ZIGBEE.html +++ b/src/websrc/html/PAGE_ZIGBEE.html @@ -109,6 +109,59 @@
+
+
+
Encrypted CZC network — up to 8 Satellites
+

Extend one Zigbee network over an encrypted IP connection. Zigbee software connects to Master; up to eight Satellites provide remote radio coverage. Use the same PSK on every node.

+
+
+
+ + +
+
+ + +
+
+
+
+
+ + + IPv4, IPv6 or hostname. +
+
+
+ +
+ + + + +
+ Use the same key on both devices. Generate once and copy to the other device. An empty field keeps the saved key. +
+
+
+
+

Current IPv4/IPv6 addresses and connection settings are in Network. Network

+
+
+ + + +
+

+
    +
    +
    +
    +
    @@ -212,4 +265,4 @@ }); } -
    \ No newline at end of file +
    diff --git a/src/websrc/js/functions.js b/src/websrc/js/functions.js index 148eb7d4..4f2abb7a 100644 --- a/src/websrc/js/functions.js +++ b/src/websrc/js/functions.js @@ -331,6 +331,7 @@ function loadPage(url) { break; case api.pages.API_PAGE_NETWORK.str: apiGetPage(api.pages.API_PAGE_NETWORK, () => { + networkStatusUiInit(); if ($("#ethEnbl").prop(chck)) { EthEnbl(true); @@ -381,6 +382,7 @@ function loadPage(url) { case api.pages.API_PAGE_ZIGBEE.str: apiGetPage(api.pages.API_PAGE_ZIGBEE, () => { generateConfig("z2m"); + backhaulUiInit(); }); break; case api.pages.API_PAGE_TOOLS.str: @@ -1232,30 +1234,120 @@ function extractVersionFromReleaseTag(url) { } } -function espFlashGitWait(params) { - setTimeout(function () { - if (typeof params !== 'undefined' && params !== null && typeof params.link !== 'undefined') { - let version = extractVersionFromReleaseTag(params.link); - $.get(apiLink + api.actions.API_CMD + "&cmd=" + api.commands.CMD_ESP_UPD_URL + "&url=" + params.link, function (data) { }); - $('#bar').html(i18next.t('md.esp.fu.vgds', { ver: version })); - } - else { - $.get(apiLink + api.actions.API_CMD + "&cmd=" + api.commands.CMD_ESP_UPD_URL, function (data) { }); - $('#bar').html(i18next.t('md.esp.fu.lgds')); +// HTTP completion and a new boot identify a successful update. SSE is only +// progress feedback: its last packet may be lost while the ESP32 restarts. +var espUpdateCurrent = null; +function espUpdateActive() { return espUpdateCurrent && espUpdateCurrent.active; } +function espUpdateReload() { window.location.reload(); } +function espUpdateFailed(run, reason) { + if (espUpdateCurrent !== run || !run.active) return; + run.active = false; + clearTimeout(run.timer); + $('#prg').removeClass('progress-bar-animated'); + $('#bar').text(i18next.t('md.esp.fu.errors.' + reason, { + defaultValue: i18next.t('md.esp.fu.failed') + })).css('color', 'red'); + $('.modal-footer').empty(); + modalAddCancel(); +} +function espUpdateProgress(value) { + if (!espUpdateActive() || espUpdateCurrent.waiting) return; + const percent = Math.min(99, Math.max(0, Number(value))); + if (!Number.isFinite(percent)) return; + $('#prg').css('width', percent + '%').removeClass('progress-bar-animated'); + $('#bar').text(i18next.t('md.esp.fu.prgs', {per: percent.toFixed(2)})); +} +function espUpdateWait(run, confirmed) { + if (espUpdateCurrent !== run || !run.active) return; + run.confirmed = run.confirmed || confirmed; + if (run.confirmed) { + $('#prg').css('width', '100%').removeClass('progress-bar-animated'); + $('#bar').text(i18next.t('md.esp.fu.ucr')).css('color', 'green'); + } else { + $('#bar').text(i18next.t('md.esp.fu.checking')); + } + if (run.waiting) return; + run.waiting = true; + const deadline = Date.now() + 90000; + function reload() { + if (espUpdateCurrent !== run || !run.active) return; + run.active = false; + espUpdateReload(); + } + function poll() { + if (espUpdateCurrent !== run || !run.active) return; + if (Date.now() >= deadline) { espUpdateFailed(run, 'reconnect_timeout'); return; } + $.ajax({url: '/api/esp-update', dataType: 'json', cache: false, timeout: 2000}) + .done(function (status) { + if (espUpdateCurrent !== run || !run.active) return; + if (!status || typeof status.boot !== 'string' || !status.boot) return; + if (status.boot !== run.boot) { reload(); return; } + if (status.state === 'failed') { espUpdateFailed(run, status.error); return; } + if (status.state === 'restarting') espUpdateWait(run, true); + }) + .fail(function (xhr) { + // A stock GitHub image has no backhaul status API. Its HTTP 404 + // after a confirmed update also proves the old app is gone. + if (run.confirmed && xhr.status === 404 && espUpdateCurrent === run && run.active) { + $.ajax({url: '/', cache: false, timeout: 2000}).done(reload); + } + }) + .always(function () { + if (espUpdateCurrent === run && run.active) run.timer = setTimeout(poll, 1500); + }); + } + run.timer = setTimeout(poll, 1000); +} +function startEspUpdate(params) { + if (espUpdateActive()) return; + const run = {active: true, boot: null, confirmed: false, waiting: false, timer: null}; + espUpdateCurrent = run; + $('#bar').text(i18next.t('md.esp.fu.wdm')); + $.ajax({url: '/api/esp-update', dataType: 'json', cache: false, timeout: 4000}) + .done(function (status) { + if (espUpdateCurrent !== run || !run.active) return; + if (!status || typeof status.boot !== 'string' || !status.boot) { espUpdateFailed(run, 'invalid_response'); return; } + run.boot = status.boot; + if (status.state === 'restarting') { espUpdateWait(run, true); return; } + let request; + if (params instanceof FormData) { + const file = params.get('update'); + if (!(file instanceof File) || !file.size) { espUpdateFailed(run, 'invalid_size'); return; } + request = {url: '/update?size=' + file.size, type: 'POST', data: params, + contentType: false, processData: false, dataType: 'json'}; + } else { + const link = params && params.link; + request = {url: apiLink + api.actions.API_CMD + '&cmd=' + api.commands.CMD_ESP_UPD_URL + + (link ? '&url=' + encodeURIComponent(link) : ''), dataType: 'text'}; + $('#bar').text(i18next.t(link ? 'md.esp.fu.vgds' : 'md.esp.fu.lgds', + {ver: link ? extractVersionFromReleaseTag(link) : ''})); } - }, 500); + request.timeout = 180000; + $.ajax(request).done(function (result) { + const value = typeof result === 'string' ? result.trim() : result && result.result; + if (value === 'esp_updated') espUpdateWait(run, true); + else espUpdateFailed(run, value || 'invalid_response'); + }).fail(function (xhr) { + if (xhr.status === 0) espUpdateWait(run, false); + else espUpdateFailed(run, (xhr.responseJSON || {}).result || 'invalid_response'); + }); + }) + .fail(function () { espUpdateFailed(run, 'status_unavailable'); }); } +function espFlashGitWait(params) { startEspUpdate(params); } let retryCount = 0; const maxRetries = 30; var sourceEvents; +var zbFlashLocal = false; +var zbFlashStage = ''; function connectEvents() { if (window.location.pathname.startsWith('/login')) { return; } - if (retryCount >= maxRetries) { + if (retryCount >= maxRetries && !espUpdateActive()) { alert(i18next.t('c.cerp')); return; } @@ -1266,13 +1358,12 @@ function connectEvents() { }, false); sourceEvents.addEventListener('error', function (e) { - if (e.target.readyState != EventSource.OPEN) { - retryCount++; - setTimeout(function () { - sourceEvents.close(); - connectEvents();//callback); - }, 100); - } + if (e.target !== sourceEvents || e.target.readyState === EventSource.OPEN) return; + e.target.close(); + if (!espUpdateActive()) retryCount++; + setTimeout(function () { + if (sourceEvents === e.target) connectEvents(); + }, espUpdateActive() ? 1500 : Math.min(1000 * Math.pow(2, Math.min(retryCount - 1, 4)), 15000)); }, false); sourceEvents.addEventListener('root_update', function (e) { @@ -1286,11 +1377,14 @@ function connectEvents() { }); sourceEvents.addEventListener('zb.dw', function (e) { + if (zbFlashLocal) return; $('#zbFlshPgsTxt').html(i18next.t('md.esp.fu.dwnl', { per: e.data })); $("#zbFlshPrgs").css("width", e.data + '%'); }, false); sourceEvents.addEventListener('zb.fp', function (e) { + if (zbFlashLocal && zbFlashStage !== 'upload' && zbFlashStage !== 'flash') return; + zbFlashStage = 'flash'; $('#zbFlshPgsTxt').html(i18next.t('md.esp.fu.flsh', { per: e.data })); $("#zbFlshPrgs").css("width", e.data + '%'); }, false); @@ -1304,6 +1398,7 @@ function connectEvents() { }, false); sourceEvents.addEventListener('zb.fi', function (e) { + if (zbFlashLocal && zbFlashStage !== 'upload' && zbFlashStage !== 'flash') return; let data = e.data.replaceAll("`", "
    "); if (e.data == "startDownload") { @@ -1312,6 +1407,7 @@ function connectEvents() { } if (e.data == "startFlash") { + zbFlashStage = 'flash'; data = i18next.t('md.zg.fu.stf'); } @@ -1319,15 +1415,12 @@ function connectEvents() { data = i18next.t('md.zg.fu.er'); } - if (e.data == "finishFlash") { - data = i18next.t('md.zg.fu.fn'); - $(".progress").addClass(classHide); - $(modalBody).css("color", "green"); - setTimeout(() => { - $(modalBtns).html(""); - modalAddClose(); + if (e.data == "verifyFlash") data = i18next.t("md.zg.fu.verify"); - }, 1000); + if (e.data == "finishFlash") { + // Local uploads finish only after the HTTP handler confirms cleanup. + if (!zbFlashLocal) finishZbFlash(); + return; } $("#zbFlshPgsTxt").html(data); @@ -1335,6 +1428,7 @@ function connectEvents() { }, false); sourceEvents.addEventListener('zb.ff', function (e) { + if (zbFlashLocal) return; let fileName = fileFromUrl(e.data); if (fileName) { data = i18next.t('md.zg.fu.f', { file: fileName }); @@ -1354,27 +1448,16 @@ function connectEvents() { }, false); sourceEvents.addEventListener('zb.fe', function (e) { - const data = e.data.replaceAll("`", "
    "); - $(modalBtns).html(""); - $("#zbFlshPgsTxt").html(data); - $(".progress").addClass(classHide); - $(modalBody).html(e.data).css("color", "red"); - modalAddClose(); + if (!zbFlashLocal) failZbFlash(e.data.replaceAll("`", "\n")); }, false); sourceEvents.addEventListener('esp.fp', function (e) { - $('#prg').css('width', e.data + '%'); - $('#bar').html(i18next.t('md.esp.fu.prgs', { per: e.data })); - $("#prg").removeClass("progress-bar-animated"); - - if (Math.round(e.data) > 99) { - setTimeout(function () { - $('#bar').html(i18next.t('md.esp.fu.ucr')).css("color", "green"); - setTimeout(function () { - restartWait(); - }, 1000); - }, 500); + espUpdateProgress(e.data); + }, false); + sourceEvents.addEventListener('esp.fi', function (e) { + if (espUpdateActive() && espUpdateCurrent.boot && e.data === 'restarting') { + espUpdateWait(espUpdateCurrent, false); } }, false); } @@ -1408,33 +1491,16 @@ function reconnectEvents() { } } -// Check if clients are connected and configure corresponding modal -// modal gets created in HTML +// The flasher takes exclusive UART ownership when the user confirms the update. function startZbFlash(link, fwMode) { - $.get(apiLink + api.actions.API_CMD + "&cmd=" + api.commands.CMD_CLIENT_CHECK, function (connectedClients) { - if(connectedClients != 0) { - configureClientErrorModal(); - } - else { - configureZigBeeFlashModal(link, fwMode); - } - }); -} - -function configureClientErrorModal() { - $(modalBtns).html(""); - $(modalBody).html(""); - - $("
    ", { - text: i18next.t("md.zb.ccn"), - class: "my-1 text-sm-center text-danger" - }).appendTo(modalBody); - - modalAddClose(); + zbFlashLocal = link instanceof File; + zbFlashStage = ''; + configureZigBeeFlashModal(link, fwMode); } function configureZigBeeFlashModal(link, fwMode) { - $.get(apiLink + api.actions.API_CMD + "&cmd=" + api.commands.CMD_DNS_CHECK, function (data) { + const localFile = link instanceof File; + function configure() { reconnectEvents(); $(modalBtns).html(""); @@ -1445,11 +1511,12 @@ function configureZigBeeFlashModal(link, fwMode) { class: "my-1 text-sm-center text-danger" }).appendTo(modalBody); - let fileName = fileFromUrl(link); + let fileName = localFile ? link.name : fileFromUrl(link); $("
    ", { text: fileName, class: "my-1 text-sm-center" }).appendTo(modalBody); + if (localFile) $('

    ', {text: i18next.t('p.bh.flashNote'), class: 'text-sm-center text-danger'}).appendTo(modalBody); modalAddCancel(); let flashButton = $('