package hotline
import (
+ "encoding/binary"
"github.com/jhalter/mobius/concat"
+ "golang.org/x/crypto/bcrypt"
)
const GuestAccount = "guest" // default account used when no login is provided for a connection
// MarshalBinary marshals an Account to byte slice
func (a *Account) MarshalBinary() (out []byte) {
+ fields := []Field{
+ NewField(fieldUserName, []byte(a.Name)),
+ NewField(fieldUserLogin, negateString([]byte(a.Login))),
+ NewField(fieldUserAccess, *a.Access),
+ }
+
+ if bcrypt.CompareHashAndPassword([]byte(a.Password), []byte("")) != nil {
+ fields = append(fields, NewField(fieldUserPassword, []byte("x")))
+ }
+
+ fieldCount := make([]byte, 2)
+ binary.BigEndian.PutUint16(fieldCount, uint16(len(fields)))
+
+ var fieldPayload []byte
+ for _, field := range fields {
+ fieldPayload = append(fieldPayload, field.Payload()...)
+ }
+
return concat.Slices(
- []byte{0x00, 0x3}, // param count -- always 3
- NewField(fieldUserName, []byte(a.Name)).Payload(),
- NewField(fieldUserLogin, negateString([]byte(a.Login))).Payload(),
- NewField(fieldUserAccess, *a.Access).Payload(),
+ fieldCount,
+ fieldPayload,
)
}
func (f Field) Payload() []byte {
return concat.Slices(f.ID, f.FieldSize, f.Data)
}
+
+func getField(id int, fields *[]Field) *Field {
+ for _, field := range *fields {
+ if id == int(binary.BigEndian.Uint16(field.ID)) {
+ return &field
+ }
+ }
+ return nil
+}
return FS.WriteFile(s.ConfigDir+"Users/"+login+".yaml", out, 0666)
}
+func (s *Server) UpdateUser(login, newLogin, name, password string, access []byte) error {
+ s.mux.Lock()
+ defer s.mux.Unlock()
+
+ fmt.Printf("login: %v, newLogin: %v: ", login, newLogin)
+
+ // update renames the user login
+ if login != newLogin {
+ err := os.Rename(s.ConfigDir+"Users/"+login+".yaml", s.ConfigDir+"Users/"+newLogin+".yaml")
+ if err != nil {
+ return err
+ }
+ s.Accounts[newLogin] = s.Accounts[login]
+ delete(s.Accounts, login)
+ }
+
+ account := s.Accounts[newLogin]
+ account.Access = &access
+ account.Name = name
+ account.Password = password
+
+ out, err := yaml.Marshal(&account)
+ if err != nil {
+ return err
+ }
+ if err := os.WriteFile(s.ConfigDir+"Users/"+newLogin+".yaml", out, 0666); err != nil {
+ return err
+ }
+
+ return nil
+}
+
// DeleteUser deletes the user account
func (s *Server) DeleteUser(login string) error {
s.mux.Lock()
tranDownloadFldr = 210
// tranDownloadInfo = 211 TODO: implement file transfer queue
// tranDownloadBanner = 212 TODO: figure out what this is used for
- tranUploadFldr = 213
- tranGetUserNameList = 300
- tranNotifyChangeUser = 301
- tranNotifyDeleteUser = 302
- tranGetClientInfoText = 303
- tranSetClientUserInfo = 304
- tranListUsers = 348
- // tranUpdateUser = 349 TODO: implement user updates from the > 1.5 account editor
+ tranUploadFldr = 213
+ tranGetUserNameList = 300
+ tranNotifyChangeUser = 301
+ tranNotifyDeleteUser = 302
+ tranGetClientInfoText = 303
+ tranSetClientUserInfo = 304
+ tranListUsers = 348
+ tranUpdateUser = 349
tranNewUser = 350
tranDeleteUser = 351
tranGetUser = 352
Name: "tranNewUser",
Handler: HandleNewUser,
},
+ tranUpdateUser: {
+ Access: accessAlwaysAllow,
+ Name: "tranUpdateUser",
+ Handler: HandleUpdateUser,
+ },
tranOldPostNews: {
Access: accessNewsPostArt,
DenyMsg: "You are not allowed to post news.",
account.Password = hashAndSalt(t.GetField(fieldUserPassword).Data)
}
- file := cc.Server.ConfigDir + "Users/" + login + ".yaml"
out, err := yaml.Marshal(&account)
if err != nil {
return res, err
}
- if err := ioutil.WriteFile(file, out, 0666); err != nil {
+ if err := os.WriteFile(cc.Server.ConfigDir+"Users/"+login+".yaml", out, 0666); err != nil {
return res, err
}
}
var userFields []Field
- // TODO: make order deterministic
for _, acc := range cc.Server.Accounts {
userField := acc.MarshalBinary()
userFields = append(userFields, NewField(fieldData, userField))
return res, err
}
+// HandleUpdateUser is used by the v1.5+ multi-user editor to perform account editing for multiple users at a time.
+// An update can be a mix of these actions:
+// * Create user
+// * Delete user
+// * Modify user (including renaming the account login)
+//
+// The Transaction sent by the client includes one data field per user that was modified. This data field in turn
+// contains another data field encoded in its payload with a varying number of sub fields depending on which action is
+// performed. This seems to be the only place in the Hotline protocol where a data field contains another data field.
+func HandleUpdateUser(cc *ClientConn, t *Transaction) (res []Transaction, err error) {
+ for _, field := range t.Fields {
+ subFields, err := ReadFields(field.Data[0:2], field.Data[2:])
+ if err != nil {
+ return res, err
+ }
+
+ if len(subFields) == 1 {
+ login := DecodeUserString(getField(fieldData, &subFields).Data)
+ cc.Server.Logger.Infow("DeleteUser", "login", login)
+
+ if !authorize(cc.Account.Access, accessDeleteUser) {
+ res = append(res, cc.NewErrReply(t, "You are not allowed to delete accounts."))
+ return res, err
+ }
+
+ if err := cc.Server.DeleteUser(login); err != nil {
+ return res, err
+ }
+ continue
+ }
+
+ login := DecodeUserString(getField(fieldUserLogin, &subFields).Data)
+
+ // check if the login exists; if so, we know we are updating an existing user
+ if acc, ok := cc.Server.Accounts[login]; ok {
+ cc.Server.Logger.Infow("UpdateUser", "login", login)
+
+ // account exists, so this is an update action
+ if !authorize(cc.Account.Access, accessModifyUser) {
+ res = append(res, cc.NewErrReply(t, "You are not allowed to modify accounts."))
+ return res, err
+ }
+
+ if getField(fieldUserPassword, &subFields) != nil {
+ newPass := getField(fieldUserPassword, &subFields).Data
+ acc.Password = hashAndSalt(newPass)
+ } else {
+ acc.Password = hashAndSalt([]byte(""))
+ }
+
+ if getField(fieldUserAccess, &subFields) != nil {
+ acc.Access = &getField(fieldUserAccess, &subFields).Data
+ }
+
+ err = cc.Server.UpdateUser(
+ DecodeUserString(getField(fieldData, &subFields).Data),
+ DecodeUserString(getField(fieldUserLogin, &subFields).Data),
+ string(getField(fieldUserName, &subFields).Data),
+ acc.Password,
+ *acc.Access,
+ )
+ if err != nil {
+ return res, err
+ }
+ } else {
+ cc.Server.Logger.Infow("CreateUser", "login", login)
+
+ if !authorize(cc.Account.Access, accessCreateUser) {
+ res = append(res, cc.NewErrReply(t, "You are not allowed to create new accounts."))
+ return res, err
+ }
+
+ err := cc.Server.NewUser(
+ login,
+ string(getField(fieldUserName, &subFields).Data),
+ string(getField(fieldUserPassword, &subFields).Data),
+ getField(fieldUserAccess, &subFields).Data,
+ )
+ if err != nil {
+ return []Transaction{}, err
+ }
+ }
+ }
+
+ res = append(res, cc.NewReply(t))
+ return res, err
+}
+
// HandleNewUser creates a new user account
func HandleNewUser(cc *ClientConn, t *Transaction) (res []Transaction, err error) {
if !authorize(cc.Account.Access, accessCreateUser) {
})
}
}
+
+func TestHandleUpdateUser(t *testing.T) {
+ type args struct {
+ cc *ClientConn
+ t *Transaction
+ }
+ tests := []struct {
+ name string
+ args args
+ wantRes []Transaction
+ wantErr assert.ErrorAssertionFunc
+ }{
+ {
+ name: "when action is create user without required permission",
+ args: args{
+ cc: &ClientConn{
+ Server: &Server{
+ Logger: NewTestLogger(),
+ },
+ Account: &Account{
+ Access: func() *[]byte {
+ var bits accessBitmap
+ access := bits[:]
+ return &access
+ }(),
+ },
+ },
+ t: NewTransaction(
+ tranUpdateUser,
+ &[]byte{0, 0},
+ NewField(fieldData, []byte{
+ 0x00, 0x04, // field count
+
+ 0x00, 0x69, // fieldUserLogin = 105
+ 0x00, 0x03,
+ 0x9d, 0x9d, 0x9d,
+
+ 0x00, 0x6a, // fieldUserPassword = 106
+ 0x00, 0x03,
+ 0x9c, 0x9c, 0x9c,
+
+ 0x00, 0x66, // fieldUserName = 102
+ 0x00, 0x03,
+ 0x61, 0x61, 0x61,
+
+ 0x00, 0x6e, // fieldUserAccess = 110
+ 0x00, 0x08,
+ 0x60, 0x70, 0x0c, 0x20, 0x03, 0x80, 0x00, 0x00,
+ }),
+ ),
+ },
+ wantRes: []Transaction{
+ {
+ Flags: 0x00,
+ IsReply: 0x01,
+ Type: []byte{0, 0x00},
+ ID: []byte{0x9a, 0xcb, 0x04, 0x42},
+ ErrorCode: []byte{0, 0, 0, 1},
+ Fields: []Field{
+ NewField(fieldError, []byte("You are not allowed to create new accounts.")),
+ },
+ },
+ },
+ wantErr: assert.NoError,
+ },
+ {
+ name: "when action is modify user without required permission",
+ args: args{
+ cc: &ClientConn{
+ Server: &Server{
+ Logger: NewTestLogger(),
+ Accounts: map[string]*Account{
+ "bbb": {},
+ },
+ },
+ Account: &Account{
+ Access: func() *[]byte {
+ var bits accessBitmap
+ access := bits[:]
+ return &access
+ }(),
+ },
+ },
+ t: NewTransaction(
+ tranUpdateUser,
+ &[]byte{0, 0},
+ NewField(fieldData, []byte{
+ 0x00, 0x04, // field count
+
+ 0x00, 0x69, // fieldUserLogin = 105
+ 0x00, 0x03,
+ 0x9d, 0x9d, 0x9d,
+
+ 0x00, 0x6a, // fieldUserPassword = 106
+ 0x00, 0x03,
+ 0x9c, 0x9c, 0x9c,
+
+ 0x00, 0x66, // fieldUserName = 102
+ 0x00, 0x03,
+ 0x61, 0x61, 0x61,
+
+ 0x00, 0x6e, // fieldUserAccess = 110
+ 0x00, 0x08,
+ 0x60, 0x70, 0x0c, 0x20, 0x03, 0x80, 0x00, 0x00,
+ }),
+ ),
+ },
+ wantRes: []Transaction{
+ {
+ Flags: 0x00,
+ IsReply: 0x01,
+ Type: []byte{0, 0x00},
+ ID: []byte{0x9a, 0xcb, 0x04, 0x42},
+ ErrorCode: []byte{0, 0, 0, 1},
+ Fields: []Field{
+ NewField(fieldError, []byte("You are not allowed to modify accounts.")),
+ },
+ },
+ },
+ wantErr: assert.NoError,
+ },
+ {
+ name: "when action is delete user without required permission",
+ args: args{
+ cc: &ClientConn{
+ Server: &Server{
+ Logger: NewTestLogger(),
+ Accounts: map[string]*Account{
+ "bbb": {},
+ },
+ },
+ Account: &Account{
+ Access: func() *[]byte {
+ var bits accessBitmap
+ access := bits[:]
+ return &access
+ }(),
+ },
+ },
+ t: NewTransaction(
+ tranUpdateUser,
+ &[]byte{0, 0},
+ NewField(fieldData, []byte{
+ 0x00, 0x01,
+ 0x00, 0x65,
+ 0x00, 0x03,
+ 0x88, 0x9e, 0x8b,
+ }),
+ ),
+ },
+ wantRes: []Transaction{
+ {
+ Flags: 0x00,
+ IsReply: 0x01,
+ Type: []byte{0, 0x00},
+ ID: []byte{0x9a, 0xcb, 0x04, 0x42},
+ ErrorCode: []byte{0, 0, 0, 1},
+ Fields: []Field{
+ NewField(fieldError, []byte("You are not allowed to delete accounts.")),
+ },
+ },
+ },
+ wantErr: assert.NoError,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ gotRes, err := HandleUpdateUser(tt.args.cc, tt.args.t)
+ if !tt.wantErr(t, err, fmt.Sprintf("HandleUpdateUser(%v, %v)", tt.args.cc, tt.args.t)) {
+ return
+ }
+
+ tranAssertEqual(t, tt.wantRes, gotRes)
+ })
+ }
+}
package hotline
-const VERSION = "0.3.0"
+const VERSION = "0.4.0"