package irc import ( "crypto/subtle" "encoding/json" "fmt" "net/http" "runtime" "strings" "github.com/ergochat/ergo/irc/modes" "github.com/ergochat/ergo/irc/sno" "github.com/ergochat/ergo/irc/utils" ) func newAPIHandler(server *Server) http.Handler { api := &ergoAPI{ server: server, mux: http.NewServeMux(), } // server-level functionality: api.mux.HandleFunc("POST /v1/rehash", api.handleRehash) api.mux.HandleFunc("POST /v1/status", api.handleStatus) api.mux.HandleFunc("POST /v1/list", api.handleList) api.mux.HandleFunc("POST /v1/defcon", api.handleDefcon) // use Ergo as a source of truth for authentication in other services: api.mux.HandleFunc("POST /v1/check_auth", api.handleCheckAuth) // legacy names for /v1/ns endpoints: api.mux.HandleFunc("POST /v1/saregister", api.handleSaregister) api.mux.HandleFunc("POST /v1/account_details", api.handleAccountDetails) api.mux.HandleFunc("POST /v1/account_list", api.handleAccountList) // /v1/ns: nickserv functionality api.mux.HandleFunc("POST /v1/ns/info", api.handleAccountDetails) api.mux.HandleFunc("POST /v1/ns/list", api.handleAccountList) api.mux.HandleFunc("POST /v1/ns/passwd", api.handleNsPasswd) api.mux.HandleFunc("POST /v1/ns/saregister", api.handleSaregister) return api } type ergoAPI struct { server *Server mux *http.ServeMux } func (a *ergoAPI) ServeHTTP(w http.ResponseWriter, r *http.Request) { defer a.server.HandlePanic(nil) defer a.server.logger.Debug("api", r.URL.Path) if a.checkBearerAuth(r.Header.Get("Authorization")) { a.mux.ServeHTTP(w, r) } else { http.Error(w, "Unauthorized", http.StatusUnauthorized) } } func (a *ergoAPI) checkBearerAuth(authHeader string) (authorized bool) { if authHeader == "" { return false } c := a.server.Config() if !c.API.Enabled { return false } spaceIdx := strings.IndexByte(authHeader, ' ') if spaceIdx < 0 { return false } if !strings.EqualFold("Bearer", authHeader[:spaceIdx]) { return false } providedTokenBytes := []byte(authHeader[spaceIdx+1:]) for _, tokenBytes := range c.API.bearerTokenBytes { if subtle.ConstantTimeCompare(tokenBytes, providedTokenBytes) == 1 { return true } } return false } func (a *ergoAPI) decodeJSONRequest(request any, w http.ResponseWriter, r *http.Request) (err error) { err = json.NewDecoder(r.Body).Decode(request) if err != nil { http.Error(w, fmt.Sprintf("failed to deserialize json request: %v", err), http.StatusBadRequest) } return err } func (a *ergoAPI) writeJSONResponse(response any, w http.ResponseWriter, r *http.Request) { j, err := json.Marshal(response) if err == nil { j = append(j, '\n') // less annoying in curl output w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) w.Write(j) } else { a.server.logger.Error("internal", "failed to serialize API response", r.URL.Path, err.Error()) http.Error(w, fmt.Sprintf("failed to serialize json response: %v", err), http.StatusInternalServerError) } } type apiGenericResponse struct { Success bool `json:"success"` Error string `json:"error,omitempty"` ErrorCode string `json:"errorCode,omitempty"` } func (a *ergoAPI) handleRehash(w http.ResponseWriter, r *http.Request) { var response apiGenericResponse err := a.server.rehash() if err == nil { response.Success = true } else { response.Success = false response.Error = err.Error() } a.writeJSONResponse(response, w, r) } type defconRequestResponse struct { apiGenericResponse Defcon int `json:"defcon"` } func (a *ergoAPI) handleDefcon(w http.ResponseWriter, r *http.Request) { var changeRequested uint32 var request defconRequestResponse // ignore errors or invalid values if err := json.NewDecoder(r.Body).Decode(&request); err == nil { if 1 <= request.Defcon && request.Defcon <= 5 { changeRequested = uint32(request.Defcon) } } if changeRequested != 0 { a.server.SetDefcon(changeRequested) message := fmt.Sprintf("API set DEFCON level to %d", changeRequested) a.server.logger.Info("server", message) a.server.snomasks.Send(sno.LocalAnnouncements, message) } a.writeJSONResponse( defconRequestResponse{ apiGenericResponse: apiGenericResponse{Success: true}, Defcon: int(a.server.Defcon()), }, w, r, ) } type apiCheckAuthResponse struct { apiGenericResponse AccountName string `json:"accountName,omitempty"` } func (a *ergoAPI) handleCheckAuth(w http.ResponseWriter, r *http.Request) { var request AuthScriptInput if err := a.decodeJSONRequest(&request, w, r); err != nil { return } var response apiCheckAuthResponse var account ClientAccount var err error // try whatever credentials are present if request.AccountName != "" && request.Passphrase != "" { account, err = a.server.accounts.checkPassphrase(request.AccountName, request.Passphrase) } else if request.Certfp != "" { account, err = a.server.accounts.checkCertAuth(nil, request.Certfp, nil, "") } else { err = errAccountInvalidCredentials } switch err { case nil: // success, no error response.Success = true response.AccountName = account.Name case errAccountDoesNotExist, errAccountInvalidCredentials, errAccountUnverified, errAccountSuspended: // fail, no error response.Success = false default: response.Success = false response.Error = err.Error() } a.writeJSONResponse(response, w, r) } type apiSaregisterRequest struct { AccountName string `json:"accountName"` Passphrase string `json:"passphrase"` } func (a *ergoAPI) handleSaregister(w http.ResponseWriter, r *http.Request) { var request apiSaregisterRequest if err := a.decodeJSONRequest(&request, w, r); err != nil { return } var response apiGenericResponse err := a.server.accounts.SARegister(request.AccountName, request.Passphrase) if err == nil { response.Success = true } else { response.Success = false response.Error = err.Error() switch err { case errAccountAlreadyRegistered, errAccountAlreadyVerified, errNameReserved: response.ErrorCode = "ACCOUNT_EXISTS" case errAccountBadPassphrase: response.ErrorCode = "INVALID_PASSPHRASE" default: response.ErrorCode = "UNKNOWN_ERROR" } } a.writeJSONResponse(response, w, r) } func (a *ergoAPI) handleNsPasswd(w http.ResponseWriter, r *http.Request) { var request apiSaregisterRequest if err := a.decodeJSONRequest(&request, w, r); err != nil { return } var response apiGenericResponse err := a.server.accounts.setPassword(request.AccountName, request.Passphrase, true) switch err { case nil: response.Success = true case errAccountDoesNotExist: response.ErrorCode = "ACCOUNT_DOES_NOT_EXIST" case errAccountBadPassphrase, errEmptyCredentials: response.ErrorCode = "INVALID_PASSPHRASE" case errCredsExternallyManaged: response.ErrorCode = "CREDENTIALS_EXTERNALLY_MANAGED" default: a.server.logger.Error("api", "could not change user password:", err.Error()) response.ErrorCode = "UNKNOWN_ERROR" } a.writeJSONResponse(response, w, r) } type apiAccountDetailsResponse struct { apiGenericResponse AccountName string `json:"accountName,omitempty"` Email string `json:"email,omitempty"` RegisteredAt string `json:"registeredAt,omitempty"` Channels []string `json:"channels,omitempty"` } type apiAccountDetailsRequest struct { AccountName string `json:"accountName"` } func (a *ergoAPI) handleAccountDetails(w http.ResponseWriter, r *http.Request) { var request apiAccountDetailsRequest if err := a.decodeJSONRequest(&request, w, r); err != nil { return } var response apiAccountDetailsResponse if request.AccountName != "" { accountData, err := a.server.accounts.LoadAccount(request.AccountName) if err == nil { if !accountData.Verified { err = errAccountUnverified } else if accountData.Suspended != nil { err = errAccountSuspended } } switch err { case nil: response.AccountName = accountData.Name response.Email = accountData.Settings.Email if !accountData.RegisteredAt.IsZero() { response.RegisteredAt = accountData.RegisteredAt.Format(utils.IRCv3TimestampFormat) } // Get channels the account is in response.Channels = a.server.channels.ChannelsForAccount(accountData.NameCasefolded) response.Success = true case errAccountDoesNotExist, errAccountUnverified, errAccountSuspended: response.Success = false default: response.Success = false response.ErrorCode = "UNKNOWN_ERROR" response.Error = err.Error() } } else { response.Success = false response.ErrorCode = "INVALID_REQUEST" } a.writeJSONResponse(response, w, r) } type apiAccountListResponse struct { apiGenericResponse Accounts []apiAccountDetailsResponse `json:"accounts"` TotalCount int `json:"totalCount"` } func (a *ergoAPI) handleAccountList(w http.ResponseWriter, r *http.Request) { var response apiAccountListResponse // Get all account names accounts := a.server.accounts.AllNicks() response.TotalCount = len(accounts) // Load account details response.Accounts = make([]apiAccountDetailsResponse, 0, len(accounts)) for _, account := range accounts { accountData, err := a.server.accounts.LoadAccount(account) if err != nil { // shouldn't happen continue } response.Accounts = append( response.Accounts, apiAccountDetailsResponse{ apiGenericResponse: apiGenericResponse{ Success: true, }, AccountName: accountData.Name, Email: accountData.Settings.Email, }, ) } response.Success = true a.writeJSONResponse(response, w, r) } type apiStatusResponse struct { apiGenericResponse Version string `json:"version"` GoVersion string `json:"go_version"` Commit string `json:"commit,omitempty"` StartTime string `json:"start_time"` Users struct { Total int `json:"total"` Invisible int `json:"invisible"` Operators int `json:"operators"` Unknown int `json:"unknown"` Max int `json:"max"` } `json:"users"` Channels int `json:"channels"` Servers int `json:"servers"` } func (a *ergoAPI) handleStatus(w http.ResponseWriter, r *http.Request) { server := a.server stats := server.stats.GetValues() response := apiStatusResponse{ apiGenericResponse: apiGenericResponse{Success: true}, Version: SemVer, GoVersion: runtime.Version(), Commit: Commit, StartTime: server.ctime.Format(utils.IRCv3TimestampFormat), } response.Users.Total = stats.Total response.Users.Invisible = stats.Invisible response.Users.Operators = stats.Operators response.Users.Unknown = stats.Unknown response.Users.Max = stats.Max response.Channels = server.channels.Len() response.Servers = 1 a.writeJSONResponse(response, w, r) } type apiChannelData struct { Name string `json:"name"` HasKey bool `json:"hasKey"` InviteOnly bool `json:"inviteOnly"` Secret bool `json:"secret"` UserCount int `json:"userCount"` Topic string `json:"topic"` TopicSetAt string `json:"topicSetAt,omitempty"` CreatedAt string `json:"createdAt"` Registered bool `json:"registered"` Owner string `json:"owner,omitempty"` RegisteredAt string `json:"registeredAt,omitempty"` } func (channel *Channel) apiData() (result apiChannelData) { channel.stateMutex.RLock() defer channel.stateMutex.RUnlock() result.Name = channel.name result.HasKey = channel.key != "" result.InviteOnly = channel.flags.HasMode(modes.InviteOnly) result.Secret = channel.flags.HasMode(modes.Secret) result.UserCount = len(channel.members) result.Topic = channel.topic if !channel.topicSetTime.IsZero() { result.TopicSetAt = channel.topicSetTime.UTC().Format(utils.IRCv3TimestampFormat) } result.CreatedAt = channel.createdTime.UTC().Format(utils.IRCv3TimestampFormat) result.Registered = channel.registeredFounder != "" if result.Registered { result.Owner = channel.registeredFounder if !channel.registeredTime.IsZero() { result.RegisteredAt = channel.registeredTime.UTC().Format(utils.IRCv3TimestampFormat) } } return } type apiListResponse struct { apiGenericResponse Channels []apiChannelData `json:"channels"` } func (a *ergoAPI) handleList(w http.ResponseWriter, r *http.Request) { channels := a.server.channels.ListableChannels() response := apiListResponse{ apiGenericResponse: apiGenericResponse{Success: true}, Channels: make([]apiChannelData, 0, len(channels)), } for _, channel := range channels { response.Channels = append(response.Channels, channel.apiData()) } a.writeJSONResponse(response, w, r) }