Rename group package and defaults to halls

This commit is contained in:
Storm Dragon
2026-05-17 22:37:38 -04:00
parent 55528b8e62
commit a8ada950d5
26 changed files with 459 additions and 459 deletions
+150
View File
@@ -0,0 +1,150 @@
package hall
import (
"bytes"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"encoding/json"
"errors"
"hash"
"net"
"runtime"
"golang.org/x/crypto/bcrypt"
"golang.org/x/crypto/pbkdf2"
"git.stormux.org/storm/skald/conn"
)
type RawPassword struct {
Type string `json:"type,omitempty"`
Hash string `json:"hash,omitempty"`
Key *string `json:"key,omitempty"`
Salt string `json:"salt,omitempty"`
Iterations int `json:"iterations,omitempty"`
}
type Password RawPassword
// limit the number of concurrent hashing operations. This avoids running
// out of memory when doing too many BCrypt hashes at the same time.
var hashSemaphore = make(chan struct{}, runtime.GOMAXPROCS(-1))
// ConstantTimeCompare compares a and b in time proportional to the length of a.
func ConstantTimeCompare(a, b string) bool {
as := []byte(a)
bs := make([]byte, len(as))
copy(bs, b)
equal := subtle.ConstantTimeCompare(as, bs) == 1
return len(a) == len(b) && equal
}
func (p Password) Match(pw string) (bool, error) {
switch p.Type {
case "":
return false, nil
case "plain":
if p.Key == nil {
return false, errors.New("missing key")
}
return ConstantTimeCompare(pw, *p.Key), nil
case "wildcard":
return true, nil
case "pbkdf2":
if p.Key == nil {
return false, errors.New("missing key")
}
key, err := hex.DecodeString(*p.Key)
if err != nil {
return false, err
}
salt, err := hex.DecodeString(p.Salt)
if err != nil {
return false, err
}
var h func() hash.Hash
switch p.Hash {
case "sha-256":
h = sha256.New
default:
return false, errors.New("unknown hash type")
}
hashSemaphore <- struct{}{}
defer func() {
<-hashSemaphore
}()
theirKey := pbkdf2.Key(
[]byte(pw), salt, p.Iterations, len(key), h,
)
return bytes.Equal(key, theirKey), nil
case "bcrypt":
if p.Key == nil {
return false, errors.New("missing key")
}
hashSemaphore <- struct{}{}
defer func() {
<-hashSemaphore
}()
err := bcrypt.CompareHashAndPassword([]byte(*p.Key), []byte(pw))
if errors.Is(err, bcrypt.ErrMismatchedHashAndPassword) {
return false, nil
}
return err == nil, err
default:
return false, errors.New("unknown password type")
}
}
func (p *Password) UnmarshalJSON(b []byte) error {
var k string
err := json.Unmarshal(b, &k)
if err == nil {
*p = Password{
Type: "plain",
Key: &k,
}
return nil
}
var r RawPassword
err = json.Unmarshal(b, &r)
if err == nil {
*p = Password(r)
}
return err
}
func (p Password) MarshalJSON() ([]byte, error) {
if p.Type == "plain" && p.Hash == "" && p.Salt == "" && p.Iterations == 0 {
return json.Marshal(p.Key)
}
return json.Marshal(RawPassword(p))
}
type ClientPattern struct {
Username string `json:"username,omitempty"`
Password *Password `json:"password,omitempty"`
}
type ClientCredentials struct {
System bool
Username *string
Password string
Token string
}
type Client interface {
Hall() *Hall
Addr() net.Addr
Id() string
Username() string
SetUsername(string)
Permissions() []string
SetPermissions([]string)
Data() map[string]interface{}
PushConn(g *Hall, id string, conn conn.Up, tracks []conn.UpTrack, replace string) error
RequestConns(target Client, g *Hall, id string) error
Joined(hall, kind string) error
PushClient(hall, kind, id, username string, perms []string, data map[string]interface{}) error
Kick(id string, user *string, message string) error
}
+160
View File
@@ -0,0 +1,160 @@
package hall
import (
"bytes"
"crypto/sha256"
"encoding/json"
"fmt"
"log"
"reflect"
"testing"
"golang.org/x/crypto/bcrypt"
"golang.org/x/crypto/pbkdf2"
)
var key1 = ""
var pw1 = Password{
Type: "plain",
Key: &key1,
}
var key2 = "pass"
var pw2 = Password{
Type: "plain",
Key: &key2,
}
var key3 = "fe499504e8f144693fae828e8e371d50e019d0e4c84994fa03f7f445bd8a570a"
var pw3 = Password{
Type: "pbkdf2",
Hash: "sha-256",
Key: &key3,
Salt: "bcc1717851030776",
Iterations: 4096,
}
var key4 = "$2a$10$afOr2f33onT/nDFFyT3mbOq5FMSw1wWXfyTXQTBMbKvZpBkoD3Qwu"
var pw4 = Password{
Type: "bcrypt",
Key: &key4,
}
var pw5 = Password{}
var pw6 = Password{
Type: "bad",
}
func TestGood(t *testing.T) {
if match, err := pw1.Match(""); err != nil || !match {
t.Errorf("pw2 doesn't match (%v)", err)
}
if match, err := pw2.Match("pass"); err != nil || !match {
t.Errorf("pw2 doesn't match (%v)", err)
}
if match, err := pw3.Match("pass"); err != nil || !match {
t.Errorf("pw3 doesn't match (%v)", err)
}
if match, err := pw4.Match("pass"); err != nil || !match {
t.Errorf("pw4 doesn't match (%v)", err)
}
}
func TestBad(t *testing.T) {
if match, err := pw2.Match("bad"); err != nil || match {
t.Errorf("pw2 matches bad")
}
if match, err := pw2.Match("bad1"); err != nil || match {
t.Errorf("pw2 matches bad1")
}
if match, err := pw3.Match("bad"); err != nil || match {
t.Errorf("pw3 matches")
}
if match, err := pw4.Match("bad"); err != nil || match {
t.Errorf("pw4 matches")
}
if match, err := pw5.Match(""); err != nil || match {
t.Errorf("pw5 matches")
}
if match, err := pw5.Match("bad"); err != nil || match {
t.Errorf("pw5 matches")
}
if match, err := pw6.Match("bad"); err == nil || match {
t.Errorf("pw6 matches")
}
}
func TestEmptyKey(t *testing.T) {
for _, tpe := range []string{"plain", "pbkdf2", "bcrypt", "bad"} {
pw := Password{Type: tpe}
if match, err := pw.Match(""); err == nil || match {
t.Errorf("empty password of type %v didn't error", tpe)
}
}
pw := Password{}
if match, err := pw.Match(""); err != nil || match {
t.Errorf("empty password empty type matched")
}
}
func TestJSON(t *testing.T) {
plain, err := json.Marshal(pw2)
if err != nil || string(plain) != `"pass"` {
t.Errorf("Expected \"pass\", got %v", string(plain))
}
for _, pw := range []Password{pw1, pw2, pw3, pw4, pw5} {
j, err := json.Marshal(pw)
if err != nil {
t.Fatalf("Marshal: %v", err)
}
if testing.Verbose() {
log.Printf("%v", string(j))
}
var pw2 Password
err = json.Unmarshal(j, &pw2)
if err != nil {
t.Errorf("Unmarshal: %v", err)
} else if !reflect.DeepEqual(pw, pw2) {
t.Errorf("Expected %v, got %v", pw, pw2)
}
}
}
func BenchmarkPBKDF2(b *testing.B) {
for iters := 1024; iters <= 1024*1024; iters *= 2 {
b.Run(fmt.Sprintf("%d", iters), func(b *testing.B) {
pw := []byte("Password1234")
salt := make([]byte, 8)
for i := range salt {
salt[i] = byte(i)
}
key := pbkdf2.Key(pw, salt, iters, 32, sha256.New)
b.ResetTimer()
for i := 0; i < b.N; i++ {
k := pbkdf2.Key(pw, salt, iters, 32, sha256.New)
if bytes.Compare(key, k) != 0 {
b.Errorf("Key compare: mismatch")
}
}
})
}
}
func BenchmarkBCrypt(b *testing.B) {
for cost := 4; cost <= 15; cost++ {
b.Run(fmt.Sprintf("%d", cost), func(b *testing.B) {
pw := []byte("Password1234")
key, err := bcrypt.GenerateFromPassword(pw, cost)
if err != nil {
b.Fatalf("GenerateFromPassword: %v", err)
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
err := bcrypt.CompareHashAndPassword(key, pw)
if err != nil {
b.Errorf(
"CompareHashAndPassword: %v", err,
)
}
}
})
}
}
+783
View File
@@ -0,0 +1,783 @@
package hall
import (
"encoding/json"
"errors"
"fmt"
"io/fs"
"log"
"os"
"path"
"path/filepath"
"strings"
"time"
"git.stormux.org/storm/skald/token"
)
var ErrTagMismatch = errors.New("tag mismatch")
var ErrDescriptionsNotWritable = &NotAuthorisedError{}
var ErrUnknownPermission = errors.New("unknown permission")
type Permissions struct {
// non-empty for a named permissions set
name string
// only used when unnamed
permissions []string
}
var permissionsMap = map[string][]string{
"op": {"op", "present", "message", "caption", "token"},
"present": {"present", "message"},
"message": {"message"},
"observe": {},
"caption": {"caption"},
"admin": {"admin"},
}
func NewPermissions(name string) (Permissions, error) {
_, ok := permissionsMap[name]
if !ok {
return Permissions{}, ErrUnknownPermission
}
return Permissions{
name: name,
}, nil
}
func (p Permissions) Permissions(desc *Description) []string {
if p.name == "" {
return p.permissions
}
perms := permissionsMap[p.name]
op := false
present := false
token := false
record := false
for _, p := range perms {
switch p {
case "op":
op = true
case "present":
present = true
case "token":
token = true
case "record":
record = true
}
}
if desc != nil && desc.AllowRecording {
if op && !record {
// copy the slice
perms = append([]string{"record"}, perms...)
}
}
if desc != nil && desc.UnrestrictedTokens {
if present && !token {
perms = append([]string{"token"}, perms...)
}
}
return perms
}
func (p Permissions) String() string {
if p.name != "" {
if p.permissions != nil {
return fmt.Sprintf("(ERROR=overconstrained %v)", p.name)
}
return p.name
}
v, err := json.Marshal(p)
if err != nil {
return fmt.Sprintf("(ERROR=%v)", err)
}
return string(v)
}
func (p *Permissions) UnmarshalJSON(b []byte) error {
var a []string
err := json.Unmarshal(b, &a)
if err == nil {
*p = Permissions{
permissions: a,
}
return nil
}
var s string
err = json.Unmarshal(b, &s)
if err == nil {
_, ok := permissionsMap[s]
if !ok {
return ErrUnknownPermission
}
*p = Permissions{
name: s,
}
return nil
}
return err
}
func (p Permissions) MarshalJSON() ([]byte, error) {
if p.name != "" {
return json.Marshal(p.name)
}
return json.Marshal(p.permissions)
}
type UserDescription struct {
Password Password `json:"password"`
Permissions Permissions `json:"permissions"`
}
// Custom MarshalJSON in order to omit empty fields
func (u UserDescription) MarshalJSON() ([]byte, error) {
uu := make(map[string]any, 2)
if u.Password.Type != "" {
uu["password"] = &u.Password
}
if u.Permissions.name != "" || u.Permissions.permissions != nil {
uu["permissions"] = &u.Permissions
}
return json.Marshal(uu)
}
// Description represents a hall description together with some metadata
// about the JSON file it was deserialised from.
type Description struct {
// The file this was deserialised from. This is not necessarily
// the name of the hall, for example in case of a subgroup.
FileName string `json:"-"`
// The modtime and size of the file. These are used to detect
// when a file has changed on disk.
modTime time.Time `json:"-"`
fileSize int64 `json:"-"`
// Whether this is an automatically generated subgroup
isSubgroup bool `json:"-"`
// The user-friendly hall name
DisplayName string `json:"displayName,omitempty"`
// A user-readable description of the hall.
Description string `json:"description,omitempty"`
// A user-readable contact, typically an e-mail address.
Contact string `json:"contact,omitempty"`
// A user-readable comment. Ignored by the server.
Comment string `json:"comment,omitempty"`
// Whether to display the hall on the landing page.
Public bool `json:"public,omitempty"`
// A URL to redirect the hall to. If this is not empty, most
// other fields are ignored.
Redirect string `json:"redirect,omitempty"`
// The maximum number of simultaneous clients. Unlimited if 0.
MaxClients int `json:"max-clients,omitempty"`
// The time for which history entries are kept.
MaxHistoryAge int `json:"max-history-age,omitempty"`
// Time after which joining is no longer allowed
Expires *time.Time `json:"expires,omitempty"`
// Time before which joining is not allowed
NotBefore *time.Time `json:"not-before,omitempty"`
// Whether recording is allowed.
AllowRecording bool `json:"allow-recording,omitempty"`
// Whether creating tokens is allowed
UnrestrictedTokens bool `json:"unrestricted-tokens,omitempty"`
// Whether subgroups are created on the fly.
AutoSubgroups bool `json:"auto-subgroups,omitempty"`
// Whether to lock the hall when the last op logs out.
Autolock bool `json:"autolock,omitempty"`
// Whether to kick all users when the last op logs out.
Autokick bool `json:"autokick,omitempty"`
// Users allowed to login
Users map[string]UserDescription `json:"users,omitempty"`
// Credentials for user with arbitrary username
WildcardUser *UserDescription `json:"wildcard-user,omitempty"`
// The (public) keys used for token authentication.
AuthKeys []map[string]interface{} `json:"authKeys,omitempty"`
// The URL of the authentication server, if any.
AuthServer string `json:"authServer,omitempty"`
// The URL of the authentication portal, if any.
AuthPortal string `json:"authPortal,omitempty"`
// Codec preferences. If empty, a suitable default is chosen in
// the APIFromNames function.
Codecs []string `json:"codecs,omitempty"`
// Obsolete fields
Op []ClientPattern `json:"op,omitempty"`
Presenter []ClientPattern `json:"presenter,omitempty"`
Other []ClientPattern `json:"other,omitempty"`
AllowSubgroups bool `json:"allow-subgroups,omitempty"`
AllowAnonymous bool `json:"allow-anonymous,omitempty"`
}
const DefaultMaxHistoryAge = 4 * time.Hour
func maxHistoryAge(desc *Description) time.Duration {
if desc.MaxHistoryAge != 0 {
return time.Duration(desc.MaxHistoryAge) * time.Second
}
return DefaultMaxHistoryAge
}
func getDescriptionFile[T any](name string, allowSubgroups bool, get func(string) (T, error)) (T, string, bool, error) {
isSubgroup := false
for name != "" {
fileName := filepath.Join(
Directory, path.Clean("/"+name)+".json",
)
r, err := get(fileName)
if !errors.Is(err, os.ErrNotExist) {
return r, fileName, isSubgroup, err
}
if !allowSubgroups {
break
}
isSubgroup = true
name, _ = path.Split(name)
name = strings.TrimRight(name, "/")
}
var zero T
return zero, "", false, os.ErrNotExist
}
// descriptionMatch returns true if the description hasn't changed between
// d1 and d2
func descriptionMatch(d1, d2 *Description) bool {
if d1.FileName != d2.FileName {
return false
}
if d1.fileSize != d2.fileSize || !d1.modTime.Equal(d2.modTime) {
return false
}
return true
}
// descriptionUnchanged returns true if a hall's description hasn't
// changed since it was last read.
func descriptionUnchanged(name string, desc *Description) bool {
fi, fileName, _, err := getDescriptionFile(name, true, os.Stat)
if err != nil || fileName != desc.FileName {
return false
}
if fi.Size() != desc.fileSize || !fi.ModTime().Equal(desc.modTime) {
return false
}
return true
}
// GetDescription gets a hall description, either from cache or from disk
func GetDescription(name string) (*Description, error) {
g := Get(name)
if g != nil {
if descriptionUnchanged(name, g.description) {
return g.description, nil
}
}
return readDescription(name, true)
}
// GetSanitisedDescription returns the subset of the description that is
// published on the web interface together with a suitable ETag.
func GetSanitisedDescription(name string) (*Description, string, error) {
d, err := GetDescription(name)
if err != nil {
return nil, "", err
}
if d.isSubgroup {
return nil, "", os.ErrNotExist
}
desc := *d
desc.Users = nil
desc.WildcardUser = nil
desc.AuthKeys = nil
return &desc, makeETag(desc.fileSize, desc.modTime), nil
}
// GetDescriptionTag returns an ETag for a description.
func GetDescriptionTag(name string) (string, error) {
fi, _, _, err := getDescriptionFile(name, false, os.Stat)
if err != nil {
return "", err
}
return makeETag(fi.Size(), fi.ModTime()), nil
}
func makeETag(fileSize int64, modTime time.Time) string {
return fmt.Sprintf("\"%v-%v\"", fileSize, modTime.UnixNano())
}
// DeleteDescription deletes a description (and therefore persistently
// deletes a hall) but only if it matches a given ETag.
func DeleteDescription(name, etag string) error {
halls.mu.Lock()
defer halls.mu.Unlock()
fi, fileName, _, err := getDescriptionFile(name, false, os.Stat)
if err != nil {
return err
}
if etag != makeETag(fi.Size(), fi.ModTime()) {
return ErrTagMismatch
}
return os.Remove(fileName)
}
// UpdateDescription overwrites a description if it matches a given ETag.
// In order to create a new hall, pass an empty ETag.
func UpdateDescription(name, etag string, desc *Description) error {
if desc.Users != nil || desc.WildcardUser != nil || desc.AuthKeys != nil {
return errors.New("description is not sanitised")
}
halls.mu.Lock()
defer halls.mu.Unlock()
oldetag := ""
var filename string
old, err := readDescription(name, false)
if err == nil {
oldetag = makeETag(old.fileSize, old.modTime)
filename = old.FileName
} else if errors.Is(err, os.ErrNotExist) {
old = nil
filename = filepath.Join(
Directory, path.Clean("/"+name)+".json",
)
} else {
return err
}
if oldetag != etag {
return ErrTagMismatch
}
newdesc := *desc
if old != nil {
newdesc.Users = old.Users
newdesc.WildcardUser = old.WildcardUser
newdesc.AuthKeys = old.AuthKeys
}
return rewriteDescriptionFile(filename, &newdesc)
}
func rewriteDescriptionFile(filename string, desc *Description) error {
conf, err := GetConfiguration()
if err != nil {
return err
}
if !conf.WritableGroups {
return ErrDescriptionsNotWritable
}
dir := filepath.Dir(filename)
err = os.MkdirAll(dir, 0700)
if err != nil {
return err
}
f, err := os.CreateTemp(dir, "*.temp")
if err != nil {
return err
}
temp := f.Name()
encoder := json.NewEncoder(f)
err = encoder.Encode(desc)
if err == nil {
err = f.Sync()
}
if err != nil {
f.Close()
os.Remove(temp)
return err
}
err = f.Close()
if err != nil {
os.Remove(temp)
return err
}
err = os.Rename(temp, filename)
if err != nil {
os.Remove(temp)
return err
}
return nil
}
// readDescription reads a hall's description from disk
func readDescription(name string, allowSubgroups bool) (*Description, error) {
r, fileName, isSubgroup, err :=
getDescriptionFile(name, allowSubgroups, os.Open)
if err != nil {
return nil, err
}
defer r.Close()
var desc Description
fi, err := r.Stat()
if err != nil {
return nil, err
}
d := json.NewDecoder(r)
d.DisallowUnknownFields()
err = d.Decode(&desc)
if err != nil {
return nil, err
}
desc.FileName = fileName
desc.fileSize = fi.Size()
desc.modTime = fi.ModTime()
err = upgradeDescription(&desc)
if err != nil {
return nil, err
}
if isSubgroup {
if !desc.AutoSubgroups {
return nil, os.ErrNotExist
}
desc.isSubgroup = true
desc.Public = false
desc.Description = ""
}
return &desc, nil
}
func upgradeDescription(desc *Description) error {
if desc.AllowAnonymous {
log.Printf(
"%v: field allow-anonymous is obsolete, ignored",
desc.FileName,
)
desc.AllowAnonymous = false
}
if desc.AllowSubgroups {
desc.AutoSubgroups = true
desc.AllowSubgroups = false
}
upgradePassword := func(pw *Password) Password {
if pw == nil {
return Password{
Type: "wildcard",
}
}
return *pw
}
upgradeUser := func(u ClientPattern, p string) UserDescription {
return UserDescription{
Password: upgradePassword(u.Password),
Permissions: Permissions{
name: p,
},
}
}
upgradeUsers := func(ps []ClientPattern, p string) {
if desc.Users == nil {
desc.Users = make(map[string]UserDescription)
}
for _, u := range ps {
if u.Username == "" {
if desc.WildcardUser != nil {
log.Printf("%v: duplicate wildcard user",
desc.FileName)
continue
}
u := upgradeUser(u, p)
desc.WildcardUser = &u
continue
}
_, found := desc.Users[u.Username]
if found {
log.Printf("%v: duplicate user %v, ignored",
desc.FileName, u.Username)
continue
}
desc.Users[u.Username] = upgradeUser(u, p)
}
}
if desc.Op != nil {
upgradeUsers(desc.Op, "op")
desc.Op = nil
}
if desc.Presenter != nil {
upgradeUsers(desc.Presenter, "present")
desc.Presenter = nil
}
if desc.Other != nil {
upgradeUsers(desc.Other, "message")
desc.Other = nil
}
return nil
}
func GetDescriptionNames() ([]string, error) {
var names []string
err := filepath.WalkDir(
Directory,
func(path string, d fs.DirEntry, err error) error {
if err != nil {
return nil
}
base := filepath.Base(path)
if d.IsDir() {
if base[0] == '.' {
return fs.SkipDir
}
return nil
}
if base[0] == '.' {
return nil
}
p, err := filepath.Rel(Directory, path)
if err != nil || !strings.HasSuffix(p, ".json") {
return nil
}
names = append(names, strings.TrimSuffix(
p, ".json",
))
return nil
},
)
return names, err
}
func SetKeys(hall string, keys []map[string]any) error {
if keys != nil {
_, err := token.ParseKeys(keys, "", "")
if err != nil {
return err
}
}
halls.mu.Lock()
defer halls.mu.Unlock()
desc, err := readDescription(hall, false)
if err != nil {
return err
}
desc.AuthKeys = keys
return rewriteDescriptionFile(desc.FileName, desc)
}
func GetUsers(hall string) ([]string, string, error) {
desc, err := GetDescription(hall)
if err != nil {
return nil, "", err
}
users := make([]string, 0, len(desc.Users))
for u := range desc.Users {
users = append(users, u)
}
return users, makeETag(desc.fileSize, desc.modTime), nil
}
func GetSanitisedUser(hall, username string, wildcard bool) (UserDescription, string, error) {
if wildcard && username != "" {
return UserDescription{}, "",
errors.New("wildcard with username")
}
desc, err := GetDescription(hall)
if err != nil {
return UserDescription{}, "", err
}
var u UserDescription
if wildcard {
if desc.WildcardUser == nil {
return UserDescription{}, "", os.ErrNotExist
}
u = *desc.WildcardUser
} else {
if desc.Users == nil {
return UserDescription{}, "", os.ErrNotExist
}
ok := false
u, ok = desc.Users[username]
if !ok {
return UserDescription{}, "", os.ErrNotExist
}
}
u.Password = Password{}
return u, makeETag(desc.fileSize, desc.modTime), nil
}
func GetUserTag(hall, username string, wildcard bool) (string, error) {
_, etag, err := GetSanitisedUser(hall, username, wildcard)
return etag, err
}
func DeleteUser(hall, username string, wildcard bool, etag string) error {
if wildcard && username != "" {
return errors.New("wildcard with username")
}
halls.mu.Lock()
defer halls.mu.Unlock()
desc, err := readDescription(hall, false)
if err != nil {
return err
}
if wildcard {
if desc.WildcardUser == nil {
return os.ErrNotExist
}
} else {
if desc.Users == nil {
return os.ErrNotExist
}
_, ok := desc.Users[username]
if !ok {
return os.ErrNotExist
}
}
oldetag := makeETag(desc.fileSize, desc.modTime)
if oldetag != etag {
return ErrTagMismatch
}
if wildcard {
desc.WildcardUser = nil
} else {
delete(desc.Users, username)
}
return rewriteDescriptionFile(desc.FileName, desc)
}
func UpdateUser(hall, username string, wildcard bool, etag string, user *UserDescription) error {
if wildcard && username != "" {
return errors.New("wildcard with username")
}
if user.Password.Type != "" || user.Password.Key != nil {
return errors.New("user description is not sanitised")
}
halls.mu.Lock()
defer halls.mu.Unlock()
desc, err := readDescription(hall, false)
if err != nil {
return err
}
var old UserDescription
var ok bool
if wildcard {
if desc.WildcardUser != nil {
ok = true
old = *desc.WildcardUser
}
} else {
if desc.Users == nil {
desc.Users = make(map[string]UserDescription)
}
old, ok = desc.Users[username]
}
var oldetag string
if ok {
oldetag = makeETag(desc.fileSize, desc.modTime)
} else {
oldetag = ""
}
if oldetag != etag {
return ErrTagMismatch
}
newuser := *user
newuser.Password = old.Password
if wildcard {
desc.WildcardUser = &newuser
} else {
desc.Users[username] = newuser
}
return rewriteDescriptionFile(desc.FileName, desc)
}
func SetUserPassword(hall, username string, wildcard bool, pw Password) error {
if wildcard && username != "" {
return errors.New("wildcard with username")
}
halls.mu.Lock()
defer halls.mu.Unlock()
desc, err := readDescription(hall, false)
if err != nil {
return err
}
if wildcard {
if desc.WildcardUser == nil {
return os.ErrNotExist
}
desc.WildcardUser.Password = pw
} else {
if desc.Users == nil {
return os.ErrNotExist
}
user, ok := desc.Users[username]
if !ok {
return os.ErrNotExist
}
user.Password = pw
desc.Users[username] = user
}
return rewriteDescriptionFile(desc.FileName, desc)
}
+321
View File
@@ -0,0 +1,321 @@
package hall
import (
"encoding/json"
"errors"
"os"
"path/filepath"
"reflect"
"testing"
)
func TestMarshalUserDescription(t *testing.T) {
tests := []string{
`{}`,
`{"permissions":"present"}`,
`{"password":"secret"}`,
`{"password":"secret","permissions":"present"}`,
`{"password":"secret","permissions":["present"]}`,
`{"password":{"type":"wildcard"},"permissions":"observe"}`,
`{"password":{"type":"wildcard"},"permissions":[]}`,
}
for _, test := range tests {
var u UserDescription
err := json.Unmarshal([]byte(test), &u)
if err != nil {
t.Errorf("Unmarshal %v: %v", t, err)
continue
}
v, err := json.Marshal(u)
if err != nil || string(v) != test {
t.Errorf("Marshal %v: got %v %v", test, string(v), err)
}
}
}
func TestEmptyJSON(t *testing.T) {
type emptyTest struct {
value any
result string
name string
}
emptyTests := []emptyTest{
{Password{}, "{}", "password"},
{Permissions{}, "null", "permissions"},
{UserDescription{}, "{}", "user description"},
}
for _, v := range emptyTests {
b, err := json.Marshal(v.value)
if err != nil || string(b) != v.result {
t.Errorf("Marshal empty %v: %#v %v, expected %#v",
v.name, string(b), err, v.result)
}
}
}
var descJSON = `
{
"max-history-age": 10,
"auto-subgroups": true,
"users": {
"jch": {"password": "topsecret", "permissions": "op"},
"john": {"password": "secret", "permissions": "present"},
"james": {"password": "secret2", "permissions": "message"},
"peter": {"password": "secret4"}
},
"wildcard-user":
{"permissions": "message", "password": {"type":"wildcard"}}
}`
func TestDescriptionJSON(t *testing.T) {
var d Description
err := json.Unmarshal([]byte(descJSON), &d)
if err != nil {
t.Fatalf("unmarshal: %v", err)
}
dd, err := json.Marshal(d)
if err != nil {
t.Fatalf("marshal: %v", err)
}
var ddd Description
err = json.Unmarshal([]byte(dd), &ddd)
if err != nil {
t.Fatalf("unmarshal: %v", err)
}
if !reflect.DeepEqual(d, ddd) {
t.Errorf("Got %v, expected %v", ddd, d)
}
}
var obsoleteJSON = `
{
"op": [{"username": "jch","password": "topsecret"}],
"max-history-age": 10,
"allow-subgroups": true,
"presenter": [
{"username": "john", "password": "secret"}
],
"other": [
{"username": "james", "password": "secret2"},
{"username": "peter", "password": "secret4"},
{}
]
}`
func TestUpgradeDescription(t *testing.T) {
var d1 Description
err := json.Unmarshal([]byte(descJSON), &d1)
if err != nil {
t.Fatalf("unmarshal: %v", err)
}
var d2 Description
err = json.Unmarshal([]byte(obsoleteJSON), &d2)
if err != nil {
t.Fatalf("unmarshal: %v", err)
}
err = upgradeDescription(&d2)
if err != nil {
t.Fatalf("upgradeDescription: %v", err)
}
if d1.AutoSubgroups != d2.AutoSubgroups ||
d1.AllowSubgroups != d2.AllowSubgroups {
t.Errorf("AllowSubgroups not upgraded correctly")
}
if d2.Op != nil || d2.Presenter != nil || d2.Other != nil {
t.Errorf("legacy field is not nil")
}
if len(d1.Users) != len(d2.Users) {
t.Errorf("length not equal: %v != %v",
len(d1.Users), len(d2.Users))
}
for k, v1 := range d1.Users {
if k == "peter" {
// not representable in the old format
continue
}
v2 := d2.Users[k]
if !reflect.DeepEqual(v1.Password, v2.Password) ||
!permissionsEqual(
v1.Permissions.Permissions(&d1),
v2.Permissions.Permissions(&d2),
) {
t.Errorf("%v not equal: %v != %v", k, v1, v2)
}
}
if d1.WildcardUser != nil || d2.WildcardUser != nil {
if !reflect.DeepEqual(
d1.WildcardUser.Password, d2.WildcardUser.Password,
) || !permissionsEqual(
d1.WildcardUser.Permissions.Permissions(&d1),
d2.WildcardUser.Permissions.Permissions(&d2),
) {
t.Errorf("WildcardUser not equal: %v != %v",
d1.WildcardUser, d2.WildcardUser)
}
}
}
func setupTest(dir, datadir string, writable bool) error {
Directory = dir
DataDirectory = datadir
f, err := os.Create(filepath.Join(datadir, "config.json"))
if err != nil {
return err
}
defer f.Close()
conf := `{}`
if writable {
conf = `{"writableGroups": true}`
}
_, err = f.WriteString(conf)
return err
}
func TestNonWritableGroups(t *testing.T) {
err := setupTest(t.TempDir(), t.TempDir(), false)
if err != nil {
t.Fatalf("setupTest: %v", err)
}
_, err = GetDescription("test")
if !errors.Is(err, os.ErrNotExist) {
t.Errorf("GetDescription: got %#v, expected ErrNotExist", err)
}
err = UpdateDescription("test", "", &Description{})
if !errors.Is(err, ErrDescriptionsNotWritable) {
t.Errorf("UpdateDescription: got %#v, "+
"expected ErrDescriptionsNotWritable", err)
}
}
func TestWritableGroups(t *testing.T) {
err := setupTest(t.TempDir(), t.TempDir(), true)
if err != nil {
t.Fatalf("setupTest: %v", err)
}
_, err = GetDescription("test")
if !errors.Is(err, os.ErrNotExist) {
t.Errorf("GetDescription: got %v, expected ErrNotExist", err)
}
err = UpdateDescription("test", "\"etag\"", &Description{})
if !errors.Is(err, ErrTagMismatch) {
t.Errorf("UpdateDescription: got %v, expected ErrTagMismatch",
err)
}
err = UpdateDescription("test", "", &Description{})
if err != nil {
t.Fatalf("UpdateDescription: got %v", err)
}
_, err = GetDescription("test")
if err != nil {
t.Errorf("GetDescription: got %v", err)
}
fi, err := os.Stat(filepath.Join(Directory, "test.json"))
if err != nil {
t.Errorf("Stat: %v", err)
}
if mode := fi.Mode(); mode != 0o600 {
t.Errorf("Mode is 0o%03o (expected 0o600)\n", mode)
}
desc, token, err := GetSanitisedDescription("test")
if err != nil || token == "" {
t.Errorf("GetSanitisedDescription: got %v", err)
}
desc.DisplayName = "Test"
err = UpdateDescription("test", "\"badetag\"", desc)
if !errors.Is(err, ErrTagMismatch) {
t.Errorf("UpdateDescription: got %v, expected ErrTagMismatch",
err)
}
err = UpdateDescription("test", token, desc)
if err != nil {
t.Errorf("UpdateDescription: got %v", err)
}
desc, err = GetDescription("test")
if err != nil || desc.DisplayName != "Test" {
t.Errorf("GetDescription: expected %v %v, got %v %v",
nil, "Test", err, desc.AllowAnonymous,
)
}
testUser(t, "jch", false)
testUser(t, "", true)
}
func testUser(t *testing.T, username string, wildcard bool) {
_, _, err := GetSanitisedUser("test", username, wildcard)
if !errors.Is(err, os.ErrNotExist) {
t.Errorf("GetSanitisedUser: got %v, expected ErrNotExist", err)
}
err = UpdateUser("test", username, wildcard, "", &UserDescription{
Permissions: Permissions{name: "observe"},
})
if err != nil {
t.Errorf("UpdateUser: got %v", err)
}
user, token, err := GetSanitisedUser("test", username, wildcard)
if err != nil || token == "" || user.Permissions.name != "observe" {
t.Errorf("GetDescription: got %v %v, expected %v %v",
err, user.Permissions.name, nil, "observe",
)
}
err = UpdateUser("test", username, wildcard, "", &UserDescription{
Permissions: Permissions{name: "present"},
})
if !errors.Is(err, ErrTagMismatch) {
t.Errorf("UpdateDescription: got %v, expected ErrTagMismatch",
err)
}
err = UpdateUser("test", username, wildcard, token, &UserDescription{
Permissions: Permissions{name: "present"},
})
if err != nil {
t.Errorf("UpdateUser: got %v", err)
}
pw := "pw"
err = SetUserPassword("test", username, wildcard, Password{
Type: "plain",
Key: &pw,
})
if err != nil {
t.Errorf("SetUserPassword: got %v", err)
}
}
func TestSubGroup(t *testing.T) {
err := setupTest(t.TempDir(), t.TempDir(), true)
if err != nil {
t.Fatalf("setupTest: %v", err)
}
err = UpdateDescription("dir/test", "", &Description{})
if err != nil {
t.Fatalf("UpdateDescription: got %v", err)
}
}
+1278
View File
File diff suppressed because it is too large Load Diff
+406
View File
@@ -0,0 +1,406 @@
package hall
import (
"encoding/json"
"errors"
"fmt"
"sort"
"testing"
"time"
"github.com/pion/webrtc/v4"
)
func TestConstantTimeCompare(t *testing.T) {
tests := []struct {
a, b string
}{
{"", ""},
{"a", "a"},
{"a", "b"},
{"foo", "foo"},
{"foo", "bar"},
{"foo", "foo1"},
{"foo1", "foo"},
}
for _, test := range tests {
e := ConstantTimeCompare(test.a, test.b)
if e != (test.a == test.b) {
t.Errorf("constantTimeCompare(%v, %v): got %v",
test.a, test.b, e,
)
}
}
}
func TestGroup(t *testing.T) {
halls.halls = nil
Add("hall", &Description{})
Add("hall/subgroup", &Description{Public: true})
if len(halls.halls) != 2 {
t.Errorf("Expected 2, got %v", len(halls.halls))
}
g := Get("hall")
g2 := Get("hall/subgroup")
if g == nil {
t.Fatalf("Couldn't get hall")
}
if g2 == nil {
t.Fatalf("Couldn't get hall/subgroup")
}
if name := g.Name(); name != "hall" {
t.Errorf("Name: expected group1, got %v", name)
}
if locked, _ := g.Locked(); locked {
t.Errorf("Locked: expected false, got %v", locked)
}
api, err := g.API()
if err != nil || api == nil {
t.Errorf("Couldn't get API: %v", err)
}
if names := GetNames(); len(names) != 2 {
t.Errorf("Expected 2, got %v", names)
}
if subs := GetSubGroups("hall"); len(subs) != 0 {
t.Errorf("Expected [], got %v", subs)
}
if public := GetPublic(nil); len(public) != 1 || public[0].Name != "hall/subgroup" {
t.Errorf("Expected hall/subgroup, got %v", public)
}
}
func TestChatHistory(t *testing.T) {
g := Hall{
description: &Description{},
}
user := "user"
for i := 0; i < 2*maxChatHistory; i++ {
g.AddToChatHistory(
fmt.Sprintf("id-%v", i),
fmt.Sprintf("source-%v", i%4),
&user, time.Now(), "",
fmt.Sprintf("%v", i),
)
}
h := g.GetChatHistory()
if len(h) != maxChatHistory {
t.Errorf("Expected %v, got %v", maxChatHistory, len(g.history))
}
for i, s := range h {
j := i + maxChatHistory
if s.Id != fmt.Sprintf("id-%v", j) {
t.Errorf("Expected %v, got %v", j, s.Id)
}
if s.Source != fmt.Sprintf("source-%v", j%4) {
t.Errorf("Expected %v, got %v", j%4, s.Id)
}
if s.Value.(string) != fmt.Sprintf("%v", j) {
t.Errorf("Expected %v, got %v", j, s.Value)
}
}
l := len(h)
j := maxChatHistory + 4
g.ClearChatHistory(
fmt.Sprintf("id-%v", j), fmt.Sprintf("source-%v", j%4),
)
if lh := len(g.GetChatHistory()); lh != l-1 {
t.Errorf("Expected %v, got %v", l-1, lh)
}
g.ClearChatHistory("", fmt.Sprintf("source-%v", j%4))
if lh := len(g.GetChatHistory()); lh != l*3/4 {
t.Errorf("Expected %v, got %v", l*3/4, lh)
}
g.ClearChatHistory("", "")
if lh := len(g.GetChatHistory()); lh != 0 {
t.Errorf("Expected 0, got %v", lh)
}
}
func permissionsEqual(a, b []string) bool {
// nil case
if len(a) == 0 && len(b) == 0 {
return true
}
if len(a) != len(b) {
return false
}
aa := append([]string(nil), a...)
sort.Slice(aa, func(i, j int) bool {
return aa[i] < aa[j]
})
bb := append([]string(nil), b...)
sort.Slice(bb, func(i, j int) bool {
return bb[i] < bb[j]
})
for i := range aa {
if aa[i] != bb[i] {
return false
}
}
return true
}
var jch = "jch"
var john = "john"
var james = "james"
var paul = "paul"
var peter = "peter"
var admin = "admin"
var stt = "speech-to-text"
var badClients = []ClientCredentials{
{Username: &jch, Password: "foo"},
{Username: &john, Password: "foo"},
{Username: &james, Password: "foo"},
}
type credPerm struct {
c ClientCredentials
p []string
}
var desc2JSON = `
{
"max-history-age": 10,
"auto-subgroups": true,
"users": {
"jch": {"password": "topsecret", "permissions": "op"},
"john": {"password": "secret", "permissions": "present"},
"james": {"password": "secret2", "permissions": "message"},
"peter": {"password": "secret4"},
"admin": {"password": "admin", "permissions": "admin"},
"speech-to-text": {"password": {"type": "wildcard"},
"permissions": "caption"}
},
"wildcard-user":
{"permissions": "message", "password": {"type":"wildcard"}}
}`
var goodClients = []credPerm{
{
ClientCredentials{Username: &jch, Password: "topsecret"},
[]string{"op", "present", "message", "caption", "token"},
},
{
ClientCredentials{Username: &john, Password: "secret"},
[]string{"present", "message"},
},
{
ClientCredentials{Username: &james, Password: "secret2"},
[]string{"message"},
},
{
ClientCredentials{Username: &paul, Password: "secret3"},
[]string{"message"},
},
{
ClientCredentials{Username: &peter, Password: "secret4"},
[]string{},
},
{
ClientCredentials{Username: &admin, Password: "admin"},
[]string{"admin"},
},
{
ClientCredentials{Username: &stt},
[]string{"caption"},
},
}
func TestPermissions(t *testing.T) {
var g Hall
err := json.Unmarshal([]byte(desc2JSON), &g.description)
if err != nil {
t.Fatalf("unmarshal: %v", err)
}
for _, c := range badClients {
t.Run("bad "+*c.Username, func(t *testing.T) {
var autherr *NotAuthorisedError
_, p, err := g.GetPermission(c)
if !errors.As(err, &autherr) {
t.Errorf("GetPermission %v: %v %v", c, err, p)
}
})
}
for _, cp := range goodClients {
t.Run("good "+*cp.c.Username, func(t *testing.T) {
u, p, err := g.GetPermission(cp.c)
if err != nil {
t.Errorf("GetPermission %v: %v", cp.c, err)
} else if u != *cp.c.Username ||
!permissionsEqual(p, cp.p) {
t.Errorf("%v: got %v %v, expected %v",
cp.c, u, p, cp.p)
}
})
}
}
func TestExtraPermissions(t *testing.T) {
j := `
{
"users": {
"jch": {"password": "topsecret", "permissions": "op"},
"john": {"password": "secret", "permissions": "present"},
"james": {"password": "secret2", "permissions": "observe"}
}
}`
var d Description
err := json.Unmarshal([]byte(j), &d)
if err != nil {
t.Fatalf("unmarshal: %v", err)
}
doit := func(u string, p []string) {
t.Helper()
pu := d.Users[u].Permissions.Permissions(&d)
if !permissionsEqual(pu, p) {
t.Errorf("%v: expected %v, got %v", u, p, pu)
}
}
doit("jch", []string{"op", "token", "present", "message", "caption"})
doit("john", []string{"present", "message"})
doit("james", []string{})
d.AllowRecording = true
d.UnrestrictedTokens = false
doit("jch", []string{
"op", "record", "token", "present", "message", "caption",
})
doit("john", []string{"present", "message"})
doit("james", []string{})
d.AllowRecording = false
d.UnrestrictedTokens = true
doit("jch", []string{"op", "token", "present", "message", "caption"})
doit("john", []string{"token", "present", "message"})
doit("james", []string{})
d.AllowRecording = true
d.UnrestrictedTokens = true
doit("jch", []string{
"op", "record", "token", "present", "message", "caption",
})
doit("john", []string{"token", "present", "message"})
doit("james", []string{})
}
func TestUsernameTaken(t *testing.T) {
var g Hall
err := json.Unmarshal([]byte(desc2JSON), &g.description)
if err != nil {
t.Fatalf("unmarshal: %v", err)
}
if g.UserExists("") {
t.Error("UserExists(\"\") is true, expected false")
}
if !g.UserExists("john") {
t.Error("UserExists(john) is false")
}
if !g.UserExists("john") {
t.Error("UserExists(james) is false")
}
if g.UserExists("paul") {
t.Error("UserExists(paul) is true")
}
}
func TestFmtpValue(t *testing.T) {
type fmtpTest struct {
fmtp string
key string
value string
}
fmtpTests := []fmtpTest{
{"", "foo", ""},
{"profile-id=0", "profile-id", "0"},
{"profile-id=0", "foo", ""},
{"level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42001f", "profile-level-id", "42001f"},
{"foo=1;bar=2;quux=3", "foo", "1"},
{"foo=1;bar=2;quux=3", "bar", "2"},
{"foo=1;bar=2;quux=3", "fu", ""},
}
for _, test := range fmtpTests {
v := fmtpValue(test.fmtp, test.key)
if v != test.value {
t.Errorf("fmtpValue(%v, %v) = %v, expected %v",
test.fmtp, test.key, v, test.value,
)
}
}
}
func TestValidHallName(t *testing.T) {
type nameTest struct {
name string
result bool
}
tests := []nameTest{
{"", false},
{"/", false},
{"/foo", false},
{"foo/", false},
{"./foo", false},
{"foo/.", false},
{"../foo", false},
{"foo/..", false},
{"foo/./bar", false},
{"foo/../bar", false},
{"foo\\bar", false},
{"foo", true},
{"foo/bar", true},
}
for _, test := range tests {
r := validHallName(test.name)
if r != test.result {
t.Errorf("Valid %v: got %v, expected %v",
test.name, r, test.result)
}
}
}
func TestPayloadTypeDistinct(t *testing.T) {
names := []string{
"vp8", "vp9", "av1", "h264",
"opus", "g722", "pcmu", "pcma",
}
m := make(map[webrtc.PayloadType]string)
for _, n := range names {
codec, err := codecsFromName(n)
if err != nil {
t.Errorf("%v: %v", n, err)
continue
}
pt, err := CodecPayloadType(codec[0].RTPCodecCapability)
if err != nil {
t.Errorf("%v: %v", codec, err)
continue
}
if other, ok := m[pt]; ok {
t.Errorf(
"Duplicate ptype %v: %v and %v",
pt, n, other,
)
continue
}
m[pt] = n
}
}