diff options
| -rw-r--r-- | .git_archival.txt | 3 | ||||
| -rw-r--r-- | .gitattributes | 1 | ||||
| -rw-r--r-- | .gitignore | 7 | ||||
| -rw-r--r-- | AUTHORS | 1 | ||||
| -rw-r--r-- | LICENSE-BSD | 30 | ||||
| -rw-r--r-- | LICENSE-LGPL (renamed from LICENSE) | 14 | ||||
| -rw-r--r-- | README.md | 527 | ||||
| -rw-r--r--[-rwxr-xr-x] | examples/oecho.py | 77 | ||||
| -rw-r--r--[-rwxr-xr-x] | examples/oecho_set.py | 84 | ||||
| -rw-r--r-- | ffi/fccntl_wrap.h | 58 | ||||
| -rw-r--r-- | ffi/irm_wrap.h | 47 | ||||
| -rw-r--r-- | ffi/pyouroboros_build_dev.py (renamed from ffi/pyouroboros_build.py) | 101 | ||||
| -rw-r--r-- | ffi/pyouroboros_build_irm.py | 291 | ||||
| -rw-r--r-- | ouroboros/__init__.py | 77 | ||||
| -rw-r--r-- | ouroboros/_qosspec.py | 61 | ||||
| -rw-r--r-- | ouroboros/_timespec.py | 59 | ||||
| -rw-r--r-- | ouroboros/cli.py | 448 | ||||
| -rw-r--r-- | ouroboros/dev.py | 596 | ||||
| -rw-r--r-- | ouroboros/errors.py | 284 | ||||
| -rw-r--r-- | ouroboros/event.py | 226 | ||||
| -rw-r--r-- | ouroboros/irm.py | 653 | ||||
| -rw-r--r-- | ouroboros/qos.py | 183 | ||||
| -rw-r--r-- | pyproject.toml | 40 | ||||
| -rwxr-xr-x | setup.py | 88 |
24 files changed, 3321 insertions, 635 deletions
diff --git a/.git_archival.txt b/.git_archival.txt new file mode 100644 index 0000000..3e26627 --- /dev/null +++ b/.git_archival.txt @@ -0,0 +1,3 @@ +node: $Format:%H$ +node-date: $Format:%cI$ +describe-name: $Format:%(describe:tags=true)$ diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..00a7b00 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +.git_archival.txt export-subst diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c363780 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +__pycache__/ +*.pyc +*.pyo +build/ +dist/ +*.egg-info/ +*.so @@ -0,0 +1 @@ +Dimitri Staessens <dimitri@ouroboros.rocks> diff --git a/LICENSE-BSD b/LICENSE-BSD new file mode 100644 index 0000000..258ef3b --- /dev/null +++ b/LICENSE-BSD @@ -0,0 +1,30 @@ +Copyright (C) 2016 - 2026, Dimitri Staessens + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + +1. Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following +disclaimer in the documentation and/or other materials provided +with the distribution. + +3. Neither the name of the copyright holder nor the names of its +contributors may be used to endorse or promote products derived +from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS +FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, +INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, +STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED +OF THE POSSIBILITY OF SUCH DAMAGE. @@ -44,7 +44,7 @@ Library. 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 Library or any portion of it, thus forming a work based on the Library, and copy and distribute such modifications or work under the terms of Section 1 @@ -102,7 +102,7 @@ instead of to this License. (If a newer version than version 2 of the ordinary GNU General Public License has appeared, then you can specify that version instead if you wish.) Do not make any other change in these notices. - + Once this change is made in a given copy, it is irreversible for that copy, so the ordinary GNU General Public License applies to all subsequent copies and derivative works made from that copy. @@ -153,7 +153,7 @@ Library will still fall under Section 6.) distribute the object code for the work under the terms of Section 6. Any executables containing that work also fall under Section 6, whether or not they are linked directly with the Library itself. - + 6. As an exception to the Sections above, you may also combine or link a "work that uses the Library" with the Library to produce a work containing portions of the Library, and distribute that work @@ -215,7 +215,7 @@ restrictions of other proprietary libraries that do not normally accompany the operating system. Such a contradiction means you cannot use both them and the Library together in an executable that you distribute. - + 7. You may place library facilities that are a work based on the Library side-by-side in a single library together with other library facilities not covered by this License, and distribute such a combined @@ -256,7 +256,7 @@ 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 with this License. - + 11. 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 @@ -308,7 +308,7 @@ conditions either of that version or of any later version published by the Free Software Foundation. If the Library does not specify a license version number, you may choose any version ever published by the Free Software Foundation. - + 14. If you wish to incorporate parts of the Library into other free programs whose distribution conditions are incompatible with these, write to the author to ask for permission. For software which is @@ -341,4 +341,4 @@ FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. - END OF TERMS AND CONDITIONS
\ No newline at end of file + END OF TERMS AND CONDITIONS @@ -4,51 +4,73 @@ ## Dependencies pyOuroboros requires <a href="https://ouroboros.rocks">Ouroboros</a> -to be installed +to be installed (matching `major.minor` version — see [Versioning](#versioning)). ## Installation -To build and install PyOuroboros: + +To build and install pyOuroboros: + +```shell +pip install . +``` + +Or for an editable install during development: ```shell -./setup.py install +pip install -e . ``` ## Basic Usage +Applications import each part of the API from its submodule +explicitly — flows, events and the control plane are three distinct +concerns, and the import line says which one is in use: + ```Python -from ouroboros.dev import * +from ouroboros.dev import Flow, flow_alloc, flow_accept, flow_join +from ouroboros.event import FEventQueue, FEventType, FlowSet +from ouroboros.irm import create_ipcp, bootstrap_ipcp, reg_name +# or, for IRM operations that mirror the `irm` CLI tool: +from ouroboros.cli import autoboot, destroy_ipcp ``` -Server side: Accepting a flow: +The pure-Python `errors` and `qos` modules are also re-exported at +the top level for convenience (`from ouroboros import QoSSpec, +OuroborosError`), but `dev`, `event`, `irm` and `cli` are only +available through their submodule path — importing them eagerly +from the top-level package would load the libouroboros CFFI +extensions on every `import ouroboros`, which is the wrong default +for a binding layer. + +Server side: accept a flow. ```Python f = flow_accept() ``` -returns a new allocated flow object. +returns a new allocated `Flow` object. -Client side: Allocating a flow to a certain _name_: +Client side: allocate a flow to a certain _name_. ```Python f = flow_alloc("name") ``` -returns a new allocated Flow object. - -Broadcast: +Broadcast: join a broadcast layer. ```Python f = flow_join("name") ``` -returns a new allocated Flow object. - -Deallocation: +Deallocate a flow: ```Python f.dealloc() ``` -To avoid having to call dealloc(), you can use the with statement: + +`dealloc()` is idempotent. To avoid calling it explicitly, use a +`with` statement — and any `Flow` that is garbage-collected without +being deallocated will be cleaned up too: ```Python with flow_alloc("dst") as f: @@ -56,71 +78,114 @@ with flow_alloc("dst") as f: print(f.readline()) ``` -deallocates the flow. After this call, the Flow object is not readable -or writeable anymore. +`Flow()` constructs an empty wrapper; `Flow(fd)` wraps an existing +flow descriptor. `f.alloc("name")` populates an empty wrapper: ```Python +f = Flow() f.alloc("name") ``` - will allocate a new flow for an existing Flow object. - -To read / write from a flow: +To read / write on a flow: ```Python -f.read(count) # read up to _count_ bytes and return bytes -f.readline(count) # read up to _count_ characters as a string -f.write(buf, count) # write up to _count_ bytes from buffer -f.writeline(str, count) # write up to _count_ characters from string +f.read(count) # read up to `count` bytes, return bytes +f.readline() # read and decode as UTF-8, return str +f.write(buf, count) # write up to `count` bytes from buffer +f.writeline(ln) # encode `ln` as UTF-8 and write, return bytes written ``` ## Quality of Service (QoS) -The QoS spec details have not been finalized in Ouroboros. It is just -here to give a general idea and to control some basics of the flow. -You can specify a QoSSpec for flow allocation. - -For instance, +`QoSSpec` describes the QoS requested for a flow. It is a **frozen** +dataclass; construct a new spec to vary fields, or use +`dataclasses.replace(qos, ...)` to derive one. ```Python -qos = QoSSpec(loss=0, cypher_s=256) +from ouroboros.qos import QoSSpec, QoSService + +qos = QoSSpec(service=QoSService.MESSAGE, loss=0, timeout=60000) f = flow_alloc("name", qos) ``` -will create a new flow with FRCP retransmission enabled and encrypted -using a 256-bit ECDHE-AES-SHA3 cypher. +The `service` field selects the framing / reliability class and +enables FRCT for values > 0: + +```Python +class QoSService(IntEnum): + RAW # No FRCT; best-effort raw messages + MESSAGE # FRCT, reliable ordered messages + STREAM # FRCT, reliable ordered byte stream +``` + +A handful of predefined QoS specs mirror the ones in +`ouroboros/qos.h` (`_safe` enables an integrity check by setting +`ber=0`; `rt` trades reliability for latency): + +```Python +from ouroboros import ( + QOS_RAW, QOS_RAW_SAFE, # Raw best-effort + QOS_RT, QOS_RT_SAFE, # Real-time, low latency + QOS_MSG, # Reliable ordered messages + QOS_STREAM, # Reliable ordered byte stream +) + +f = flow_alloc("name", QOS_STREAM) +``` ## Manipulating flows -A number of methods are available for how to interact with Flow +A number of methods are available to inspect and tune a `Flow`: ```Python -f.set_snd_timeout(0.5) # set timeout for blocking write -f.set_rcv_timeout(1.0) # set timeout for blocking read +f.set_snd_timeout(0.5) # set timeout for blocking write (seconds) +f.set_rcv_timeout(1.0) # set timeout for blocking read (seconds) f.get_snd_timeout() # get timeout for blocking write f.get_rcv_timeout() # get timeout for blocking read f.get_qos() # get the QoSSpec for this flow -f.get_rx_queue_len() # get the number of packets in the rx buffer -f.get_tx_queue_len() # get the number of packets in the tx buffer -f.set_flags(flags) # set a number of flags for this flow +f.get_rx_queue_len() # bytes pending in the rx buffer +f.get_tx_queue_len() # bytes pending in the tx buffer +f.get_mtu() # per-packet MTU (0 if unknown) +f.set_flags(flags) # replace the full set of flags +f.add_flags(flags) # OR new flags into the current flow flags +f.remove_flags(flags) # clear flags while preserving the rest f.get_flags() # get the flags for this flow +f.fileno() # underlying ouroboros flow descriptor ``` -The flags are specified as an enum FlowProperties: +The flags are specified as an `IntFlag` enum, `FlowProperties`: ```Python class FlowProperties(IntFlag): - ReadOnly - WriteOnly - ReadWrite - Down - NonBlockingRead - NonBlockingWrite - NonBlocking - NoPartialRead - NoPartialWrite + READ_ONLY + WRITE_ONLY + READ_WRITE + DOWN + NON_BLOCKING_READ + NON_BLOCKING_WRITE + NON_BLOCKING # NON_BLOCKING_READ | NON_BLOCKING_WRITE + NO_PARTIAL_READ + NO_PARTIAL_WRITE ``` +For FRCT-enabled flows (`service > 0`), the FRCT state can be tuned: + +```Python +from ouroboros.dev import FrctFlags + +f.set_frct_flags(FrctFlags.RESCNTL | FrctFlags.LINGER) +f.get_frct_flags() +f.set_frct_max_sdu(size) # max reassembly SDU (bytes) +f.get_frct_max_sdu() +f.set_frct_rcv_ring_size(size) # stream rcv ring (bytes, pow2) +f.get_frct_rcv_ring_size() +f.set_frct_rto_min(rto_ns) # RTO floor in nanoseconds +f.get_frct_rto_min() +``` + +FRCT flags: `FrctFlags.RETRANSMIT` (fixed at flow alloc), +`FrctFlags.RESCNTL`, `FrctFlags.LINGER`. + See the Ouroboros fccntl documentation for more details. ```shell @@ -129,66 +194,360 @@ man fccntl ## Event API -Multiple flows can be monitored for activity in parallel using a -FlowSet and FEventQueue objects. +Multiple flows can be monitored for activity in parallel using +`FlowSet` and `FEventQueue` objects. -FlowSets allow grouping a bunch of Flow objects together to listen for -activity. It can be constructed with an optional list of Flows, or -flows can be added or removed using the following methods: +A `FlowSet` groups `Flow` objects together. It can be constructed +with an optional list of flows; flows can be added or removed at any +time: ```Python -set = FlowSet() # create a flow set, -set.add(f) # add a Flow 'f' to this set -set.remove(f) # remove a Flow 'f' from this set -set.zero() # remove all Flows in this set +from ouroboros.event import FlowSet + +fs = FlowSet() # create an empty flow set +fs.add(f) # add a Flow `f` to this set +fs.remove(f) # remove a Flow `f` from this set +fs.zero() # remove all Flows from this set ``` -An FEventQueue stores pending events on flows. +An `FEventQueue` stores pending events on flows. Event types: -The event types are defined as follows: ```Python class FEventType(IntFlag): - FlowPkt - FlowDown - FlowUp - FlowAlloc - FlowDealloc + FLOW_PKT + FLOW_DOWN + FLOW_UP + FLOW_ALLOC + FLOW_DEALLOC + FLOW_PEER ``` -and can be obtained by calling the next method: +`FlowSet.wait()` populates an `FEventQueue` from a set; pending +events are then drained via `FEventQueue.next()`: ```Python - f, t = fq.next() # Return active flow 'f' and type of event 't' -``` +from ouroboros.event import FEventQueue, FEventType, FlowSet -An FEventQueue is populated from a FlowSet. - -```Python -fq = FEventQueue() # Create an eventqueue -set = FlowSet([f1, f2, f3]) # Create a new set with a couple of Flow objects -set.wait(fq, timeo=1.0) # Wait for 1 second or until event -while f, t = fq.next(): - if t == FEventType.FlowPkt: +fq = FEventQueue() +fs = FlowSet([f1, f2, f3]) +fs.wait(fq, timeo=1.0) # block up to 1 second +while True: + try: + f, t = fq.next() + except OuroborosError: + break # queue drained + if t == FEventType.FLOW_PKT: msg = f.readline() ... -set.destroy() +fs.destroy() ``` -A flow_set must be destroyed when it goes out of scope. -To avoid having to call destroy, Python's with statement can be used: +Both `FlowSet` and `FEventQueue` are context managers (preferred): ```Python -fq = FEventQueue() -with FlowSet([f]) as fs: +with FEventQueue() as fq, FlowSet([f]) as fs: fs.wait(fq) -f2, t = fq.next() -if t == FEventType.FlowPkt: - line = f2.readline() + f2, t = fq.next() + if t == FEventType.FLOW_PKT: + line = f2.readline() +``` + +`destroy()` is idempotent; GC will also reclaim the underlying C +handles when the wrapper goes out of scope. + +## Error handling + +All pyOuroboros exceptions derive from `OuroborosError`. The +hierarchy mirrors stdlib conventions: flow-plane and IRM-plane errors +have their own base classes, and several specific subclasses inherit +from Python builtins so they can be caught either way. + +``` +OuroborosError +├── IrmError +│ ├── IpcpCreateError, IpcpBootstrapError, IpcpEnrollError +│ ├── IpcpConnectError, IpcpTypeError (ValueError), IpcpStateError +│ ├── IrmdError, IpcpdError, BindError +│ └── NameNotFoundError, NameExistsError, InvalidNameError (ValueError) +└── FlowError + ├── FlowAlreadyAllocatedError, FlowNotAllocatedError + ├── FlowDownError (ConnectionError), FlowPeerError (FlowDownError) + ├── FlowPermissionError (PermissionError) + ├── FlowTimeout (TimeoutError) + ├── FlowCryptError, FlowAuthError, FlowReplayError + └── FlowEventError +FlowDeallocWarning (Warning) +``` + +ouroboros errno codes (`ENOTALLOC`, `EFLOWDOWN`, `EFLOWPEER`, +`ENAME`, `ECRYPT`, ...) translate to the matching semantic subclass; +ambiguous libc errnos (`ETIMEDOUT`, `EAGAIN`, `ENOTCONN`, +`ECONNRESET`) translate to the stdlib base class. + +```Python +from ouroboros import OuroborosError, FlowDownError + +try: + f.read() +except TimeoutError: + ... # rcv timeout elapsed +except FlowDownError: + ... # peer went away +except OuroborosError as e: + log.warning("flow error: %s", e) +``` + +## IRM API + +The IRM (IPC Resource Manager) module exposes the raw C API for +managing IPCPs, names, and bindings: + +```Python +from ouroboros.irm import ( + IpcpType, IpcpConfig, NameInfo, BIND_AUTO, DT_COMP, MGMT_COMP, + create_ipcp, bootstrap_ipcp, enroll_ipcp, destroy_ipcp, list_ipcps, + connect_ipcp, disconnect_ipcp, + create_name, destroy_name, list_names, reg_name, unreg_name, + bind_program, unbind_program, bind_process, unbind_process, +) ``` +For most use cases, the higher-level [`ouroboros.cli`](#cli-helpers) +wrappers are easier to work with — they mirror the `irm` CLI tool and +are keyed by IPCP name. + +### IPCP Management + +Create, bootstrap, enroll, and destroy IPCPs: + +```Python +# Create a local IPCP +pid = create_ipcp("my_ipcp", IpcpType.LOCAL) + +# Bootstrap it into a layer +conf = IpcpConfig(ipcp_type=IpcpType.LOCAL, layer_name="my_layer") +bootstrap_ipcp(pid, conf) + +# List all running IPCPs +for ipcp in list_ipcps(): + print(ipcp) + +# Enroll an IPCP +enroll_ipcp(pid, "enrollment_dst") + +# Destroy an IPCP +destroy_ipcp(pid) +``` + +IPCP types: `LOCAL`, `UNICAST`, `BROADCAST`, `ETH_LLC`, `ETH_DIX`, +`UDP4`, `UDP6`. + +### IPCP Configuration + +`IpcpConfig` is a dataclass used to bootstrap an IPCP. It takes +the following fields: + +```Python +IpcpConfig( + ipcp_type, # IpcpType (required) + layer_name="", # Layer name (string) + dir_hash_algo=DirectoryHashAlgo.SHA3_256, # Hash algorithm + unicast=None, eth=None, udp4=None, udp6=None # Type-specific config +) +``` + +`dir_hash_algo` can be `SHA3_224`, `SHA3_256`, `SHA3_384`, or +`SHA3_512`. + +#### Local and Broadcast IPCPs + +Local and broadcast IPCPs need no type-specific configuration: + +```Python +conf = IpcpConfig(ipcp_type=IpcpType.LOCAL, layer_name="local_layer") +conf = IpcpConfig(ipcp_type=IpcpType.BROADCAST, layer_name="bc_layer") +``` + +#### Unicast IPCPs + +Unicast IPCPs have the most detailed configuration: + +```Python +conf = IpcpConfig( + ipcp_type=IpcpType.UNICAST, + layer_name="my_layer", + unicast=UnicastConfig( + dt=DtConfig( + addr_size=4, # Address size in bytes (default: 4) + eid_size=8, # Endpoint ID size in bytes (default: 8) + max_ttl=60, # Maximum time-to-live (default: 60) + routing=RoutingConfig( + pol=RoutingPolicy.LINK_STATE, + ls=LinkStateConfig( + pol=LinkStatePolicy.SIMPLE, # SIMPLE, LFA, or ECMP + t_recalc=4, # Recalculation interval (s) + t_update=15, # Update interval (s) + t_timeo=60 # Timeout (s) + ) + ) + ), + dir=DirConfig( + pol=DirectoryPolicy.DHT, + dht=DhtConfig( + alpha=3, # Concurrency parameter + k=8, # Replication factor + t_expire=86400, # Entry expiry time (s) + t_refresh=900, # Refresh interval (s) + t_replicate=900 # Replication interval (s) + ) + ), + addr_auth=AddressAuthPolicy.FLAT_RANDOM, + cong_avoid=CongestionAvoidPolicy.MB_ECN # or NONE + ) +) +``` + +All sub-configs have sensible defaults, so for most cases a simpler +form suffices: + +```Python +conf = IpcpConfig( + ipcp_type=IpcpType.UNICAST, + layer_name="my_layer", + unicast=UnicastConfig() +) +``` + +#### Ethernet IPCPs (LLC and DIX) + +```Python +conf = IpcpConfig( + ipcp_type=IpcpType.ETH_LLC, # or IpcpType.ETH_DIX + layer_name="eth_layer", + eth=EthConfig( + dev="eth0", # Network device name + ethertype=0xA000 # Ethertype (mainly for DIX) + ) +) +``` + +#### UDP IPCPs + +For UDP over IPv4: + +```Python +conf = IpcpConfig( + ipcp_type=IpcpType.UDP4, + layer_name="udp4_layer", + udp4=Udp4Config( + ip_addr="192.168.1.1", # Local IP address + dns_addr="192.168.1.254", # DNS server address + port=3435 # UDP port (default: 3435) + ) +) +``` + +For UDP over IPv6: + +```Python +conf = IpcpConfig( + ipcp_type=IpcpType.UDP6, + layer_name="udp6_layer", + udp6=Udp6Config( + ip_addr="fd00::1", # Local IPv6 address + dns_addr="fd00::fe", # DNS server address + port=3435 # UDP port (default: 3435) + ) +) +``` + +### Connecting IPCP Components + +```Python +connect_ipcp(pid, DT_COMP, "destination") # data transfer plane +connect_ipcp(pid, MGMT_COMP, "destination") # management plane +disconnect_ipcp(pid, DT_COMP, "destination") +``` + +`connect_ipcp` optionally takes a `qos=QoSSpec(...)` argument. + +### Name Management + +Create, destroy, and list names: + +```Python +# Create a name +info = NameInfo(name="my_name", pol_lb=LoadBalancePolicy.ROUND_ROBIN) +create_name(info) + +# Register / unregister an IPCP to a name +reg_name("my_name", pid) +unreg_name("my_name", pid) + +# List all registered names +for name in list_names(): + print(name.name) + +# Destroy a name +destroy_name("my_name") +``` + +### Binding Programs and Processes + +```Python +# Bind a program to a name (auto-start on flow allocation) +bind_program("/usr/bin/my_server", "my_name", BIND_AUTO) +unbind_program("/usr/bin/my_server", "my_name") + +# Bind a running process to a name +bind_process(pid, "my_name") +unbind_process(pid, "my_name") +``` + +## CLI helpers + +`ouroboros.cli` mirrors the C-side `ouroboros/tools/irm` command-line +tool. The functions take IPCP _names_ (not pids), call `realpath()` +on programs, and handle the `autobind` flag for bootstrap / enroll: + +```Python +from ouroboros.cli import ( + create_ipcp, destroy_ipcp, bootstrap_ipcp, enroll_ipcp, + connect_ipcp, disconnect_ipcp, + bind_program, bind_ipcp, autoboot, + reg_name, unreg_name, + IpcpType, IpcpConfig, # re-exported for convenience +) + +# Create + bootstrap + autobind in one step +autoboot("my_ipcp", IpcpType.LOCAL, layer="my_layer") + +# Bootstrap with autobind (binds the IPCP to its name and layer first) +conf = IpcpConfig(ipcp_type=IpcpType.UNICAST, layer_name="dc") +bootstrap_ipcp("my_ipcp", conf, autobind=True) + +# Register a name with multiple IPCPs at once +reg_name("server", ipcps=["ipcp1", "ipcp2"]) +``` + +See the module docstring for the full CLI-command ↔ Python mapping. + ## Examples -Some example code is in the examples folder. +See the [`examples/`](examples/) directory for runnable client/server +demos. + +## Versioning + +pyOuroboros uses `setuptools_scm` to derive its version from git tags. + +**Compatibility contract across Ouroboros repositories:** + +| Scope | Rule | +|---|---| +| ouroboros (C) ↔ pyouroboros / rumba | Shared `major.minor`, C side at least equal | +| pyouroboros ↔ rumba ↔ ouroboros-integration | Lockstep `major.minor.patch`, released together | ## License -pyOuorboros is LGPLv2.1. The examples are 3-clause BSD. + +pyOuroboros is LGPLv2.1. The examples are 3-clause BSD. diff --git a/examples/oecho.py b/examples/oecho.py index 395930c..ae94103 100755..100644 --- a/examples/oecho.py +++ b/examples/oecho.py @@ -1,62 +1,57 @@ -#!/bin/python - -# Ouroboros - Copyright (C) 2016 - 2020 -# -# A simple echo application -# -# Dimitri Staessens <dimitri@ouroboros.rocks> -# -# Redistribution and use in source and binary forms, with or without -# modification, are permitted provided that the following conditions -# are met: -# -# 1. Redistributions of source code must retain the above copyright -# notice, this list of conditions and the following disclaimer. # -# 2. Redistributions in binary form must reproduce the above -# copyright notice, this list of conditions and the following -# disclaimer in the documentation and/or other materials provided -# with the distribution. +# SPDX-FileCopyrightText: 2016 - 2026 Dimitri Staessens +# SPDX-License-Identifier: BSD-3-Clause # -# 3. Neither the name of the copyright holder nor the names of its -# contributors may be used to endorse or promote products derived -# from this software without specific prior written permission. -# -# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS -# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT -# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS -# FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE -# COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, -# INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES -# (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR -# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) -# HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, -# STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) -# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED -# OF THE POSSIBILITY OF SUCH DAMAGE. - -from ouroboros.dev import * + +""" +A simple echo client/server example using Ouroboros flows. +""" + +# Standalone demo; duplicates oecho_set.py. +# pylint: disable=duplicate-code + +from __future__ import annotations + import argparse +from ouroboros.dev import flow_accept, flow_alloc + + +def client() -> None: + """ + Send a message and print the echo reply. + """ -def client(): with flow_alloc("oecho") as f: f.writeline("Hello, PyOuroboros!") print(f.readline()) -def server(): +def server() -> None: + """ + Accept flows and echo back received messages. + """ + print("Starting the server.") + while True: with flow_accept() as f: print("New flow.") + line = f.readline() - print("Message from client is " + line) + + print(f"Message from client is {line}") f.writeline(line) if __name__ == "__main__": parser = argparse.ArgumentParser(description='A simple echo client/server') - parser.add_argument('-l', '--listen', help='run as a server', action='store_true') + + parser.add_argument('-l', '--listen', help='run as a server', + action='store_true') + args = parser.parse_args() - server() if args.listen is True else client() + if args.listen: + server() + else: + client() diff --git a/examples/oecho_set.py b/examples/oecho_set.py index 4d9d1f7..469fdb0 100755..100644 --- a/examples/oecho_set.py +++ b/examples/oecho_set.py @@ -1,71 +1,65 @@ -#!/bin/python - -# Ouroboros - Copyright (C) 2016 - 2020 -# -# A simple echo application -# -# Dimitri Staessens <dimitri@ouroboros.rocks> -# -# Redistribution and use in source and binary forms, with or without -# modification, are permitted provided that the following conditions -# are met: -# -# 1. Redistributions of source code must retain the above copyright -# notice, this list of conditions and the following disclaimer. # -# 2. Redistributions in binary form must reproduce the above -# copyright notice, this list of conditions and the following -# disclaimer in the documentation and/or other materials provided -# with the distribution. +# SPDX-FileCopyrightText: 2016 - 2026 Dimitri Staessens +# SPDX-License-Identifier: BSD-3-Clause # -# 3. Neither the name of the copyright holder nor the names of its -# contributors may be used to endorse or promote products derived -# from this software without specific prior written permission. -# -# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS -# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT -# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS -# FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE -# COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, -# INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES -# (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR -# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) -# HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, -# STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) -# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED -# OF THE POSSIBILITY OF SUCH DAMAGE. - -from ouroboros.event import * + +""" +A simple echo client/server example using Ouroboros flow event sets. +""" + +# Standalone demo; duplicates oecho.py. +# pylint: disable=duplicate-code + +from __future__ import annotations + import argparse +from ouroboros.dev import flow_accept, flow_alloc +from ouroboros.event import FEventQueue, FEventType, FlowSet + + +def client() -> None: + """ + Send a message and print the echo reply. + """ -def client(): with flow_alloc("oecho") as f: f.writeline("Hello, PyOuroboros!") print(f.readline()) -def server(): +def server() -> None: + """ + Accept flows and echo back received messages using flow events. + """ + print("Starting the server.") + while True: with flow_accept() as f: print("New flow.") - fq = FEventQueue() - with FlowSet([f]) as fs: + with FEventQueue() as fq, FlowSet([f]) as fs: fs.wait(fq) - f2, t = fq.next() - if t != FEventType.FlowPkt: + + f2, t = fq.next() + + if t != FEventType.FLOW_PKT: continue + line = f2.readline() - print("Message from client is " + line) + + print(f"Message from client is {line}") f2.writeline(line) if __name__ == "__main__": parser = argparse.ArgumentParser(description='A simple echo client/server') - parser.add_argument('-l', '--listen', help='run as a server', action='store_true') + + parser.add_argument('-l', '--listen', help='run as a server', + action='store_true') + args = parser.parse_args() - if args.listen is True: + if args.listen: server() else: client() diff --git a/ffi/fccntl_wrap.h b/ffi/fccntl_wrap.h index ab227ea..7cfc902 100644 --- a/ffi/fccntl_wrap.h +++ b/ffi/fccntl_wrap.h @@ -1,10 +1,10 @@ /* - * Ouroboros - Copyright (C) 2016 - 2020 + * Ouroboros - Copyright (C) 2016 - 2026 * * An fccntl wrapper * - * Dimitri Staessens <dimitri.staessens@ugent.be> - * Sander Vrijders <sander.vrijders@ugent.be> + * Dimitri Staessens <dimitri@ouroboros.rocks> + * Sander Vrijders <sander@ouroboros.rocks> * * This library is free software; you can redistribute it and/or * modify it under the terms of the GNU Lesser General Public License @@ -57,6 +57,11 @@ int flow_get_tx_qlen(int fd, size_t * sz) return fccntl(fd, FLOWGTXQLEN, sz); } +int flow_get_mtu(int fd, size_t * mtu) +{ + return fccntl(fd, FLOWGMTU, mtu); +} + int flow_set_flags(int fd, uint32_t flags) { return fccntl(fd, FLOWSFLAGS, flags); @@ -66,8 +71,53 @@ int flow_get_flags(int fd) { uint32_t flags; - if (fccntl(fd, FLOWGFLAGS, &flags)) + if (fccntl(fd, FLOWGFLAGS, &flags) < 0) + return -EPERM; + + return (int) flags; +} + +int flow_set_frct_flags(int fd, uint16_t flags) +{ + return fccntl(fd, FRCTSFLAGS, flags); +} + +int flow_get_frct_flags(int fd) +{ + uint16_t flags; + + if (fccntl(fd, FRCTGFLAGS, &flags) < 0) return -EPERM; return (int) flags; } + +int flow_set_frct_max_sdu(int fd, size_t max) +{ + return fccntl(fd, FRCTSMAXSDU, max); +} + +int flow_get_frct_max_sdu(int fd, size_t * max) +{ + return fccntl(fd, FRCTGMAXSDU, max); +} + +int flow_set_frct_rcv_ring_sz(int fd, size_t sz) +{ + return fccntl(fd, FRCTSRRINGSZ, sz); +} + +int flow_get_frct_rcv_ring_sz(int fd, size_t * sz) +{ + return fccntl(fd, FRCTGRRINGSZ, sz); +} + +int flow_set_frct_rto_min(int fd, time_t rto_ns) +{ + return fccntl(fd, FRCTSRTOMIN, rto_ns); +} + +int flow_get_frct_rto_min(int fd, time_t * rto_ns) +{ + return fccntl(fd, FRCTGRTOMIN, rto_ns); +} diff --git a/ffi/irm_wrap.h b/ffi/irm_wrap.h new file mode 100644 index 0000000..9e5ff91 --- /dev/null +++ b/ffi/irm_wrap.h @@ -0,0 +1,47 @@ +/* + * Ouroboros - Copyright (C) 2016 - 2026 + * + * An IRM wrapper for Python bindings + * + * Dimitri Staessens <dimitri@ouroboros.rocks> + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public License + * version 2.1 as published by the Free Software Foundation. + * + * This library 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 + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., http://www.fsf.org/about/contact/. + */ + +#include <ouroboros/irm.h> +#include <arpa/inet.h> + +static int ipcp_config_udp4_set_ip(struct ipcp_config * conf, + const char * ip_str) +{ + return inet_pton(AF_INET, ip_str, &conf->udp4.ip_addr) == 1 ? 0 : -1; +} + +static int ipcp_config_udp4_set_dns(struct ipcp_config * conf, + const char * dns_str) +{ + return inet_pton(AF_INET, dns_str, &conf->udp4.dns_addr) == 1 ? 0 : -1; +} + +static int ipcp_config_udp6_set_ip(struct ipcp_config * conf, + const char * ip_str) +{ + return inet_pton(AF_INET6, ip_str, &conf->udp6.ip_addr) == 1 ? 0 : -1; +} + +static int ipcp_config_udp6_set_dns(struct ipcp_config * conf, + const char * dns_str) +{ + return inet_pton(AF_INET6, dns_str, &conf->udp6.dns_addr) == 1 ? 0 : -1; +} diff --git a/ffi/pyouroboros_build.py b/ffi/pyouroboros_build_dev.py index b4ace8e..fc7a05a 100644 --- a/ffi/pyouroboros_build.py +++ b/ffi/pyouroboros_build_dev.py @@ -1,39 +1,47 @@ # -# Ouroboros - Copyright (C) 2016 - 2020 -# -# Python API for applications -# -# Dimitri Staessens <dimitri@ouroboros.rocks> -# -# This library is free software; you can redistribute it and/or -# modify it under the terms of the GNU Lesser General Public License -# version 2.1 as published by the Free Software Foundation. -# -# This library 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 -# Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public -# License along with this library; if not, write to the Free Software -# Foundation, Inc., http://www.fsf.org/about/contact/. +# SPDX-FileCopyrightText: 2016 - 2026 Dimitri Staessens +# SPDX-License-Identifier: LGPL-2.1-only # +""" +CFFI build script for the ouroboros-dev library. +""" + +# qosspec_t / timespec cdef is duplicated in the irm builder, one copy +# per CFFI module. +# pylint: disable=duplicate-code + +from __future__ import annotations + from cffi import FFI ffibuilder: FFI = FFI() ffibuilder.cdef(""" +/* System types */ +typedef long... time_t; + +struct timespec { + time_t tv_sec; + long tv_nsec; + ...; +}; + +/* OUROBOROS VERSION.H */ +#define OUROBOROS_VERSION_MAJOR ... +#define OUROBOROS_VERSION_MINOR ... +#define OUROBOROS_VERSION_PATCH ... + /* OUROBOROS QOS.H */ typedef struct qos_spec { - uint32_t delay; /* In ms */ - uint64_t bandwidth; /* In bits/s */ - uint8_t availability; /* Class of 9s */ - uint32_t loss; /* Packet loss */ - uint32_t ber; /* Bit error rate, errors per billion bits */ - uint8_t in_order; /* In-order delivery, enables FRCT */ - uint32_t max_gap; /* In ms */ - uint16_t cypher_s; /* Cypher strength, 0 = no encryption */ + uint8_t service; + uint32_t delay; + uint64_t bandwidth; + uint8_t availability; + uint32_t loss; + uint32_t ber; + uint32_t max_gap; + uint32_t timeout; /* Timeout in ms */ } qosspec_t; /* OUROBOROS DEV.H */ @@ -46,9 +54,8 @@ int flow_alloc(const char * dst_name, int flow_accept(qosspec_t * qs, const struct timespec * timeo); -/* Returns flow descriptor, qs updates to supplied QoS. */ +/* Returns flow descriptor. */ int flow_join(const char * bc, - qosspec_t * qs, const struct timespec * timeo); int flow_dealloc(int fd); @@ -61,7 +68,7 @@ ssize_t flow_read(int fd, void * buf, size_t count); -/*OUROBOROS FCCNTL.H, VIA WRAPPER */ +/* OUROBOROS FCCNTL.H, VIA WRAPPER */ int flow_set_snd_timeout(int fd, struct timespec * ts); int flow_set_rcv_timeout(int fd, struct timespec * ts); @@ -76,17 +83,36 @@ int flow_get_rx_qlen(int fd, size_t * sz); int flow_get_tx_qlen(int fd, size_t * sz); +int flow_get_mtu(int fd, size_t * mtu); + int flow_set_flags(int fd, uint32_t flags); int flow_get_flags(int fd); -/*OUROBOROS FQUEUE.H */ +int flow_set_frct_flags(int fd, uint16_t flags); + +int flow_get_frct_flags(int fd); + +int flow_set_frct_max_sdu(int fd, size_t max); + +int flow_get_frct_max_sdu(int fd, size_t * max); + +int flow_set_frct_rcv_ring_sz(int fd, size_t sz); + +int flow_get_frct_rcv_ring_sz(int fd, size_t * sz); + +int flow_set_frct_rto_min(int fd, time_t rto_ns); + +int flow_get_frct_rto_min(int fd, time_t * rto_ns); + +/* OUROBOROS FQUEUE.H */ enum fqtype { - FLOW_PKT = (1 << 0), - FLOW_DOWN = (1 << 1), - FLOW_UP = (1 << 2), - FLOW_ALLOC = (1 << 3), - FLOW_DEALLOC = (1 << 4) + FLOW_PKT = ..., + FLOW_DOWN = ..., + FLOW_UP = ..., + FLOW_ALLOC = ..., + FLOW_DEALLOC = ..., + FLOW_PEER = ... }; struct flow_set; @@ -117,15 +143,16 @@ void fset_del(fset_t * set, int fqueue_next(fqueue_t * fq); -int fqueue_type(fqueue_t * fq); +enum fqtype fqueue_type(fqueue_t * fq); ssize_t fevent(fset_t * set, fqueue_t * fq, const struct timespec * timeo); """) -ffibuilder.set_source("_ouroboros_cffi", +ffibuilder.set_source("_ouroboros_dev_cffi", """ +#include "ouroboros/version.h" #include "ouroboros/qos.h" #include "ouroboros/dev.h" #include "fccntl_wrap.h" diff --git a/ffi/pyouroboros_build_irm.py b/ffi/pyouroboros_build_irm.py new file mode 100644 index 0000000..a9f4bc9 --- /dev/null +++ b/ffi/pyouroboros_build_irm.py @@ -0,0 +1,291 @@ +# +# SPDX-FileCopyrightText: 2016 - 2026 Dimitri Staessens +# SPDX-License-Identifier: LGPL-2.1-only +# + +""" +CFFI build script for the ouroboros-irm library. +""" + +# qosspec_t / timespec cdef is duplicated in the dev builder, one copy +# per CFFI module. +# pylint: disable=duplicate-code + +from __future__ import annotations + +from cffi import FFI + +ffibuilder: FFI = FFI() + +ffibuilder.cdef(""" +/* System types */ +typedef int... pid_t; +typedef long... time_t; + +struct timespec { + time_t tv_sec; + long tv_nsec; + ...; +}; + +/* OUROBOROS VERSION.H */ +#define OUROBOROS_VERSION_MAJOR ... +#define OUROBOROS_VERSION_MINOR ... +#define OUROBOROS_VERSION_PATCH ... + +/* Network types */ +struct in_addr { ...; }; +struct in6_addr { ...; }; + +/* OUROBOROS QOS.H */ +typedef struct qos_spec { + uint8_t service; + uint32_t delay; + uint64_t bandwidth; + uint8_t availability; + uint32_t loss; + uint32_t ber; + uint32_t max_gap; + uint32_t timeout; /* Timeout in ms */ +} qosspec_t; + +/* OUROBOROS IPCP.H */ +enum ipcp_type { + IPCP_LOCAL = ..., + IPCP_UNICAST = ..., + IPCP_BROADCAST = ..., + IPCP_ETH_LLC = ..., + IPCP_ETH_DIX = ..., + IPCP_UDP4 = ..., + IPCP_UDP6 = ..., + IPCP_INVALID = ... +}; + +/* Unicast IPCP policies */ +enum pol_addr_auth { + ADDR_AUTH_FLAT_RANDOM = ..., + ADDR_AUTH_INVALID = ... +}; + +enum pol_link_state { + LS_SIMPLE = ..., + LS_LFA = ..., + LS_ECMP = ..., + LS_INVALID = ... +}; + +struct ls_config { + enum pol_link_state pol; + time_t t_recalc; + time_t t_update; + time_t t_timeo; +}; + +enum pol_routing { + ROUTING_LINK_STATE = ..., + ROUTING_INVALID = ... +}; + +struct routing_config { + enum pol_routing pol; + union { + struct ls_config ls; + }; +}; + +enum pol_cong_avoid { + CA_NONE = ..., + CA_MB_ECN = ..., + CA_INVALID = ... +}; + +struct dt_config { + struct { + uint8_t addr_size; + uint8_t eid_size; + uint8_t max_ttl; + }; + struct routing_config routing; +}; + +enum pol_dir { + DIR_DHT = ..., + DIR_INVALID = ... +}; + +enum pol_dir_hash { + DIR_HASH_SHA3_224 = ..., + DIR_HASH_SHA3_256 = ..., + DIR_HASH_SHA3_384 = ..., + DIR_HASH_SHA3_512 = ..., + DIR_HASH_INVALID = ... +}; + +struct dir_dht_config { + struct { + uint32_t alpha; + uint32_t k; + uint32_t t_expire; + uint32_t t_refresh; + uint32_t t_replicate; + } params; + uint64_t peer; +}; + +struct dir_config { + enum pol_dir pol; + union { + struct dir_dht_config dht; + }; +}; + +struct uni_config { + struct dt_config dt; + struct dir_config dir; + enum pol_addr_auth addr_auth_type; + enum pol_cong_avoid cong_avoid; +}; + +struct eth_config { + char dev[256]; /* DEV_NAME_SIZE + 1 */ + uint16_t ethertype; +}; + +struct udp4_config { + struct in_addr ip_addr; + struct in_addr dns_addr; + uint16_t port; +}; + +struct udp6_config { + struct in6_addr ip_addr; + struct in6_addr dns_addr; + uint16_t port; +}; + +struct layer_info { + char name[256]; /* LAYER_NAME_SIZE + 1 */ + enum pol_dir_hash dir_hash_algo; +}; + +struct ipcp_config { + struct layer_info layer_info; + enum ipcp_type type; + + union { + struct uni_config unicast; + struct udp4_config udp4; + struct udp6_config udp6; + struct eth_config eth; + }; +}; + +/* OUROBOROS NAME.H */ +#define BIND_AUTO ... + +enum pol_balance { + LB_RR = ..., + LB_SPILL = ..., + LB_INVALID = ... +}; + +struct name_sec_paths { + char sec[512]; /* NAME_PATH_SIZE + 1 */ + char key[512]; /* NAME_PATH_SIZE + 1 */ + char crt[512]; /* NAME_PATH_SIZE + 1 */ +}; + +struct name_info { + char name[256]; /* NAME_SIZE + 1 */ + enum pol_balance pol_lb; + + struct name_sec_paths s; + struct name_sec_paths c; +}; + +/* OUROBOROS IRM.H */ + +struct ipcp_list_info { + pid_t pid; + enum ipcp_type type; + char name[255]; /* NAME_SIZE */ + char layer[255]; /* LAYER_NAME_SIZE */ +}; + +pid_t irm_create_ipcp(const char * name, + enum ipcp_type type); + +int irm_destroy_ipcp(pid_t pid); + +ssize_t irm_list_ipcps(struct ipcp_list_info ** ipcps); + +int irm_enroll_ipcp(pid_t pid, + const char * dst); + +int irm_bootstrap_ipcp(pid_t pid, + const struct ipcp_config * conf); + +int irm_connect_ipcp(pid_t pid, + const char * dst, + const char * component, + qosspec_t qs); + +int irm_disconnect_ipcp(pid_t pid, + const char * dst, + const char * component); + +int irm_bind_program(const char * prog, + const char * name, + uint16_t opts, + int argc, + char ** argv); + +int irm_unbind_program(const char * progr, + const char * name); + +int irm_bind_process(pid_t pid, + const char * name); + +int irm_unbind_process(pid_t pid, + const char * name); + +int irm_create_name(struct name_info * info); + +int irm_destroy_name(const char * name); + +ssize_t irm_list_names(struct name_info ** names); + +int irm_reg_name(const char * name, + pid_t pid); + +int irm_unreg_name(const char * name, + pid_t pid); + +/* IRM WRAPPER HELPERS */ +int ipcp_config_udp4_set_ip(struct ipcp_config * conf, + const char * ip_str); + +int ipcp_config_udp4_set_dns(struct ipcp_config * conf, + const char * dns_str); + +int ipcp_config_udp6_set_ip(struct ipcp_config * conf, + const char * ip_str); + +int ipcp_config_udp6_set_dns(struct ipcp_config * conf, + const char * dns_str); + +/* libc */ +void free(void *ptr); +""") + +ffibuilder.set_source("_ouroboros_irm_cffi", + """ +#include "ouroboros/version.h" +#include "ouroboros/qos.h" +#include "irm_wrap.h" + """, + libraries=['ouroboros-irm'], + extra_compile_args=["-I./ffi/"]) + +if __name__ == "__main__": + ffibuilder.compile(verbose=True) diff --git a/ouroboros/__init__.py b/ouroboros/__init__.py new file mode 100644 index 0000000..cbca95d --- /dev/null +++ b/ouroboros/__init__.py @@ -0,0 +1,77 @@ +# +# SPDX-FileCopyrightText: 2016 - 2026 Dimitri Staessens +# SPDX-License-Identifier: LGPL-2.1-only +# + +""" +Public Python API for Ouroboros. + +The package surface re-exports the pure-Python helpers only: errors and +QoS. The rest of the API is imported from its submodule: flows from +:mod:`ouroboros.dev`, the event loop from :mod:`ouroboros.event`, the +control plane from :mod:`ouroboros.irm` (or :mod:`ouroboros.cli`). +""" + +from __future__ import annotations + +from importlib.metadata import PackageNotFoundError, version + +from ouroboros.errors import ( + BindError, + FlowAuthError, + FlowCryptError, + FlowDeallocWarning, + FlowDownError, + FlowError, + FlowEventError, + FlowPeerError, + FlowPermissionError, + FlowReplayError, + InvalidNameError, + IpcpBootstrapError, + IpcpConnectError, + IpcpCreateError, + IpcpEnrollError, + IpcpStateError, + IpcpTypeError, + IpcpdError, + IrmError, + IrmdError, + NameExistsError, + NameNotFoundError, + OuroborosError, +) +from ouroboros.qos import ( + QOS_MSG, + QOS_RAW, + QOS_RAW_SAFE, + QOS_RT, + QOS_RT_SAFE, + QOS_STREAM, + QoSService, + QoSSpec, +) + +try: + __version__ = version("PyOuroboros") +except PackageNotFoundError: + __version__ = "0.0.0+unknown" + +__all__ = [ + "__version__", + # qos + "QoSService", "QoSSpec", + "QOS_MSG", "QOS_RAW", "QOS_RAW_SAFE", + "QOS_RT", "QOS_RT_SAFE", "QOS_STREAM", + # errors + "OuroborosError", + "IrmError", "IrmdError", "IpcpdError", + "IpcpCreateError", "IpcpBootstrapError", "IpcpEnrollError", + "IpcpConnectError", "IpcpTypeError", "IpcpStateError", + "BindError", + "NameNotFoundError", "NameExistsError", "InvalidNameError", + "FlowError", "FlowDownError", "FlowPeerError", + "FlowPermissionError", "FlowEventError", + "FlowCryptError", "FlowAuthError", "FlowReplayError", + "FlowDeallocWarning", +] diff --git a/ouroboros/_qosspec.py b/ouroboros/_qosspec.py new file mode 100644 index 0000000..5c8b245 --- /dev/null +++ b/ouroboros/_qosspec.py @@ -0,0 +1,61 @@ +# +# SPDX-FileCopyrightText: 2016 - 2026 Dimitri Staessens +# SPDX-License-Identifier: LGPL-2.1-only +# + +""" +Shared QoSSpec <-> qosspec_t conversion helpers. + +CFFI types are not interchangeable across extension modules, so the +caller passes its own ``ffi`` instance. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Optional + +from ouroboros.qos import QoSSpec + +if TYPE_CHECKING: + from cffi import FFI + + +def qos_to_qosspec(ffi: FFI, qos: Optional[QoSSpec]) -> Any: + """ + Convert a :class:`QoSSpec` to a freshly-allocated ``qosspec_t *``. + + Returns ``ffi.NULL`` when *qos* is ``None``. + """ + + if qos is None: + return ffi.NULL + + return ffi.new("qosspec_t *", + [qos.service, + qos.delay, + qos.bandwidth, + qos.availability, + qos.loss, + qos.ber, + qos.max_gap, + qos.timeout]) + + +def qosspec_to_qos(ffi: FFI, _qos: Any) -> Optional[QoSSpec]: + """ + Convert a ``qosspec_t *`` back to a :class:`QoSSpec`. + + Returns ``None`` when *_qos* is ``ffi.NULL``. + """ + + if _qos == ffi.NULL: + return None + + return QoSSpec(service=_qos.service, + delay=_qos.delay, + bandwidth=_qos.bandwidth, + availability=_qos.availability, + loss=_qos.loss, + ber=_qos.ber, + max_gap=_qos.max_gap, + timeout=_qos.timeout) diff --git a/ouroboros/_timespec.py b/ouroboros/_timespec.py new file mode 100644 index 0000000..1656eb2 --- /dev/null +++ b/ouroboros/_timespec.py @@ -0,0 +1,59 @@ +# +# SPDX-FileCopyrightText: 2016 - 2026 Dimitri Staessens +# SPDX-License-Identifier: LGPL-2.1-only +# + +""" +Shared float-seconds <-> ``struct timespec *`` conversions. + +The caller passes its own ``ffi`` instance so the returned CData is +bound to the right CFFI extension module. +""" + +from __future__ import annotations + +from math import modf +from typing import TYPE_CHECKING, Any, Optional + +if TYPE_CHECKING: + from cffi import FFI + +BILLION = 1000 * 1000 * 1000 + + +def fl_to_timespec(ffi: FFI, timeo: Optional[float]) -> Any: + """ + Convert *timeo* (seconds, or None) to a ``struct timespec *``. + + *None* yields ``ffi.NULL`` (block forever). A non-positive *timeo* + yields a zeroed timespec (async / poll). + """ + + if timeo is None: + return ffi.NULL + + if timeo <= 0: + return ffi.new("struct timespec *", [0, 0]) + + frac, whole = modf(timeo) + _timeo = ffi.new("struct timespec *") + _timeo.tv_sec = int(whole) + _timeo.tv_nsec = int(frac * BILLION) + + return _timeo + + +def timespec_to_fl(ffi: FFI, _timeo: Any) -> Optional[float]: + """ + Convert a ``struct timespec *`` to seconds-as-float. + + ``ffi.NULL`` yields *None* (block forever). + """ + + if _timeo == ffi.NULL: + return None + + if _timeo.tv_sec <= 0 and _timeo.tv_nsec == 0: + return 0.0 + + return _timeo.tv_sec + _timeo.tv_nsec / BILLION diff --git a/ouroboros/cli.py b/ouroboros/cli.py new file mode 100644 index 0000000..e887410 --- /dev/null +++ b/ouroboros/cli.py @@ -0,0 +1,448 @@ +# +# SPDX-FileCopyrightText: 2016 - 2026 Dimitri Staessens +# SPDX-License-Identifier: LGPL-2.1-only +# + +""" +Higher-level wrappers that mirror the ``irm`` CLI tool behaviour. + +Adds program path resolution, IPCP name-to-pid lookup and ``autobind`` +handling on top of the raw IRM API. +""" + +from __future__ import annotations + +import shutil +from typing import List, Optional, Set + +from ouroboros.errors import BindError +from ouroboros.irm import ( + DT_COMP, + MGMT_COMP, + DhtConfig, + DirConfig, + DtConfig, + EthConfig, + IpcpConfig, + IpcpInfo, + IpcpType, + LinkStateConfig, + NameInfo, + RoutingConfig, + Udp4Config, + Udp6Config, + UnicastConfig, + bind_program as _irm_bind_program, + bind_process as _irm_bind_process, + bootstrap_ipcp as _irm_bootstrap_ipcp, + connect_ipcp as _irm_connect_ipcp, + create_ipcp, + create_name as _irm_create_name, + destroy_ipcp as _irm_destroy_ipcp, + destroy_name, + disconnect_ipcp as _irm_disconnect_ipcp, + enroll_ipcp as _irm_enroll_ipcp, + list_ipcps as _irm_list_ipcps, + list_names as _irm_list_names, + reg_name as _irm_reg_name, + unbind_process as _irm_unbind_process, + unbind_program as _irm_unbind_program, + unreg_name as _irm_unreg_name, +) +from ouroboros.qos import QoSSpec + +__all__ = [ + # Functions + "create_ipcp", "destroy_ipcp", "bootstrap_ipcp", "enroll_ipcp", + "connect_ipcp", "disconnect_ipcp", "list_ipcps", + "bind_program", "bind_process", "bind_ipcp", + "unbind_program", "unbind_process", "unbind_ipcp", + "create_name", "destroy_name", "reg_name", "unreg_name", "list_names", + "autoboot", + # Re-exported configuration types + "DhtConfig", "DirConfig", "DtConfig", "EthConfig", + "IpcpConfig", "IpcpInfo", "IpcpType", + "LinkStateConfig", "NameInfo", "RoutingConfig", + "Udp4Config", "Udp6Config", "UnicastConfig", +] + + +def _pid_of(ipcp_name: str) -> int: + """ + Look up the pid of a running IPCP by its name. + """ + + for info in _irm_list_ipcps(): + if info.name == ipcp_name: + return info.pid + + raise ValueError(f"No IPCP named {ipcp_name!r}") + + +def _collect_pids(ipcp: Optional[str], + ipcps: Optional[List[str]], + layer: Optional[str], + layers: Optional[List[str]]) -> Set[int]: + """ + Collect the set of pids matching the given name/layer filters. + """ + + ipcp_names: List[str] = [] + + if ipcp is not None: + ipcp_names.append(ipcp) + + if ipcps is not None: + ipcp_names.extend(ipcps) + + layer_names: List[str] = [] + + if layer is not None: + layer_names.append(layer) + + if layers is not None: + layer_names.extend(layers) + + pids: Set[int] = set() + + if not ipcp_names and not layer_names: + return pids + + all_ipcps = _irm_list_ipcps() + + for ipcp_name in ipcp_names: + for i in all_ipcps: + if i.name == ipcp_name: + pids.add(i.pid) + break + + for lyr in layer_names: + for i in all_ipcps: + if i.layer == lyr: + pids.add(i.pid) + + return pids + + +def destroy_ipcp(name: str) -> None: + """ + Destroy an IPCP by name. + + :param name: Name of the IPCP to destroy. + :raises ValueError: If no IPCP with *name* exists. + """ + + _irm_destroy_ipcp(_pid_of(name)) + + +def list_ipcps(name: Optional[str] = None, + layer: Optional[str] = None, + ipcp_type: Optional[IpcpType] = None) -> List[IpcpInfo]: + """ + List running IPCPs, optionally filtered. + + Filters are exact matches. + + :param name: Filter by IPCP name (exact match). + :param layer: Filter by layer name (exact match). + :param ipcp_type: Filter by IPCP type. + :return: List of matching :class:`IpcpInfo` objects. + """ + + result = _irm_list_ipcps() + + if name is not None: + result = [i for i in result if i.name == name] + + if layer is not None: + result = [i for i in result if i.layer == layer] + + if ipcp_type is not None: + result = [i for i in result if i.type == ipcp_type] + + return result + + +def reg_name(name: str, + ipcp: Optional[str] = None, + ipcps: Optional[List[str]] = None, + layer: Optional[str] = None, + layers: Optional[List[str]] = None) -> None: + """ + Register a name with IPCP(s), creating it first if needed. + + Registers with every IPCP in *layer* and *layers*. + + :param name: The name to register. + :param ipcp: Single IPCP name to register with. + :param ipcps: List of IPCP names to register with. + :param layer: Single layer name to register with. + :param layers: List of layer names to register with. + """ + + existing = {n.name for n in _irm_list_names()} + if name not in existing: + _irm_create_name(NameInfo(name=name)) + + for pid in _collect_pids(ipcp, ipcps, layer, layers): + _irm_reg_name(name, pid) + + +def bind_program(prog: str, + name: str, + opts: int = 0, + argv: Optional[List[str]] = None) -> None: + """ + Bind a program to a name. + + :param prog: Program name or path. Bare names (without ``/``) are + resolved on ``PATH``. + :param name: Name to bind to. + :param opts: Bind options (e.g. ``BIND_AUTO``). + :param argv: Arguments passed when the program is auto-started. + :raises BindError: If the program is not found on ``PATH``. + """ + + if '/' not in prog: + resolved = shutil.which(prog) + if resolved is None: + raise BindError(f"Program {prog!r} not found on PATH") + + prog = resolved + + _irm_bind_program(prog, name, opts=opts, argv=argv) + + +def unbind_program(prog: str, name: str) -> None: + """ + Unbind a program from a name. + + :param prog: Path to the program. + :param name: Name to unbind from. + """ + + _irm_unbind_program(prog, name) + + +def bind_process(pid: int, name: str) -> None: + """ + Bind a running process to a name. + + :param pid: PID of the process. + :param name: Name to bind to. + """ + + _irm_bind_process(pid, name) + + +def unbind_process(pid: int, name: str) -> None: + """ + Unbind a process from a name. + + :param pid: PID of the process. + :param name: Name to unbind from. + """ + + _irm_unbind_process(pid, name) + + +def bind_ipcp(ipcp: str, name: str) -> None: + """ + Bind an IPCP to a name. + + :param ipcp: IPCP instance name. + :param name: Name to bind to. + :raises ValueError: If no IPCP with *ipcp* exists. + """ + + _irm_bind_process(_pid_of(ipcp), name) + + +def unbind_ipcp(ipcp: str, name: str) -> None: + """ + Unbind an IPCP from a name. + + :param ipcp: IPCP instance name. + :param name: Name to unbind from. + :raises ValueError: If no IPCP with *ipcp* exists. + """ + + _irm_unbind_process(_pid_of(ipcp), name) + + +def create_name(name: str, + pol_lb: Optional[int] = None, + info: Optional[NameInfo] = None) -> None: + """ + Create a registered name. + + :param name: The name to create. + :param pol_lb: Load-balance policy. + :param info: Full :class:`NameInfo`; overrides *name* and + *pol_lb*. + """ + + if info is not None: + _irm_create_name(info) + else: + ni = NameInfo(name=name) + + if pol_lb is not None: + ni.pol_lb = pol_lb + + _irm_create_name(ni) + + +def list_names(name: Optional[str] = None) -> List[NameInfo]: + """ + List all registered names, optionally filtered on exact name. + + :param name: Filter by name (exact match). + :return: List of :class:`NameInfo` objects. + """ + + result = _irm_list_names() + + if name is not None: + result = [n for n in result if n.name == name] + + return result + + +def unreg_name(name: str, + ipcp: Optional[str] = None, + ipcps: Optional[List[str]] = None, + layer: Optional[str] = None, + layers: Optional[List[str]] = None) -> None: + """ + Unregister a name from IPCP(s). + + Unregisters from every IPCP in *layer* and *layers*. + + :param name: The name to unregister. + :param ipcp: Single IPCP name to unregister from. + :param ipcps: List of IPCP names to unregister from. + :param layer: Single layer name to unregister from. + :param layers: List of layer names to unregister from. + """ + + for pid in _collect_pids(ipcp, ipcps, layer, layers): + _irm_unreg_name(name, pid) + + +def bootstrap_ipcp(name: str, + conf: IpcpConfig, + autobind: bool = False) -> None: + """ + Bootstrap an IPCP, optionally binding it to its name and layer. + + Autobind applies to UNICAST and BROADCAST IPCPs only, and is rolled + back if the bootstrap fails. + + :param name: Name of the IPCP. + :param conf: IPCP configuration, including layer name and type. + :param autobind: Bind the IPCP process to its name and layer. + """ + + pid = _pid_of(name) + layer_name = conf.layer_name + + autobind_types = (IpcpType.UNICAST, IpcpType.BROADCAST) + if autobind and conf.ipcp_type in autobind_types: + _irm_bind_process(pid, name) + _irm_bind_process(pid, layer_name) + + try: + _irm_bootstrap_ipcp(pid, conf) + except Exception: + if autobind and conf.ipcp_type in autobind_types: + _irm_unbind_process(pid, name) + _irm_unbind_process(pid, layer_name) + + raise + + +def enroll_ipcp(name: str, dst: str, + autobind: bool = False) -> None: + """ + Enroll an IPCP, optionally binding it to its name and layer. + + :param name: Name of the IPCP. + :param dst: Destination name or layer to enroll with. + :param autobind: Bind the IPCP process to its name and to the layer + learned during enrollment. + """ + + pid = _pid_of(name) + + _irm_enroll_ipcp(pid, dst) + + if autobind: + for info in _irm_list_ipcps(): + if info.pid == pid: + _irm_bind_process(pid, info.name) + _irm_bind_process(pid, info.layer) + break + + +def connect_ipcp(name: str, dst: str, comp: str = "*", + qos: Optional[QoSSpec] = None) -> None: + """ + Connect IPCP components to a destination. + + :param name: Name of the IPCP. + :param dst: Destination IPCP name. + :param comp: ``"dt"``, ``"mgmt"``, or ``"*"`` for both. + :param qos: QoS specification for the dt component. + """ + + pid = _pid_of(name) + + if comp in ("*", "mgmt"): + _irm_connect_ipcp(pid, MGMT_COMP, dst) + + if comp in ("*", "dt"): + _irm_connect_ipcp(pid, DT_COMP, dst, qos=qos) + + +def disconnect_ipcp(name: str, dst: str, comp: str = "*") -> None: + """ + Disconnect IPCP components from a destination. + + :param name: Name of the IPCP. + :param dst: Destination IPCP name. + :param comp: ``"dt"``, ``"mgmt"``, or ``"*"`` for both. + """ + + pid = _pid_of(name) + + if comp in ("*", "mgmt"): + _irm_disconnect_ipcp(pid, MGMT_COMP, dst) + + if comp in ("*", "dt"): + _irm_disconnect_ipcp(pid, DT_COMP, dst) + + +def autoboot(name: str, + ipcp_type: IpcpType, + layer: str, + conf: Optional[IpcpConfig] = None) -> None: + """ + Create, autobind and bootstrap an IPCP in one step. + + :param name: Name for the IPCP. + :param ipcp_type: Type of IPCP to create. + :param layer: Layer name to bootstrap into. + :param conf: IPCP configuration. If *None*, a default + :class:`IpcpConfig` is built from *ipcp_type* and + *layer*. + """ + + create_ipcp(name, ipcp_type) + + if conf is None: + conf = IpcpConfig(ipcp_type=ipcp_type, layer_name=layer) + else: + conf.layer_name = layer + + bootstrap_ipcp(name, conf, autobind=True) diff --git a/ouroboros/dev.py b/ouroboros/dev.py index 7d29624..1397bca 100644 --- a/ouroboros/dev.py +++ b/ouroboros/dev.py @@ -1,398 +1,582 @@ # -# Ouroboros - Copyright (C) 2016 - 2020 -# -# Python API for applications -# -# Dimitri Staessens <dimitri@ouroboros.rocks> -# -# This library is free software; you can redistribute it and/or -# modify it under the terms of the GNU Lesser General Public License -# version 2.1 as published by the Free Software Foundation. -# -# This library 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 -# Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public -# License along with this library; if not, write to the Free Software -# Foundation, Inc., http://www.fsf.org/about/contact/. +# SPDX-FileCopyrightText: 2016 - 2026 Dimitri Staessens +# SPDX-License-Identifier: LGPL-2.1-only # -from _ouroboros_cffi import ffi, lib -import errno +""" +Flow allocation, acceptance and I/O for the Ouroboros dev API. +""" + +from __future__ import annotations + +import warnings from enum import IntFlag -from ouroboros.qos import * -from ouroboros.qos import _qos_to_qosspec, _fl_to_timespec, _qosspec_to_qos, _timespec_to_fl +from typing import TYPE_CHECKING, Optional, Type -# Some constants -MILLION = 1000 * 1000 -BILLION = 1000 * 1000 * 1000 +from _ouroboros_dev_cffi import ffi, lib +from ouroboros._qosspec import qos_to_qosspec, qosspec_to_qos +from ouroboros._timespec import fl_to_timespec, timespec_to_fl +from ouroboros.errors import ( + FlowAlreadyAllocatedError, + FlowDeallocWarning, + FlowNotAllocatedError, + check_ouroboros_version, + raise_errno, +) +from ouroboros.qos import QoSSpec -# ouroboros exceptions -class FlowAllocatedException(Exception): - pass +if TYPE_CHECKING: + from types import TracebackType +check_ouroboros_version(lib.OUROBOROS_VERSION_MAJOR, + lib.OUROBOROS_VERSION_MINOR) -class FlowNotAllocatedException(Exception): - pass +class FrctFlags(IntFlag): + """ + FRCT-level feature flags. + """ -class FlowDownException(Exception): - pass + RETRANSMIT = 0o1 + RESCNTL = 0o2 + LINGER = 0o4 -class FlowPermissionException(Exception): - pass +class FlowProperties(IntFlag): + """ + Flags describing flow properties and blocking behaviour. + """ + READ_ONLY = 0o0 + WRITE_ONLY = 0o1 + READ_WRITE = 0o2 + DOWN = 0o4 + NON_BLOCKING_READ = 0o1000 + NON_BLOCKING_WRITE = 0o2000 + NON_BLOCKING = NON_BLOCKING_READ | NON_BLOCKING_WRITE + NO_PARTIAL_READ = 0o10000 + NO_PARTIAL_WRITE = 0o20000 -class FlowException(Exception): - pass +class Flow: + """ + Represents an allocated Ouroboros flow. + """ -class FlowDeallocWarning(Warning): - pass + def __init__(self, fd: int = -1) -> None: + """ + Construct a Flow wrapper. + :param fd: Existing flow descriptor, or -1 for an unallocated + flow. + """ -def _raise(e: int) -> None: - if e >= 0: - return + self._fd: int = fd - print("error: " + str(e)) - if e == -errno.ETIMEDOUT: - raise TimeoutError() - if e == -errno.EINVAL: - raise ValueError() - if e == -errno.ENOMEM: - raise MemoryError() - else: - raise FlowException() + def __enter__(self) -> Flow: + return self + def __exit__(self, exc_type: Optional[Type[BaseException]], + exc_value: Optional[BaseException], + tb: Optional[TracebackType]) -> None: + self.dealloc() -class FlowProperties(IntFlag): - ReadOnly = 0o0 - WriteOnly = 0o1 - ReadWrite = 0o2 - Down = 0o4 - NonBlockingRead = 0o1000 - NonBlockingWrite = 0o2000 - NonBlocking = NonBlockingRead | NonBlockingWrite - NoPartialRead = 0o10000 - NoPartialWrite = 0o200000 + def __del__(self) -> None: + """ + Deallocate on collection; errors are swallowed as the library + may already be torn down at interpreter shutdown. + """ + try: + self.dealloc() + except Exception: # pylint: disable=broad-exception-caught + pass -class Flow: + def __repr__(self) -> str: + return f"Flow(fd={self._fd})" - def __init__(self): - self.__fd: int = -1 + def fileno(self) -> int: + """ + Return the underlying ouroboros flow descriptor. - def __enter__(self): - return self + :return: The ouroboros flow descriptor, or -1 if unallocated. + """ - def __exit__(self, exc_type, exc_value, tb): - lib.flow_dealloc(self.__fd) + return self._fd def alloc(self, dst: str, - qos: QoSSpec = None, - timeo: float = None) -> Optional[QoSSpec]: + qos: Optional[QoSSpec] = None, + timeo: Optional[float] = None) -> Optional[QoSSpec]: """ - Allocates a flow with a certain QoS to a destination + Allocate a flow with a certain QoS to a destination. - :param dst: The destination name (string) - :param qos: The QoS for the requested flow (QoSSpec) - :param timeo: The timeout for the flow allocation (None -> forever, 0->async) - :return: The QoS for the new flow + :param dst: Destination name. + :param qos: Requested QoS. + :param timeo: Allocation timeout (None blocks forever, 0 is + async). + :return: The QoS the IRM granted for the new flow. + :raises FlowAlreadyAllocatedError: If this Flow is already + allocated. + :raises FlowError: On a negative ouroboros return code. """ - if self.__fd >= 0: - raise FlowAllocatedException() + if self._fd >= 0: + raise FlowAlreadyAllocatedError() - _qos = _qos_to_qosspec(qos) + _qos = qos_to_qosspec(ffi, qos) + _timeo = fl_to_timespec(ffi, timeo) - _timeo = _fl_to_timespec(timeo) + rc = lib.flow_alloc(dst.encode(), _qos, _timeo) - self.__fd = lib.flow_alloc(dst.encode(), _qos, _timeo) + raise_errno(rc) - _raise(self.__fd) + self._fd = rc - return _qosspec_to_qos(_qos) + return qosspec_to_qos(ffi, _qos) def accept(self, - timeo: float = None) -> QoSSpec: + timeo: Optional[float] = None) -> Optional[QoSSpec]: """ - Accepts new flows and returns the QoS + Accept an incoming flow and return its QoS. - :param timeo: The timeout for the flow allocation (None -> forever, 0->async) - :return: The QoS for the new flow + :param timeo: Accept timeout (None blocks forever, 0 is async). + :return: The QoS of the accepted flow. + :raises FlowAlreadyAllocatedError: If this Flow is already + allocated. + :raises FlowError: On a negative ouroboros return code. """ - if self.__fd >= 0: - raise FlowAllocatedException() + if self._fd >= 0: + raise FlowAlreadyAllocatedError() _qos = ffi.new("qosspec_t *") + _timeo = fl_to_timespec(ffi, timeo) - _timeo = _fl_to_timespec(timeo) + rc = lib.flow_accept(_qos, _timeo) - self.__fd = lib.flow_accept(_qos, _timeo) + raise_errno(rc) - _raise(self.__fd) + self._fd = rc - return _qosspec_to_qos(_qos) + return qosspec_to_qos(ffi, _qos) def join(self, dst: str, - qos: QoSSpec = None, - timeo: float = None) -> Optional[QoSSpec]: + timeo: Optional[float] = None) -> None: """ - Join a broadcast layer + Join a broadcast layer. - :param dst: The destination broadcast layer name (string) - :param qos: The QoS for the requested flow (QoSSpec) - :param timeo: The timeout for the flow allocation (None -> forever, 0->async) - :return: The QoS for the flow + :param dst: Broadcast layer name. + :param timeo: Join timeout (None blocks forever, 0 is async). + :raises FlowAlreadyAllocatedError: If this Flow is already + allocated. + :raises FlowError: On a negative ouroboros return code. """ - if self.__fd >= 0: - raise FlowAllocatedException() - - _qos = _qos_to_qosspec(qos) + if self._fd >= 0: + raise FlowAlreadyAllocatedError() - _timeo = _fl_to_timespec(timeo) + _timeo = fl_to_timespec(ffi, timeo) - self.__fd = lib.flow_join(dst.encode(), _qos, _timeo) + rc = lib.flow_join(dst.encode(), _timeo) - _raise(self.__fd) + raise_errno(rc) - return _qosspec_to_qos(_qos) + self._fd = rc - def dealloc(self): + def dealloc(self) -> None: """ - Deallocate a flow - + Deallocate this flow; a no-op on an unallocated flow. """ - self.__fd = lib.flow_dealloc(self.__fd) + if self._fd < 0: + return - if self.__fd < 0: - raise FlowDeallocWarning + rc = lib.flow_dealloc(self._fd) + self._fd = -1 - self.__fd = -1 + if rc < 0: + warnings.warn(f"flow_dealloc returned {rc}", + FlowDeallocWarning, stacklevel=2) def write(self, buf: bytes, - count: int = None) -> int: + count: Optional[int] = None) -> int: """ - Attempt to write <count> bytes to a flow + Write up to *count* bytes to the flow. - :param buf: Buffer to write from - :param count: Number of bytes to write from the buffer - :return: Number of bytes written + :param buf: Buffer to write from. + :param count: Number of bytes to write (defaults to + ``len(buf)``). + :return: Number of bytes written. + :raises FlowError: On a negative ouroboros return code. + :raises FlowNotAllocatedError: If the flow is not allocated. """ - if self.__fd < 0: - raise FlowNotAllocatedException() + if self._fd < 0: + raise FlowNotAllocatedError() if count is None: - return lib.flow_write(self.__fd, ffi.from_buffer(buf), len(buf)) - else: - return lib.flow_write(self.__fd, ffi.from_buffer(buf), count) + count = len(buf) + + rc = lib.flow_write(self._fd, ffi.from_buffer(buf), count) + + return raise_errno(rc) def writeline(self, ln: str) -> int: """ - Attempt to write a string to a flow + Encode *ln* as UTF-8 and write it to the flow. - :param ln: String to write - :return: Number of bytes written + :param ln: String to write. + :return: Number of bytes written. + :raises FlowNotAllocatedError: If the flow is not allocated. """ - if self.__fd < 0: - raise FlowNotAllocatedException() + if self._fd < 0: + raise FlowNotAllocatedError() - return self.write(ln.encode(), len(ln)) + data = ln.encode() + + return self.write(data, len(data)) def read(self, - count: int = None) -> bytes: + count: Optional[int] = None) -> bytes: """ - Attempt to read bytes from a flow + Read up to *count* bytes from the flow. - :param count: Maximum number of bytes to read - :return: Bytes read + :param count: Maximum number of bytes to read (default 2048). + :return: Bytes read; empty when a previous read fully + consumed the SDU. + :raises FlowError: On a negative ouroboros return code. + :raises FlowNotAllocatedError: If the flow is not allocated. """ - if self.__fd < 0: - raise FlowNotAllocatedException() + if self._fd < 0: + raise FlowNotAllocatedError() if count is None: count = 2048 _buf = ffi.new("char []", count) - result = lib.flow_read(self.__fd, _buf, count) + rc = lib.flow_read(self._fd, _buf, count) + + raise_errno(rc) - return ffi.unpack(_buf, result) + return ffi.unpack(_buf, rc) - def readline(self): + def readline(self) -> str: """ + Read from the flow and decode the result as UTF-8. - :return: A string + :return: Decoded UTF-8 string read from the flow. + :raises FlowNotAllocatedError: If the flow is not allocated. """ - if self.__fd < 0: - raise FlowNotAllocatedException() + + if self._fd < 0: + raise FlowNotAllocatedError() return self.read().decode() - # flow manipulation - def set_snd_timeout(self, - timeo: float): + def set_snd_timeout(self, timeo: float) -> None: """ - Set the timeout for blocking writes + Set the timeout for blocking writes (seconds). + + :param timeo: Write timeout in seconds. + :raises FlowError: On a negative ouroboros return code. """ - _timeo = _fl_to_timespec(timeo) - if lib.flow_set_snd_timout(self.__fd, _timeo) != 0: - raise FlowPermissionException() + _timeo = fl_to_timespec(ffi, timeo) - def get_snd_timeout(self) -> float: + raise_errno(lib.flow_set_snd_timeout(self._fd, _timeo)) + + def get_snd_timeout(self) -> Optional[float]: """ - Get the timeout for blocking writes + Return the timeout for blocking writes (seconds). - :return: timeout for blocking writes + :return: Timeout for blocking writes, in seconds. + :raises FlowError: On a negative ouroboros return code. """ + _timeo = ffi.new("struct timespec *") - if lib.flow_get_snd_timeout(self.__fd, _timeo) != 0: - raise FlowPermissionException() + raise_errno(lib.flow_get_snd_timeout(self._fd, _timeo)) - return _timespec_to_fl(_timeo) + return timespec_to_fl(ffi, _timeo) - def set_rcv_timeout(self, - timeo: float): + def set_rcv_timeout(self, timeo: float) -> None: """ - Set the timeout for blocking writes + Set the timeout for blocking reads (seconds). + + :param timeo: Read timeout in seconds. + :raises FlowError: On a negative ouroboros return code. """ - _timeo = _fl_to_timespec(timeo) - if lib.flow_set_rcv_timout(self.__fd, _timeo) != 0: - raise FlowPermissionException() + _timeo = fl_to_timespec(ffi, timeo) - def get_rcv_timeout(self) -> float: + raise_errno(lib.flow_set_rcv_timeout(self._fd, _timeo)) + + def get_rcv_timeout(self) -> Optional[float]: """ - Get the timeout for blocking reads + Return the timeout for blocking reads (seconds). - :return: timeout for blocking writes + :return: Timeout for blocking reads, in seconds. + :raises FlowError: On a negative ouroboros return code. """ + _timeo = ffi.new("struct timespec *") - if lib.flow_get_rcv_timeout(self.__fd, _timeo) != 0: - raise FlowPermissionException() + raise_errno(lib.flow_get_rcv_timeout(self._fd, _timeo)) - return _timespec_to_fl(_timeo) + return timespec_to_fl(ffi, _timeo) - def get_qos(self) -> QoSSpec: + def get_qos(self) -> Optional[QoSSpec]: """ + Return the current QoS in effect on the flow. - :return: Current QoS on the flow + :return: The QoS currently in effect on the flow. + :raises FlowError: On a negative ouroboros return code. """ + _qos = ffi.new("qosspec_t *") - if lib.flow_get_qos(self.__fd, _qos) != 0: - raise FlowPermissionException() + raise_errno(lib.flow_get_qos(self._fd, _qos)) - return _qosspec_to_qos(_qos) + return qosspec_to_qos(ffi, _qos) def get_rx_queue_len(self) -> int: """ + Return the receive queue length (bytes). - :return: + :return: Receive queue length in bytes. + :raises FlowError: On a negative ouroboros return code. """ size = ffi.new("size_t *") - if lib.flow_get_rx_qlen(self.__fd, size) != 0: - raise FlowPermissionException() + raise_errno(lib.flow_get_rx_qlen(self._fd, size)) - return int(size) + return int(size[0]) def get_tx_queue_len(self) -> int: """ + Return the transmit queue length (bytes). - :return: + :return: Transmit queue length in bytes. + :raises FlowError: On a negative ouroboros return code. """ size = ffi.new("size_t *") - if lib.flow_get_tx_qlen(self.__fd, size) != 0: - raise FlowPermissionException() + raise_errno(lib.flow_get_tx_qlen(self._fd, size)) + + return int(size[0]) + + def get_mtu(self) -> int: + """ + Return the maximum user payload that fits in one n-1 PDU after + crypto headers (bytes), or 0 if unknown. + + :return: Maximum user payload in bytes, or 0 if unknown. + :raises FlowError: On a negative ouroboros return code. + """ + + mtu = ffi.new("size_t *") + + raise_errno(lib.flow_get_mtu(self._fd, mtu)) + + return int(mtu[0]) + + def set_flags(self, flags: FlowProperties) -> None: + """ + Replace the full set of flags for this flow. + + :param flags: Bitmask of :class:`FlowProperties` values. + :raises FlowError: On a negative ouroboros return code. + """ + + raise_errno(lib.flow_set_flags(self._fd, int(flags))) + + def add_flags(self, flags: FlowProperties) -> None: + """ + OR *flags* into the current flags, leaving other bits set. + + :param flags: :class:`FlowProperties` bits to set. + :raises FlowError: On a negative ouroboros return code. + """ + + current = self.get_flags() - return int(size) + self.set_flags(current | FlowProperties(int(flags))) - def set_flags(self, flags: FlowProperties): + def remove_flags(self, flags: FlowProperties) -> None: """ - Set flags for this flow. - :param flags: + Clear *flags* from the current flags, leaving other bits set. + + :param flags: :class:`FlowProperties` bits to clear. + :raises FlowError: On a negative ouroboros return code. """ - _flags = ffi.new("uint32_t *", int(flags)) + current = self.get_flags() - if lib.flow_set_flag(self.__fd, _flags): - raise FlowPermissionException() + self.set_flags(current & ~FlowProperties(int(flags))) def get_flags(self) -> FlowProperties: """ - Get the flags for this flow + Return the current flags for this flow. + + :return: The current :class:`FlowProperties` bitmask. + :raises FlowError: On a negative ouroboros return code. """ - flags = lib.flow_get_flag(self.__fd) - if flags < 0: - raise FlowPermissionException() + flags = raise_errno(lib.flow_get_flags(self._fd)) return FlowProperties(int(flags)) + def set_frct_flags(self, flags: FrctFlags) -> None: + """ + Set FRCT flags for this flow. + + :param flags: Bitmask of :class:`FrctFlags`. + :raises FlowError: On a negative ouroboros return code. + """ + + raise_errno(lib.flow_set_frct_flags(self._fd, int(flags))) + + def get_frct_flags(self) -> FrctFlags: + """ + Return the FRCT flags for this flow. + + :return: The current :class:`FrctFlags` bitmask. + :raises FlowError: On a negative ouroboros return code. + """ + + flags = raise_errno(lib.flow_get_frct_flags(self._fd)) + + return FrctFlags(int(flags)) + + def set_frct_max_sdu(self, size: int) -> None: + """ + Set the maximum reassembly SDU size for FRCT (bytes). + + :param size: Maximum reassembly SDU size in bytes. + :raises FlowError: On a negative ouroboros return code. + """ + + raise_errno(lib.flow_set_frct_max_sdu(self._fd, size)) + + def get_frct_max_sdu(self) -> int: + """ + Return the maximum reassembly SDU size for FRCT (bytes). + + :return: Maximum reassembly SDU size in bytes. + :raises FlowError: On a negative ouroboros return code. + """ + + size = ffi.new("size_t *") + + raise_errno(lib.flow_get_frct_max_sdu(self._fd, size)) + + return int(size[0]) + + def set_frct_rcv_ring_size(self, size: int) -> None: + """ + Set the stream receive ring size (bytes, power of two). + + :param size: Stream receive ring size in bytes (power of two). + :raises FlowError: On a negative ouroboros return code. + """ + + raise_errno(lib.flow_set_frct_rcv_ring_sz(self._fd, size)) + + def get_frct_rcv_ring_size(self) -> int: + """ + Return the stream receive ring size (bytes). + + :return: Stream receive ring size in bytes. + :raises FlowError: On a negative ouroboros return code. + """ + + size = ffi.new("size_t *") + + raise_errno(lib.flow_get_frct_rcv_ring_sz(self._fd, size)) + + return int(size[0]) + + def set_frct_rto_min(self, rto_ns: int) -> None: + """ + Set the FRCT RTO floor (nanoseconds). + + :param rto_ns: FRCT RTO floor in nanoseconds. + :raises FlowError: On a negative ouroboros return code. + """ + + raise_errno(lib.flow_set_frct_rto_min(self._fd, rto_ns)) + + def get_frct_rto_min(self) -> int: + """ + Return the FRCT RTO floor (nanoseconds). + + :return: FRCT RTO floor in nanoseconds. + :raises FlowError: On a negative ouroboros return code. + """ + + rto = ffi.new("time_t *") + + raise_errno(lib.flow_get_frct_rto_min(self._fd, rto)) + + return int(rto[0]) + def flow_alloc(dst: str, - qos: QoSSpec = None, - timeo: float = None) -> Flow: + qos: Optional[QoSSpec] = None, + timeo: Optional[float] = None) -> Flow: """ + Allocate a new flow and return the :class:`Flow` wrapper. - :param dst: Destination name - :param qos: Requested QoS - :param timeo: Timeout to wait for the allocation - :return: A new Flow() + :param dst: Destination name. + :param qos: Requested QoS. + :param timeo: Allocation timeout (None blocks forever, 0 is async). + :return: The allocated flow. """ f = Flow() + f.alloc(dst, qos, timeo) + return f -def flow_accept(timeo: float = None) -> Flow: +def flow_accept(timeo: Optional[float] = None) -> Flow: """ + Accept an incoming flow and return the :class:`Flow` wrapper. - :param timeo: Timeout to wait for the allocation - :return: A new Flow() + :param timeo: Accept timeout (None blocks forever, 0 is async). + :return: The accepted flow. """ f = Flow() + f.accept(timeo) + return f def flow_join(dst: str, - qos: QoSSpec = None, - timeo: float = None) -> Flow: + timeo: Optional[float] = None) -> Flow: """ + Join a broadcast layer and return the :class:`Flow` wrapper. - :param dst: Broadcast layer name - :param qos: Requested QoS - :param timeo: Timeout to wait for the allocation - :return: A new Flow() + :param dst: Broadcast layer name. + :param timeo: Join timeout (None blocks forever, 0 is async). + :return: The joined broadcast flow. """ f = Flow() - f.join(dst, qos, timeo) - return f + f.join(dst, timeo) + return f diff --git a/ouroboros/errors.py b/ouroboros/errors.py new file mode 100644 index 0000000..6ab6053 --- /dev/null +++ b/ouroboros/errors.py @@ -0,0 +1,284 @@ +# +# SPDX-FileCopyrightText: 2016 - 2026 Dimitri Staessens +# SPDX-License-Identifier: LGPL-2.1-only +# + +""" +Exception hierarchy and errno translation for pyouroboros. + +The ``E*`` constants mirror the Ouroboros-specific errno values in +``include/ouroboros/errno.h``. +""" + +from __future__ import annotations + +import errno +import os +from importlib.metadata import PackageNotFoundError, version + +ENOTALLOC = 1000 # Flow is not allocated +EIPCPTYPE = 1001 # Unknown IPCP type +EIRMD = 1002 # Failed to communicate with IRMD +EIPCP = 1003 # Failed to communicate with IPCP +EIPCPSTATE = 1004 # Target in wrong state +EFLOWDOWN = 1005 # Flow is down +EFLOWPEER = 1006 # Flow is down (peer timed out) +ENAME = 1007 # Naming error +ECRYPT = 1008 # Encryption error +EAUTH = 1009 # Authentication error +EREPLAY = 1010 # OAP replay detected + +_O7S_ERRNO_STR: dict[int, str] = { + ENOTALLOC: "flow is not allocated", + EIPCPTYPE: "unknown IPCP type", + EIRMD: "failed to communicate with IRMd", + EIPCP: "failed to communicate with IPCP", + EIPCPSTATE: "target in wrong state", + EFLOWDOWN: "flow is down", + EFLOWPEER: "flow peer timed out", + ENAME: "naming error", + ECRYPT: "encryption error", + EAUTH: "authentication error", + EREPLAY: "OAP replay detected", +} + + +class OuroborosError(Exception): + """ + Base class for all pyouroboros exceptions. + """ + + +class IrmError(OuroborosError): + """ + Base class for IRM (control plane) errors. + """ + + +class IpcpCreateError(IrmError): + """ + Raised when IPCP creation fails. + """ + + +class IpcpBootstrapError(IrmError): + """ + Raised when IPCP bootstrapping fails. + """ + + +class IpcpEnrollError(IrmError): + """ + Raised when IPCP enrollment fails. + """ + + +class IpcpConnectError(IrmError): + """ + Raised when IPCP connection or disconnection fails. + """ + + +class IpcpTypeError(IrmError, ValueError): + """ + Raised when an unknown IPCP type is encountered. + """ + + +class IpcpStateError(IrmError): + """ + Raised when an IPCP/IRMd is in the wrong state for the request. + """ + + +class IrmdError(IrmError): + """ + Raised when the IRM daemon is unreachable. + """ + + +class IpcpdError(IrmError): + """ + Raised when the IPCP daemon is unreachable. + """ + + +class BindError(IrmError): + """ + Raised when binding a program/process/IPCP to a name fails. + """ + + +class NameNotFoundError(IrmError): + """ + Raised when a name lookup or unregister target is missing. + """ + + +class NameExistsError(IrmError): + """ + Raised when a name already exists. + """ + + +class InvalidNameError(IrmError, ValueError): + """ + Raised when a name is malformed or otherwise invalid. + """ + + +class FlowError(OuroborosError): + """ + Base class for flow-plane errors. + """ + + +class FlowAlreadyAllocatedError(FlowError): + """ + Raised when allocating on a Flow that already holds an fd. + """ + + +class FlowNotAllocatedError(FlowError): + """ + Raised when operating on a Flow that has no fd. + """ + + +class FlowDownError(FlowError, ConnectionError): + """ + Raised when the flow has gone down. + """ + + +class FlowPeerError(FlowDownError): + """ + Raised when the flow's peer has timed out. + """ + + +class FlowPermissionError(FlowError, PermissionError): + """ + Raised when an operation is not permitted on the flow. + """ + + +class FlowTimeout(FlowError, TimeoutError): + """ + Raised when a flow operation times out. + """ + + +class FlowCryptError(FlowError): + """ + Raised on flow encryption errors. + """ + + +class FlowAuthError(FlowError): + """ + Raised on flow authentication errors. + """ + + +class FlowReplayError(FlowError): + """ + Raised when OAP replay is detected. + """ + + +class FlowEventError(FlowError): + """ + Raised on errors from the flow event subsystem. + """ + + +class FlowDeallocWarning(Warning): + """ + Warning issued when a deallocation reports a non-fatal problem. + """ + + +_ERRNO_MAP: dict[int, type[BaseException]] = { + errno.ETIMEDOUT: TimeoutError, + errno.EAGAIN: BlockingIOError, + errno.EWOULDBLOCK: BlockingIOError, + errno.ENOMEM: MemoryError, + errno.EACCES: PermissionError, + errno.ENOTCONN: ConnectionError, + errno.ECONNRESET: ConnectionResetError, + ENOTALLOC: FlowNotAllocatedError, + EIPCPTYPE: IpcpTypeError, + EIRMD: IrmdError, + EIPCP: IpcpdError, + EIPCPSTATE: IpcpStateError, + EFLOWDOWN: FlowDownError, + EFLOWPEER: FlowPeerError, + ENAME: NameNotFoundError, + ECRYPT: FlowCryptError, + EAUTH: FlowAuthError, + EREPLAY: FlowReplayError, +} + + +def _strerror(err: int) -> str: + if err >= 1000: + return _O7S_ERRNO_STR.get(err, f"ouroboros errno {err}") + + return os.strerror(err) + + +def raise_errno(rc: int, default: type[OuroborosError] = FlowError) -> int: + """ + Translate a non-negative-or-negative-errno return code. + + ``_ERRNO_MAP`` holds errnos in their canonical positive form. + + :param rc: The return code to translate; a negative + value is a negated errno. + :param default: Exception class to raise when the errno + has no mapping; a mapped errno overrides + it. + :return: *rc* unchanged when it is non-negative. + :raises OuroborosError: (or the builtin mapped for ``-rc``) when + *rc* is negative. + """ + + if rc >= 0: + return rc + + err = -rc + exc_cls = _ERRNO_MAP.get(err, default) + raise exc_cls(_strerror(err)) + + +def check_ouroboros_version(o7s_major: int, o7s_minor: int) -> None: + """ + Verify that the installed pyouroboros matches the linked library. + + Compares the linked library version against the installed + pyouroboros distribution metadata. Silently returns when running + from a source tree without distribution metadata. + + :param o7s_major: Major version of the linked library, from + ``OUROBOROS_VERSION_MAJOR`` in the CFFI + module. + :param o7s_minor: Minor version of the linked library, from + ``OUROBOROS_VERSION_MINOR`` in the CFFI + module. + :raises RuntimeError: If the library and pyouroboros ``major.minor`` + differ. + """ + + try: + pyo7s_parts = version("PyOuroboros").split(".") + except PackageNotFoundError: + return + + if o7s_major != int(pyo7s_parts[0]) or \ + o7s_minor != int(pyo7s_parts[1]): + raise RuntimeError( + f"Ouroboros version mismatch: library is " + f"{o7s_major}.{o7s_minor}, pyouroboros is " + f"{pyo7s_parts[0]}.{pyo7s_parts[1]}" + ) diff --git a/ouroboros/event.py b/ouroboros/event.py index b707c1b..24b58b8 100644 --- a/ouroboros/event.py +++ b/ouroboros/event.py @@ -1,147 +1,197 @@ # -# Ouroboros - Copyright (C) 2016 - 2020 -# -# Python API for applications -# -# Dimitri Staessens <dimitri@ouroboros.rocks> -# -# This library is free software; you can redistribute it and/or -# modify it under the terms of the GNU Lesser General Public License -# version 2.1 as published by the Free Software Foundation. -# -# This library 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 -# Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public -# License along with this library; if not, write to the Free Software -# Foundation, Inc., http://www.fsf.org/about/contact/. +# SPDX-FileCopyrightText: 2016 - 2026 Dimitri Staessens +# SPDX-License-Identifier: LGPL-2.1-only # -from ouroboros.dev import * -from ouroboros.qos import _fl_to_timespec +""" +Asynchronous flow event monitoring for Ouroboros. +""" +from __future__ import annotations -# async API -class FlowEventError(Exception): - pass +from enum import IntFlag +from typing import TYPE_CHECKING, Any, List, Optional, Tuple, Type + +from _ouroboros_dev_cffi import ffi, lib + +from ouroboros._timespec import fl_to_timespec +from ouroboros.dev import Flow +from ouroboros.errors import FlowEventError, raise_errno + +if TYPE_CHECKING: + from types import TracebackType class FEventType(IntFlag): - FlowPkt = lib.FLOW_PKT - FlowDown = lib.FLOW_DOWN - FlowUp = lib.FLOW_UP - FlowAlloc = lib.FLOW_ALLOC - FlowDealloc = lib.FLOW_DEALLOC + """ + Types of flow events. + """ + + FLOW_PKT = lib.FLOW_PKT + FLOW_DOWN = lib.FLOW_DOWN + FLOW_UP = lib.FLOW_UP + FLOW_ALLOC = lib.FLOW_ALLOC + FLOW_DEALLOC = lib.FLOW_DEALLOC + FLOW_PEER = lib.FLOW_PEER class FEventQueue: """ - A queue of events waiting to be handled + A queue of flow events waiting to be drained. """ - def __init__(self): - self.__fq = lib.fqueue_create() - if self.__fq is ffi.NULL: + def __init__(self) -> None: + self._fq = lib.fqueue_create() + if self._fq == ffi.NULL: raise MemoryError("Failed to create FEventQueue") - def __del__(self): - lib.fqueue_destroy(self.__fq) + def __enter__(self) -> FEventQueue: + return self + + def __exit__(self, exc_type: Optional[Type[BaseException]], + exc_value: Optional[BaseException], + tb: Optional[TracebackType]) -> None: + self.destroy() - def next(self): + def __del__(self) -> None: + self.destroy() + + def destroy(self) -> None: """ - Get the next event - :return: Flow and eventtype on that flow + Destroy the underlying queue. Idempotent. """ - f = Flow() - f._Flow__fd = lib.fqueue_next(self.__fq) - if f._Flow__fd < 0: - raise FlowEventError - _type = lib.fqueue_type(self.__fq) - if _type < 0: - raise FlowEventError + if self._fq != ffi.NULL: + lib.fqueue_destroy(self._fq) + + self._fq = ffi.NULL + + def next(self) -> Tuple[Flow, FEventType]: + """ + Return the next ``(flow, event_type)`` pair from the queue. + + :return: The :class:`~ouroboros.dev.Flow` the event occurred + on, and its :class:`FEventType`. + :raises FlowEventError: On a negative ouroboros return code. + """ - return f, _type + fd = lib.fqueue_next(self._fq) + + raise_errno(fd, default=FlowEventError) + + _type = lib.fqueue_type(self._fq) + + raise_errno(_type, default=FlowEventError) + + return Flow(fd), FEventType(_type) + + @property + def handle(self) -> Any: + """ + Return the underlying ``fqueue_t *``. + + :return: The underlying ``fqueue_t *`` CFFI pointer. + """ + + return self._fq class FlowSet: """ - A set of flows that can be monitored for events + A set of flows that can be monitored for events. """ - def __init__(self, - flows: [Flow] = None): - self.__set = lib.fset_create() - if self.__set is ffi.NULL: + def __init__(self, flows: Optional[List[Flow]] = None) -> None: + self._set = lib.fset_create() + if self._set == ffi.NULL: raise MemoryError("Failed to create FlowSet") if flows is not None: for flow in flows: - if lib.fset_add(self.__set, flow._Flow__fd) != 0: - lib.fset_destroy(self.__set) - self.__set = ffi.NULL - raise MemoryError("Failed to add flow " + str(flow._Flow__fd) + ".") + if lib.fset_add(self._set, flow.fileno()) != 0: + lib.fset_destroy(self._set) - def __enter__(self): + self._set = ffi.NULL + raise MemoryError(f"Failed to add flow {flow.fileno()}.") + + def __enter__(self) -> FlowSet: return self - def __exit__(self, exc_type, exc_value, tb): - lib.fset_destroy(self.__set) + def __exit__(self, exc_type: Optional[Type[BaseException]], + exc_value: Optional[BaseException], + tb: Optional[TracebackType]) -> None: + self.destroy() + + def __del__(self) -> None: + self.destroy() - def add(self, - flow: Flow): + def destroy(self) -> None: + """ + Destroy the underlying set. Idempotent. """ - Add a Flow - :param flow: The flow object to add + if self._set != ffi.NULL: + lib.fset_destroy(self._set) + + self._set = ffi.NULL + + def add(self, flow: Flow) -> None: """ + Add *flow* to this set. - if self.__set is ffi.NULL: - raise ValueError + :param flow: The flow to add to this set. + :raises ValueError: If the FlowSet has been destroyed. + :raises MemoryError: If the flow could not be added. + """ + + if self._set == ffi.NULL: + raise ValueError("FlowSet has been destroyed") - if lib.fset_add(self.__set, flow._Flow___fd) != 0: - raise MemoryError("Failed to add flow") + if lib.fset_add(self._set, flow.fileno()) != 0: + raise MemoryError(f"Failed to add flow {flow.fileno()}") - def zero(self): + def zero(self) -> None: """ - Remove all Flows from this set + Remove all flows from this set. + + :raises ValueError: If the FlowSet has been destroyed. """ - if self.__set is ffi.NULL: - raise ValueError + if self._set == ffi.NULL: + raise ValueError("FlowSet has been destroyed") - lib.fset_zero(self.__set) + lib.fset_zero(self._set) - def remove(self, - flow: Flow): + def remove(self, flow: Flow) -> None: """ - Remove a flow from a set + Remove *flow* from this set. - :param flow: + :param flow: The flow to remove from this set. + :raises ValueError: If the FlowSet has been destroyed. """ - if self.__set is ffi.NULL: - raise ValueError + if self._set == ffi.NULL: + raise ValueError("FlowSet has been destroyed") - lib.fset_del(self.__set, flow._Flow__fd) + lib.fset_del(self._set, flow.fileno()) def wait(self, - fq: FEventType, - timeo: float = None): + fq: FEventQueue, + timeo: Optional[float] = None) -> None: """ - Wait for at least one event on one of the monitored flows + Wait for at least one event on one of the monitored flows. + + :param fq: The :class:`FEventQueue` to drain events into. + :param timeo: Wait timeout in seconds (None blocks forever). + :raises ValueError: If the FlowSet has been destroyed. + :raises FlowEventError: On a negative ouroboros return code. """ - if self.__set is ffi.NULL: - raise ValueError + if self._set == ffi.NULL: + raise ValueError("FlowSet has been destroyed") - _timeo = _fl_to_timespec(timeo) + _timeo = fl_to_timespec(ffi, timeo) - ret = lib.fevent(self.__set, fq._FEventQueue__fq, _timeo) - if ret < 0: - raise FlowEventError + rc = lib.fevent(self._set, fq.handle, _timeo) - def destroy(self): - lib.fset_destroy(self.__set) + raise_errno(rc, default=FlowEventError) diff --git a/ouroboros/irm.py b/ouroboros/irm.py new file mode 100644 index 0000000..ea7d665 --- /dev/null +++ b/ouroboros/irm.py @@ -0,0 +1,653 @@ +# +# SPDX-FileCopyrightText: 2016 - 2026 Dimitri Staessens +# SPDX-License-Identifier: LGPL-2.1-only +# + +""" +IRM (IPC Resource Manager) bindings for Ouroboros. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import IntEnum +from typing import Any, List, Optional + +from _ouroboros_irm_cffi import ffi, lib + +from ouroboros._qosspec import qos_to_qosspec +from ouroboros.errors import ( + BindError, + IpcpBootstrapError, + IpcpConnectError, + IpcpCreateError, + IpcpEnrollError, + IrmError, + NameExistsError, + NameNotFoundError, + check_ouroboros_version, + raise_errno, +) +from ouroboros.qos import QoSSpec + +check_ouroboros_version(lib.OUROBOROS_VERSION_MAJOR, + lib.OUROBOROS_VERSION_MINOR) + +BIND_AUTO = lib.BIND_AUTO + +DT_COMP = "Data Transfer" +MGMT_COMP = "Management" + + +class IpcpType(IntEnum): + """ + IPCP types available in Ouroboros. + """ + + LOCAL = lib.IPCP_LOCAL + UNICAST = lib.IPCP_UNICAST + BROADCAST = lib.IPCP_BROADCAST + ETH_LLC = lib.IPCP_ETH_LLC + ETH_DIX = lib.IPCP_ETH_DIX + UDP4 = lib.IPCP_UDP4 + UDP6 = lib.IPCP_UDP6 + + +class AddressAuthPolicy(IntEnum): + """ + Address authority policies for unicast IPCPs. + """ + + FLAT_RANDOM = lib.ADDR_AUTH_FLAT_RANDOM + + +class LinkStatePolicy(IntEnum): + """ + Link state routing policies. + """ + + SIMPLE = lib.LS_SIMPLE + LFA = lib.LS_LFA + ECMP = lib.LS_ECMP + + +class RoutingPolicy(IntEnum): + """ + Routing policies. + """ + + LINK_STATE = lib.ROUTING_LINK_STATE + + +class CongestionAvoidPolicy(IntEnum): + """ + Congestion avoidance policies. + """ + + NONE = lib.CA_NONE + MB_ECN = lib.CA_MB_ECN + + +class DirectoryPolicy(IntEnum): + """ + Directory policies. + """ + + DHT = lib.DIR_DHT + + +class DirectoryHashAlgo(IntEnum): + """ + Directory hash algorithms. + """ + + SHA3_224 = lib.DIR_HASH_SHA3_224 + SHA3_256 = lib.DIR_HASH_SHA3_256 + SHA3_384 = lib.DIR_HASH_SHA3_384 + SHA3_512 = lib.DIR_HASH_SHA3_512 + + +class LoadBalancePolicy(IntEnum): + """ + Load balancing policies for names. + """ + + ROUND_ROBIN = lib.LB_RR + SPILL = lib.LB_SPILL + + +@dataclass +class LinkStateConfig: + """ + Configuration for link state routing. + """ + + pol: LinkStatePolicy = LinkStatePolicy.SIMPLE + t_recalc: int = 4 + t_update: int = 15 + t_timeo: int = 60 + + +@dataclass +class RoutingConfig: + """ + Routing configuration. + """ + + pol: RoutingPolicy = RoutingPolicy.LINK_STATE + ls: LinkStateConfig = field(default_factory=LinkStateConfig) + + +@dataclass +class DtConfig: + """ + Data transfer configuration for unicast IPCPs. + """ + + addr_size: int = 4 + eid_size: int = 8 + max_ttl: int = 60 + routing: RoutingConfig = field(default_factory=RoutingConfig) + + +@dataclass +class DhtConfig: + """ + DHT directory configuration. + """ + + alpha: int = 3 + k: int = 8 + t_expire: int = 86400 + t_refresh: int = 900 + t_replicate: int = 900 + + +@dataclass +class DirConfig: + """ + Directory configuration. + """ + + pol: DirectoryPolicy = DirectoryPolicy.DHT + dht: DhtConfig = field(default_factory=DhtConfig) + + +@dataclass +class UnicastConfig: + """ + Configuration for unicast IPCPs. + """ + + dt: DtConfig = field(default_factory=DtConfig) + dir: DirConfig = field(default_factory=DirConfig) + addr_auth: AddressAuthPolicy = AddressAuthPolicy.FLAT_RANDOM + cong_avoid: CongestionAvoidPolicy = CongestionAvoidPolicy.MB_ECN + + +@dataclass +class EthConfig: + """ + Configuration for Ethernet IPCPs (LLC or DIX). + """ + + dev: str = "" + ethertype: int = 0xA000 + + +@dataclass +class Udp4Config: + """ + Configuration for UDP over IPv4 IPCPs. + """ + + ip_addr: str = "0.0.0.0" + dns_addr: str = "0.0.0.0" + port: int = 3435 + + +@dataclass +class Udp6Config: + """ + Configuration for UDP over IPv6 IPCPs. + """ + + ip_addr: str = "::" + dns_addr: str = "::" + port: int = 3435 + + +@dataclass +class IpcpConfig: + """ + Configuration for bootstrapping an IPCP. + """ + + ipcp_type: IpcpType + layer_name: str = "" + dir_hash_algo: DirectoryHashAlgo = DirectoryHashAlgo.SHA3_256 + unicast: Optional[UnicastConfig] = None + eth: Optional[EthConfig] = None + udp4: Optional[Udp4Config] = None + udp6: Optional[Udp6Config] = None + + +@dataclass +class NameSecPaths: + """ + Security paths for a name. + """ + + sec: str = "" + key: str = "" + crt: str = "" + + +@dataclass +class NameInfo: + """ + Information about a registered name. + """ + + name: str + pol_lb: LoadBalancePolicy = LoadBalancePolicy.ROUND_ROBIN + server_sec: NameSecPaths = field(default_factory=NameSecPaths) + client_sec: NameSecPaths = field(default_factory=NameSecPaths) + + +@dataclass +class IpcpInfo: + """ + Information about a running IPCP. + """ + + pid: int + type: IpcpType + name: str + layer: str + + +def _ipcp_config_to_c(conf: IpcpConfig) -> Any: + """ + Convert an :class:`IpcpConfig` to a C ``struct ipcp_config *``. + + The layer name is truncated to the 255 bytes the C struct holds. + """ + + _conf = ffi.new("struct ipcp_config *") + + layer_name = conf.layer_name.encode() + + ffi.memmove(_conf.layer_info.name, layer_name, + min(len(layer_name), 255)) + + _conf.layer_info.dir_hash_algo = conf.dir_hash_algo + + _conf.type = conf.ipcp_type + + if conf.ipcp_type == IpcpType.UNICAST: + uc = conf.unicast or UnicastConfig() + _conf.unicast.dt.addr_size = uc.dt.addr_size + _conf.unicast.dt.eid_size = uc.dt.eid_size + _conf.unicast.dt.max_ttl = uc.dt.max_ttl + _conf.unicast.dt.routing.pol = uc.dt.routing.pol + _conf.unicast.dt.routing.ls.pol = uc.dt.routing.ls.pol + _conf.unicast.dt.routing.ls.t_recalc = uc.dt.routing.ls.t_recalc + _conf.unicast.dt.routing.ls.t_update = uc.dt.routing.ls.t_update + _conf.unicast.dt.routing.ls.t_timeo = uc.dt.routing.ls.t_timeo + _conf.unicast.dir.pol = uc.dir.pol + _conf.unicast.dir.dht.params.alpha = uc.dir.dht.alpha + _conf.unicast.dir.dht.params.k = uc.dir.dht.k + _conf.unicast.dir.dht.params.t_expire = uc.dir.dht.t_expire + _conf.unicast.dir.dht.params.t_refresh = uc.dir.dht.t_refresh + _conf.unicast.dir.dht.params.t_replicate = uc.dir.dht.t_replicate + _conf.unicast.addr_auth_type = uc.addr_auth + _conf.unicast.cong_avoid = uc.cong_avoid + + elif conf.ipcp_type in (IpcpType.ETH_LLC, IpcpType.ETH_DIX): + ec = conf.eth or EthConfig() + dev = ec.dev.encode() + + ffi.memmove(_conf.eth.dev, dev, min(len(dev), 255)) + + _conf.eth.ethertype = ec.ethertype + + elif conf.ipcp_type == IpcpType.UDP4: + uc4 = conf.udp4 or Udp4Config() + _conf.udp4.port = uc4.port + if lib.ipcp_config_udp4_set_ip(_conf, uc4.ip_addr.encode()) != 0: + raise ValueError(f"Invalid IPv4 address: {uc4.ip_addr}") + + if lib.ipcp_config_udp4_set_dns(_conf, uc4.dns_addr.encode()) != 0: + raise ValueError(f"Invalid IPv4 DNS address: {uc4.dns_addr}") + + elif conf.ipcp_type == IpcpType.UDP6: + uc6 = conf.udp6 or Udp6Config() + _conf.udp6.port = uc6.port + if lib.ipcp_config_udp6_set_ip(_conf, uc6.ip_addr.encode()) != 0: + raise ValueError(f"Invalid IPv6 address: {uc6.ip_addr}") + + if lib.ipcp_config_udp6_set_dns(_conf, uc6.dns_addr.encode()) != 0: + raise ValueError(f"Invalid IPv6 DNS address: {uc6.dns_addr}") + + return _conf + + +def _name_info_to_c(info: NameInfo) -> Any: + """ + Convert a :class:`NameInfo` to a C ``struct name_info *``. + """ + + _info = ffi.new("struct name_info *") + + name = info.name.encode() + + ffi.memmove(_info.name, name, min(len(name), 255)) + + _info.pol_lb = info.pol_lb + + for attr, sec in (('s', info.server_sec), ('c', info.client_sec)): + sec_paths = getattr(_info, attr) + + for fld in ('sec', 'key', 'crt'): + val = getattr(sec, fld).encode() + + ffi.memmove(getattr(sec_paths, fld), val, + min(len(val), 511)) + + return _info + + +def create_ipcp(name: str, + ipcp_type: IpcpType) -> int: + """ + Create a new IPCP. + + :param name: Name for the IPCP. + :param ipcp_type: Type of IPCP to create. + :return: PID of the created IPCP. + :raises IpcpCreateError: If creation fails or the IPCP is not found. + """ + + raise_errno(lib.irm_create_ipcp(name.encode(), ipcp_type), + default=IpcpCreateError) + + for info in list_ipcps(): + if info.name == name: + return info.pid + + raise IpcpCreateError(f"IPCP '{name}' created but not found in list") + + +def destroy_ipcp(pid: int) -> None: + """ + Destroy an IPCP. + + :param pid: PID of the IPCP to destroy. + :raises IrmError: If the IPCP could not be destroyed. + """ + + raise_errno(lib.irm_destroy_ipcp(pid), default=IrmError) + + +def list_ipcps() -> List[IpcpInfo]: + """ + List all running IPCPs. + + :return: List of :class:`IpcpInfo` objects. + :raises IrmError: If the IPCPs could not be listed. + """ + + _ipcps = ffi.new("struct ipcp_list_info **") + n = raise_errno(lib.irm_list_ipcps(_ipcps), default=IrmError) + + result = [] + + for i in range(n): + info = _ipcps[0][i] + + result.append(IpcpInfo( + pid=info.pid, + type=IpcpType(info.type), + name=ffi.string(info.name).decode(), + layer=ffi.string(info.layer).decode(), + )) + + if n > 0: + lib.free(_ipcps[0]) + + return result + + +def enroll_ipcp(pid: int, dst: str) -> None: + """ + Enroll an IPCP in a layer. + + :param pid: PID of the IPCP to enroll. + :param dst: Name of a member of the layer to enroll with. + :raises IpcpEnrollError: If the IPCP could not enroll with *dst*. + """ + + raise_errno(lib.irm_enroll_ipcp(pid, dst.encode()), + default=IpcpEnrollError) + + +def bootstrap_ipcp(pid: int, conf: IpcpConfig) -> None: + """ + Bootstrap an IPCP. + + :param pid: PID of the IPCP to bootstrap. + :param conf: Configuration for the IPCP. + :raises IpcpBootstrapError: If the IPCP could not be bootstrapped. + """ + + _conf = _ipcp_config_to_c(conf) + + raise_errno(lib.irm_bootstrap_ipcp(pid, _conf), + default=IpcpBootstrapError) + + +def connect_ipcp(pid: int, + component: str, + dst: str, + qos: Optional[QoSSpec] = None) -> None: + """ + Connect an IPCP component to a destination. + + :param pid: PID of the IPCP. + :param component: :data:`DT_COMP` or :data:`MGMT_COMP`. + :param dst: Destination name. + :param qos: QoS specification for the connection. + :raises IpcpConnectError: If *component* could not be + connected to *dst*. + """ + + _qos = qos_to_qosspec(ffi, qos) + if _qos == ffi.NULL: + _qos = ffi.new("qosspec_t *") + + raise_errno( + lib.irm_connect_ipcp(pid, dst.encode(), component.encode(), + _qos[0]), + default=IpcpConnectError, + ) + + +def disconnect_ipcp(pid: int, + component: str, + dst: str) -> None: + """ + Disconnect an IPCP component from a destination. + + :param pid: PID of the IPCP. + :param component: :data:`DT_COMP` or :data:`MGMT_COMP`. + :param dst: Destination name. + :raises IpcpConnectError: If *component* could not be + disconnected from *dst*. + """ + + raise_errno( + lib.irm_disconnect_ipcp(pid, dst.encode(), component.encode()), + default=IpcpConnectError, + ) + + +def bind_program(prog: str, + name: str, + opts: int = 0, + argv: Optional[List[str]] = None) -> None: + """ + Bind a program to a name. + + :param prog: Path to the program. + :param name: Name to bind to. + :param opts: Bind options (e.g. :data:`BIND_AUTO`). + :param argv: Arguments passed when the IRM starts the program. + :raises BindError: If *prog* could not be bound to *name*. + """ + + if argv: + argc = len(argv) + _args = [] + + for a in argv: + _args.append(ffi.new("char[]", a.encode())) + + _argv = ffi.new("char *[]", _args) + else: + argc = 0 + _argv = ffi.NULL + + raise_errno( + lib.irm_bind_program(prog.encode(), name.encode(), + opts, argc, _argv), + default=BindError, + ) + + +def unbind_program(prog: str, name: str) -> None: + """ + Unbind a program from a name. + + :param prog: Path to the program. + :param name: Name to unbind from. + :raises BindError: If *prog* could not be unbound from *name*. + """ + + raise_errno(lib.irm_unbind_program(prog.encode(), name.encode()), + default=BindError) + + +def bind_process(pid: int, name: str) -> None: + """ + Bind a running process to a name. + + :param pid: PID of the process. + :param name: Name to bind to. + :raises BindError: If the process could not be bound to *name*. + """ + + raise_errno(lib.irm_bind_process(pid, name.encode()), + default=BindError) + + +def unbind_process(pid: int, name: str) -> None: + """ + Unbind a process from a name. + + :param pid: PID of the process. + :param name: Name to unbind from. + :raises BindError: If the process could not be unbound from *name*. + """ + + raise_errno(lib.irm_unbind_process(pid, name.encode()), + default=BindError) + + +def create_name(info: NameInfo) -> None: + """ + Create a name in the IRM. + + :param info: :class:`NameInfo` describing the name to create. + :raises NameExistsError: If the name already exists. + """ + + _info = _name_info_to_c(info) + + raise_errno(lib.irm_create_name(_info), default=NameExistsError) + + +def destroy_name(name: str) -> None: + """ + Destroy a name in the IRM. + + :param name: The name to destroy. + :raises NameNotFoundError: If the name is unknown. + """ + + raise_errno(lib.irm_destroy_name(name.encode()), + default=NameNotFoundError) + + +def list_names() -> List[NameInfo]: + """ + List all registered names. + + :return: List of :class:`NameInfo` objects. + :raises IrmError: If the names could not be listed. + """ + + _names = ffi.new("struct name_info **") + n = raise_errno(lib.irm_list_names(_names), default=IrmError) + + result = [] + + for i in range(n): + info = _names[0][i] + + result.append(NameInfo( + name=ffi.string(info.name).decode(), + pol_lb=LoadBalancePolicy(info.pol_lb), + server_sec=NameSecPaths( + sec=ffi.string(info.s.sec).decode(), + key=ffi.string(info.s.key).decode(), + crt=ffi.string(info.s.crt).decode(), + ), + client_sec=NameSecPaths( + sec=ffi.string(info.c.sec).decode(), + key=ffi.string(info.c.key).decode(), + crt=ffi.string(info.c.crt).decode(), + ), + )) + + if n > 0: + lib.free(_names[0]) + + return result + + +def reg_name(name: str, pid: int) -> None: + """ + Register an IPCP to a name. + + :param name: The name to register. + :param pid: PID of the IPCP to register. + :raises NameNotFoundError: If the name is unknown. + """ + + raise_errno(lib.irm_reg_name(name.encode(), pid), + default=NameNotFoundError) + + +def unreg_name(name: str, pid: int) -> None: + """ + Unregister an IPCP from a name. + + :param name: The name to unregister. + :param pid: PID of the IPCP to unregister. + :raises NameNotFoundError: If the name or the registration + is unknown. + """ + + raise_errno(lib.irm_unreg_name(name.encode(), pid), + default=NameNotFoundError) diff --git a/ouroboros/qos.py b/ouroboros/qos.py index f437ee2..c42a62e 100644 --- a/ouroboros/qos.py +++ b/ouroboros/qos.py @@ -1,110 +1,93 @@ # -# Ouroboros - Copyright (C) 2016 - 2020 -# -# Python API for applications - QoS -# -# Dimitri Staessens <dimitri@ouroboros.rocks> -# -# This library is free software; you can redistribute it and/or -# modify it under the terms of the GNU Lesser General Public License -# version 2.1 as published by the Free Software Foundation. -# -# This library 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 -# Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public -# License along with this library; if not, write to the Free Software -# Foundation, Inc., http://www.fsf.org/about/contact/. +# SPDX-FileCopyrightText: 2016 - 2026 Dimitri Staessens +# SPDX-License-Identifier: LGPL-2.1-only # -from _ouroboros_cffi import ffi -from math import modf -from typing import Optional +""" +QoS specification for Ouroboros flows. +""" + +from __future__ import annotations -# Some constants -MILLION = 1000 * 1000 -BILLION = 1000 * 1000 * 1000 +from dataclasses import dataclass +from enum import IntEnum + +DEFAULT_PEER_TIMEOUT = 120000 +UINT32_MAX = 0xFFFFFFFF +UINT64_MAX = 0xFFFFFFFFFFFFFFFF + + +class QoSService(IntEnum): + """ + Mirrors ``enum qos_service`` in ``ouroboros/qos.h``. + """ + RAW = 0 # No FRCT; best-effort raw messages + MESSAGE = 1 # FRCT, reliable ordered messages + STREAM = 2 # FRCT, reliable ordered byte stream + +@dataclass(frozen=True) class QoSSpec: """ - delay: In ms, default 1000s - bandwidth: In bits / s, default 0 - availability: Class of 9s, default 0 - loss: Packet loss in ppm, default MILLION - ber: Bit error rate, errors per billion bits. default BILLION - in_order: In-order delivery, enables FRCT, default 0 - max_gap: Maximum interruption in ms, default MILLION - cypher_s: Requested encryption strength in bits + QoS specification for a flow. + + :ivar service: :class:`QoSService` (gates FRCT when > 0). + :ivar delay: Maximum one-way delay in ms. + :ivar bandwidth: Required bandwidth in bits/s. + :ivar availability: Class of 9s (number of nines of uptime). + :ivar loss: Maximum packet loss (per million). + :ivar ber: Maximum bit error rate (errors per billion). + :ivar max_gap: Maximum interruption in ms. + :ivar timeout: Peer timeout in ms (default 120000 ms). """ - def __init__(self, - delay: int = MILLION, - bandwidth: int = 0, - availability: int = 0, - loss: int = 1, - ber: int = MILLION, - in_order: int = 0, - max_gap: int = MILLION, - cypher_s: int = 0): - self.delay = delay - self.bandwidth = bandwidth - self.availability = availability - self.loss = loss - self.ber = ber - self.in_order = in_order - self.max_gap = max_gap - self.cypher_s = cypher_s - - -def _fl_to_timespec(timeo: float): - if timeo is None: - return ffi.NULL - elif timeo <= 0: - return ffi.new("struct timespec *", [0, 0]) - else: - frac, whole = modf(timeo) - _timeo = ffi.new("struct timespec *") - _timeo.tv_sec = whole - _timeo.tv_nsec = frac * BILLION - return _timeo - - -def _timespec_to_fl(_timeo) -> Optional[float]: - if _timeo is ffi.NULL: - return None - elif _timeo.tv_sec <= 0 and _timeo.tv_nsec == 0: - return 0 - else: - return _timeo.tv_sec + _timeo.tv_nsec / BILLION - - -def _qos_to_qosspec(qos: QoSSpec): - if qos is None: - return ffi.NULL - else: - return ffi.new("qosspec_t *", - [qos.delay, - qos.bandwidth, - qos.availability, - qos.loss, - qos.ber, - qos.in_order, - qos.max_gap, - qos.cypher_s]) - - -def _qosspec_to_qos(_qos) -> Optional[QoSSpec]: - if _qos is ffi.NULL: - return None - else: - return QoSSpec(delay=_qos.delay, - bandwidth=_qos.bandwidth, - availability=_qos.availability, - loss=_qos.loss, - ber=_qos.ber, - in_order=_qos.in_order, - max_gap=_qos.max_gap, - cypher_s=_qos.cypher_s) + service: QoSService = QoSService.RAW + delay: int = UINT32_MAX + bandwidth: int = 0 + availability: int = 0 + loss: int = 1 + ber: int = 1 + max_gap: int = UINT32_MAX + timeout: int = DEFAULT_PEER_TIMEOUT + + +# Predefined QoS specs, mirroring the static const qosspec_t values in +# ouroboros/qos.h. "_safe" sets ber=0 (integrity check); "rt" trades +# reliability for latency. + +QOS_RAW = QoSSpec( + service=QoSService.RAW, + delay=UINT32_MAX, bandwidth=0, availability=0, + loss=1, ber=1, + max_gap=UINT32_MAX, timeout=0) + +QOS_RAW_SAFE = QoSSpec( + service=QoSService.RAW, + delay=UINT32_MAX, bandwidth=0, availability=0, + loss=1, ber=0, + max_gap=UINT32_MAX, timeout=0) + +QOS_RT = QoSSpec( + service=QoSService.MESSAGE, + delay=100, bandwidth=UINT64_MAX, availability=3, + loss=1, ber=1, + max_gap=100, timeout=DEFAULT_PEER_TIMEOUT) + +QOS_RT_SAFE = QoSSpec( + service=QoSService.MESSAGE, + delay=100, bandwidth=UINT64_MAX, availability=3, + loss=1, ber=0, + max_gap=100, timeout=DEFAULT_PEER_TIMEOUT) + +QOS_MSG = QoSSpec( + service=QoSService.MESSAGE, + delay=1000, bandwidth=0, availability=0, + loss=0, ber=0, + max_gap=2000, timeout=DEFAULT_PEER_TIMEOUT) + +QOS_STREAM = QoSSpec( + service=QoSService.STREAM, + delay=1000, bandwidth=0, availability=0, + loss=0, ber=0, + max_gap=2000, timeout=DEFAULT_PEER_TIMEOUT) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..d6f94c5 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,40 @@ +# SPDX-FileCopyrightText: 2016 - 2026 Dimitri Staessens +# SPDX-License-Identifier: LGPL-2.1-only + +[build-system] +requires = ["setuptools>=64", "setuptools-scm>=8", "cffi>=1.0.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "PyOuroboros" +dynamic = ["version"] +description = "Python API for Ouroboros" +license = "LGPL-2.1-only" +requires-python = ">=3.8" +authors = [{ name = "Dimitri Staessens", email = "dimitri@ouroboros.rocks" }] +keywords = ["ouroboros", "IPC"] +dependencies = ["cffi>=1.0.0"] + +[project.urls] +Homepage = "https://ouroboros.rocks" + +[tool.setuptools] +packages = ["ouroboros"] + +[tool.setuptools_scm] + +[tool.pylint.main] +ignored-modules = [ + "setuptools_scm", + "_ouroboros_dev_cffi", + "_ouroboros_irm_cffi", +] + +[tool.pylint.format] +max-line-length = 100 + +[tool.pylint.design] +max-args = 10 +max-positional-arguments = 10 +max-attributes = 10 +max-public-methods = 30 @@ -1,25 +1,67 @@ -#!/usr/bin/env python - -import setuptools - -setuptools.setup( - name='PyOuroboros', - version=0.17, - url='https://ouroboros.rocks', - keywords='ouroboros IPC subsystem', - author='Dimitri Staessens', - author_email='dimitri@ouroboros.rocks', - license='LGPLv2.1', - description='Python API for Ouroboros', - packages=[ - 'ouroboros' - ], - setup_requires=[ - "cffi>=1.0.0" - ], +# +# SPDX-FileCopyrightText: 2016 - 2026 Dimitri Staessens +# SPDX-License-Identifier: LGPL-2.1-only +# + +""" +Build script for the PyOuroboros CFFI extensions. + +Resolves the ouroboros library version via ``pkg-config`` and the +pyouroboros source version via ``setuptools_scm``, verifies that the +``major.minor`` halves match, and delegates the actual build to the CFFI +builders under ``ffi/``. +""" + +from __future__ import annotations + +import subprocess +import sys + +from setuptools import setup +from setuptools_scm import get_version + + +def _get_ouroboros_version() -> str: + try: + out = subprocess.check_output( + ['pkg-config', '--modversion', 'ouroboros-dev'], + stderr=subprocess.DEVNULL, + ) + return out.decode().strip() + except (subprocess.CalledProcessError, FileNotFoundError): + sys.exit("ERROR: ouroboros-dev not found via pkg-config. " + "Is Ouroboros installed?") + + +def _check_build_version_compat() -> None: + """ + Exit when the ouroboros and pyouroboros ``major.minor`` disagree. + + Returns without checking when the tree carries no SCM information. + """ + + try: + pyo7s_ver = get_version(root='.', relative_to=__file__) + except (LookupError, OSError): + return + + o7s_ver = _get_ouroboros_version() + + o7s_parts = o7s_ver.split('.') + pyo7s_parts = pyo7s_ver.split('.') + if o7s_parts[0] != pyo7s_parts[0] or o7s_parts[1] != pyo7s_parts[1]: + sys.exit( + f"ERROR: Version mismatch: ouroboros {o7s_ver} " + f"vs pyouroboros {pyo7s_ver} " + f"(major.minor must match)" + ) + + +_check_build_version_compat() + +setup( cffi_modules=[ - "ffi/pyouroboros_build.py:ffibuilder" + "ffi/pyouroboros_build_dev.py:ffibuilder", + "ffi/pyouroboros_build_irm.py:ffibuilder", ], - install_requires=[ - "cffi>=1.0.0" - ]) +) |
