]> git.r.bdr.sh - rbdr/mobius/blame - hotline/server.go
Extensive refactor, quality of life enhancements
[rbdr/mobius] / hotline / server.go
CommitLineData
6988a057
JH
1package hotline
2
3import (
7cd900d6 4 "bufio"
5cc444c8 5 "bytes"
6988a057 6 "context"
f8e4cd54 7 "crypto/rand"
6988a057
JH
8 "encoding/binary"
9 "errors"
10 "fmt"
2e1aec0f 11 "golang.org/x/text/encoding/charmap"
6988a057 12 "io"
a6216dd8
JH
13 "log"
14 "log/slog"
6988a057 15 "net"
6988a057
JH
16 "strings"
17 "sync"
18 "time"
6988a057
JH
19)
20
7cd900d6
JH
21type contextKey string
22
23var contextKeyReq = contextKey("req")
24
25type requestCtx struct {
26 remoteAddr string
7cd900d6
JH
27}
28
2e1aec0f
JH
29// Converts bytes from Mac Roman encoding to UTF-8
30var txtDecoder = charmap.Macintosh.NewDecoder()
31
32// Converts bytes from UTF-8 to Mac Roman encoding
33var txtEncoder = charmap.Macintosh.NewEncoder()
34
6988a057 35type Server struct {
a2ef262a
JH
36 NetInterface string
37 Port int
a2ef262a 38
fd740bc4
JH
39 handlers map[TranType]HandlerFunc
40
41 Config Config
42 Logger *slog.Logger
c1c44744 43
40414f92 44 TrackerPassID [4]byte
00913df3 45
d9bc63a1 46 Stats Counter
6988a057 47
7cd900d6 48 FS FileStore // Storage backend to use for File storage
6988a057
JH
49
50 outbox chan Transaction
51
fd740bc4
JH
52 Agreement io.ReadSeeker
53 Banner []byte
8eb43f95 54
d9bc63a1
JH
55 FileTransferMgr FileTransferMgr
56 ChatMgr ChatManager
57 ClientMgr ClientManager
58 AccountManager AccountManager
59 ThreadedNewsMgr ThreadedNewsMgr
60 BanList BanMgr
46862572 61
d9bc63a1 62 MessageBoard io.ReadWriteSeeker
6988a057
JH
63}
64
fd740bc4 65type Option = func(s *Server)
d9bc63a1 66
fd740bc4
JH
67func WithConfig(config Config) func(s *Server) {
68 return func(s *Server) {
69 s.Config = config
d9bc63a1 70 }
fd740bc4 71}
d9bc63a1 72
fd740bc4
JH
73func WithLogger(logger *slog.Logger) func(s *Server) {
74 return func(s *Server) {
75 s.Logger = logger
d9bc63a1 76 }
fd740bc4 77}
d9bc63a1 78
fd740bc4
JH
79// WithPort optionally overrides the default TCP port.
80func WithPort(port int) func(s *Server) {
81 return func(s *Server) {
82 s.Port = port
d9bc63a1 83 }
fd740bc4 84}
d9bc63a1 85
fd740bc4
JH
86// WithInterface optionally sets a specific interface to listen on.
87func WithInterface(netInterface string) func(s *Server) {
88 return func(s *Server) {
89 s.NetInterface = netInterface
d9bc63a1 90 }
fd740bc4 91}
d9bc63a1 92
fd740bc4
JH
93type ServerConfig struct {
94}
d9bc63a1 95
fd740bc4
JH
96func NewServer(options ...Option) (*Server, error) {
97 server := Server{
98 handlers: make(map[TranType]HandlerFunc),
99 outbox: make(chan Transaction),
100 FS: &OSFileStore{},
101 ChatMgr: NewMemChatManager(),
102 ClientMgr: NewMemClientMgr(),
103 FileTransferMgr: NewMemFileTransferMgr(),
104 Stats: NewStats(),
105 }
d9bc63a1 106
fd740bc4
JH
107 for _, opt := range options {
108 opt(&server)
d9bc63a1 109 }
00913df3 110
fd740bc4
JH
111 // generate a new random passID for tracker registration
112 _, err := rand.Read(server.TrackerPassID[:])
113 if err != nil {
114 return nil, err
115 }
00913df3 116
d9bc63a1 117 return &server, nil
00913df3
JH
118}
119
d9bc63a1
JH
120func (s *Server) CurrentStats() map[string]interface{} {
121 return s.Stats.Values()
6988a057
JH
122}
123
a2ef262a 124func (s *Server) ListenAndServe(ctx context.Context) error {
fd740bc4
JH
125 go s.registerWithTrackers(ctx)
126 go s.keepaliveHandler(ctx)
127 go s.processOutbox()
128
6988a057
JH
129 var wg sync.WaitGroup
130
131 wg.Add(1)
8168522a 132 go func() {
2d0f2abe 133 ln, err := net.Listen("tcp", fmt.Sprintf("%s:%v", s.NetInterface, s.Port))
8168522a 134 if err != nil {
a6216dd8 135 log.Fatal(err)
8168522a
JH
136 }
137
a6216dd8 138 log.Fatal(s.Serve(ctx, ln))
8168522a 139 }()
6988a057
JH
140
141 wg.Add(1)
8168522a 142 go func() {
2d0f2abe 143 ln, err := net.Listen("tcp", fmt.Sprintf("%s:%v", s.NetInterface, s.Port+1))
8168522a 144 if err != nil {
a6216dd8 145 log.Fatal(err)
8168522a
JH
146 }
147
a6216dd8 148 log.Fatal(s.ServeFileTransfers(ctx, ln))
8168522a 149 }()
6988a057
JH
150
151 wg.Wait()
152
153 return nil
154}
155
7cd900d6 156func (s *Server) ServeFileTransfers(ctx context.Context, ln net.Listener) error {
6988a057
JH
157 for {
158 conn, err := ln.Accept()
159 if err != nil {
160 return err
161 }
162
163 go func() {
7cd900d6
JH
164 defer func() { _ = conn.Close() }()
165
166 err = s.handleFileTransfer(
a2ef262a 167 context.WithValue(ctx, contextKeyReq, requestCtx{remoteAddr: conn.RemoteAddr().String()}),
7cd900d6
JH
168 conn,
169 )
170
171 if err != nil {
a6216dd8 172 s.Logger.Error("file transfer error", "reason", err)
6988a057
JH
173 }
174 }()
175 }
176}
177
178func (s *Server) sendTransaction(t Transaction) error {
fd740bc4 179 client := s.ClientMgr.Get(t.ClientID)
a2ef262a 180
d9bc63a1 181 if client == nil {
a2ef262a 182 return nil
6988a057 183 }
6988a057 184
a2ef262a 185 _, err := io.Copy(client.Connection, &t)
75e4191b 186 if err != nil {
fd740bc4 187 return fmt.Errorf("failed to send transaction to client %v: %v", t.ClientID, err)
6988a057 188 }
3178ae58 189
6988a057
JH
190 return nil
191}
192
67db911d
JH
193func (s *Server) processOutbox() {
194 for {
195 t := <-s.outbox
196 go func() {
197 if err := s.sendTransaction(t); err != nil {
a6216dd8 198 s.Logger.Error("error sending transaction", "err", err)
67db911d
JH
199 }
200 }()
201 }
202}
203
7cd900d6 204func (s *Server) Serve(ctx context.Context, ln net.Listener) error {
6988a057 205 for {
fd740bc4
JH
206 select {
207 case <-ctx.Done():
208 s.Logger.Info("Server shutting down")
209 return ctx.Err()
210 default:
211 conn, err := ln.Accept()
212 if err != nil {
213 s.Logger.Error("Error accepting connection", "err", err)
214 continue
215 }
6988a057 216
fd740bc4
JH
217 go func() {
218 connCtx := context.WithValue(ctx, contextKeyReq, requestCtx{
219 remoteAddr: conn.RemoteAddr().String(),
220 })
221
222 s.Logger.Info("Connection established", "addr", conn.RemoteAddr())
223 defer conn.Close()
224
225 if err := s.handleNewConnection(connCtx, conn, conn.RemoteAddr().String()); err != nil {
226 if err == io.EOF {
227 s.Logger.Info("Client disconnected", "RemoteAddr", conn.RemoteAddr())
228 } else {
229 s.Logger.Error("Error serving request", "RemoteAddr", conn.RemoteAddr(), "err", err)
230 }
6988a057 231 }
fd740bc4
JH
232 }()
233 }
6988a057
JH
234 }
235}
236
fd740bc4
JH
237// time in seconds between tracker re-registration
238const trackerUpdateFrequency = 300
239
240// registerWithTrackers runs every trackerUpdateFrequency seconds to update the server's tracker entry on all configured
241// trackers.
242func (s *Server) registerWithTrackers(ctx context.Context) {
243 ticker := time.NewTicker(trackerUpdateFrequency * time.Second)
244 defer ticker.Stop()
6988a057 245
d9bc63a1 246 for {
fd740bc4
JH
247 select {
248 case <-ctx.Done():
249 return
250 case <-ticker.C:
251 if s.Config.EnableTrackerRegistration {
252 tr := &TrackerRegistration{
253 UserCount: len(s.ClientMgr.List()),
254 PassID: s.TrackerPassID,
255 Name: s.Config.Name,
256 Description: s.Config.Description,
257 }
258 binary.BigEndian.PutUint16(tr.Port[:], uint16(s.Port))
259
260 for _, t := range s.Config.Trackers {
261 if err := register(&RealDialer{}, t, tr); err != nil {
262 s.Logger.Error(fmt.Sprintf("Unable to register with tracker %v", t), "error", err)
263 }
264 }
6988a057 265 }
d9bc63a1 266 }
d9bc63a1 267 }
6988a057 268
fd740bc4 269}
d9bc63a1 270
fd740bc4
JH
271const (
272 userIdleSeconds = 300 // time in seconds before an inactive user is marked idle
273 idleCheckInterval = 10 // time in seconds to check for idle users
274)
6988a057 275
fd740bc4
JH
276// keepaliveHandler runs every idleCheckInterval seconds and increments a user's idle time by idleCheckInterval seconds.
277// If the updated idle time exceeds userIdleSeconds and the user was not previously idle, we notify all connected clients
278// that the user has gone idle. For most clients, this turns the user grey in the user list.
279func (s *Server) keepaliveHandler(ctx context.Context) {
280 ticker := time.NewTicker(idleCheckInterval * time.Second)
281 defer ticker.Stop()
d9bc63a1 282
fd740bc4
JH
283 for {
284 select {
285 case <-ctx.Done():
286 return
287 case <-ticker.C:
288 for _, c := range s.ClientMgr.List() {
289 c.mu.Lock()
290 c.IdleTime += idleCheckInterval
291
292 // Check if the user
293 if c.IdleTime > userIdleSeconds && !c.Flags.IsSet(UserFlagAway) {
294 c.Flags.Set(UserFlagAway, 1)
295
296 c.SendAll(
297 TranNotifyChangeUser,
298 NewField(FieldUserID, c.ID[:]),
299 NewField(FieldUserFlags, c.Flags[:]),
300 NewField(FieldUserName, c.UserName),
301 NewField(FieldUserIconID, c.Icon),
302 )
303 }
304 c.mu.Unlock()
6988a057
JH
305 }
306 }
46862572 307 }
6988a057
JH
308}
309
67db911d 310func (s *Server) NewClientConn(conn io.ReadWriteCloser, remoteAddr string) *ClientConn {
6988a057 311 clientConn := &ClientConn{
a2ef262a 312 Icon: []byte{0, 0}, // TODO: make array type
6988a057
JH
313 Connection: conn,
314 Server: s,
d4c152a4 315 RemoteAddr: remoteAddr,
d2810ae9 316
d9bc63a1 317 ClientFileTransferMgr: NewClientFileTransferMgr(),
180d6544
JH
318 }
319
d9bc63a1 320 s.ClientMgr.Add(clientConn)
6988a057 321
d9bc63a1 322 return clientConn
6988a057
JH
323}
324
a2ef262a
JH
325func sendBanMessage(rwc io.Writer, message string) {
326 t := NewTransaction(
327 TranServerMsg,
328 [2]byte{0, 0},
329 NewField(FieldData, []byte(message)),
330 NewField(FieldChatOptions, []byte{0, 0}),
331 )
332 _, _ = io.Copy(rwc, &t)
333 time.Sleep(1 * time.Second)
334}
335
6988a057 336// handleNewConnection takes a new net.Conn and performs the initial login sequence
3178ae58 337func (s *Server) handleNewConnection(ctx context.Context, rwc io.ReadWriteCloser, remoteAddr string) error {
c4208f86
JH
338 defer dontPanic(s.Logger)
339
fd740bc4
JH
340 if err := performHandshake(rwc); err != nil {
341 return fmt.Errorf("perform handshake: %w", err)
342 }
343
a2ef262a
JH
344 // Check if remoteAddr is present in the ban list
345 ipAddr := strings.Split(remoteAddr, ":")[0]
d9bc63a1 346 if isBanned, banUntil := s.BanList.IsBanned(ipAddr); isBanned {
a2ef262a
JH
347 // permaban
348 if banUntil == nil {
349 sendBanMessage(rwc, "You are permanently banned on this server")
350 s.Logger.Debug("Disconnecting permanently banned IP", "remoteAddr", ipAddr)
351 return nil
352 }
353
354 // temporary ban
355 if time.Now().Before(*banUntil) {
356 sendBanMessage(rwc, "You are temporarily banned on this server")
357 s.Logger.Debug("Disconnecting temporarily banned IP", "remoteAddr", ipAddr)
358 return nil
359 }
360 }
361
3178ae58
JH
362 // Create a new scanner for parsing incoming bytes into transaction tokens
363 scanner := bufio.NewScanner(rwc)
364 scanner.Split(transactionScanner)
365
366 scanner.Scan()
6988a057 367
f4cdaddc
JH
368 // Make a new []byte slice and copy the scanner bytes to it. This is critical to avoid a data race as the
369 // scanner re-uses the buffer for subsequent scans.
370 buf := make([]byte, len(scanner.Bytes()))
371 copy(buf, scanner.Bytes())
372
854a92fc 373 var clientLogin Transaction
f4cdaddc 374 if _, err := clientLogin.Write(buf); err != nil {
a2ef262a 375 return fmt.Errorf("error writing login transaction: %w", err)
46862572 376 }
e9c043c0
JH
377
378 c := s.NewClientConn(rwc, remoteAddr)
6988a057 379 defer c.Disconnect()
6988a057 380
d005ef04
JH
381 encodedPassword := clientLogin.GetField(FieldUserPassword).Data
382 c.Version = clientLogin.GetField(FieldVersion).Data
6988a057 383
d9bc63a1 384 login := clientLogin.GetField(FieldUserLogin).DecodeObfuscatedString()
6988a057
JH
385 if login == "" {
386 login = GuestAccount
387 }
388
fd740bc4 389 c.Logger = s.Logger.With("ip", ipAddr, "login", login)
0da28a1f 390
6988a057
JH
391 // If authentication fails, send error reply and close connection
392 if !c.Authenticate(login, encodedPassword) {
a2ef262a 393 t := c.NewErrReply(&clientLogin, "Incorrect login.")[0]
95159e55
JH
394
395 _, err := io.Copy(rwc, &t)
72dd37f1
JH
396 if err != nil {
397 return err
398 }
0da28a1f 399
fd740bc4 400 c.Logger.Info("Login failed", "clientVersion", fmt.Sprintf("%x", c.Version))
0da28a1f
JH
401
402 return nil
6988a057
JH
403 }
404
d005ef04
JH
405 if clientLogin.GetField(FieldUserIconID).Data != nil {
406 c.Icon = clientLogin.GetField(FieldUserIconID).Data
59097464
JH
407 }
408
d9bc63a1
JH
409 c.Account = c.Server.AccountManager.Get(login)
410 if c.Account == nil {
411 return nil
412 }
59097464 413
d005ef04 414 if clientLogin.GetField(FieldUserName).Data != nil {
d9bc63a1 415 if c.Authorize(AccessAnyName) {
d005ef04 416 c.UserName = clientLogin.GetField(FieldUserName).Data
ea5d8c51
JH
417 } else {
418 c.UserName = []byte(c.Account.Name)
419 }
6988a057
JH
420 }
421
d9bc63a1 422 if c.Authorize(AccessDisconUser) {
a2ef262a 423 c.Flags.Set(UserFlagAdmin, 1)
6988a057
JH
424 }
425
854a92fc 426 s.outbox <- c.NewReply(&clientLogin,
d005ef04
JH
427 NewField(FieldVersion, []byte{0x00, 0xbe}),
428 NewField(FieldCommunityBannerID, []byte{0, 0}),
429 NewField(FieldServerName, []byte(s.Config.Name)),
6988a057
JH
430 )
431
432 // Send user access privs so client UI knows how to behave
a2ef262a 433 c.Server.outbox <- NewTransaction(TranUserAccess, c.ID, NewField(FieldUserAccess, c.Account.Access[:]))
6988a057 434
d9bc63a1 435 // Accounts with AccessNoAgreement do not receive the server agreement on login. The behavior is different between
d005ef04
JH
436 // client versions. For 1.2.3 client, we do not send TranShowAgreement. For other client versions, we send
437 // TranShowAgreement but with the NoServerAgreement field set to 1.
d9bc63a1 438 if c.Authorize(AccessNoAgreement) {
a322be02
JH
439 // If client version is nil, then the client uses the 1.2.3 login behavior
440 if c.Version != nil {
a2ef262a 441 c.Server.outbox <- NewTransaction(TranShowAgreement, c.ID, NewField(FieldNoServerAgreement, []byte{1}))
a322be02 442 }
688c86d2 443 } else {
fd740bc4
JH
444 _, _ = c.Server.Agreement.Seek(0, 0)
445 data, _ := io.ReadAll(c.Server.Agreement)
446
447 c.Server.outbox <- NewTransaction(TranShowAgreement, c.ID, NewField(FieldData, data))
688c86d2 448 }
6988a057 449
2f8472fa
JH
450 // If the client has provided a username as part of the login, we can infer that it is using the 1.2.3 login
451 // flow and not the 1.5+ flow.
452 if len(c.UserName) != 0 {
453 // Add the client username to the logger. For 1.5+ clients, we don't have this information yet as it comes as
d005ef04 454 // part of TranAgreed
fd740bc4
JH
455 c.Logger = c.Logger.With("name", string(c.UserName))
456 c.Logger.Info("Login successful")
2f8472fa
JH
457
458 // Notify other clients on the server that the new user has logged in. For 1.5+ clients we don't have this
d005ef04 459 // information yet, so we do it in TranAgreed instead
d9bc63a1 460 for _, t := range c.NotifyOthers(
a2ef262a
JH
461 NewTransaction(
462 TranNotifyChangeUser, [2]byte{0, 0},
d005ef04 463 NewField(FieldUserName, c.UserName),
a2ef262a 464 NewField(FieldUserID, c.ID[:]),
d005ef04 465 NewField(FieldUserIconID, c.Icon),
a2ef262a 466 NewField(FieldUserFlags, c.Flags[:]),
2f8472fa
JH
467 ),
468 ) {
469 c.Server.outbox <- t
470 }
6988a057 471 }
bd1ce113 472
d9bc63a1
JH
473 c.Server.Stats.Increment(StatConnectionCounter, StatCurrentlyConnected)
474 defer c.Server.Stats.Decrement(StatCurrentlyConnected)
475
476 if len(s.ClientMgr.List()) > c.Server.Stats.Get(StatConnectionPeak) {
477 c.Server.Stats.Set(StatConnectionPeak, len(s.ClientMgr.List()))
00913df3 478 }
6988a057 479
3178ae58
JH
480 // Scan for new transactions and handle them as they come in.
481 for scanner.Scan() {
a2ef262a 482 // Copy the scanner bytes to a new slice to it to avoid a data race when the scanner re-uses the buffer.
fd740bc4
JH
483 tmpBuf := make([]byte, len(scanner.Bytes()))
484 copy(tmpBuf, scanner.Bytes())
6988a057 485
854a92fc 486 var t Transaction
fd740bc4 487 if _, err := t.Write(tmpBuf); err != nil {
854a92fc 488 return err
6988a057 489 }
854a92fc 490
a2ef262a 491 c.handleTransaction(t)
6988a057 492 }
3178ae58 493 return nil
6988a057
JH
494}
495
85767504 496// handleFileTransfer receives a client net.Conn from the file transfer server, performs the requested transfer type, then closes the connection
7cd900d6 497func (s *Server) handleFileTransfer(ctx context.Context, rwc io.ReadWriter) error {
37a954c8 498 defer dontPanic(s.Logger)
0a92e50b 499
a2ef262a 500 // The first 16 bytes contain the file transfer.
df2735b2 501 var t transfer
a2ef262a
JH
502 if _, err := io.CopyN(&t, rwc, 16); err != nil {
503 return fmt.Errorf("error reading file transfer: %w", err)
6988a057
JH
504 }
505
d9bc63a1
JH
506 fileTransfer := s.FileTransferMgr.Get(t.ReferenceNumber)
507 if fileTransfer == nil {
508 return errors.New("invalid transaction ID")
509 }
510
0a92e50b 511 defer func() {
d9bc63a1 512 s.FileTransferMgr.Delete(t.ReferenceNumber)
df1ade54 513
94742e2f
JH
514 // Wait a few seconds before closing the connection: this is a workaround for problems
515 // observed with Windows clients where the client must initiate close of the TCP connection before
516 // the server does. This is gross and seems unnecessary. TODO: Revisit?
517 time.Sleep(3 * time.Second)
0a92e50b 518 }()
6988a057 519
7cd900d6
JH
520 rLogger := s.Logger.With(
521 "remoteAddr", ctx.Value(contextKeyReq).(requestCtx).remoteAddr,
df1ade54 522 "login", fileTransfer.ClientConn.Account.Login,
95159e55 523 "Name", string(fileTransfer.ClientConn.UserName),
7cd900d6
JH
524 )
525
fd740bc4 526 fullPath, err := ReadPath(s.Config.FileRoot, fileTransfer.FilePath, fileTransfer.FileName)
df1ade54
JH
527 if err != nil {
528 return err
529 }
530
6988a057 531 switch fileTransfer.Type {
d9bc63a1 532 case BannerDownload:
fd740bc4
JH
533 if _, err := io.Copy(rwc, bytes.NewBuffer(s.Banner)); err != nil {
534 return fmt.Errorf("error sending Banner: %w", err)
9067f234 535 }
6988a057 536 case FileDownload:
d9bc63a1 537 s.Stats.Increment(StatDownloadCounter, StatDownloadsInProgress)
94742e2f 538 defer func() {
d9bc63a1 539 s.Stats.Decrement(StatDownloadsInProgress)
94742e2f 540 }()
23411fc2 541
a2ef262a 542 err = DownloadHandler(rwc, fullPath, fileTransfer, s.FS, rLogger, true)
6988a057 543 if err != nil {
d9bc63a1 544 return fmt.Errorf("file download: %w", err)
7cd900d6
JH
545 }
546
6988a057 547 case FileUpload:
d9bc63a1
JH
548 s.Stats.Increment(StatUploadCounter, StatUploadsInProgress)
549 defer func() {
550 s.Stats.Decrement(StatUploadsInProgress)
551 }()
23411fc2 552
a2ef262a 553 err = UploadHandler(rwc, fullPath, fileTransfer, s.FS, rLogger, s.Config.PreserveResourceForks)
7cd900d6 554 if err != nil {
a2ef262a 555 return fmt.Errorf("file upload error: %w", err)
6988a057 556 }
85767504 557
6988a057 558 case FolderDownload:
d9bc63a1
JH
559 s.Stats.Increment(StatDownloadCounter, StatDownloadsInProgress)
560 defer func() {
561 s.Stats.Decrement(StatDownloadsInProgress)
562 }()
00913df3 563
a2ef262a 564 err = DownloadFolderHandler(rwc, fullPath, fileTransfer, s.FS, rLogger, s.Config.PreserveResourceForks)
67db911d 565 if err != nil {
a2ef262a 566 return fmt.Errorf("file upload error: %w", err)
67db911d
JH
567 }
568
6988a057 569 case FolderUpload:
d9bc63a1
JH
570 s.Stats.Increment(StatUploadCounter, StatUploadsInProgress)
571 defer func() {
572 s.Stats.Decrement(StatUploadsInProgress)
573 }()
574
a6216dd8 575 rLogger.Info(
6988a057 576 "Folder upload started",
df1ade54
JH
577 "dstPath", fullPath,
578 "TransferSize", binary.BigEndian.Uint32(fileTransfer.TransferSize),
6988a057
JH
579 "FolderItemCount", fileTransfer.FolderItemCount,
580 )
581
a2ef262a
JH
582 err = UploadFolderHandler(rwc, fullPath, fileTransfer, s.FS, rLogger, s.Config.PreserveResourceForks)
583 if err != nil {
584 return fmt.Errorf("file upload error: %w", err)
6988a057 585 }
6988a057 586 }
6988a057
JH
587 return nil
588}