Skip to content

About

A Rust implementation of TASE.2 / ICCP (IEC 60870-6) — the inter-control- centre protocol used between SCADA / EMS control centres

Resources

Stars

1 star

Watchers

0 watching

Forks

Repository files navigation

telcon-tase2

A Rust implementation of TASE.2 / ICCP (IEC 60870-6) — the inter-control- centre protocol used between SCADA / EMS control centres — together with a reusable, sans-I/O MMS + OSI upper-layer stack it is built on.

telcon-tase2 is the workspace/repository name; it publishes the mms-proto, tase2-proto, and tase2 crates (the protocol crate is still named tase2).

Status

0.1.0-dev. TASE.2 Blocks 1 (data values, datasets, naming), 2 (DS transfer sets and condition-monitoring reports) and 5 (device control with select-before-operate) are implemented and exercised by in-process loopback tests and an end-to-end shell-spec suite. Plain TCP and TLS (incl. mTLS with SHA-256 fingerprint pinning) transports are supported.

Feature State
Reusable sans-I/O MMS stack (BER, TPKT, COTP, Session, Presentation, ACSE, MMS) ✅
MMS association state machine (handshake, release, abort, timers) ✅
Block 1 — points (15 kinds), Read/Write, GetNameList, Define/Delete NVL ✅
Block 2 — DS transfer sets, condition monitoring, reports ✅
Block 5 — device control, direct + select-before-operate, tags ✅
Bilateral-table access control (default-deny) ✅
Async Tase2Client / Tase2Server on tokio ✅
TLS / mTLS via tokio-rustls (feature tls) ✅
End-to-end controllable daemons + shell-spec suite + Claude skills ✅
Critical/ACK transfer sets, BlockData transfer deferred
Operator-request / external-event conditions deferred
TASE.2 Blocks 3, 4, 6, 7, 8, 9 deferred

Workspace

Crate Description
mms-proto Reusable on its own. Sans-I/O MMS + OSI upper-layer stack: BER codec, TPKT/COTP framing, ISO Session/Presentation/ACSE, MMS services, and an MmsConnection association state machine. No async, no sockets, no clocks — every state-machine entry point takes the current Instant explicitly. Has no TASE.2 knowledge, so it can back any MMS-based protocol (e.g. IEC 61850). #![forbid(unsafe_code)].
tase2-proto Sans-I/O TASE.2 domain layer over mms-proto: point values + quality, datasets, Block-2 transfer sets + the TransferSetScheduler, Block-5 device control, and bilateral tables. No I/O.
tase2 Async client/server on tokio that drives the proto core over TCP/TLS. Public surface: Tase2Client, Tase2Server, ServeOptions, the DataProvider / ControlHandler traits, reporting helpers, the EventHandler observer, and the optional tls module. Re-exports mms-proto as tase2::mms and tase2-proto as tase2::proto.
tase2-test-tools publish = false. Two long-running, JSON-controllable daemons (tase2-server, tase2-client) plus the e2e shell specs. Used by the conformance suite.

The dependency direction is strict and one-way: mms-proto knows nothing about TASE.2 or tokio; tase2-proto knows nothing about tokio; tase2 drives both over a real transport.

Quickstart

Two runnable examples live under crates/tase2/examples/. Bring up a server, then a client:

cargo run --example simple_server      # terminal 1: binds, serves ICC1 points + DS1 reports
cargo run --example simple_client      # terminal 2: reads P1, subscribes a transfer set

A client: associate, read a point, subscribe a transfer set

use std::net::Ipv4Addr;
use std::time::Duration;
use tase2::{
    AssocConfig, DomainName, DsConditions, Identifier, ObjectName, PointKind, Tase2Client,
    Transport, DEFAULT_PORT,
};

fn dn(item: &str) -> ObjectName {
    ObjectName::DomainSpecific {
        domain: Identifier::new("ICC1").unwrap(),
        item: Identifier::new(item).unwrap(),
    }
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let addr = (Ipv4Addr::LOCALHOST, DEFAULT_PORT).into();
    let mut client = Tase2Client::connect(Transport::tcp(addr), AssocConfig::default()).await?;

    // Read one point.
    let value = client.read_point(&dn("P1"), PointKind::Discrete).await?;
    println!("P1 = {value:?}");

    // Claim, configure and enable a DS transfer set, then collect a report.
    let domain = DomainName::from(Identifier::new("ICC1")?);
    let ts_name = client.next_transfer_set(&domain).await?;
    let mut ts = client.read_transfer_set(&ts_name).await?;
    ts.data_set_name = Some(tase2::TsDataSetName {
        domain: Identifier::new("ICC1")?,
        name: Identifier::new("DS1")?,
    });
    ts.interval = 1;
    ts.conditions = DsConditions::INTERVAL_TIMEOUT | DsConditions::OBJECT_CHANGE;
    ts.status = true;
    client.write_transfer_set(&ts_name, &ts).await?;

    let members = vec![
        dn(tase2::names::TRANSFER_SET_NAME),
        dn(tase2::names::TRANSFER_SET_TIME_STAMP),
        dn(tase2::names::DSCONDITIONS_DETECTED),
        dn(tase2::names::EVENT_CODE_DETECTED),
        dn("P1"),
        dn("P2"),
    ];
    if let Ok(Some(pdu)) =
        tokio::time::timeout(Duration::from_secs(5), client.recv_unsolicited()).await
    {
        let report = client.decode_report(&pdu, &members)?;
        println!("report conditions={:?} values={:?}", report.conditions, report.values);
    }

    client.release().await?;
    Ok(())
}

A server: serve points and reports from a MapDataProvider

use std::net::Ipv4Addr;
use std::sync::Arc;
use std::time::Duration;
use tase2::{
    serve_connection_with_reports_interval, AssocConfig, DataSet, Identifier, MapDataProvider,
    ObjectName, PointValue, Tase2Server, TransferSetManager, DEFAULT_PORT,
};

fn dn(item: &str) -> ObjectName {
    ObjectName::DomainSpecific {
        domain: Identifier::new("ICC1").unwrap(),
        item: Identifier::new(item).unwrap(),
    }
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let provider = Arc::new(MapDataProvider::new());
    provider.insert_point(dn("P1"), PointValue::Discrete(10)).await;
    provider.insert_point(dn("P2"), PointValue::Discrete(20)).await;
    provider
        .insert_data_set(DataSet::with_object_name(
            dn("DS1"),
            vec![
                dn(tase2::names::TRANSFER_SET_NAME),
                dn(tase2::names::TRANSFER_SET_TIME_STAMP),
                dn(tase2::names::DSCONDITIONS_DETECTED),
                dn(tase2::names::EVENT_CODE_DETECTED),
                dn("P1"),
                dn("P2"),
            ],
        ))
        .await;

    let bind = (Ipv4Addr::UNSPECIFIED, DEFAULT_PORT).into();
    let server = Tase2Server::bind(bind, AssocConfig::default()).await?;
    let mut conn = server.accept().await?;

    let manager = Arc::new(TransferSetManager::new(provider.clone()));
    serve_connection_with_reports_interval(&mut conn, provider, manager, Duration::from_millis(250))
        .await?;
    Ok(())
}

A real server implements the DataProvider trait over its own process image instead of using MapDataProvider, optionally adds a ControlHandler for Block-5 control, and gates access with a BilateralTable via ServeOptions.

Reusing the MMS stack on its own

mms-proto is a standalone, sans-I/O MMS / OSI upper-layer stack with no TASE.2 dependency. If you need to speak MMS for a different application (e.g. an IEC 61850 client), depend on it directly and drive MmsConnection over your own transport — tase2 is just one consumer of it.

TLS

Enable the tls feature for tokio-rustls transport, including mutual TLS and SHA-256 certificate-fingerprint pinning (common for self-signed substation deployments). See the tls module re-exports in crates/tase2/src/lib.rs and the mTLS loopback test crates/tase2/tests/loopback_mtls.rs.

End-to-end tooling

The tase2-test-tools crate builds two long-running daemons controllable at runtime over a JSON-over-Unix-socket protocol — tase2-server (the outstation) and tase2-client (the master) — plus short-lived CLI subcommands so a shell script or an agent can drive a live conversation step by step.

On top of them sit 12 reproducible shell specs and an orchestrator:

cargo build -p tase2-test-tools --bins                              # required first
bash crates/tase2-test-tools/tests/specs/run_all.sh                 # whole suite
bash crates/tase2-test-tools/tests/specs/test_01_smoke.sh           # one spec

SPECS.md in that directory documents each spec (including negative tests where a failure response is the pass condition). Three in-repo Claude skills — tase2-server, tase2-client, tase2-e2e-tests (under .claude/skills/) — drive the daemons and the suite.

Design

The codebase follows the sans-I/O pattern (as in quinn-proto / h2): the protocol logic — codecs and the association state machine — lives in mms-proto and tase2-proto with zero dependency on tokio, sockets, or wall-clock time. Every state-machine entry point accepts the current Instant explicitly; the async tase2 crate drives that state machine over a real transport. This makes timeouts deterministically testable, lets one state machine serve TCP and TLS unchanged, and keeps the protocol core reusable. See docs/protocol-notes.md for byte-level reference and HANDOVER.md for the architecture rationale.

Testing & security

cargo test --workspace --all-features

The library crates are exercised by unit tests, proptest round-trip tests over the codec layer, in-process loopback integration tests per transport, and the shell-spec suite. cargo fuzz targets under fuzz/ feed arbitrary bytes to the decoders and opcode streams to the state machine. Both library crates are #![forbid(unsafe_code)]; all parsing is bounds-checked and returns typed errors rather than panicking on malformed or hostile input. CI enforces ≥ 80 % line coverage on the publishable crates.

MSRV

Rust 1.83, pinned in clippy.toml and tested in CI.

License

Dual-licensed under either of

at your option.

About

A Rust implementation of TASE.2 / ICCP (IEC 60870-6) — the inter-control- centre protocol used between SCADA / EMS control centres

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages