mirror of
https://github.com/ergochat/ergo.git
synced 2025-12-20 02:00:11 -08:00
MVP for HTTP API (#2231)
Co-authored-by: Klaas Tammling <klaas@tammling.hamburg>
This commit is contained in:
parent
4bcd008416
commit
ea81ec86e1
9 changed files with 456 additions and 0 deletions
224
irc/api.go
Normal file
224
irc/api.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue