diff --git a/README.adoc b/README.adoc index e5e285f..c40a351 100644 --- a/README.adoc +++ b/README.adoc @@ -230,39 +230,4 @@ Copyright (c) 2026, RtBrick === SPDX Identifier -`BSD-3-Clause` - - - - -rtb-buddy lix1 generate ca --cn rtbrick-lab --org rtbrick --ou devops -rtb-buddy lix1 generate ca --cn rtbrick-lab --org rtbrick --ou devops --ca-crt client-ca.crt --ca-crt-key client-ca.key - -rtb-buddy lix1 generate cert --cn ufi31 --org rtbrick --ou devops --crt ufi31.crt --crt-key ufi31.key - -rtb-buddy lix1 generate cert --cn client1 --org rtbrick --ou devops --ca-crt client-ca.crt --ca-crt-key client-ca.key --crt client1.crt --crt-key client1.key - - -set lawful-intercept protocol x1 mutual-tls server certificate LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUVjVENDQTFtZ0F3SUJBZ0lDQm5vd0RRWUpLb1pJaHZjT... -set lawful-intercept protocol x1 mutual-tls server key-plain-text LS0tLS1CRUdJTiBSU0EgUFJJVkFURSBLRVktL... -set lawful-intercept protocol x1 mutual-tls client root-ca LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSURYakNDQWthZ0F3SUJBZ0lSQVBtUjVoRWxtZ0NOaVliWVZ -jZXV6Yll3RFFZSktvWklodmNOQVFFTEJRQXcKT1RFUU1BNEdBMVVFQ2hNSGNuUmljbWxqYXpFUE1BMEdBMVVFQ3hNR1pHVjJiM0J6TV -JRd0VnWURWUVFERXd0eQpkR0p5YVdOckxXeGhZakFlRncweU5qRXdNVFF4TUR - -[source,sh] ----- -curl --insecure -vvv --key client1.key --cert client1.crt --cacert ca.crt --location 'https://ufi31.q2a.u39.r3.nbg.rtbrick.net:12322/X1/NE' \ ---header 'Content-Type: application/json' \ ---data ' - - ADMF-ID-01 - NE-ID-102 - 2026-09-22T20:51:00Z - v1.12.1 - f81d4fae-7dec-11d0-a765-00a0c91e6bf6 - -' ----- - - -2026/10/14 10:36:23 INFO Programm info version=0.4.0-g6daily.20261013144632+Bdevelopment.C53e4a3f0 runtime=go1.27.1 \ No newline at end of file +`BSD-3-Clause` \ No newline at end of file diff --git a/cmd/rtb-buddy/cmd/root.go b/cmd/rtb-buddy/cmd/root.go index 7713b87..8e37e9d 100644 --- a/cmd/rtb-buddy/cmd/root.go +++ b/cmd/rtb-buddy/cmd/root.go @@ -1,6 +1,8 @@ package cmd import ( + "os" + "github.com/rtbrick/tools/cmd/rtb-buddy/config" "github.com/rtbrick/tools/pkg/cmd/apigw" "github.com/rtbrick/tools/pkg/cmd/lix1" @@ -15,6 +17,7 @@ func NewRootCmd(silenceErrors, silenceUsage bool) *cobra.Command { SilenceErrors: silenceErrors, SilenceUsage: silenceUsage, } + cmd.SetOut(os.Stdout) cmd.Version = config.VERSION cmd.AddCommand(apigw.NewApigwCmd()) cmd.AddCommand(lix1.NewLix1Cmd()) diff --git a/cmd/rtb-buddy/rbfs_lix1.adoc b/cmd/rtb-buddy/rbfs_lix1.adoc index f637016..fb3af1d 100644 --- a/cmd/rtb-buddy/rbfs_lix1.adoc +++ b/cmd/rtb-buddy/rbfs_lix1.adoc @@ -1,8 +1,8 @@ -==== rbfs lix1d +==== rbfs lix1 -Generates RBFS LI X1 mTLS CLI `set` commands from certificates. +Generates RBFS LI X1 mTLS CLI `set` commands from certificates and sends PingRequest. -===== rbfs lix1d server certificate +===== rbfs lix1 server certificate Generates server certificate and key configuration commands. @@ -11,7 +11,7 @@ Generates server certificate and key configuration commands. [source,sh] ---- -rtb-buddy rbfs lix1d server certificate --cert server.crt --cert-key server.key +rtb-buddy rbfs lix1 server certificate --cert server.crt --cert-key server.key ---- Outputs: @@ -20,7 +20,7 @@ set lawful-intercept protocol x1 mutual-tls server certificate set lawful-intercept protocol x1 mutual-tls server key-plain-text ``` -===== rbfs lix1d server client-ca +===== rbfs lix1 server client-ca Generates server client CA configuration command. @@ -28,7 +28,7 @@ Generates server client CA configuration command. [source,sh] ---- -rtb-buddy rbfs lix1d server client-ca --cert client-ca.crt +rtb-buddy rbfs lix1 server client-ca --cert client-ca.crt ---- Outputs: @@ -36,7 +36,7 @@ Outputs: set lawful-intercept protocol x1 mutual-tls server client-ca ``` -===== rbfs lix1d client root-ca +===== rbfs lix1 client root-ca Generates client root CA configuration command. @@ -44,10 +44,42 @@ Generates client root CA configuration command. [source,sh] ---- -rtb-buddy rbfs lix1d client root-ca --cert root-ca.crt +rtb-buddy rbfs lix1 client root-ca --cert root-ca.crt ---- Outputs: ``` set lawful-intercept protocol x1 mutual-tls client root-ca +``` + +===== rbfs lix1 ping + +Send X1 PingRequest to RBFS router (LI X1 over mTLS). + +* `--cert` — Client certificate PEM file (required) +* `--cert-key` — Client private key PEM file (required) +* `` — Router hostname or address (required) + +[source,sh] +---- +rtb-buddy rbfs lix1 ping --cert client1.crt --cert-key client1.key +---- + +Output: +``` +Status: 200 +Content-Type: application/xml +Response: + + + + ADMF-ID-01 + NE-ID-102 + 2026-01-15T12:00:00Z + v1.12.1 + f81d4fae-7dec-11d0-a765-00a0c91e6bf6 + basic + 1 + + ``` \ No newline at end of file diff --git a/pkg/cmd/apigw/apigw_test.go b/pkg/cmd/apigw/apigw_test.go index 7db677f..012d48a 100644 --- a/pkg/cmd/apigw/apigw_test.go +++ b/pkg/cmd/apigw/apigw_test.go @@ -174,26 +174,8 @@ func TestGenerateTokenCmd(t *testing.T) { }, { name: "valid token without name", - args: []string{"token", "--priv-key", privPath, "--sub", "user1", "--preferred-username", "testuser"}, - wantErr: false, - }, - { - name: "missing sub", - args: []string{"token", "--priv-key", privPath, "--name", "Test User", "--preferred-username", "testuser"}, - wantErr: true, - errMsg: "required flag(s) \"sub\" not set", - }, - { - name: "missing preferred-username", - args: []string{"token", "--priv-key", privPath, "--sub", "user1", "--name", "Test User"}, - wantErr: true, - errMsg: "required flag(s) \"preferred-username\" not set", - }, - { - name: "missing all required", args: []string{"token", "--priv-key", privPath}, - wantErr: true, - errMsg: "required flag(s)", + wantErr: false, }, { name: "invalid priv path", diff --git a/pkg/cmd/apigw/flags.go b/pkg/cmd/apigw/flags.go index 35cc2a3..49c3d8b 100644 --- a/pkg/cmd/apigw/flags.go +++ b/pkg/cmd/apigw/flags.go @@ -5,7 +5,7 @@ import ( ) const ( - flagCert = "cert" + flagCert = flags.FlagCert flagKey = "key" flagOrg = "org" flagHost = "host" diff --git a/pkg/cmd/apigw/generate.go b/pkg/cmd/apigw/generate.go index 606e9f7..b64ab39 100644 --- a/pkg/cmd/apigw/generate.go +++ b/pkg/cmd/apigw/generate.go @@ -68,9 +68,6 @@ func newTokenCmd() *cobra.Command { cmd.Flags().StringVar(&overrideKid, flagOverrideKid, "", "Override the kid in the JWT header") cmd.Flags().DurationVar(&dur, flagDur, defaultDuration, "Token duration") - _ = cmd.MarkFlagRequired(flagSub) - _ = cmd.MarkFlagRequired(flagPreferredUser) - return cmd } diff --git a/pkg/cmd/lix1/flags.go b/pkg/cmd/lix1/flags.go index b13f80b..ca91a5d 100644 --- a/pkg/cmd/lix1/flags.go +++ b/pkg/cmd/lix1/flags.go @@ -1,6 +1,8 @@ package lix1 -import "time" +import ( + "time" +) const ( flagCACrt = "ca-crt" diff --git a/pkg/cmd/rbfs/lix1d.go b/pkg/cmd/rbfs/lix1.go similarity index 96% rename from pkg/cmd/rbfs/lix1d.go rename to pkg/cmd/rbfs/lix1.go index 7a3f775..8c11b0f 100644 --- a/pkg/cmd/rbfs/lix1d.go +++ b/pkg/cmd/rbfs/lix1.go @@ -5,9 +5,9 @@ import ( "github.com/spf13/cobra" ) -func NewLix1dCmd() *cobra.Command { +func NewLix1Cmd() *cobra.Command { cmd := &cobra.Command{ - Use: "lix1d", + Use: "lix1", Short: "Generate RBFS LI X1 mTLS CLI commands from certificates", } @@ -24,8 +24,11 @@ func NewLix1dCmd() *cobra.Command { } clientCmd.AddCommand(newClientRootCACmd()) + pingCmd := NewLix1PingCmd() + cmd.AddCommand(serverCmd) cmd.AddCommand(clientCmd) + cmd.AddCommand(pingCmd) return cmd } diff --git a/pkg/cmd/rbfs/lix1_ping.go b/pkg/cmd/rbfs/lix1_ping.go new file mode 100644 index 0000000..dc516fd --- /dev/null +++ b/pkg/cmd/rbfs/lix1_ping.go @@ -0,0 +1,166 @@ +package rbfs + +import ( + "bytes" + "crypto/tls" + "encoding/xml" + "fmt" + "io" + "net/http" + "time" + + "github.com/spf13/cobra" +) + +// buildLix1dPingBody builds the XML PingRequest body for rbfs lix1 ping +// Produces XML matching ETSI TS 103 221-1 format: +// +// +// +// ADMF-ID-01 +// NE-ID-102 +// 2026-09-22T20:51:00Z +// v1.12.1 +// f81d4fae-7dec-11d0-a765-00a0c91e6bf6 +// basic +// 1 +// +// +// +func buildLix1dPingBody(admfID, neID, version, pingType string, txTime time.Time) ([]byte, error) { + txID := fmt.Sprintf("%d-%d", txTime.Unix(), txTime.Nanosecond()) + + body := []byte(fmt.Sprintf(` + + + %s + %s + %s + %s + %s + %s + 1 + +`, + admfID+"-01", + neID+"-102", + txTime.Format(time.RFC3339), + version, + txID, + pingType, + )) + + return body, nil +} + +// NewLix1PingCmd creates the rbfs lix1 ping command +func NewLix1PingCmd() *cobra.Command { + var admfID, neID, version, pingType, cert, certKey string + var insecure bool + + cmd := &cobra.Command{ + Use: "ping", + Short: "Send X1 PingRequest to RBFS router (LI X1 over mTLS)", + Args: cobra.ExactArgs(1), // + RunE: func(cmd *cobra.Command, args []string) error { + path := args[0] + + // Build XML body + txTime := time.Now() + body, err := buildLix1dPingBody(admfID, neID, version, pingType, txTime) + if err != nil { + return fmt.Errorf("build ping body: %w", err) + } + + // Build HTTP client + tlsConfig := &tls.Config{ + InsecureSkipVerify: insecure, + } + if cert != "" && certKey != "" { + // Load client certificate for mTLS + certData, err := tls.LoadX509KeyPair(cert, certKey) + if err != nil { + return fmt.Errorf("load client cert/key: %w", err) + } + tlsConfig.Certificates = append(tlsConfig.Certificates, certData) + } + + // Parse element from path (could be hostname or include more details) + element := path + url := fmt.Sprintf("https://%s:12322/X1/NE", element) + + req, err := http.NewRequest("POST", url, bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("create request: %w", err) + } + req.Header.Set("Content-Type", "application/xml") + + client := &http.Client{ + Timeout: 30 * time.Second, + Transport: &http.Transport{TLSClientConfig: tlsConfig}, + } + + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("do request: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("read response: %w", err) + } + prettyResp, err := PrettyPrintXML(respBody) + if err != nil { + return fmt.Errorf("pretty print XML: %w", err) + } + + cmd.Printf("Status: %d\n", resp.StatusCode) + cmd.Printf("Content-Type: %s\n", resp.Header.Get("Content-Type")) + cmd.Print("Response:\n") + cmd.Printf("%s\n", prettyResp) + + return nil + }, + } + + // Flags - mandatory: cert, cert-key + cmd.Flags().StringVar(&admfID, "admf-identifier", "ADMF-ID", "ADMf Identifier base") + cmd.Flags().StringVar(&neID, "ne-identifier", "NE-ID", "NE Identifier base") + cmd.Flags().StringVar(&version, "version", "v1.12.1", "X1 version") + cmd.Flags().StringVar(&pingType, "ping-type", "basic", "Ping type") + cmd.Flags().BoolVar(&insecure, "insecure", false, "Skip TLS certificate verification") + cmd.Flags().StringVar(&cert, "cert", "", "Client certificate PEM path (required)") + cmd.Flags().StringVar(&certKey, "cert-key", "", "Client private key PEM path (required)") + _ = cmd.MarkFlagRequired("cert") + _ = cmd.MarkFlagRequired("cert-key") + + return cmd +} + +// PrettyPrintXML takes raw XML bytes and returns an indented XML string. +func PrettyPrintXML(raw []byte) (string, error) { + decoder := xml.NewDecoder(bytes.NewReader(raw)) + var buf bytes.Buffer + encoder := xml.NewEncoder(&buf) + encoder.Indent("", " ") // Use 2 spaces for indentation + + for { + token, err := decoder.Token() + if err == io.EOF { + break + } + if err != nil { + return "", err + } + if err := encoder.EncodeToken(token); err != nil { + return "", err + } + } + + if err := encoder.Flush(); err != nil { + return "", err + } + + return buf.String(), nil +} diff --git a/pkg/cmd/rbfs/lix1_ping_test.go b/pkg/cmd/rbfs/lix1_ping_test.go new file mode 100644 index 0000000..31ce7c6 --- /dev/null +++ b/pkg/cmd/rbfs/lix1_ping_test.go @@ -0,0 +1,129 @@ +package rbfs + +import ( + "bytes" + "testing" + "time" +) + +func TestBuildLix1PingBody(t *testing.T) { + tests := []struct { + name string + admfID string + neID string + version string + pingType string + expectedIdentifiers []string // substrings that must appear in XML output + }{ + { + name: "default values", + admfID: "ADMF-ID", + neID: "NE-ID", + version: "v1.12.1", + pingType: "basic", + expectedIdentifiers: []string{"ADMF-ID-01", "NE-ID-102", "v1.12.1", "basic"}, + }, + { + name: "custom identifiers", + admfID: "CUSTOM-A", + neID: "CUSTOM-N", + version: "v1.13.0", + pingType: "extended", + expectedIdentifiers: []string{"CUSTOM-A-01", "CUSTOM-N-102", "v1.13.0", "extended"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + txTime := time.Now() + body, err := buildLix1dPingBody(tt.admfID, tt.neID, tt.version, tt.pingType, txTime) + if err != nil { + t.Fatalf("buildLix1dPingBody returned error: %v", err) + } + + // Should contain XML declaration + if !bytes.HasPrefix(body, []byte(``)) { + t.Fatalf("XML should start with declaration") + } + + // Should contain expected identifiers + for _, id := range tt.expectedIdentifiers { + if !bytes.Contains(body, []byte(id)) { + t.Fatalf("expected XML fragment %q not found in body", id) + } + } + + // Should contain ETSI namespace + if !bytes.Contains(body, []byte("xmlns=\"http://uri.etsi.org/03221/X1/2017/10\"")) { + t.Fatalf("expected ETSI namespace not found in body") + } + }) + } +} + +func TestPingRequestXMLStructure(t *testing.T) { + txTime := time.Now() + body, err := buildLix1dPingBody("ADMF-ID", "NE-ID", "v1.12.1", "basic", txTime) + if err != nil { + t.Fatalf("buildLix1dPingBody returned error: %v", err) + } + + // Should contain XML namespace declaration + namespaces := `xmlns="http://uri.etsi.org/03221/X1/2017/10"` + if !bytes.Contains(body, []byte(namespaces)) { + t.Fatalf("expected namespace %q not found in body", namespaces) + } + + // Should contain expected elements + checks := []string{ + "ADMF-ID-01", + "NE-ID-102", + "v1.12.1", + "basic", + "x1TransactionId", + } + for _, check := range checks { + if !bytes.Contains(body, []byte(check)) { + t.Fatalf("expected %q not found in body", check) + } + } + + // Verify structure: X1Request root element + if !bytes.Contains(body, []byte("= 0 { + // Check that the transaction ID value contains a dash + txIDSection := body[txIDIndex : txIDIndex+100] + if !bytes.Contains(txIDSection, []byte("-")) { + t.Logf("transaction ID dash check: checking format around x1TransactionId") + } + } +} diff --git a/pkg/cmd/rbfs/lix1d_test.go b/pkg/cmd/rbfs/lix1_test.go similarity index 95% rename from pkg/cmd/rbfs/lix1d_test.go rename to pkg/cmd/rbfs/lix1_test.go index 8549de4..9cdf768 100644 --- a/pkg/cmd/rbfs/lix1d_test.go +++ b/pkg/cmd/rbfs/lix1_test.go @@ -72,7 +72,7 @@ func TestServerCertificateCmd(t *testing.T) { certPath, keyPath := createTempCert(t, tmpDir, "server", false) - cmd := NewLix1dCmd() + cmd := NewLix1Cmd() output, err := runCmd(t, cmd, "server", "certificate", "--cert", certPath, "--cert-key", keyPath) require.NoError(t, err) require.Contains(t, output, "set lawful-intercept protocol x1 mutual-tls server certificate ") @@ -85,7 +85,7 @@ func TestServerCertificateCmd_CertKeyMismatch(t *testing.T) { certPath1, _ := createTempCert(t, tmpDir, "server1", false) _, keyPath2 := createTempCert(t, tmpDir, "server2", false) - cmd := NewLix1dCmd() + cmd := NewLix1Cmd() _, err := runCmd(t, cmd, "server", "certificate", "--cert", certPath1, "--cert-key", keyPath2) require.Error(t, err) require.Contains(t, err.Error(), "do not match") @@ -96,7 +96,7 @@ func TestServerCertificateCmd_CARejected(t *testing.T) { certPath, keyPath := createTempCert(t, tmpDir, "ca", true) - cmd := NewLix1dCmd() + cmd := NewLix1Cmd() _, err := runCmd(t, cmd, "server", "certificate", "--cert", certPath, "--cert-key", keyPath) require.Error(t, err) require.Contains(t, err.Error(), "is a CA certificate") @@ -107,7 +107,7 @@ func TestServerCertificateCmd_MissingCert(t *testing.T) { _, keyPath := createTempCert(t, tmpDir, "server", false) - cmd := NewLix1dCmd() + cmd := NewLix1Cmd() _, err := runCmd(t, cmd, "server", "certificate", "--cert-key", keyPath) require.Error(t, err) require.Contains(t, err.Error(), "required flag") @@ -118,7 +118,7 @@ func TestServerCertificateCmd_MissingKey(t *testing.T) { certPath, _ := createTempCert(t, tmpDir, "server", false) - cmd := NewLix1dCmd() + cmd := NewLix1Cmd() _, err := runCmd(t, cmd, "server", "certificate", "--cert", certPath) require.Error(t, err) require.Contains(t, err.Error(), "required flag") @@ -129,7 +129,7 @@ func TestServerClientCACmd(t *testing.T) { certPath, _ := createTempCert(t, tmpDir, "client-ca", true) - cmd := NewLix1dCmd() + cmd := NewLix1Cmd() output, err := runCmd(t, cmd, "server", "client-ca", "--cert", certPath) require.NoError(t, err) require.Contains(t, output, "set lawful-intercept protocol x1 mutual-tls server client-ca ") @@ -140,14 +140,14 @@ func TestServerClientCACmd_NonCARejected(t *testing.T) { certPath, _ := createTempCert(t, tmpDir, "server", false) - cmd := NewLix1dCmd() + cmd := NewLix1Cmd() _, err := runCmd(t, cmd, "server", "client-ca", "--cert", certPath) require.Error(t, err) require.Contains(t, err.Error(), "not a CA certificate") } func TestServerClientCACmd_MissingCert(t *testing.T) { - cmd := NewLix1dCmd() + cmd := NewLix1Cmd() _, err := runCmd(t, cmd, "server", "client-ca") require.Error(t, err) require.Contains(t, err.Error(), "required flag") @@ -158,7 +158,7 @@ func TestClientRootCACmd(t *testing.T) { certPath, _ := createTempCert(t, tmpDir, "root-ca", true) - cmd := NewLix1dCmd() + cmd := NewLix1Cmd() output, err := runCmd(t, cmd, "client", "root-ca", "--cert", certPath) require.NoError(t, err) require.Contains(t, output, "set lawful-intercept protocol x1 mutual-tls client root-ca ") @@ -169,14 +169,14 @@ func TestClientRootCACmd_NonCARejected(t *testing.T) { certPath, _ := createTempCert(t, tmpDir, "server", false) - cmd := NewLix1dCmd() + cmd := NewLix1Cmd() _, err := runCmd(t, cmd, "client", "root-ca", "--cert", certPath) require.Error(t, err) require.Contains(t, err.Error(), "not a CA certificate") } func TestClientRootCACmd_MissingCert(t *testing.T) { - cmd := NewLix1dCmd() + cmd := NewLix1Cmd() _, err := runCmd(t, cmd, "client", "root-ca") require.Error(t, err) require.Contains(t, err.Error(), "required flag") @@ -188,14 +188,14 @@ func TestInvalidPEMFile(t *testing.T) { badPath := filepath.Join(tmpDir, "bad.pem") require.NoError(t, os.WriteFile(badPath, []byte("not a pem"), 0o644)) - cmd := NewLix1dCmd() + cmd := NewLix1Cmd() _, err := runCmd(t, cmd, "server", "client-ca", "--cert", badPath) require.Error(t, err) require.Contains(t, err.Error(), "not a valid PEM") } func TestMissingFile(t *testing.T) { - cmd := NewLix1dCmd() + cmd := NewLix1Cmd() _, err := runCmd(t, cmd, "server", "client-ca", "--cert", "/nonexistent/path.crt") require.Error(t, err) require.Contains(t, err.Error(), "read file") diff --git a/pkg/cmd/rbfs/rbfs.go b/pkg/cmd/rbfs/rbfs.go index 829506b..a48a0df 100644 --- a/pkg/cmd/rbfs/rbfs.go +++ b/pkg/cmd/rbfs/rbfs.go @@ -10,6 +10,6 @@ func NewRbfsCmd() *cobra.Command { Short: "RBF router management", } cmd.AddCommand(NewConfigCmd()) - cmd.AddCommand(NewLix1dCmd()) + cmd.AddCommand(NewLix1Cmd()) return cmd }