First pass at full fork pretty much complete.
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user