Compare commits

...

3 Commits

Author SHA1 Message Date
Rob Watson
add511e3dd refactor: extract commands to domain package
Some checks are pending
ci-build / build (push) Blocked by required conditions
ci-build / release (push) Blocked by required conditions
ci-build / lint (push) Waiting to run
ci-scan / Analyze (go) (push) Waiting to run
ci-scan / Analyze (actions) (push) Waiting to run
2025-04-20 20:55:10 +02:00
Rob Watson
7afa84505e fix(mediaserver): handle custom hostname with self-signed certs 2025-04-20 18:59:27 +02:00
Rob Watson
4a863a3212 feat(mediaserver): custom TLS certs 2025-04-20 11:27:17 +02:00
16 changed files with 329 additions and 73 deletions

View File

@ -7,7 +7,7 @@
Octoplex is a live video restreamer for the terminal. 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 * Broadcast using OBS and other standard tools
* Add and remove destinations while streaming * Add and remove destinations while streaming
* Automatic reconnections * Automatic reconnections
@ -100,6 +100,9 @@ sources:
mediaServer: mediaServer:
streamKey: live # defaults to "live" streamKey: live # defaults to "live"
host: rtmp.example.com # defaults to "localhost" 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: rtmp:
enabled: true # defaults to false enabled: true # defaults to false
ip: 127.0.0.1 # defaults to 127.0.0.1 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 := func() { ui.SetState(*state) }
updateUI() 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{ srv, err := mediaserver.NewActor(ctx, mediaserver.NewActorParams{
RTMPAddr: buildNetAddr(cfg.Sources.MediaServer.RTMP), RTMPAddr: buildNetAddr(cfg.Sources.MediaServer.RTMP),
RTMPSAddr: buildNetAddr(cfg.Sources.MediaServer.RTMPS), RTMPSAddr: buildNetAddr(cfg.Sources.MediaServer.RTMPS),
Host: cfg.Sources.MediaServer.Host, Host: cfg.Sources.MediaServer.Host,
TLSCertPath: tlsCertPath,
TLSKeyPath: tlsKeyPath,
StreamKey: mediaserver.StreamKey(cfg.Sources.MediaServer.StreamKey), StreamKey: mediaserver.StreamKey(cfg.Sources.MediaServer.StreamKey),
ContainerClient: containerClient, ContainerClient: containerClient,
Logger: logger.With("component", "mediaserver"), Logger: logger.With("component", "mediaserver"),
@ -147,7 +155,7 @@ func Run(ctx context.Context, params RunParams) error {
logger.Debug("Command received", "cmd", cmd.Name()) logger.Debug("Command received", "cmd", cmd.Name())
switch c := cmd.(type) { switch c := cmd.(type) {
case terminal.CommandAddDestination: case domain.CommandAddDestination:
newCfg := cfg newCfg := cfg
newCfg.Destinations = append(newCfg.Destinations, config.Destination{ newCfg.Destinations = append(newCfg.Destinations, config.Destination{
Name: c.DestinationName, Name: c.DestinationName,
@ -161,7 +169,7 @@ func Run(ctx context.Context, params RunParams) error {
cfg = newCfg cfg = newCfg
handleConfigUpdate(cfg, state, ui) handleConfigUpdate(cfg, state, ui)
ui.DestinationAdded() ui.DestinationAdded()
case terminal.CommandRemoveDestination: case domain.CommandRemoveDestination:
repl.StopDestination(c.URL) // no-op if not live repl.StopDestination(c.URL) // no-op if not live
newCfg := cfg newCfg := cfg
newCfg.Destinations = slices.DeleteFunc(newCfg.Destinations, func(dest config.Destination) bool { newCfg.Destinations = slices.DeleteFunc(newCfg.Destinations, func(dest config.Destination) bool {
@ -175,16 +183,16 @@ func Run(ctx context.Context, params RunParams) error {
cfg = newCfg cfg = newCfg
handleConfigUpdate(cfg, state, ui) handleConfigUpdate(cfg, state, ui)
ui.DestinationRemoved() ui.DestinationRemoved()
case terminal.CommandStartDestination: case domain.CommandStartDestination:
if !state.Source.Live { if !state.Source.Live {
ui.ShowSourceNotLiveModal() ui.ShowSourceNotLiveModal()
continue continue
} }
repl.StartDestination(c.URL) repl.StartDestination(c.URL)
case terminal.CommandStopDestination: case domain.CommandStopDestination:
repl.StopDestination(c.URL) repl.StopDestination(c.URL)
case terminal.CommandQuit: case domain.CommandQuit:
return nil return nil
} }
case <-uiUpdateT.C: case <-uiUpdateT.C:

View File

@ -5,9 +5,13 @@ package app_test
import ( import (
"cmp" "cmp"
"context" "context"
"crypto/tls"
"crypto/x509"
"encoding/pem"
"errors" "errors"
"fmt" "fmt"
"net" "net"
"os"
"testing" "testing"
"time" "time"
@ -292,7 +296,7 @@ func testIntegration(t *testing.T, mediaServerConfig config.MediaServerSource) {
<-done <-done
} }
func TestIntegrationCustomRTMPURL(t *testing.T) { func TestIntegrationCustomHost(t *testing.T) {
ctx, cancel := context.WithTimeout(t.Context(), 10*time.Minute) ctx, cancel := context.WithTimeout(t.Context(), 10*time.Minute)
defer cancel() defer cancel()
@ -303,7 +307,7 @@ func TestIntegrationCustomRTMPURL(t *testing.T) {
configService := setupConfigService(t, config.Config{ configService := setupConfigService(t, config.Config{
Sources: config.Sources{ Sources: config.Sources{
MediaServer: config.MediaServerSource{ MediaServer: config.MediaServerSource{
Host: "rtmp.live.tv", Host: "rtmp.example.com",
RTMP: config.RTMPSource{Enabled: true}, RTMP: config.RTMPSource{Enabled: true},
}, },
}, },
@ -325,7 +329,7 @@ func TestIntegrationCustomRTMPURL(t *testing.T) {
require.EventuallyWithT( require.EventuallyWithT(
t, t,
func(t *assert.CollectT) { func(t *assert.CollectT) {
assert.True(t, contentsIncludes(getContents(), "rtmp://rtmp.live.tv:1935/live"), "expected to see custom host name") assert.True(t, contentsIncludes(getContents(), "rtmp://rtmp.example.com:1935/live"), "expected to see custom host name")
}, },
waitTime, waitTime,
time.Second, time.Second,
@ -333,6 +337,94 @@ func TestIntegrationCustomRTMPURL(t *testing.T) {
) )
printScreen(t, getContents, "Ater opening the app with a custom host name") printScreen(t, getContents, "Ater opening the app with a custom host name")
require.EventuallyWithT(
t,
func(c *assert.CollectT) {
conn, err := tls.Dial("tcp", "localhost:9997", &tls.Config{
InsecureSkipVerify: true,
})
require.NoError(c, err)
require.Nil(
c,
conn.
ConnectionState().
PeerCertificates[0].
VerifyHostname("rtmp.example.com"),
"expected to verify custom host name",
)
},
waitTime,
time.Second,
"expected to connect to API using self-signed TLS cert with custom host name",
)
cancel()
<-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")
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)
peerCert := conn.ConnectionState().PeerCertificates[0]
wantCert, err := x509.ParseCertificate(block.Bytes)
require.NoError(c, err)
require.True(c, peerCert.Equal(wantCert), "expected peer certificate to match the expected certificate")
},
waitTime,
time.Second,
)
printScreen(t, getContents, "After starting the app with custom TLS certs")
cancel() cancel()
<-done <-done

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"` 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. // MediaServerSource holds the configuration for the media server source.
type MediaServerSource struct { type MediaServerSource struct {
StreamKey string `yaml:"streamKey,omitempty"` StreamKey string `yaml:"streamKey,omitempty"`
Host string `yaml:"host,omitempty"` Host string `yaml:"host,omitempty"`
RTMP RTMPSource `yaml:"rtmp,omitempty"` TLS *TLS `yaml:"tls,omitempty"`
RTMPS RTMPSource `yaml:"rtmps,omitempty"` RTMP RTMPSource `yaml:"rtmp"`
RTMPS RTMPSource `yaml:"rtmps"`
} }
// Sources holds the configuration for the sources. // Sources holds the configuration for the sources.

View File

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

View File

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

View File

@ -1,4 +1,4 @@
package terminal package domain
// CommandAddDestination adds a destination. // CommandAddDestination adds a destination.
type CommandAddDestination struct { type CommandAddDestination struct {

View File

@ -66,6 +66,16 @@ func (n NetAddr) IsZero() bool {
return n.IP == "" && n.Port == 0 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. // Container status strings.
// //
// TODO: refactor to strictly reflect Docker status strings. // TODO: refactor to strictly reflect Docker status strings.

View File

@ -40,3 +40,12 @@ func TestNetAddr(t *testing.T) {
addr.Port = 3000 addr.Port = 3000
assert.False(t, addr.IsZero()) 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" "fmt"
"log/slog" "log/slog"
"net/http" "net/http"
"os"
"time" "time"
typescontainer "github.com/docker/docker/api/types/container" typescontainer "github.com/docker/docker/api/types/container"
@ -37,6 +38,11 @@ const (
defaultStreamKey StreamKey = "live" // Default stream key. See [StreamKey]. defaultStreamKey StreamKey = "live" // Default stream key. See [StreamKey].
componentName = "mediaserver" // component name, mostly used for Docker labels componentName = "mediaserver" // component name, mostly used for Docker labels
httpClientTimeout = time.Second // timeout for outgoing HTTP client requests 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. // action is an action to be performed by the actor.
@ -54,8 +60,9 @@ type Actor struct {
host string host string
streamKey StreamKey streamKey StreamKey
updateStateInterval time.Duration updateStateInterval time.Duration
pass string // password for the media server 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 logger *slog.Logger
apiClient *http.Client apiClient *http.Client
@ -70,6 +77,8 @@ type NewActorParams struct {
RTMPSAddr OptionalNetAddr // defaults to disabled, or 127.0.0.1:1936 RTMPSAddr OptionalNetAddr // defaults to disabled, or 127.0.0.1:1936
APIPort int // defaults to 9997 APIPort int // defaults to 9997
Host string // defaults to "localhost" Host string // defaults to "localhost"
TLSCertPath string // defaults to empty
TLSKeyPath string // defaults to empty
StreamKey StreamKey // defaults to "live" StreamKey StreamKey // defaults to "live"
ChanSize int // defaults to 64 ChanSize int // defaults to 64
UpdateStateInterval time.Duration // defaults to 5 seconds UpdateStateInterval time.Duration // defaults to 5 seconds
@ -89,11 +98,30 @@ type OptionalNetAddr struct {
// //
// Callers must consume the state channel exposed via [C]. // Callers must consume the state channel exposed via [C].
func NewActor(ctx context.Context, params NewActorParams) (_ *Actor, err error) { func NewActor(ctx context.Context, params NewActorParams) (_ *Actor, err error) {
tlsCert, tlsKey, err := generateTLSCert() dnsNames := []string{"localhost"}
if params.Host != "" {
dnsNames = append(dnsNames, params.Host)
}
keyPairInternal, err := generateTLSCert(dnsNames...)
if err != nil { if err != nil {
return nil, fmt.Errorf("generate TLS cert: %w", err) 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 { if err != nil {
return nil, fmt.Errorf("build API client: %w", err) return nil, fmt.Errorf("build API client: %w", err)
} }
@ -106,8 +134,8 @@ func NewActor(ctx context.Context, params NewActorParams) (_ *Actor, err error)
host: cmp.Or(params.Host, defaultHost), host: cmp.Or(params.Host, defaultHost),
streamKey: cmp.Or(params.StreamKey, defaultStreamKey), streamKey: cmp.Or(params.StreamKey, defaultStreamKey),
updateStateInterval: cmp.Or(params.UpdateStateInterval, defaultUpdateStateInterval), updateStateInterval: cmp.Or(params.UpdateStateInterval, defaultUpdateStateInterval),
tlsCert: tlsCert, keyPairInternal: keyPairInternal,
tlsKey: tlsKey, keyPairCustom: keyPairCustom,
pass: generatePassword(), pass: generatePassword(),
actorC: make(chan action, chanSize), actorC: make(chan action, chanSize),
state: new(domain.Source), state: new(domain.Source),
@ -138,6 +166,45 @@ func (a *Actor) Start(ctx context.Context) error {
return fmt.Errorf("build server config: %w", err) 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} args := []any{"host", a.host}
if a.rtmpAddr.IsZero() { if a.rtmpAddr.IsZero() {
args = append(args, "rtmp.enabled", false) args = append(args, "rtmp.enabled", false)
@ -166,7 +233,7 @@ func (a *Actor) Start(ctx context.Context) error {
"curl", "curl",
"--fail", "--fail",
"--silent", "--silent",
"--cacert", "/etc/tls.crt", "--cacert", "/etc/tls-internal.crt",
"--config", "/etc/healthcheckopts.txt", "--config", "/etc/healthcheckopts.txt",
a.healthCheckURL(), a.healthCheckURL(),
}, },
@ -183,28 +250,7 @@ func (a *Actor) Start(ctx context.Context) error {
}, },
NetworkCountConfig: container.NetworkCountConfig{Rx: "eth0", Tx: "eth1"}, NetworkCountConfig: container.NetworkCountConfig{Rx: "eth0", Tx: "eth1"},
Logs: container.LogConfig{Stdout: true}, Logs: container.LogConfig{Stdout: true},
CopyFiles: []container.CopyFileConfig{ CopyFiles: copyFiles,
{
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,
},
},
}, },
) )
@ -224,6 +270,15 @@ func (a *Actor) buildServerConfig() ([]byte, error) {
encryptionString = "optional" encryptionString = "optional"
} }
var certPath, keyPath string
if a.keyPairCustom.IsZero() {
certPath = tlsInternalCertPath
keyPath = tlsInternalKeyPath
} else {
certPath = tlsCertPath
keyPath = tlsKeyPath
}
return yaml.Marshal( return yaml.Marshal(
Config{ Config{
LogLevel: "debug", LogLevel: "debug",
@ -256,12 +311,12 @@ func (a *Actor) buildServerConfig() ([]byte, error) {
RTMPEncryption: encryptionString, RTMPEncryption: encryptionString,
RTMPAddress: ":1935", RTMPAddress: ":1935",
RTMPSAddress: ":1936", RTMPSAddress: ":1936",
RTMPServerCert: "/etc/tls.crt", // TODO: custom certs RTMPServerCert: certPath,
RTMPServerKey: "/etc/tls.key", // TODO: custom certs RTMPServerKey: keyPath,
API: true, API: true,
APIEncryption: true, APIEncryption: true,
APIServerCert: "/etc/tls.crt", APIServerCert: tlsInternalCertPath,
APIServerKey: "/etc/tls.key", APIServerKey: tlsInternalKeyPath,
Paths: map[string]Path{ Paths: map[string]Path{
string(a.streamKey): {Source: "publisher"}, string(a.streamKey): {Source: "publisher"},
}, },

View File

@ -10,23 +10,20 @@ import (
"encoding/pem" "encoding/pem"
"math/big" "math/big"
"time" "time"
)
type ( "git.netflux.io/rob/octoplex/internal/domain"
tlsCert []byte
tlsKey []byte
) )
// generateTLSCert generates a self-signed TLS certificate and private key. // generateTLSCert generates a self-signed TLS certificate and private key.
func generateTLSCert() (tlsCert, tlsKey, error) { func generateTLSCert(dnsNames ...string) (domain.KeyPair, error) {
privKey, err := ecdsa.GenerateKey(elliptic.P384(), rand.Reader) privKey, err := ecdsa.GenerateKey(elliptic.P384(), rand.Reader)
if err != nil { 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)) serialNumber, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
if err != nil { if err != nil {
return nil, nil, err return domain.KeyPair{}, err
} }
now := time.Now() now := time.Now()
@ -40,28 +37,31 @@ func generateTLSCert() (tlsCert, tlsKey, error) {
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth}, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth},
BasicConstraintsValid: true, BasicConstraintsValid: true,
DNSNames: []string{"localhost"}, DNSNames: dnsNames,
} }
certDER, err := x509.CreateCertificate(rand.Reader, &template, &template, &privKey.PublicKey, privKey) certDER, err := x509.CreateCertificate(rand.Reader, &template, &template, &privKey.PublicKey, privKey)
if err != nil { if err != nil {
return nil, nil, err return domain.KeyPair{}, err
} }
var certPEM, keyPEM bytes.Buffer var certPEM, keyPEM bytes.Buffer
if err = pem.Encode(&certPEM, &pem.Block{Type: "CERTIFICATE", Bytes: certDER}); err != nil { 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) privKeyDER, err := x509.MarshalECPrivateKey(privKey)
if err != nil { 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 { 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) { func TestGenerateTLSCert(t *testing.T) {
certPEM, keyPEM, err := generateTLSCert() keyPair, err := generateTLSCert("localhost", "rtmp.example.com")
require.NoError(t, err) require.NoError(t, err)
require.NotEmpty(t, certPEM) require.NotEmpty(t, keyPair.Cert)
require.NotEmpty(t, keyPEM) require.NotEmpty(t, keyPair.Key)
block, _ := pem.Decode(certPEM) block, _ := pem.Decode(keyPair.Cert)
require.NotNil(t, block, "failed to decode certificate PEM") require.NotNil(t, block, "failed to decode certificate PEM")
cert, err := x509.ParseCertificate(block.Bytes) cert, err := x509.ParseCertificate(block.Bytes)
@ -33,8 +33,10 @@ func TestGenerateTLSCert(t *testing.T) {
assert.True(t, cert.BasicConstraintsValid, "basic constraints should be valid") assert.True(t, cert.BasicConstraintsValid, "basic constraints should be valid")
assert.Contains(t, cert.ExtKeyUsage, x509.ExtKeyUsageServerAuth) assert.Contains(t, cert.ExtKeyUsage, x509.ExtKeyUsageServerAuth)
assert.Contains(t, cert.ExtKeyUsage, x509.ExtKeyUsageClientAuth) assert.Contains(t, cert.ExtKeyUsage, x509.ExtKeyUsageClientAuth)
assert.Contains(t, cert.DNSNames, "localhost", "DNS names should include localhost")
assert.Contains(t, cert.DNSNames, "rtmp.example.com", "DNS names should include rtmp.example.com")
block, _ = pem.Decode(keyPEM) block, _ = pem.Decode(keyPair.Key)
require.NotNil(t, block, "failed to decode private key PEM") require.NotNil(t, block, "failed to decode private key PEM")
privKey, err := x509.ParseECPrivateKey(block.Bytes) privKey, err := x509.ParseECPrivateKey(block.Bytes)

View File

@ -40,7 +40,7 @@ const (
// UI is responsible for managing the terminal user interface. // UI is responsible for managing the terminal user interface.
type UI struct { type UI struct {
commandC chan Command commandC chan domain.Command
clipboardAvailable bool clipboardAvailable bool
configFilePath string configFilePath string
rtmpURL, rtmpsURL string rtmpURL, rtmpsURL string
@ -106,7 +106,7 @@ const defaultChanSize = 64
// StartUI starts the terminal user interface. // StartUI starts the terminal user interface.
func StartUI(ctx context.Context, params StartParams) (*UI, error) { func StartUI(ctx context.Context, params StartParams) (*UI, error) {
chanSize := cmp.Or(params.ChanSize, defaultChanSize) chanSize := cmp.Or(params.ChanSize, defaultChanSize)
commandCh := make(chan Command, chanSize) commandCh := make(chan domain.Command, chanSize)
app := tview.NewApplication() app := tview.NewApplication()
@ -268,7 +268,7 @@ func (ui *UI) renderAboutView() {
} }
// C returns a channel that receives commands from the user interface. // C returns a channel that receives commands from the user interface.
func (ui *UI) C() <-chan Command { func (ui *UI) C() <-chan domain.Command {
return ui.commandC return ui.commandC
} }
@ -444,7 +444,7 @@ func (ui *UI) ShowFatalErrorModal(errString string) {
[]string{"Quit"}, []string{"Quit"},
false, false,
func(int, string) { func(int, string) {
ui.commandC <- CommandQuit{} ui.commandC <- domain.CommandQuit{}
}, },
) )
}) })
@ -697,7 +697,7 @@ func (ui *UI) handleMediaServerClosed(exitReason string) {
SetBackgroundColor(tcell.ColorBlack). SetBackgroundColor(tcell.ColorBlack).
SetTextColor(tcell.ColorWhite). SetTextColor(tcell.ColorWhite).
SetDoneFunc(func(int, string) { SetDoneFunc(func(int, string) {
ui.commandC <- CommandQuit{} ui.commandC <- domain.CommandQuit{}
}) })
modal.SetBorderStyle(tcell.StyleDefault.Background(tcell.ColorBlack).Foreground(tcell.ColorWhite)) modal.SetBorderStyle(tcell.StyleDefault.Background(tcell.ColorBlack).Foreground(tcell.ColorWhite))
@ -887,7 +887,7 @@ func (ui *UI) addDestination() {
AddInputField(inputLabelName, "My stream", inputLen, nil, nil). AddInputField(inputLabelName, "My stream", inputLen, nil, nil).
AddInputField(inputLabelURL, "rtmp://", inputLen, nil, nil). AddInputField(inputLabelURL, "rtmp://", inputLen, nil, nil).
AddButton("Add", func() { AddButton("Add", func() {
ui.commandC <- CommandAddDestination{ ui.commandC <- domain.CommandAddDestination{
DestinationName: form.GetFormItemByLabel(inputLabelName).(*tview.InputField).GetText(), DestinationName: form.GetFormItemByLabel(inputLabelName).(*tview.InputField).GetText(),
URL: form.GetFormItemByLabel(inputLabelURL).(*tview.InputField).GetText(), URL: form.GetFormItemByLabel(inputLabelURL).(*tview.InputField).GetText(),
} }
@ -945,7 +945,7 @@ func (ui *UI) removeDestination() {
false, false,
func(buttonIndex int, _ string) { func(buttonIndex int, _ string) {
if buttonIndex == 0 { if buttonIndex == 0 {
ui.commandC <- CommandRemoveDestination{URL: url} ui.commandC <- domain.CommandRemoveDestination{URL: url}
} }
}, },
) )
@ -1009,12 +1009,12 @@ func (ui *UI) toggleDestination() {
switch ss { switch ss {
case startStateNotStarted: case startStateNotStarted:
ui.urlsToStartState[url] = startStateStarting ui.urlsToStartState[url] = startStateStarting
ui.commandC <- CommandStartDestination{URL: url} ui.commandC <- domain.CommandStartDestination{URL: url}
case startStateStarting: case startStateStarting:
// do nothing // do nothing
return return
case startStateStarted: case startStateStarted:
ui.commandC <- CommandStopDestination{URL: url} ui.commandC <- domain.CommandStopDestination{URL: url}
} }
} }
@ -1067,7 +1067,7 @@ func (ui *UI) confirmQuit() {
false, false,
func(buttonIndex int, _ string) { func(buttonIndex int, _ string) {
if buttonIndex == 0 { if buttonIndex == 0 {
ui.commandC <- CommandQuit{} ui.commandC <- domain.CommandQuit{}
} }
}, },
) )