feat(mediaserver): custom TLS certs

This commit is contained in:
Rob Watson 2025-04-20 11:22:33 +02:00
parent 98d93ad286
commit 4a863a3212
14 changed files with 283 additions and 53 deletions

View File

@ -7,7 +7,7 @@
Octoplex is a live video restreamer for the terminal.
* Restream RTMP to unlimited destinations
* Restream RTMP/RTMPS to unlimited destinations
* Broadcast using OBS and other standard tools
* Add and remove destinations while streaming
* Automatic reconnections
@ -100,6 +100,9 @@ sources:
mediaServer:
streamKey: live # defaults to "live"
host: rtmp.example.com # defaults to "localhost"
tls: # optional. If RTMPS is enabled, defaults to a
cert: /etc/mycert.pem # self-signed keypair corresponding to the host
key: /etc/mykey.pem # key.
rtmp:
enabled: true # defaults to false
ip: 127.0.0.1 # defaults to 127.0.0.1

View File

@ -88,10 +88,18 @@ func Run(ctx context.Context, params RunParams) error {
updateUI := func() { ui.SetState(*state) }
updateUI()
var tlsCertPath, tlsKeyPath string
if cfg.Sources.MediaServer.TLS != nil {
tlsCertPath = cfg.Sources.MediaServer.TLS.CertPath
tlsKeyPath = cfg.Sources.MediaServer.TLS.KeyPath
}
srv, err := mediaserver.NewActor(ctx, mediaserver.NewActorParams{
RTMPAddr: buildNetAddr(cfg.Sources.MediaServer.RTMP),
RTMPSAddr: buildNetAddr(cfg.Sources.MediaServer.RTMPS),
Host: cfg.Sources.MediaServer.Host,
TLSCertPath: tlsCertPath,
TLSKeyPath: tlsKeyPath,
StreamKey: mediaserver.StreamKey(cfg.Sources.MediaServer.StreamKey),
ContainerClient: containerClient,
Logger: logger.With("component", "mediaserver"),

View File

@ -5,9 +5,13 @@ package app_test
import (
"cmp"
"context"
"crypto/tls"
"crypto/x509"
"encoding/pem"
"errors"
"fmt"
"net"
"os"
"testing"
"time"
@ -292,6 +296,75 @@ func testIntegration(t *testing.T, mediaServerConfig config.MediaServerSource) {
<-done
}
func TestIntegrationCustomTLSCerts(t *testing.T) {
ctx, cancel := context.WithTimeout(t.Context(), 10*time.Minute)
defer cancel()
logger := testhelpers.NewTestLogger(t).With("component", "integration")
dockerClient, err := dockerclient.NewClientWithOpts(dockerclient.FromEnv, dockerclient.WithAPIVersionNegotiation())
require.NoError(t, err)
configService := setupConfigService(t, config.Config{
Sources: config.Sources{
MediaServer: config.MediaServerSource{
TLS: &config.TLS{
CertPath: "testdata/server.crt",
KeyPath: "testdata/server.key",
},
RTMPS: config.RTMPSource{Enabled: true},
},
},
})
screen, screenCaptureC, getContents := setupSimulationScreen(t)
done := make(chan struct{})
go func() {
defer func() {
done <- struct{}{}
}()
require.NoError(t, app.Run(ctx, buildAppParams(t, configService, dockerClient, screen, screenCaptureC, logger)))
}()
require.EventuallyWithT(
t,
func(c *assert.CollectT) {
certPEM, err := os.ReadFile("testdata/server.crt")
require.NoError(c, err)
block, _ := pem.Decode(certPEM)
require.NotNil(c, block, "failed to decode PEM block containing certificate")
require.True(c, block.Type == "CERTIFICATE", "expected PEM block to be a certificate")
certDERBytes := block.Bytes
rootCAs := x509.NewCertPool()
require.True(c, rootCAs.AppendCertsFromPEM(certPEM), "failed to append cert to root CA pool")
conn, err := tls.Dial("tcp", "localhost:1936", &tls.Config{
RootCAs: rootCAs,
ServerName: "localhost",
InsecureSkipVerify: false,
})
require.NoError(c, err)
state := conn.ConnectionState()
peerCert := state.PeerCertificates[0]
expectedCert, err := x509.ParseCertificate(certDERBytes)
require.NoError(c, err)
require.True(c, peerCert.Equal(expectedCert), "expected peer certificate to match the expected certificate")
},
waitTime,
time.Second,
)
printScreen(t, getContents, "After starting the app with custom TLS certs")
cancel()
<-done
}
func TestIntegrationCustomRTMPURL(t *testing.T) {
ctx, cancel := context.WithTimeout(t.Context(), 10*time.Minute)
defer cancel()

17
internal/app/testdata/openssl.cnf vendored Normal file
View File

@ -0,0 +1,17 @@
# openssl req -x509 -nodes -days 3650 -newkey rsa:2048 -keyout server.key -out server.crt -config openssl.cnf
[req]
default_bits = 2048
prompt = no
default_md = sha256
distinguished_name = dn
x509_extensions = v3_req
[dn]
CN = localhost
[v3_req]
subjectAltName = @alt_names
[alt_names]
DNS.1 = localhost

18
internal/app/testdata/server.crt vendored Normal file
View File

@ -0,0 +1,18 @@
-----BEGIN CERTIFICATE-----
MIIC7TCCAdWgAwIBAgIUTeqv46R19q+BS2e4DBkbIHuWyIIwDQYJKoZIhvcNAQEL
BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTI1MDQyMDA4NTMwN1oXDTM1MDQx
ODA4NTMwN1owFDESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEF
AAOCAQ8AMIIBCgKCAQEA0v/KndfKfG8XItStHeMQ/3z1r8vhkH9KGpfSwDMp8MdH
Mox6vcAsIIr1RFKmalQQg+T+TK9v3XM6F4sJ+WPyb5/31xLUqG6zivitrMy1AZ8w
XLgAz/CTufXL3OBntDwg29QXWt9lOUJyjRa66AQqreTlItuLG65bswfPA4g35f+U
hyr49paukqnVHRr44GtyiNxlfYCEdQWdOR0EQmZ7y6WNQQhnR8odQyftR2lykf17
MSJ8us4JAgZ2fr1QR+DfX5bCSS/WJ2aO7xxeES40NizBx08qYFami1zXrGMMo35I
SfedCohcok8ZZ1oWL+MfSJ2OLVclDnznDPTx39pZPQIDAQABozcwNTAUBgNVHREE
DTALgglsb2NhbGhvc3QwHQYDVR0OBBYEFCgZah+m2NXkI9biS2vnhNUrd3FiMA0G
CSqGSIb3DQEBCwUAA4IBAQAPbofZIKCm3DnudFnK+LRkdlpMNOyH2zn3g8h8vrfL
Tfi0oBgHb7EYxcHYDanZbcIKracWCfQVze2FRLgNFBWiyhDO4IXe/LpwSnbyLWCh
psbGuyVmEz9CuiyVdIi+CWQs5dBBRUCFg6NE2/r6Diw9LD0fVCVUwkvqopetfp1B
tvA74O0RduLWs+iXNs5XW4sODVkrOmhBbRrP9GRCVqiqVWJka6CzrNdBm0Y9zZMQ
GD/6fEgDaW8YlShoO+e4FwmD2IgIx+m4xamr/cQkWpbOHMxAwv7vP0stfkpyUacW
dh9eJmsDAmgGgdtMJvbIfyR9ilG8D6zwOmSlkF6fDJ3E
-----END CERTIFICATE-----

28
internal/app/testdata/server.key vendored Normal file
View File

@ -0,0 +1,28 @@
-----BEGIN PRIVATE KEY-----
MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDS/8qd18p8bxci
1K0d4xD/fPWvy+GQf0oal9LAMynwx0cyjHq9wCwgivVEUqZqVBCD5P5Mr2/dczoX
iwn5Y/Jvn/fXEtSobrOK+K2szLUBnzBcuADP8JO59cvc4Ge0PCDb1Bda32U5QnKN
FrroBCqt5OUi24sbrluzB88DiDfl/5SHKvj2lq6SqdUdGvjga3KI3GV9gIR1BZ05
HQRCZnvLpY1BCGdHyh1DJ+1HaXKR/XsxIny6zgkCBnZ+vVBH4N9flsJJL9YnZo7v
HF4RLjQ2LMHHTypgVqaLXNesYwyjfkhJ950KiFyiTxlnWhYv4x9InY4tVyUOfOcM
9PHf2lk9AgMBAAECggEAC3E3qaukHW9gz9C8upwvtcsu/6OMzes5N4v4L9gWdCo6
YDFiDpw3SGSAvH3G7Ik2hBCNAdeZt2aiRdiSZ+XVpdwE8rLguWmXbvfhYzeOsVHS
q5SG5r/jIviDX60DsrB4D7PGuHTY5mwGDkSnSiG/tsJs8qD5QD0KWAEaZtSiQ2Sp
kcRbdq13/2tjHyx7nBxEYUFC4EJQjK3cNNV4G7nG2xcfT46uPvFV0+1CQtMpFYhi
IsGaSBhW9gOAheycYxCi+LRdUh1IAnLUyYUenu0o8PoXsHp6KD8eS5RXtfA6THd/
Jr614gdAB2Sffw+bFf6FIBNWa5Jwsg9UtbGtjNdo+QKBgQDrOJ2nj7El6MIqeDHs
1cCeGDKmjB1CYWALLHrwwiwmrvEoeBMiJuMN4epZdQw9hwExa7fNpERI7Ay8s5HD
cdppxgcW7CWChNncbVZ39P+YI9URWC2Q2Y8FBhc9FA0sKpDak0rf5UE63SGjU8/I
FGgwjd1Ln5wws00OsYXBZw1lzwKBgQDlo2kRy6xvrUNAbeggT9OQeg2SdkWqvS3v
NUhBzZkVhJNf1oApNRoAvRMQt+Xt+Euw1pQ+TvdOZQhhqxs/pD/wGdM7rhq9r0+G
itsQ5LvNCxCePbSkbFMLgC8JgNuM3aRqhtsU+Illk9xvCj2nKsd+UUN3NxYgjCqa
evTKSzUfMwKBgFapy1w7EteWxEMFec96ibc1zyORqA4W9l3ni3w87itqdSul4dbJ
YQpyW/eNqm7Y2NWujE/V39rGLYMw3dmWjxQ9g8ssQj2uWN5f4mXb/He/a/cx98fQ
gGMndVRpmNjW7fu6HPIU802Ov5//dySOcDzDZ+8+5TsENLXfLhqtrz/9AoGBALc+
/BQoTFTdlSHv0mEecjwDOZtbZ+KEjggpo5xm/TbPkW7T03eOmU5nkrQvm3qXPYdC
5A8Ioo5bTyHpEZhqcF8frJEeMNaW88XwPjmv3TEVGFC9+s2OZ4Jw6pgRzKEPKSmc
rWyBm9qD8E5nhKVGHOVu4YBbY/va/hBB998Jvr1DAoGBAK5nnswLyQZi0lgpkl1P
ITkmvnQlZBfuqvoD7wcQ3nx/K/mdacsxepRne+U/4+iNzRtd3gU0iccCWUTJl4aB
cFRW1eXWuff+4vmM4JToDevGPXrS0CHE20mATJRZPH+YjZFl0pFSc4/tnjxBnx4y
vgM382WU9N9jIHCCnM6DYsbK
-----END PRIVATE KEY-----

View File

@ -35,12 +35,19 @@ type RTMPSource struct {
NetAddr `yaml:",inline"`
}
// TLS holds the TLS configuration.
type TLS struct {
CertPath string `yaml:"cert,omitempty"`
KeyPath string `yaml:"key,omitempty"`
}
// MediaServerSource holds the configuration for the media server source.
type MediaServerSource struct {
StreamKey string `yaml:"streamKey,omitempty"`
Host string `yaml:"host,omitempty"`
RTMP RTMPSource `yaml:"rtmp,omitempty"`
RTMPS RTMPSource `yaml:"rtmps,omitempty"`
TLS *TLS `yaml:"tls,omitempty"`
RTMP RTMPSource `yaml:"rtmp"`
RTMPS RTMPSource `yaml:"rtmps"`
}
// Sources holds the configuration for the sources.

View File

@ -100,6 +100,10 @@ func TestConfigServiceReadConfig(t *testing.T) {
MediaServer: config.MediaServerSource{
StreamKey: "s3cr3t",
Host: "rtmp.example.com",
TLS: &config.TLS{
CertPath: "/etc/cert.pem",
KeyPath: "/etc/key.pem",
},
RTMP: config.RTMPSource{
Enabled: true,
NetAddr: config.NetAddr{

View File

@ -6,6 +6,9 @@ sources:
mediaServer:
streamKey: s3cr3t
host: rtmp.example.com
tls:
cert: /etc/cert.pem
key: /etc/key.pem
rtmp:
enabled: true
ip: 0.0.0.0

View File

@ -66,6 +66,16 @@ func (n NetAddr) IsZero() bool {
return n.IP == "" && n.Port == 0
}
// KeyPair holds a TLS key pair.
type KeyPair struct {
Cert, Key []byte
}
// IsZero returns true if the KeyPair is zero value.
func (k KeyPair) IsZero() bool {
return k.Cert == nil && k.Key == nil
}
// Container status strings.
//
// TODO: refactor to strictly reflect Docker status strings.

View File

@ -40,3 +40,12 @@ func TestNetAddr(t *testing.T) {
addr.Port = 3000
assert.False(t, addr.IsZero())
}
func TestKeyPair(t *testing.T) {
var keyPair domain.KeyPair
assert.True(t, keyPair.IsZero())
keyPair.Cert = []byte("cert")
keyPair.Key = []byte("key")
assert.False(t, keyPair.IsZero())
}

View File

@ -8,6 +8,7 @@ import (
"fmt"
"log/slog"
"net/http"
"os"
"time"
typescontainer "github.com/docker/docker/api/types/container"
@ -37,6 +38,11 @@ const (
defaultStreamKey StreamKey = "live" // Default stream key. See [StreamKey].
componentName = "mediaserver" // component name, mostly used for Docker labels
httpClientTimeout = time.Second // timeout for outgoing HTTP client requests
configPath = "/mediamtx.yml" // path to the media server config file
tlsInternalCertPath = "/etc/tls-internal.crt" // path to the internal TLS cert
tlsInternalKeyPath = "/etc/tls-internal.key" // path to the internal TLS key
tlsCertPath = "/etc/tls.crt" // path to the custom TLS cert
tlsKeyPath = "/etc/tls.key" // path to the custom TLS key
)
// action is an action to be performed by the actor.
@ -55,7 +61,8 @@ type Actor struct {
streamKey StreamKey
updateStateInterval time.Duration
pass string // password for the media server
tlsCert, tlsKey []byte // TLS cert and key for the media server
keyPairInternal domain.KeyPair // TLS key pair for the media server
keyPairCustom domain.KeyPair // TLS key pair for the media server
logger *slog.Logger
apiClient *http.Client
@ -70,6 +77,8 @@ type NewActorParams struct {
RTMPSAddr OptionalNetAddr // defaults to disabled, or 127.0.0.1:1936
APIPort int // defaults to 9997
Host string // defaults to "localhost"
TLSCertPath string // defaults to empty
TLSKeyPath string // defaults to empty
StreamKey StreamKey // defaults to "live"
ChanSize int // defaults to 64
UpdateStateInterval time.Duration // defaults to 5 seconds
@ -89,11 +98,25 @@ type OptionalNetAddr struct {
//
// Callers must consume the state channel exposed via [C].
func NewActor(ctx context.Context, params NewActorParams) (_ *Actor, err error) {
tlsCert, tlsKey, err := generateTLSCert()
keyPairInternal, err := generateTLSCert()
if err != nil {
return nil, fmt.Errorf("generate TLS cert: %w", err)
}
apiClient, err := buildAPIClient(tlsCert)
var keyPairCustom domain.KeyPair
if params.TLSCertPath != "" {
keyPairCustom.Cert, err = os.ReadFile(params.TLSCertPath)
if err != nil {
return nil, fmt.Errorf("read TLS cert: %w", err)
}
keyPairCustom.Key, err = os.ReadFile(params.TLSKeyPath)
if err != nil {
return nil, fmt.Errorf("read TLS key: %w", err)
}
}
// TODO: custom cert for API?
apiClient, err := buildAPIClient(keyPairInternal.Cert)
if err != nil {
return nil, fmt.Errorf("build API client: %w", err)
}
@ -106,8 +129,8 @@ func NewActor(ctx context.Context, params NewActorParams) (_ *Actor, err error)
host: cmp.Or(params.Host, defaultHost),
streamKey: cmp.Or(params.StreamKey, defaultStreamKey),
updateStateInterval: cmp.Or(params.UpdateStateInterval, defaultUpdateStateInterval),
tlsCert: tlsCert,
tlsKey: tlsKey,
keyPairInternal: keyPairInternal,
keyPairCustom: keyPairCustom,
pass: generatePassword(),
actorC: make(chan action, chanSize),
state: new(domain.Source),
@ -138,6 +161,45 @@ func (a *Actor) Start(ctx context.Context) error {
return fmt.Errorf("build server config: %w", err)
}
copyFiles := []container.CopyFileConfig{
{
Path: configPath,
Payload: bytes.NewReader(cfg),
Mode: 0600,
},
{
Path: tlsInternalCertPath,
Payload: bytes.NewReader(a.keyPairInternal.Cert),
Mode: 0600,
},
{
Path: tlsInternalKeyPath,
Payload: bytes.NewReader(a.keyPairInternal.Key),
Mode: 0600,
},
{
Path: "/etc/healthcheckopts.txt",
Payload: bytes.NewReader([]byte(fmt.Sprintf("--user api:%s", a.pass))),
Mode: 0600,
},
}
if !a.keyPairCustom.IsZero() {
copyFiles = append(
copyFiles,
container.CopyFileConfig{
Path: tlsCertPath,
Payload: bytes.NewReader(a.keyPairCustom.Cert),
Mode: 0600,
},
container.CopyFileConfig{
Path: tlsKeyPath,
Payload: bytes.NewReader(a.keyPairCustom.Key),
Mode: 0600,
},
)
}
args := []any{"host", a.host}
if a.rtmpAddr.IsZero() {
args = append(args, "rtmp.enabled", false)
@ -166,7 +228,7 @@ func (a *Actor) Start(ctx context.Context) error {
"curl",
"--fail",
"--silent",
"--cacert", "/etc/tls.crt",
"--cacert", "/etc/tls-internal.crt",
"--config", "/etc/healthcheckopts.txt",
a.healthCheckURL(),
},
@ -183,28 +245,7 @@ func (a *Actor) Start(ctx context.Context) error {
},
NetworkCountConfig: container.NetworkCountConfig{Rx: "eth0", Tx: "eth1"},
Logs: container.LogConfig{Stdout: true},
CopyFiles: []container.CopyFileConfig{
{
Path: "/mediamtx.yml",
Payload: bytes.NewReader(cfg),
Mode: 0600,
},
{
Path: "/etc/tls.crt",
Payload: bytes.NewReader(a.tlsCert),
Mode: 0600,
},
{
Path: "/etc/tls.key",
Payload: bytes.NewReader(a.tlsKey),
Mode: 0600,
},
{
Path: "/etc/healthcheckopts.txt",
Payload: bytes.NewReader([]byte(fmt.Sprintf("--user api:%s", a.pass))),
Mode: 0600,
},
},
CopyFiles: copyFiles,
},
)
@ -224,6 +265,15 @@ func (a *Actor) buildServerConfig() ([]byte, error) {
encryptionString = "optional"
}
var certPath, keyPath string
if a.keyPairCustom.IsZero() {
certPath = tlsInternalCertPath
keyPath = tlsInternalKeyPath
} else {
certPath = tlsCertPath
keyPath = tlsKeyPath
}
return yaml.Marshal(
Config{
LogLevel: "debug",
@ -256,12 +306,12 @@ func (a *Actor) buildServerConfig() ([]byte, error) {
RTMPEncryption: encryptionString,
RTMPAddress: ":1935",
RTMPSAddress: ":1936",
RTMPServerCert: "/etc/tls.crt", // TODO: custom certs
RTMPServerKey: "/etc/tls.key", // TODO: custom certs
RTMPServerCert: certPath,
RTMPServerKey: keyPath,
API: true,
APIEncryption: true,
APIServerCert: "/etc/tls.crt",
APIServerKey: "/etc/tls.key",
APIServerCert: tlsInternalCertPath,
APIServerKey: tlsInternalKeyPath,
Paths: map[string]Path{
string(a.streamKey): {Source: "publisher"},
},

View File

@ -10,23 +10,20 @@ import (
"encoding/pem"
"math/big"
"time"
)
type (
tlsCert []byte
tlsKey []byte
"git.netflux.io/rob/octoplex/internal/domain"
)
// generateTLSCert generates a self-signed TLS certificate and private key.
func generateTLSCert() (tlsCert, tlsKey, error) {
func generateTLSCert() (domain.KeyPair, error) {
privKey, err := ecdsa.GenerateKey(elliptic.P384(), rand.Reader)
if err != nil {
return nil, nil, err
return domain.KeyPair{}, err
}
serialNumber, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
if err != nil {
return nil, nil, err
return domain.KeyPair{}, err
}
now := time.Now()
@ -45,23 +42,26 @@ func generateTLSCert() (tlsCert, tlsKey, error) {
certDER, err := x509.CreateCertificate(rand.Reader, &template, &template, &privKey.PublicKey, privKey)
if err != nil {
return nil, nil, err
return domain.KeyPair{}, err
}
var certPEM, keyPEM bytes.Buffer
if err = pem.Encode(&certPEM, &pem.Block{Type: "CERTIFICATE", Bytes: certDER}); err != nil {
return nil, nil, err
return domain.KeyPair{}, err
}
privKeyDER, err := x509.MarshalECPrivateKey(privKey)
if err != nil {
return nil, nil, err
return domain.KeyPair{}, err
}
if err := pem.Encode(&keyPEM, &pem.Block{Type: "EC PRIVATE KEY", Bytes: privKeyDER}); err != nil {
return nil, nil, err
return domain.KeyPair{}, err
}
return certPEM.Bytes(), keyPEM.Bytes(), nil
return domain.KeyPair{
Cert: certPEM.Bytes(),
Key: keyPEM.Bytes(),
}, nil
}

View File

@ -12,12 +12,12 @@ import (
)
func TestGenerateTLSCert(t *testing.T) {
certPEM, keyPEM, err := generateTLSCert()
keyPair, err := generateTLSCert()
require.NoError(t, err)
require.NotEmpty(t, certPEM)
require.NotEmpty(t, keyPEM)
require.NotEmpty(t, keyPair.Cert)
require.NotEmpty(t, keyPair.Key)
block, _ := pem.Decode(certPEM)
block, _ := pem.Decode(keyPair.Cert)
require.NotNil(t, block, "failed to decode certificate PEM")
cert, err := x509.ParseCertificate(block.Bytes)
@ -34,7 +34,7 @@ func TestGenerateTLSCert(t *testing.T) {
assert.Contains(t, cert.ExtKeyUsage, x509.ExtKeyUsageServerAuth)
assert.Contains(t, cert.ExtKeyUsage, x509.ExtKeyUsageClientAuth)
block, _ = pem.Decode(keyPEM)
block, _ = pem.Decode(keyPair.Key)
require.NotNil(t, block, "failed to decode private key PEM")
privKey, err := x509.ParseECPrivateKey(block.Bytes)