diff --git a/.goreleaser.yml b/.goreleaser.yml index 71dfe094..c47ef05c 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -71,6 +71,7 @@ archives: - ergo.motd - default.yaml - traditional.yaml + - docs/API.md - docs/MANUAL.md - docs/USERGUIDE.md - languages/*.yaml diff --git a/default.yaml b/default.yaml index 56068f02..6f589ef0 100644 --- a/default.yaml +++ b/default.yaml @@ -1089,3 +1089,21 @@ webpush: # by the client reconnecting to IRC. we also detect whether the client is no longer # successfully receiving push messages. expiration: 14d + +# HTTP API. we strongly recommend leaving this disabled unless you have a specific +# need for it. +api: + # is the API enabled at all? + enabled: false + # listen address: + listener: "127.0.0.1:8089" + # serve over TLS (strongly recommended if the listener is public): + #tls: + #cert: fullchain.pem + #key: privkey.pem + # one or more static bearer tokens accepted for HTTP bearer authentication. + # these must be strong, unique, high-entropy printable ASCII strings. + # to generate a new token, use `ergo gentoken` or: + # python3 -c "import secrets; print(secrets.token_urlsafe(32))" + bearer-tokens: + - "example" diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 00000000..056f88c8 --- /dev/null +++ b/docs/API.md @@ -0,0 +1,88 @@ + __ __ ______ ___ ______ ___ + __/ // /_/ ____/ __ \/ ____/ __ \ + /_ // __/ __/ / /_/ / / __/ / / / + /_ // __/ /___/ _, _/ /_/ / /_/ / + /_//_/ /_____/_/ |_|\____/\____/ + + Ergo IRCd API Documentation + https://ergo.chat/ + +_Copyright © Daniel Oaks , Shivaram Lingamneni _ + + +-------------------------------------------------------------------------------------------- + +Ergo has an experimental HTTP API. Some general information about the API: + +1. All requests to the API are via POST. +1. All requests to the API are authenticated via bearer authentication. This is a header named `Authorization` with the value `Bearer `. A list of valid tokens is hardcoded in the Ergo config. Future versions of Ergo may allow additional validation schemes for tokens. +1. The request parameters are sent as JSON in the POST body. +1. Any status code other than 200 is an error response; the response body is undefined in this case (likely human-readable text for debugging). +1. A 200 status code indicates successful execution of the request. The response body will be JSON and may indicate application-level success or failure (typically via the `success` field, which takes a boolean value). + +API endpoints are versioned (currently all endpoints have a `/v1/` path prefix). Backwards-incompatible updates will most likely take the form of endpoints with new names, or an increased version prefix. Any exceptions to this will be specifically documented in the changelog. + +All API endpoints should be considered highly privileged. Bearer tokens should be kept secret. Access to the API should be either over a trusted link (like loopback) or secured via verified TLS. See the `api` section of `default.yaml` for examples of how to configure this. + +Here's an example of how to test an API configured to run over loopback TCP in plaintext: + +```bash +curl -d '{"accountName": "invalidaccountname", "passphrase": "invalidpassphrase"}' -H 'Authorization: Bearer EYBbXVilnumTtfn4A9HE8_TiKLGWEGylre7FG6gEww0' -v http://127.0.0.1:8089/v1/check_auth +``` + +This returns: + +```json +{"success":false} +``` + +Endpoints +========= + +`/v1/account_details` +---------------- + +This endpoint fetches account details and returns them as JSON. The request is a JSON object with fields: + +* `accountName`: string, name of the account + +The response is a JSON object with fields: + +* `success`: whether the account exists or not +* `accountName`: canonical, case-unfolded version of the account name +* `email`: email address of the account provided + +`/v1/check_auth` +---------------- + +This endpoint verifies the credentials of a NickServ account; this allows Ergo to be used as the source of truth for authentication by another system. The request is a JSON object with fields: + +* `accountName`: string, name of the account +* `passphrase`: string, alleged passphrase of the account + +The response is a JSON object with fields: + +* `success`: whether the credentials provided were valid +* `accountName`: canonical, case-unfolded version of the account name + +`/v1/rehash` +------------ + +This endpoint rehashes the server (i.e. reloads the configuration file, TLS certificates, and other associated data). The body is ignored. The response is a JSON object with fields: + +* `success`: boolean, indicates whether the rehash was successful +* `error`: string, optional, human-readable description of the failure + +`/v1/saregister` +---------------- + +This endpoint registers an account in NickServ, with the same semantics as `NS SAREGISTER`. The request is a JSON object with fields: + +* `accountName`: string, name of the account +* `passphrase`: string, passphrase of the account + +The response is a JSON object with fields: + +* `success`: whether the account creation succeeded +* `errorCode`: string, optional, machine-readable description of the error. Possible values include: `ACCOUNT_EXISTS`, `INVALID_PASSPHRASE`, `UNKNOWN_ERROR`. +* `error`: string, optional, human-readable description of the failure. diff --git a/docs/MANUAL.md b/docs/MANUAL.md index 75bf0562..17cfcc94 100644 --- a/docs/MANUAL.md +++ b/docs/MANUAL.md @@ -63,6 +63,7 @@ _Copyright © Daniel Oaks , Shivaram Lingamneni ] [--quiet] ergo mkcerts [--conf ] [--quiet] ergo defaultconfig + ergo gentoken ergo run [--conf ] [--quiet] [--smoke] ergo -h | --help ergo --version @@ -141,6 +143,9 @@ Options: } else if arguments["defaultconfig"].(bool) { fmt.Print(defaultConfig) return + } else if arguments["gentoken"].(bool) { + fmt.Println(utils.GenerateSecretKey()) + return } else if arguments["mkcerts"].(bool) { doMkcerts(arguments["--conf"].(string), arguments["--quiet"].(bool)) return diff --git a/irc/api.go b/irc/api.go new file mode 100644 index 00000000..a25b6537 --- /dev/null +++ b/irc/api.go @@ -0,0 +1,224 @@ +package irc + +import ( + "crypto/subtle" + "encoding/json" + "fmt" + "net/http" + "strings" +) + +func newAPIHandler(server *Server) http.Handler { + api := &ergoAPI{ + server: server, + mux: http.NewServeMux(), + } + + api.mux.HandleFunc("POST /v1/rehash", api.handleRehash) + api.mux.HandleFunc("POST /v1/check_auth", api.handleCheckAuth) + api.mux.HandleFunc("POST /v1/saregister", api.handleSaregister) + api.mux.HandleFunc("POST /v1/account_details", api.handleAccountDetails) + + 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 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 + + // try passphrase if present + if request.AccountName != "" && request.Passphrase != "" { + // TODO this only checks the internal database, not auth-script; + // it's a little weird to use both auth-script and the API but we should probably handle it + account, err := a.server.accounts.checkPassphrase(request.AccountName, request.Passphrase) + 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() + } + } + + // try certfp if present + if !response.Success && request.Certfp != "" { + // TODO support cerftp + } + + 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) +} + +type apiAccountDetailsResponse struct { + apiGenericResponse + AccountName string `json:"accountName,omitempty"` + Email string `json:"email,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 + + // TODO could probably use better error handling and more details + + 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 + 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) +} diff --git a/irc/config.go b/irc/config.go index 5b26cd5b..bbbb8aee 100644 --- a/irc/config.go +++ b/irc/config.go @@ -610,6 +610,15 @@ type Config struct { SuppressLusers bool `yaml:"suppress-lusers"` } + API struct { + Enabled bool + Listener string + TLS TLSListenConfig + tlsConfig *tls.Config + BearerTokens []string `yaml:"bearer-tokens"` + bearerTokenBytes [][]byte + } `yaml:"api"` + Roleplay struct { Enabled bool RequireChanops bool `yaml:"require-chanops"` @@ -1009,6 +1018,40 @@ func (config *Config) processExtjwt() (err error) { return nil } +func (config *Config) processAPI() (err error) { + if !config.API.Enabled { + return nil + } + + if config.API.Listener == "" { + return errors.New("config.api.enabled is true, but listener address is empty") + } + + config.API.bearerTokenBytes = make([][]byte, len(config.API.BearerTokens)) + for i, tok := range config.API.BearerTokens { + if tok == "" || tok == "example" { + continue + } + config.API.bearerTokenBytes[i] = []byte(tok) + } + + var tlsConfig *tls.Config + if config.API.TLS.Cert != "" { + cert, err := loadCertWithLeaf(config.API.TLS.Cert, config.API.TLS.Key) + if err != nil { + return err + } + tlsConfig = &tls.Config{ + Certificates: []tls.Certificate{cert}, + MinVersion: tls.VersionTLS12, + // TODO consider supporting client certificates + } + } + config.API.tlsConfig = tlsConfig + + return nil +} + // LoadRawConfig loads the config without doing any consistency checks or postprocessing func LoadRawConfig(filename string) (config *Config, err error) { data, err := os.ReadFile(filename) @@ -1611,6 +1654,11 @@ func LoadConfig(filename string) (config *Config, err error) { config.Server.supportedCaps.Disable(caps.SojuWebPush) } + err = config.processAPI() + if err != nil { + return nil, err + } + // now that all postprocessing is complete, regenerate ISUPPORT: err = config.generateISupport() if err != nil { diff --git a/irc/server.go b/irc/server.go index e4b93e81..fbc69415 100644 --- a/irc/server.go +++ b/irc/server.go @@ -99,6 +99,11 @@ type Server struct { flock flock.Flocker connIDCounter atomic.Uint64 defcon atomic.Uint32 + + // API stuff + apiHandler http.Handler // always initialized + apiListener *utils.ReloadableListener + apiServer *http.Server // nil if API is not enabled } // NewServer returns a new Oragono server. @@ -125,6 +130,8 @@ func NewServer(config *Config, logger *logger.Manager) (*Server, error) { server.monitorManager.Initialize() server.snomasks.Initialize() + server.apiHandler = newAPIHandler(server) + if err := server.applyConfig(config); err != nil { return nil, err } @@ -839,6 +846,8 @@ func (server *Server) applyConfig(config *Config) (err error) { server.setupPprofListener(config) + server.setupAPIListener(config) + // set RPL_ISUPPORT var newISupportReplies [][]string if oldConfig != nil { @@ -907,6 +916,46 @@ func (server *Server) setupPprofListener(config *Config) { } } +func (server *Server) setupAPIListener(config *Config) { + if server.apiServer != nil { + if !config.API.Enabled || (config.API.Listener != server.apiServer.Addr) { + server.logger.Info("server", "Stopping API listener", server.apiServer.Addr) + server.apiServer.Close() + server.apiListener = nil + server.apiServer = nil + } + } + if !config.API.Enabled { + return + } + listenerConfig := utils.ListenerConfig{ + TLSConfig: config.API.tlsConfig, + } + if server.apiListener != nil { + server.apiListener.Reload(listenerConfig) + return + } + listener, err := net.Listen("tcp", config.API.Listener) + if err != nil { + server.logger.Error("server", "Couldn't create API listener", config.API.Listener, err.Error()) + return + } + server.apiListener = utils.NewReloadableListener(listener, listenerConfig) + server.apiServer = &http.Server{ + Addr: config.API.Listener, // just informational since we created the listener ourselves + Handler: server.apiHandler, + ReadTimeout: 10 * time.Second, + WriteTimeout: 10 * time.Second, + MaxHeaderBytes: 16384, + } + go func(hs *http.Server, listener net.Listener) { + if err := hs.Serve(listener); err != nil { + server.logger.Error("server", "API listener failed", err.Error()) + } + }(server.apiServer, server.apiListener) + server.logger.Info("server", "Started API listener", server.apiServer.Addr) +} + func (server *Server) loadDatastore(config *Config) error { // open the datastore and load server state for which it (rather than config) // is the source of truth diff --git a/traditional.yaml b/traditional.yaml index d8009949..dfda71fc 100644 --- a/traditional.yaml +++ b/traditional.yaml @@ -1060,3 +1060,21 @@ webpush: # by the client reconnecting to IRC. we also detect whether the client is no longer # successfully receiving push messages. expiration: 14d + +# HTTP API. we strongly recommend leaving this disabled unless you have a specific +# need for it. +api: + # is the API enabled at all? + enabled: false + # listen address: + listener: "127.0.0.1:8089" + # serve over TLS (strongly recommended if the listener is public): + #tls: + #cert: fullchain.pem + #key: privkey.pem + # one or more static bearer tokens accepted for HTTP bearer authentication. + # these must be strong, unique, high-entropy printable ASCII strings. + # to generate a new token, use `ergo gentoken` or: + # python3 -c "import secrets; print(secrets.token_urlsafe(32))" + bearer-tokens: + - "example"