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 activeRecordings sync.Map
type Client struct {
hall *hall.Hall
id string
@@ -250,6 +252,7 @@ func newRecorder(client *Client, directory string) (*recorder, error) {
done: make(chan error, 1),
sources: make(map[*diskTrack]chan []byte),
}
activeRecordings.Store(path, struct{}{})
go r.mix()
return r, nil
}
@@ -358,6 +361,7 @@ func (r *recorder) Close() error {
err := <-r.done
stdinErr := r.stdin.Close()
waitErr := r.cmd.Wait()
activeRecordings.Delete(r.path)
if err != nil {
return err
@@ -368,6 +372,10 @@ func (r *recorder) Close() error {
if waitErr != nil {
return waitErr
}
now := time.Now()
if err := os.Chtimes(r.path, now, now); err != nil {
return err
}
return nil
}
@@ -658,6 +666,49 @@ func openDiskFile(directory, extension string) (*os.File, error) {
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) {
buf := make([]byte, 1024)
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) {
a := make(chan []byte, 1)
b := make(chan []byte, 1)
+7 -6
View File
@@ -857,12 +857,13 @@ type Configuration struct {
modTime time.Time `json:"-"`
fileSize int64 `json:"-"`
CanonicalHost string `json:"canonicalHost,omitempty"`
AllowOrigin []string `json:"allowOrigin,omitempty"`
AllowAdminOrigin []string `json:"allowAdminOrigin,omitempty"`
ProxyURL string `json:"proxyURL,omitempty"`
WritableHalls bool `json:"writableHalls,omitempty"`
Users map[string]UserDescription `json:"users,omitempty"`
CanonicalHost string `json:"canonicalHost,omitempty"`
AllowOrigin []string `json:"allowOrigin,omitempty"`
AllowAdminOrigin []string `json:"allowAdminOrigin,omitempty"`
ProxyURL string `json:"proxyURL,omitempty"`
WritableHalls bool `json:"writableHalls,omitempty"`
RecordingRetention string `json:"recordingRetention,omitempty"`
Users map[string]UserDescription `json:"users,omitempty"`
// obsolete fields
Admin []ClientPattern `json:"admin,omitempty"`
+2 -2
View File
@@ -37,7 +37,7 @@ cleanup() {
}
trap cleanup EXIT INT TERM
mkdir -p "$dataDir" "$hallsDir" "$recordingsDir"
mkdir -p "$dataDir" "$hallsDir" "$recordingsDir/$hallName"
hallFile="$hallsDir/$hallName.json"
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}
EOF
if [[ ! -x "$binary" ]]; then
if [[ ! -x "$binary" ]] || find . -name '*.go' -newer "$binary" -print -quit | grep -q .; then
echo "Building $binary"
go build -o "$binary" .
fi
+97
View File
@@ -1,11 +1,70 @@
package rtpconn
import (
"net"
"testing"
"github.com/pion/webrtc/v4"
"git.stormux.org/storm/skald/conn"
"git.stormux.org/storm/skald/hall"
"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) {
down := &rtpDownTrack{
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) {
ts := []struct{ x, y, z uint64 }{
{0, 0, 0},
+18
View File
@@ -58,6 +58,18 @@ func isWSNormalError(err error) bool {
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 {
hall *hall.Hall
addr net.Addr
@@ -1670,18 +1682,24 @@ func handleClientMessage(c *webClient, m clientMessage) error {
disk.Close()
return c.error(err)
}
broadcastRecordingStatus(g, "Recording started")
requestConns(disk, c.hall, "")
case "unrecord":
if !member("record", c.permissions) {
return c.error(hall.UserError("not authorised"))
}
stopped := false
for _, cc := range g.GetClients(c) {
disk, ok := cc.(*diskwriter.Client)
if ok {
disk.Close()
hall.DelClient(disk)
stopped = true
}
}
if stopped {
broadcastRecordingStatus(g, "Recording stopped")
}
case "subhalls":
if !member("op", c.permissions) {
return c.error(hall.UserError("not authorised"))
+24
View File
@@ -159,6 +159,7 @@ func main() {
go func() {
hall.Update()
token.Expire()
cleanupRecordings()
}()
case <-slowTicker.C:
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() {
now := time.Now()
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"}},
"canonicalHost": "skald.example.org",
"writableHalls": true
"writableHalls": true,
"recordingRetention": "168h"
}
```
@@ -140,7 +141,8 @@ or, better, with a hashed password:
}
},
"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
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
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
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
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>
<div id="box"></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 class="reply">
<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);
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': {
if(!privileged) {
console.error(`Got unprivileged message of kind ${kind}`);
@@ -2283,9 +2296,10 @@ let lastMessage = {};
/**
* @param {string} text
* @param {string} elementId
*/
function announceChat(text) {
let announcement = document.getElementById('chat-announcements');
function announceLiveRegion(text, elementId) {
let announcement = document.getElementById(elementId);
if(!announcement)
return;
@@ -2295,6 +2309,20 @@ function announceChat(text) {
}, 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} 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
+ 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
- Verify two browser clients can join one hall and exchange audio only
- Verify a client attempting camera or screenshare cannot publish video
X Verify two browser clients can join one hall and exchange audio only
X Verify a client attempting camera or screenshare cannot publish video
# ============================================================================
# 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
- Ensure controls have labels or accessible names after icon/button cleanup
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
@@ -226,12 +226,12 @@ X Build skaldctl binary
X Run a local server and verify static pages load without 404s
X Test: create a hall with skaldctl
X Test: list halls with skaldctl
- Test: join one hall from two browsers and confirm two-way audio
- Test: attempt to publish video and confirm rejection
- Test: start recording and confirm one Ogg Opus file appears
X Test: join one hall from two browsers and confirm two-way audio
X Test: attempt to publish video and confirm rejection
X Test: start recording and confirm one Ogg Opus file appears
- Test: join a third participant during recording and confirm the mix continues
- 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
X Test: restart server and verify halls/config load from the new paths
X Remove obsolete files that are no longer served or built