First pass at full fork pretty much complete.

This commit is contained in:
Storm Dragon
2026-05-20 14:29:39 -04:00
parent 6275e3adef
commit e257b344d1
11 changed files with 307 additions and 19 deletions
+51
View File
@@ -34,6 +34,8 @@ const (
var Directory string var Directory string
var activeRecordings sync.Map
type Client struct { type Client struct {
hall *hall.Hall hall *hall.Hall
id string id string
@@ -250,6 +252,7 @@ func newRecorder(client *Client, directory string) (*recorder, error) {
done: make(chan error, 1), done: make(chan error, 1),
sources: make(map[*diskTrack]chan []byte), sources: make(map[*diskTrack]chan []byte),
} }
activeRecordings.Store(path, struct{}{})
go r.mix() go r.mix()
return r, nil return r, nil
} }
@@ -358,6 +361,7 @@ func (r *recorder) Close() error {
err := <-r.done err := <-r.done
stdinErr := r.stdin.Close() stdinErr := r.stdin.Close()
waitErr := r.cmd.Wait() waitErr := r.cmd.Wait()
activeRecordings.Delete(r.path)
if err != nil { if err != nil {
return err return err
@@ -368,6 +372,10 @@ func (r *recorder) Close() error {
if waitErr != nil { if waitErr != nil {
return waitErr return waitErr
} }
now := time.Now()
if err := os.Chtimes(r.path, now, now); err != nil {
return err
}
return nil return nil
} }
@@ -658,6 +666,49 @@ func openDiskFile(directory, extension string) (*os.File, error) {
return nil, errors.New("couldn't create file") return nil, errors.New("couldn't create file")
} }
func CleanupOldRecordings(retention time.Duration) error {
if retention <= 0 {
return nil
}
cutoff := time.Now().Add(-retention)
err := filepath.WalkDir(
Directory,
func(path string, d os.DirEntry, err error) error {
if err != nil {
log.Printf("Recording cleanup %v: %v", path, err)
return nil
}
if d.IsDir() {
return nil
}
name := d.Name()
if !strings.HasPrefix(name, "skald-recording-") ||
filepath.Ext(name) != ".ogg" {
return nil
}
if _, ok := activeRecordings.Load(path); ok {
return nil
}
info, err := d.Info()
if err != nil {
log.Printf("Recording cleanup %v: %v", path, err)
return nil
}
if info.ModTime().After(cutoff) {
return nil
}
if err := os.Remove(path); err != nil {
log.Printf("Recording cleanup %v: %v", path, err)
}
return nil
},
)
if errors.Is(err, os.ErrNotExist) {
return nil
}
return err
}
func drainStderr(prefix string, r io.Reader) { func drainStderr(prefix string, r io.Reader) {
buf := make([]byte, 1024) buf := make([]byte, 1024)
for { for {
+57
View File
@@ -46,6 +46,63 @@ func TestOpenDiskFileUsesSkaldOggName(t *testing.T) {
} }
} }
func TestCleanupOldRecordings(t *testing.T) {
oldDirectory := Directory
defer func() {
Directory = oldDirectory
}()
Directory = t.TempDir()
hallDir := filepath.Join(Directory, "test")
if err := os.MkdirAll(hallDir, 0700); err != nil {
t.Fatal(err)
}
oldRecording := filepath.Join(hallDir, "skald-recording-20000101-000000.ogg")
activeRecording := filepath.Join(hallDir, "skald-recording-20000101-010000.ogg")
newRecording := filepath.Join(hallDir, "skald-recording-29990101-000000.ogg")
otherFile := filepath.Join(hallDir, "notes.txt")
for _, name := range []string{oldRecording, activeRecording, newRecording, otherFile} {
if err := os.WriteFile(name, []byte("test"), 0600); err != nil {
t.Fatal(err)
}
}
oldTime := time.Now().Add(-48 * time.Hour)
newTime := time.Now()
if err := os.Chtimes(oldRecording, oldTime, oldTime); err != nil {
t.Fatal(err)
}
if err := os.Chtimes(activeRecording, oldTime, oldTime); err != nil {
t.Fatal(err)
}
if err := os.Chtimes(newRecording, newTime, newTime); err != nil {
t.Fatal(err)
}
if err := os.Chtimes(otherFile, oldTime, oldTime); err != nil {
t.Fatal(err)
}
activeRecordings.Store(activeRecording, struct{}{})
defer activeRecordings.Delete(activeRecording)
if err := CleanupOldRecordings(24 * time.Hour); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(oldRecording); !os.IsNotExist(err) {
t.Fatalf("old recording still exists or stat failed unexpectedly: %v", err)
}
if _, err := os.Stat(newRecording); err != nil {
t.Fatalf("new recording was removed: %v", err)
}
if _, err := os.Stat(activeRecording); err != nil {
t.Fatalf("active recording was removed: %v", err)
}
if _, err := os.Stat(otherFile); err != nil {
t.Fatalf("non-recording file was removed: %v", err)
}
}
func TestMixFrameClipsAndSumsSources(t *testing.T) { func TestMixFrameClipsAndSumsSources(t *testing.T) {
a := make(chan []byte, 1) a := make(chan []byte, 1)
b := make(chan []byte, 1) b := make(chan []byte, 1)
+7 -6
View File
@@ -857,12 +857,13 @@ type Configuration struct {
modTime time.Time `json:"-"` modTime time.Time `json:"-"`
fileSize int64 `json:"-"` fileSize int64 `json:"-"`
CanonicalHost string `json:"canonicalHost,omitempty"` CanonicalHost string `json:"canonicalHost,omitempty"`
AllowOrigin []string `json:"allowOrigin,omitempty"` AllowOrigin []string `json:"allowOrigin,omitempty"`
AllowAdminOrigin []string `json:"allowAdminOrigin,omitempty"` AllowAdminOrigin []string `json:"allowAdminOrigin,omitempty"`
ProxyURL string `json:"proxyURL,omitempty"` ProxyURL string `json:"proxyURL,omitempty"`
WritableHalls bool `json:"writableHalls,omitempty"` WritableHalls bool `json:"writableHalls,omitempty"`
Users map[string]UserDescription `json:"users,omitempty"` RecordingRetention string `json:"recordingRetention,omitempty"`
Users map[string]UserDescription `json:"users,omitempty"`
// obsolete fields // obsolete fields
Admin []ClientPattern `json:"admin,omitempty"` Admin []ClientPattern `json:"admin,omitempty"`
+2 -2
View File
@@ -37,7 +37,7 @@ cleanup() {
} }
trap cleanup EXIT INT TERM trap cleanup EXIT INT TERM
mkdir -p "$dataDir" "$hallsDir" "$recordingsDir" mkdir -p "$dataDir" "$hallsDir" "$recordingsDir/$hallName"
hallFile="$hallsDir/$hallName.json" hallFile="$hallsDir/$hallName.json"
escapedAdminUsername="$(json_string "$adminUsername")" escapedAdminUsername="$(json_string "$adminUsername")"
@@ -47,7 +47,7 @@ cat > "$hallFile" <<EOF
{"users":{"$escapedAdminUsername":{"password":"$escapedAdminPassword","permissions":"op"}},"wildcard-user":{"password":"$escapedGuestPassword","permissions":"present"},"allow-recording":true} {"users":{"$escapedAdminUsername":{"password":"$escapedAdminPassword","permissions":"op"}},"wildcard-user":{"password":"$escapedGuestPassword","permissions":"present"},"allow-recording":true}
EOF EOF
if [[ ! -x "$binary" ]]; then if [[ ! -x "$binary" ]] || find . -name '*.go' -newer "$binary" -print -quit | grep -q .; then
echo "Building $binary" echo "Building $binary"
go build -o "$binary" . go build -o "$binary" .
fi fi
+97
View File
@@ -1,11 +1,70 @@
package rtpconn package rtpconn
import ( import (
"net"
"testing" "testing"
"github.com/pion/webrtc/v4"
"git.stormux.org/storm/skald/conn"
"git.stormux.org/storm/skald/hall"
"git.stormux.org/storm/skald/rtptime" "git.stormux.org/storm/skald/rtptime"
) )
type testClient struct {
hall *hall.Hall
}
func (c *testClient) Hall() *hall.Hall {
return c.hall
}
func (c *testClient) Addr() net.Addr {
return nil
}
func (c *testClient) Id() string {
return "test-client"
}
func (c *testClient) Username() string {
return "Test User"
}
func (c *testClient) SetUsername(string) {
}
func (c *testClient) Permissions() []string {
return []string{"present"}
}
func (c *testClient) SetPermissions([]string) {
}
func (c *testClient) Data() map[string]interface{} {
return nil
}
func (c *testClient) PushConn(*hall.Hall, string, conn.Up, []conn.UpTrack, string) error {
return nil
}
func (c *testClient) RequestConns(hall.Client, *hall.Hall, string) error {
return nil
}
func (c *testClient) Joined(string, string) error {
return nil
}
func (c *testClient) PushClient(string, string, string, string, []string, map[string]interface{}) error {
return nil
}
func (c *testClient) Kick(string, *string, string) error {
return nil
}
func TestDownTrackAtomics(t *testing.T) { func TestDownTrackAtomics(t *testing.T) {
down := &rtpDownTrack{ down := &rtpDownTrack{
atomics: &downTrackAtomics{}, atomics: &downTrackAtomics{},
@@ -38,6 +97,44 @@ func TestDownTrackAtomics(t *testing.T) {
} }
} }
func TestNewUpConnRejectsVideoMediaSection(t *testing.T) {
g, err := hall.Add("rtpconn-video-rejection-test", &hall.Description{})
if err != nil {
t.Fatalf("Add hall: %v", err)
}
defer hall.Delete(g.Name())
offer := `v=0
o=- 1 1 IN IP4 127.0.0.1
s=-
t=0 0
m=audio 9 UDP/TLS/RTP/SAVPF 111
c=IN IP4 0.0.0.0
a=mid:0
a=sendrecv
a=rtpmap:111 opus/48000/2
m=video 9 UDP/TLS/RTP/SAVPF 96
c=IN IP4 0.0.0.0
a=mid:1
a=sendrecv
a=rtpmap:96 VP8/90000
`
up, err := newUpConn(&testClient{hall: g}, "up-test", "audio", offer)
if err != nil {
t.Fatalf("newUpConn: %v", err)
}
defer up.pc.Close()
transceivers := up.pc.GetTransceivers()
if len(transceivers) != 1 {
t.Fatalf("expected only audio transceiver, got %d", len(transceivers))
}
if kind := transceivers[0].Kind(); kind != webrtc.RTPCodecTypeAudio {
t.Fatalf("expected audio transceiver, got %v", kind)
}
}
func TestSadd(t *testing.T) { func TestSadd(t *testing.T) {
ts := []struct{ x, y, z uint64 }{ ts := []struct{ x, y, z uint64 }{
{0, 0, 0}, {0, 0, 0},
+18
View File
@@ -58,6 +58,18 @@ func isWSNormalError(err error) bool {
websocket.CloseGoingAway) websocket.CloseGoingAway)
} }
func broadcastRecordingStatus(g *hall.Hall, message string) {
err := broadcast(g.GetClients(nil), clientMessage{
Type: "usermessage",
Kind: "recording",
Privileged: true,
Value: message,
})
if err != nil {
log.Printf("broadcast(recording): %v", err)
}
}
type webClient struct { type webClient struct {
hall *hall.Hall hall *hall.Hall
addr net.Addr addr net.Addr
@@ -1670,18 +1682,24 @@ func handleClientMessage(c *webClient, m clientMessage) error {
disk.Close() disk.Close()
return c.error(err) return c.error(err)
} }
broadcastRecordingStatus(g, "Recording started")
requestConns(disk, c.hall, "") requestConns(disk, c.hall, "")
case "unrecord": case "unrecord":
if !member("record", c.permissions) { if !member("record", c.permissions) {
return c.error(hall.UserError("not authorised")) return c.error(hall.UserError("not authorised"))
} }
stopped := false
for _, cc := range g.GetClients(c) { for _, cc := range g.GetClients(c) {
disk, ok := cc.(*diskwriter.Client) disk, ok := cc.(*diskwriter.Client)
if ok { if ok {
disk.Close() disk.Close()
hall.DelClient(disk) hall.DelClient(disk)
stopped = true
} }
} }
if stopped {
broadcastRecordingStatus(g, "Recording stopped")
}
case "subhalls": case "subhalls":
if !member("op", c.permissions) { if !member("op", c.permissions) {
return c.error(hall.UserError("not authorised")) return c.error(hall.UserError("not authorised"))
+24
View File
@@ -159,6 +159,7 @@ func main() {
go func() { go func() {
hall.Update() hall.Update()
token.Expire() token.Expire()
cleanupRecordings()
}() }()
case <-slowTicker.C: case <-slowTicker.C:
go relayTest() go relayTest()
@@ -169,6 +170,29 @@ func main() {
} }
} }
func cleanupRecordings() {
conf, err := hall.GetConfiguration()
if err != nil {
log.Printf("Recording cleanup: %v", err)
return
}
if conf.RecordingRetention == "" {
return
}
retention, err := time.ParseDuration(conf.RecordingRetention)
if err != nil {
log.Printf("Recording cleanup: bad recordingRetention %q: %v",
conf.RecordingRetention, err)
return
}
if retention <= 0 {
return
}
if err := diskwriter.CleanupOldRecordings(retention); err != nil {
log.Printf("Recording cleanup: %v", err)
}
}
func relayTest() { func relayTest() {
now := time.Now() now := time.Now()
d, err := ice.RelayTest(20 * time.Second) d, err := ice.RelayTest(20 * time.Second)
+13 -2
View File
@@ -125,7 +125,8 @@ file may look as follows:
{ {
"users":{"vetinari": {"password":"lagniappe", "permissions": "admin"}}, "users":{"vetinari": {"password":"lagniappe", "permissions": "admin"}},
"canonicalHost": "skald.example.org", "canonicalHost": "skald.example.org",
"writableHalls": true "writableHalls": true,
"recordingRetention": "168h"
} }
``` ```
@@ -140,7 +141,8 @@ or, better, with a hashed password:
} }
}, },
"canonicalHost": "skald.example.org", "canonicalHost": "skald.example.org",
"writableHalls": true "writableHalls": true,
"recordingRetention": "168h"
} }
``` ```
@@ -156,6 +158,12 @@ fields are as follows:
to modify hall definitions; if unset or false, then only read-only to modify hall definitions; if unset or false, then only read-only
access is allowed; access is allowed;
- `recordingRetention`: if set, completed recording files older than this
duration are automatically deleted from the recordings directory. The
value uses Go duration syntax, for example `"24h"` for one day or
`"168h"` for seven days. If unset, recordings are kept until deleted
manually;
- `allowOrigin` is an array containing the list of HTTP origins that - `allowOrigin` is an array containing the list of HTTP origins that
are allowed to access the server; are allowed to access the server;
@@ -421,6 +429,9 @@ with an operator-visible error instead of silently creating a broken file.
The recorder closes when recording is stopped explicitly or when the hall no The recorder closes when recording is stopped explicitly or when the hall no
longer has any non-system clients. longer has any non-system clients.
If `recordingRetention` is set in `data/config.json`, Skald periodically
deletes completed recording files older than the configured duration.
## Client Authorisation ## Client Authorisation
Skald implements three authorisation methods: a username/password Skald implements three authorisation methods: a username/password
+1
View File
@@ -71,6 +71,7 @@
<h2 id="chat-history-heading" class="sr-only">Chat History</h2> <h2 id="chat-history-heading" class="sr-only">Chat History</h2>
<div id="box"></div> <div id="box"></div>
<div id="chat-announcements" role="status" aria-live="polite" aria-atomic="true" class="sr-only"></div> <div id="chat-announcements" role="status" aria-live="polite" aria-atomic="true" class="sr-only"></div>
<div id="urgent-announcements" role="alert" aria-live="assertive" aria-atomic="true" class="sr-only"></div>
<div id="audio-streams" aria-hidden="true"></div> <div id="audio-streams" aria-hidden="true"></div>
<div class="reply"> <div class="reply">
<form id="inputform"> <form id="inputform">
+30 -2
View File
@@ -2101,6 +2101,19 @@ function gotUserMessage(id, dest, username, time, privileged, kind, error, messa
displayError(`${from} said: ${message}`, kind); displayError(`${from} said: ${message}`, kind);
break; break;
} }
case 'recording': {
if(!privileged) {
console.error(`Got unprivileged message of kind ${kind}`);
return;
}
let text = '' + message;
if(text === 'Recording started')
announceUrgent(text);
else
announceChat(text);
displayMessage(text);
break;
}
case 'mute': { case 'mute': {
if(!privileged) { if(!privileged) {
console.error(`Got unprivileged message of kind ${kind}`); console.error(`Got unprivileged message of kind ${kind}`);
@@ -2283,9 +2296,10 @@ let lastMessage = {};
/** /**
* @param {string} text * @param {string} text
* @param {string} elementId
*/ */
function announceChat(text) { function announceLiveRegion(text, elementId) {
let announcement = document.getElementById('chat-announcements'); let announcement = document.getElementById(elementId);
if(!announcement) if(!announcement)
return; return;
@@ -2295,6 +2309,20 @@ function announceChat(text) {
}, 20); }, 20);
} }
/**
* @param {string} text
*/
function announceChat(text) {
announceLiveRegion(text, 'chat-announcements');
}
/**
* @param {string} text
*/
function announceUrgent(text) {
announceLiveRegion(text, 'urgent-announcements');
}
/** /**
* @param {string} id * @param {string} id
* @param {string} peerId * @param {string} peerId
+7 -7
View File
@@ -121,8 +121,8 @@ X Verify runtime protocol backward compatibility with Skald is intentionally bro
X Remove video fields from hall descriptions, stats, API responses, and protocol docs X Remove video fields from hall descriptions, stats, API responses, and protocol docs
+ Check WHIP/WHEP-style endpoints for video assumptions and either make audio-only or remove + Check WHIP/WHEP-style endpoints for video assumptions and either make audio-only or remove
+ Ensure browsers that request audio+video get a working audio-only session or a clear video rejection + Ensure browsers that request audio+video get a working audio-only session or a clear video rejection
- Verify two browser clients can join one hall and exchange audio only X Verify two browser clients can join one hall and exchange audio only
- Verify a client attempting camera or screenshare cannot publish video X Verify a client attempting camera or screenshare cannot publish video
# ============================================================================ # ============================================================================
# PHASE 5: FRONTEND - AUDIO-ONLY USER EXPERIENCE # PHASE 5: FRONTEND - AUDIO-ONLY USER EXPERIENCE
@@ -145,7 +145,7 @@ X Remove video fields from hall descriptions, stats, API responses, and protocol
- Preserve keyboard access with Tab and Shift+Tab through all controls - Preserve keyboard access with Tab and Shift+Tab through all controls
- Ensure controls have labels or accessible names after icon/button cleanup - Ensure controls have labels or accessible names after icon/button cleanup
X Verify audio-only UI loads without 404s for renamed assets X Verify audio-only UI loads without 404s for renamed assets
- Verify joining, muting, chat, recording controls, and leaving work from keyboard X Verify joining, muting, chat, recording controls, and leaving work from keyboard
# ============================================================================ # ============================================================================
# PHASE 6: RECORDING REWRITE - SINGLE MIXED OGG OPUS # PHASE 6: RECORDING REWRITE - SINGLE MIXED OGG OPUS
@@ -226,12 +226,12 @@ X Build skaldctl binary
X Run a local server and verify static pages load without 404s X Run a local server and verify static pages load without 404s
X Test: create a hall with skaldctl X Test: create a hall with skaldctl
X Test: list halls with skaldctl X Test: list halls with skaldctl
- Test: join one hall from two browsers and confirm two-way audio X Test: join one hall from two browsers and confirm two-way audio
- Test: attempt to publish video and confirm rejection X Test: attempt to publish video and confirm rejection
- Test: start recording and confirm one Ogg Opus file appears X Test: start recording and confirm one Ogg Opus file appears
- Test: join a third participant during recording and confirm the mix continues - Test: join a third participant during recording and confirm the mix continues
- Test: mute/unmute during recording and confirm the output reflects it - Test: mute/unmute during recording and confirm the output reflects it
- Test: leave all participants and confirm recording stops cleanly X Test: leave all participants and confirm recording stops cleanly
- Test: play the recording in a standard audio player - Test: play the recording in a standard audio player
X Test: restart server and verify halls/config load from the new paths X Test: restart server and verify halls/config load from the new paths
X Remove obsolete files that are no longer served or built X Remove obsolete files that are no longer served or built