server.go 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412
  1. package webapi
  2. import (
  3. "context"
  4. "errors"
  5. "fmt"
  6. "log/slog"
  7. "net/http"
  8. "time"
  9. "golang.org/x/sync/errgroup"
  10. "github.com/mk6i/open-oscar-server/server/webapi/handlers"
  11. "github.com/mk6i/open-oscar-server/server/webapi/middleware"
  12. "github.com/mk6i/open-oscar-server/state"
  13. "github.com/mk6i/open-oscar-server/wire"
  14. )
  15. func NewServer(listeners []string, logger *slog.Logger, handler Handler, apiKeyValidator middleware.APIKeyValidator, sessionManager *state.WebAPISessionManager) *Server {
  16. servers := make([]*http.Server, 0, len(listeners))
  17. authMiddleware := middleware.NewAuthMiddleware(apiKeyValidator, logger)
  18. rateLimiter := handlers.NewRateLimitMiddleware(handler.SNACRateLimits, logger)
  19. authHandler := &handlers.AuthHandler{
  20. AuthService: handler.AuthService,
  21. CookieBaker: handler.CookieBaker,
  22. Logger: logger,
  23. }
  24. sessionHandler := &handlers.SessionHandler{
  25. SessionManager: sessionManager,
  26. OSCARAuthService: handler.AuthService,
  27. FeedbagService: handler.FeedbagService,
  28. BuddyListManager: handler.BuddyListManager.(*handlers.BuddyListManager),
  29. IconSource: handler.IconSource,
  30. Logger: logger,
  31. OServiceService: handler.OServiceService,
  32. }
  33. eventsHandler := &handlers.EventsHandler{
  34. SessionManager: sessionManager,
  35. Logger: logger,
  36. }
  37. presenceHandler := &handlers.PresenceHandler{
  38. SessionManager: sessionManager,
  39. FeedbagService: handler.FeedbagService,
  40. BuddyBroadcaster: handler.BuddyBroadcaster,
  41. LocateService: handler.LocateService,
  42. IconSource: handler.IconSource,
  43. Logger: logger,
  44. }
  45. buddyListHandler := &handlers.BuddyListHandler{
  46. BuddyListManager: handler.BuddyListManager.(*handlers.BuddyListManager),
  47. Logger: logger,
  48. FeedbagService: handler.FeedbagService,
  49. }
  50. messagingHandler := &handlers.MessagingHandler{
  51. SessionManager: sessionManager,
  52. ICBMService: handler.ICBMService,
  53. LocateService: handler.LocateService,
  54. FeedbagService: handler.FeedbagService,
  55. Logger: logger,
  56. }
  57. preferenceHandler := &handlers.PreferenceHandler{
  58. SessionManager: sessionManager,
  59. FeedbagService: handler.FeedbagService,
  60. Logger: logger,
  61. }
  62. memberDirHandler := &handlers.MemberDirHandler{
  63. DirSearchService: handler.DirSearchService,
  64. LocateService: handler.LocateService,
  65. Logger: logger,
  66. }
  67. oscarBridgeHandler := &handlers.OSCARBridgeHandler{
  68. SessionManager: sessionManager,
  69. OSCARAuthService: handler.AuthService,
  70. CookieBaker: handler.CookieBaker,
  71. Config: handler.OSCARConfig,
  72. Logger: logger,
  73. }
  74. shutdownCtx, shutdownCancel := context.WithCancel(context.Background())
  75. for _, l := range listeners {
  76. mux := http.NewServeMux()
  77. // oscarRoute charges the request against the rate class for (foodGroup,
  78. // subGroup) before the handler runs; sessionRoute and stubRoute reach no
  79. // food group and so are not rate limited here.
  80. oscarRoute := func(foodGroup uint16, subGroup uint16, h handlers.SessionHandlerFunc) http.Handler {
  81. return authMiddleware.AuthenticateFlexible(
  82. authMiddleware.CORSMiddleware(
  83. authMiddleware.RequireSession(sessionManager,
  84. rateLimiter.OSCAR(foodGroup, subGroup)(h))))
  85. }
  86. sessionRoute := func(h handlers.SessionHandlerFunc) http.Handler {
  87. return authMiddleware.AuthenticateFlexible(
  88. authMiddleware.CORSMiddleware(
  89. authMiddleware.RequireSession(sessionManager, h)))
  90. }
  91. stubRoute := func(h http.HandlerFunc) http.Handler {
  92. return authMiddleware.AuthenticateFlexible(
  93. authMiddleware.CORSMiddleware(h))
  94. }
  95. // Exact root only. Pattern "GET /" matches every GET path in Go 1.22+ (prefix /), which
  96. // would steal /getAggregated and other lifestream URLs before stubs/404.
  97. mux.Handle("GET /{$}", http.HandlerFunc(handler.GetHelloWorldHandler))
  98. // Authentication endpoint (public - no API key required for user login)
  99. // Using pattern with explicit method for Go 1.22+ routing.
  100. mux.Handle("POST /auth/clientLogin", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  101. // Set CORS headers for public endpoint
  102. w.Header().Set("Access-Control-Allow-Origin", "*")
  103. w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS")
  104. w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
  105. authHandler.ClientLogin(w, r)
  106. }))
  107. // Handle OPTIONS for CORS preflight
  108. mux.HandleFunc("OPTIONS /auth/clientLogin", func(w http.ResponseWriter, r *http.Request) {
  109. w.Header().Set("Access-Control-Allow-Origin", "*")
  110. w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS")
  111. w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
  112. w.WriteHeader(http.StatusNoContent)
  113. })
  114. mux.Handle("GET /auth/getToken", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  115. w.Header().Set("Access-Control-Allow-Origin", "*")
  116. w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS")
  117. w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
  118. authHandler.GetToken(w, r)
  119. }))
  120. mux.HandleFunc("OPTIONS /auth/getToken", func(w http.ResponseWriter, r *http.Request) {
  121. w.Header().Set("Access-Control-Allow-Origin", "*")
  122. w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS")
  123. w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
  124. w.WriteHeader(http.StatusNoContent)
  125. })
  126. // Web AIM navigates the browser here on File > Logout; clear SSO state
  127. // and redirect to the login screen.
  128. mux.Handle("GET /auth/logout", http.HandlerFunc(authHandler.Logout))
  129. mux.Handle("GET /_cqr/login/login.psp", http.HandlerFunc(authHandler.LoginPSP))
  130. mux.Handle("POST /_cqr/login/login.psp", http.HandlerFunc(authHandler.LoginPSP))
  131. // Authenticated Web AIM API endpoints
  132. // SessionInstance management - supports multiple auth methods (k, a, ts+sig_sha256).
  133. mux.Handle("GET /aim/startSession",
  134. authMiddleware.AuthenticateFlexible(
  135. authMiddleware.CORSMiddleware(
  136. http.HandlerFunc(sessionHandler.StartSession))))
  137. // End session - uses aimsid for auth, no k required
  138. mux.Handle("GET /aim/endSession", sessionRoute(sessionHandler.EndSession))
  139. // Event fetching - uses aimsid for auth, no k required. This is the
  140. // long-poll loop the client runs continuously; RecoverOnPoll uses it to
  141. // surface a rate limit "clear" to an idle session that already recovered.
  142. mux.Handle("GET /aim/fetchEvents", authMiddleware.AuthenticateFlexible(
  143. authMiddleware.CORSMiddleware(
  144. authMiddleware.RequireSession(sessionManager,
  145. rateLimiter.RecoverOnPoll(eventsHandler.FetchEvents)))))
  146. // Temp buddies are session-local rather than feedbag-backed, but they
  147. // are the Web API's equivalent of the BUDDY temp buddy SNACs and are
  148. // charged as such.
  149. mux.Handle("GET /aim/addTempBuddy", oscarRoute(wire.Buddy, wire.BuddyAddTempBuddies, buddyListHandler.AddTempBuddy))
  150. mux.Handle("GET /aim/removeTempBuddy", oscarRoute(wire.Buddy, wire.BuddyDelTempBuddies, buddyListHandler.RemoveTempBuddy))
  151. aimStub := &handlers.AimStubHandler{Logger: logger}
  152. mux.Handle("GET /aim/setForwardDomain", stubRoute(aimStub.SetForwardDomain))
  153. mux.Handle("GET /aim/getData", stubRoute(aimStub.GetData))
  154. conversationStub := &handlers.ConversationStubHandler{
  155. SessionManager: sessionManager,
  156. Logger: logger,
  157. }
  158. mux.Handle("GET /conversation/update", stubRoute(conversationStub.Update))
  159. mux.Handle("GET /conversation/close", stubRoute(conversationStub.Close))
  160. mux.Handle("GET /imlog/markRead", stubRoute(conversationStub.MarkRead))
  161. mux.Handle("GET /imlog/fetchStoredIMs", sessionRoute(conversationStub.FetchStoredIMs))
  162. // Presence and buddy list
  163. // GetPresence supports aimsid-based auth, so we use flexible auth
  164. mux.Handle("GET /presence/get", oscarRoute(wire.Feedbag, wire.FeedbagQuery, presenceHandler.GetPresence))
  165. mux.Handle("GET /buddylist/addBuddy", oscarRoute(wire.Feedbag, wire.FeedbagInsertItem, buddyListHandler.AddBuddy))
  166. mux.Handle("GET /buddylist/addGroup", oscarRoute(wire.Feedbag, wire.FeedbagInsertItem, buddyListHandler.AddGroup))
  167. mux.Handle("GET /buddylist/removeBuddy", oscarRoute(wire.Feedbag, wire.FeedbagDeleteItem, buddyListHandler.RemoveBuddy))
  168. mux.Handle("GET /buddylist/removeGroup", oscarRoute(wire.Feedbag, wire.FeedbagDeleteItem, buddyListHandler.RemoveGroup))
  169. mux.Handle("GET /buddylist/renameGroup", oscarRoute(wire.Feedbag, wire.FeedbagUpdateItem, buddyListHandler.RenameGroup))
  170. mux.Handle("GET /buddylist/moveBuddy", oscarRoute(wire.Feedbag, wire.FeedbagUpdateItem, buddyListHandler.MoveBuddy))
  171. mux.Handle("GET /buddylist/setBuddyAttribute", oscarRoute(wire.Feedbag, wire.FeedbagUpdateItem, buddyListHandler.SetBuddyAttribute))
  172. mux.Handle("GET /buddylist/setGroupAttribute", oscarRoute(wire.Feedbag, wire.FeedbagUpdateItem, buddyListHandler.SetGroupAttribute))
  173. // sendIM supports aimsid-based auth, so we use flexible auth.
  174. // The Web AIM client POSTs the message body (non-IE browsers); IE uses GET.
  175. sendIMHandler := oscarRoute(wire.ICBM, wire.ICBMChannelMsgToHost, messagingHandler.SendIM)
  176. mux.Handle("GET /im/sendIM", sendIMHandler)
  177. mux.Handle("POST /im/sendIM", sendIMHandler)
  178. mux.Handle("GET /im/setTyping", oscarRoute(wire.ICBM, wire.ICBMClientEvent, messagingHandler.SetTyping))
  179. // SetState only requires aimsid, no k parameter needed
  180. mux.Handle("GET /presence/setState", oscarRoute(wire.OService, wire.OServiceSetUserInfoFields, presenceHandler.SetState))
  181. // These presence endpoints support aimsid-based auth where k is not required
  182. mux.Handle("GET /presence/setStatus", oscarRoute(wire.OService, wire.OServiceSetUserInfoFields, presenceHandler.SetStatus))
  183. mux.Handle("GET /presence/setProfile", oscarRoute(wire.Locate, wire.LocateSetInfo, presenceHandler.SetProfile))
  184. mux.Handle("GET /presence/getProfile", oscarRoute(wire.Locate, wire.LocateUserInfoQuery, presenceHandler.GetProfile))
  185. // Unauthenticated, like /expressions/get below: buddy icons load as plain
  186. // <img> sources that carry no aimsid.
  187. mux.Handle("GET /presence/icon", http.HandlerFunc(presenceHandler.Icon))
  188. // Member directory search and self directory-info retrieval. Both use
  189. // aimsid-based auth, so we use flexible auth.
  190. mux.Handle("GET /memberDir/search", oscarRoute(wire.ODir, wire.ODirInfoQuery, memberDirHandler.Search))
  191. mux.Handle("GET /memberDir/get", oscarRoute(wire.Locate, wire.LocateGetDirInfo, memberDirHandler.Get))
  192. mux.Handle("GET /memberDir/update", oscarRoute(wire.Locate, wire.LocateSetDirInfo, memberDirHandler.Update))
  193. // These endpoints support aimsid-based auth, so we use a flexible auth approach
  194. mux.Handle("GET /preference/set", oscarRoute(wire.Feedbag, wire.FeedbagUpdateItem, preferenceHandler.SetPreferences))
  195. mux.Handle("GET /preference/get", oscarRoute(wire.Feedbag, wire.FeedbagQuery, preferenceHandler.GetPreferences))
  196. mux.Handle("GET /preference/setPermitDeny", oscarRoute(wire.Feedbag, wire.FeedbagUpdateItem, preferenceHandler.SetPermitDeny))
  197. mux.Handle("GET /preference/getPermitDeny", oscarRoute(wire.Feedbag, wire.FeedbagQuery, preferenceHandler.GetPermitDeny))
  198. // OSCAR Bridge endpoint. Hands off to a BOS session rather than reaching
  199. // a food group, so there is no OSCAR budget to charge.
  200. mux.Handle("GET /aim/startOSCARSession",
  201. authMiddleware.Authenticate(
  202. authMiddleware.CORSMiddleware(
  203. http.HandlerFunc(oscarBridgeHandler.StartOSCARSession))))
  204. // Expressions endpoint (for buddy icons, etc.).
  205. //
  206. // Unauthenticated, like /presence/icon: the buddyIcon URLs this serves are
  207. // published to the client and loaded as plain <img> sources, which carry
  208. // neither an aimsid nor an API key. Threading a session token through them
  209. // instead would leak it into the DOM and defeat caching, since these URLs
  210. // outlive the session that produced them. Buddy icons are public assets.
  211. expressionsHandler := handlers.NewExpressionsHandler(handler.IconSource, logger)
  212. mux.Handle("GET /expressions/get",
  213. authMiddleware.CORSMiddleware(
  214. http.HandlerFunc(expressionsHandler.Get)))
  215. // Web AIM calls lifestream/* on the API host (e.g. /lifestream/getUserDetails).
  216. lifestreamStub := &handlers.UserInfoStubHandler{Logger: logger}
  217. // getUserDetails returns a minimal AIM identity. Every other lifestream/*
  218. // method is an unimplemented social-feed feature; the subtree catch-all
  219. // acknowledges them with an empty 200 so the client doesn't error.
  220. mux.Handle("GET /lifestream/getUserDetails", stubRoute(lifestreamStub.GetUserDetails))
  221. mux.Handle("GET /lifestream/", stubRoute(lifestreamStub.EmptyOK))
  222. // Unmatched paths (pattern "/" matches anything not covered by routes above).
  223. mux.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  224. logger.Debug("webapi 404", "method", r.Method, "path", r.URL.Path)
  225. handlers.SendError(w, http.StatusNotFound, "not found")
  226. }))
  227. servers = append(servers, &http.Server{
  228. Addr: l,
  229. Handler: middleware.RequestLogger(logger, mux),
  230. })
  231. }
  232. sessionHandler.FnSessCfg = func(sess *state.Session) {
  233. sess.OnSessionClose(func() {
  234. ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
  235. defer cancel()
  236. if !shuttingDown(shutdownCtx) {
  237. if err := handler.BuddyBroadcaster.BroadcastBuddyDeparted(ctx, sess.IdentScreenName()); err != nil {
  238. logger.ErrorContext(ctx, "error sending buddy departure notifications", "err", err.Error())
  239. }
  240. }
  241. // buddy list must be cleared before session is closed, otherwise
  242. // there will be a race condition that could cause the buddy list
  243. // be prematurely deleted.
  244. if err := handler.BuddyListRegistry.UnregisterBuddyList(ctx, sess.IdentScreenName()); err != nil {
  245. logger.ErrorContext(ctx, "error removing buddy list entry", "err", err.Error())
  246. }
  247. handler.ChatSessionManager.RemoveUserFromAllChats(sess.IdentScreenName())
  248. handler.AuthService.Signout(ctx, sess)
  249. })
  250. }
  251. sessionHandler.FnSessInit = func(instance *state.SessionInstance) func() error {
  252. return func() error {
  253. // make buddy list visible to other users
  254. if err := handler.BuddyListRegistry.RegisterBuddyList(shutdownCtx, instance.IdentScreenName()); err != nil {
  255. return fmt.Errorf("unable to init buddy list: %w", err)
  256. }
  257. // restore warning level from last session
  258. if err := handler.RecalcWarning(shutdownCtx, instance); err != nil {
  259. return fmt.Errorf("failed to recalculate warning level: %w", err)
  260. }
  261. // periodically decay warning level
  262. go handler.LowerWarnLevel(shutdownCtx, instance)
  263. return nil
  264. }
  265. }
  266. sessionHandler.FnInstanceClose = func(instance *state.SessionInstance) func() {
  267. return func() {
  268. if shuttingDown(shutdownCtx) {
  269. return
  270. }
  271. if instance.Session().Invisible() {
  272. if err := handler.BuddyBroadcaster.BroadcastBuddyDeparted(shutdownCtx, instance.IdentScreenName()); err != nil {
  273. logger.ErrorContext(shutdownCtx, "error sending buddy departure notifications", "err", err.Error())
  274. }
  275. } else {
  276. if err := handler.BuddyBroadcaster.BroadcastBuddyArrived(shutdownCtx, instance.IdentScreenName(), instance.Session().TLVUserInfo()); err != nil {
  277. logger.ErrorContext(shutdownCtx, "error sending buddy arrival notifications", "err", err.Error())
  278. }
  279. }
  280. }
  281. }
  282. return &Server{
  283. servers: servers,
  284. logger: logger,
  285. sessionManager: sessionManager,
  286. shutdownCtx: shutdownCtx,
  287. shutdownCancel: shutdownCancel,
  288. }
  289. }
  290. // Server hosts an HTTP endpoint capable of handling AIM-style Kerberos
  291. // authentication. The messages are structured as SNACs transmitted over HTTP.
  292. //
  293. // shutdownCtx bounds the lifetime of the background session reaper: ListenAndServe
  294. // drives it, and Shutdown (or a failed listener) calls shutdownCancel to unwind.
  295. type Server struct {
  296. servers []*http.Server
  297. logger *slog.Logger
  298. sessionManager *state.WebAPISessionManager
  299. shutdownCtx context.Context
  300. shutdownCancel context.CancelFunc
  301. }
  302. func (s *Server) ListenAndServe() error {
  303. if len(s.servers) == 0 {
  304. s.logger.Debug("no webapi listeners defined")
  305. return nil
  306. }
  307. g, ctx := errgroup.WithContext(s.shutdownCtx)
  308. g.Go(func() error {
  309. s.sessionManager.Run(ctx)
  310. return nil
  311. })
  312. for _, server := range s.servers {
  313. g.Go(func() error {
  314. s.logger.Info("starting server", "addr", server.Addr)
  315. if err := server.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
  316. s.shutdownCancel()
  317. return fmt.Errorf("unable to start webapi server: %w", err)
  318. }
  319. return nil
  320. })
  321. }
  322. return g.Wait()
  323. }
  324. func (s *Server) Shutdown(ctx context.Context) error {
  325. s.logger.Debug("Initiating graceful shutdown...")
  326. s.shutdownCancel() // stop the session reaper so ListenAndServe's errgroup can drain
  327. var errs []error
  328. if err := s.sessionManager.Shutdown(ctx); err != nil {
  329. errs = append(errs, fmt.Errorf("draining webapi sessions: %w", err))
  330. }
  331. for _, srv := range s.servers {
  332. if err := srv.Shutdown(ctx); err != nil {
  333. errs = append(errs, fmt.Errorf("stopping webapi listener %s: %w", srv.Addr, err))
  334. }
  335. }
  336. if err := errors.Join(errs...); err != nil {
  337. s.logger.Error("shutdown incomplete", "err", err.Error())
  338. return err
  339. }
  340. s.logger.Info("shutdown complete")
  341. return nil
  342. }
  343. func shuttingDown(ctx context.Context) bool {
  344. select {
  345. case <-ctx.Done():
  346. // server is shutting down, don't send buddy notifications
  347. return true
  348. default:
  349. }
  350. return false
  351. }