diff --git a/CHANGELOG.md b/CHANGELOG.md index 701fdd04ec..d040bd8d01 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/). - Replaces all Traffic Portal Tenant select boxes with a novel tree select box [#6427](https://github.com/apache/trafficcontrol/issues/6427). - Traffic Monitor: Add support for `access.log` to TM. - Added functionality for login to provide a Bearer token and for that token to be later used for authorization. +- [Traffic Ops] Added support for backend configurations so that Traffic Ops can act as a reverse proxy for these services [#6754](https://github.com/apache/trafficcontrol/pull/6754). - Added functionality for CDN locks, so that they can be shared amongst a list of specified usernames. - [Traffic Ops | Traffic Go Clients | T3C] Add additional timestamp fields to server for queuing and dequeueing config and revalidate updates. - Added layered profile feature to 4.0 for `GET` /servers/, `POST` /servers/, `PUT` /servers/{id} and `DELETE` /servers/{id}. diff --git a/docs/source/admin/traffic_ops.rst b/docs/source/admin/traffic_ops.rst index d0b1d84120..6ea3cbf479 100644 --- a/docs/source/admin/traffic_ops.rst +++ b/docs/source/admin/traffic_ops.rst @@ -19,7 +19,7 @@ *********** Traffic Ops *********** -Traffic Ops is quite possible the single most complex and most important Traffic Control component. It has many different configuration options that affect a wide range of other components and their interactions. +Traffic Ops is quite possibly the single most complex and most important Traffic Control component. It has many different configuration options that affect a wide range of other components and their interactions. .. _to-install: @@ -257,7 +257,7 @@ While this section contains instructions for running Traffic Ops manually, the o traffic_ops_golang ------------------ -``traffic_ops_golang [--version] [--plugins] [--api-routes] --cfg CONFIG_PATH --dbcfg DB_CONFIG_PATH [--riakcfg RIAK_CONFIG_PATH]`` +``traffic_ops_golang [--version] [--plugins] [--api-routes] --cfg CONFIG_PATH --dbcfg DB_CONFIG_PATH [--riakcfg RIAK_CONFIG_PATH] [--backendcfg BACKEND_CONFIG_PATH]`` .. option:: --cfg CONFIG_PATH @@ -282,6 +282,10 @@ traffic_ops_golang .. impl-detail:: The name of this flag is derived from the current database used in the implementation of Traffic Vault - `Riak KV `_. +.. option:: --backendcfg BACKEND_CONFIG_PATH + + This optional command line flag specifies the absolute or relative path to a configuration file used by Traffic Ops to act as a reverse proxy and forward requests on the specified paths to the corresponding hosts - `backends.conf`_ + .. option:: --version Print version information and exit. @@ -586,6 +590,32 @@ This file sets authentication options for connections to Riak when used as the T .. impl-detail:: The name of this file is derived from the current database used in the implementation of Traffic Vault - `Riak KV `_. +backends.conf +""""""""""""" +This file deals with the configuration parameters of running Traffic Ops as a reverse proxy for certain endpoints that need to be served externally by other backend services. It is a JSON-format set of options and their respective values. `traffic_ops_golang`_ will use whatever file is specified (if any) by its :option:`--backendcfg` option. The keys of the file are described below. + +:routes: This is an array of options to configure Traffic Ops to forward requests of specified types to the appropriate backends. + + :path: The regex matching the endpoint that will be served by the backend, for example, :regexp:`^/api/4.0/foo?$`. + :method: The HTTP method for the above mentioned path, for example, ``GET`` or ``PUT``. + :routeId: The integral identifier for the new route being added. + :hosts: An array of the host object, which specifies the protocol, hostname and port where the request (if matched) needs to be forwarded to. + + :protocol: The protocol/scheme to be followed while forwarding the requests to the backend service. + :hostname: The hostname of the server where the backend service is running. + :port: The port (integer) on the backend server where the service is running. + + :insecure: A boolean specifying whether or not TO should verify the backend server's certificate chain and host name. This is not recommended for production use. This is an optional parameter, defaulting to ``false`` when not present. + :permissions: An array of permissions (strings) specifying the permissions required by the user to use this API route. + :opts: A collection of key value pairs to control how the requests should be forwarded/ handled, for example, ``"alg": "roundrobin"``. Currently, only ``roundrobin`` is supported (which is also the default if nothing is specified) by Traffic Ops. + +Example backends.conf +''''''''''''''''''''' +.. include:: ../../../traffic_ops/app/conf/backends.conf + :code: json + :tab-width: 4 + + Installing the SSL Certificate ------------------------------ By default, Traffic Ops runs as an SSL web server (that is, over HTTPS), and a certificate needs to be installed. diff --git a/traffic_ops/app/conf/backends.conf b/traffic_ops/app/conf/backends.conf new file mode 100644 index 0000000000..2401a44996 --- /dev/null +++ b/traffic_ops/app/conf/backends.conf @@ -0,0 +1,52 @@ +{ + "routes": [ + { + "path": "^/api/4.0/foo?$", + "method": "GET", + "hosts": [ + { + "protocol": "https", + "hostname": "localhost", + "port": 8444 + }, + { + "protocol": "https", + "hostname": "localhost", + "port": 8445 + } + ], + "insecure": true, + "permissions": [ + "CDN:READ" + ], + "routeId": 123456, + "opts": { + "alg": "roundrobin" + } + }, + { + "path": "^/api/4.0/foos?$", + "method": "GET", + "hosts": [ + { + "protocol": "https", + "hostname": "localhost", + "port": 8444 + }, + { + "protocol": "https", + "hostname": "localhost", + "port": 8445 + } + ], + "insecure": true, + "permissions": [ + "CDN:READ" + ], + "routeId": 123457, + "opts": { + "alg": "roundrobin" + } + } + ] +} diff --git a/traffic_ops/app/conf/production/backends.conf b/traffic_ops/app/conf/production/backends.conf new file mode 100644 index 0000000000..4feaf1db51 --- /dev/null +++ b/traffic_ops/app/conf/production/backends.conf @@ -0,0 +1,3 @@ +{ + "routes": [], +} diff --git a/traffic_ops/etc/init.d/traffic_ops b/traffic_ops/etc/init.d/traffic_ops index d2fad881b1..12674c981b 100755 --- a/traffic_ops/etc/init.d/traffic_ops +++ b/traffic_ops/etc/init.d/traffic_ops @@ -50,7 +50,7 @@ start () stop echo -e "Starting Traffic Ops\n" ulimit -n 200000 || echo "Setting ulimit max files failed for traffic_ops_golang" - cd $TO_DIR && $TO_DIR/bin/traffic_ops_golang -cfg $TO_DIR/conf/cdn.conf -dbcfg $TO_DIR/conf/production/database.conf -riakcfg $TO_DIR/conf/production/riak.conf & + cd $TO_DIR && $TO_DIR/bin/traffic_ops_golang -cfg $TO_DIR/conf/cdn.conf -dbcfg $TO_DIR/conf/production/database.conf -riakcfg $TO_DIR/conf/production/riak.conf -backendcfg $TO_DIR/conf/production/backends.conf & } stop () diff --git a/traffic_ops/traffic_ops_golang/config/config.go b/traffic_ops/traffic_ops_golang/config/config.go index f9680c10a3..521da1d9bd 100644 --- a/traffic_ops/traffic_ops_golang/config/config.go +++ b/traffic_ops/traffic_ops_golang/config/config.go @@ -36,6 +36,35 @@ import ( "github.com/apache/trafficcontrol/lib/go-util" ) +// Options is a structure used to hold the route configuration options that can be supplied for the backend routes. +type Options struct { + Algorithm string `json:"alg"` +} + +// Host is a structure that holds the host info for the backend route. +type Host struct { + Protocol string `json:"protocol"` + Hostname string `json:"hostname"` + Port int `json:"port"` +} + +// BackendRoute holds all the information about a configured route, for which Traffic Ops serves as a reverse proxy. +type BackendRoute struct { + Path string `json:"path"` + Method string `json:"method"` + Hosts []Host `json:"hosts"` + Opts Options `json:"opts"` + ID int `json:"routeId"` + Insecure bool `json:"insecure"` + Permissions []string `json:"permissions"` + Index int +} + +// BackendConfig is a structure that holds the configuration supplied to Traffic Ops, which makes it act as a reverse proxy to the specified routes. +type BackendConfig struct { + Routes []BackendRoute `json:"routes"` +} + // Config reflects the structure of the cdn.conf file type Config struct { URL *url.URL `json:"-"` @@ -286,6 +315,31 @@ func (c Config) EventLog() log.LogLocation { const BlockStartup = true const AllowStartup = false +func LoadBackendConfig(backendConfigPath string) (BackendConfig, error) { + confBytes, err := ioutil.ReadFile(backendConfigPath) + if err != nil { + return BackendConfig{}, fmt.Errorf("reading backend conf '%s': %v", backendConfigPath, err) + } + + cfg := BackendConfig{} + err = json.Unmarshal(confBytes, &cfg) + if err != nil { + return BackendConfig{}, fmt.Errorf("unmarshalling '%s': %v", backendConfigPath, err) + } + for _, r := range cfg.Routes { + if r.Opts.Algorithm != "" && r.Opts.Algorithm != "roundrobin" { + return cfg, errors.New("algorithm can only be roundrobin or blank") + } + for _, h := range r.Hosts { + rawURL := h.Protocol + "://" + h.Hostname + ":" + strconv.Itoa(h.Port) + if _, err = url.ParseRequestURI(rawURL); err != nil { + return cfg, fmt.Errorf("couldn't convert host info into a valid URI: %v", err) + } + } + } + return cfg, nil +} + func LoadCdnConfig(cdnConfPath string) (Config, error) { // load json from cdn.conf confBytes, err := ioutil.ReadFile(cdnConfPath) diff --git a/traffic_ops/traffic_ops_golang/routing/middleware/wrappers.go b/traffic_ops/traffic_ops_golang/routing/middleware/wrappers.go index e307faadb9..57ed0bb520 100644 --- a/traffic_ops/traffic_ops_golang/routing/middleware/wrappers.go +++ b/traffic_ops/traffic_ops_golang/routing/middleware/wrappers.go @@ -212,7 +212,7 @@ func WrapAccessLog(secret string, h http.Handler) http.HandlerFunc { imsType = IMSMISS } } - log.EventfRaw(`%s - %s [%s] "%v %v?%v %s" %v %v %v "%v" %v %s`, r.RemoteAddr, user, time.Now().Format(AccessLogTimeFormat), r.Method, r.URL.Path, r.URL.RawQuery, r.Proto, iw.Code, iw.ByteCount, int(time.Now().Sub(start)/time.Millisecond), r.UserAgent(), r.Header.Get(RouteID), imsType) + log.EventfRaw(`%s - %s [%s] "%v %v?%v %s" %v %v %v "%v" %v %s`, r.RemoteAddr, user, time.Now().Format(AccessLogTimeFormat), r.Method, r.URL.Path, r.URL.RawQuery, r.Proto, iw.Code, iw.ByteCount, int(time.Now().Sub(start)/time.Millisecond), r.UserAgent(), r.Context().Value(RouteID), imsType) }() h.ServeHTTP(iw, r) } @@ -276,6 +276,14 @@ func NotImplementedHandler() http.Handler { }) } +func BackendErrorHandler(code int, userErr error, sysErr error) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set(rfc.ContentType, rfc.ApplicationJSON) + w.WriteHeader(code) + api.HandleErr(w, r, nil, code, userErr, sysErr) + }) +} + // DisabledRouteHandler returns a http.Handler which returns a HTTP 5xx code to the client, and an error message indicating the route is currently disabled. // This is used for routes which have been disabled via configuration. See config.ConfigTrafficOpsGolang.RoutingBlacklist.DisabledRoutes. func DisabledRouteHandler() http.Handler { diff --git a/traffic_ops/traffic_ops_golang/routing/routing.go b/traffic_ops/traffic_ops_golang/routing/routing.go index 3031e1c209..c3ca1adcb4 100644 --- a/traffic_ops/traffic_ops_golang/routing/routing.go +++ b/traffic_ops/traffic_ops_golang/routing/routing.go @@ -23,18 +23,23 @@ package routing import ( "context" + "crypto/tls" "errors" "fmt" "net/http" + "net/http/httputil" + "net/url" "regexp" "sort" "strconv" "strings" + "sync" "sync/atomic" "time" "github.com/apache/trafficcontrol/lib/go-log" "github.com/apache/trafficcontrol/traffic_ops/traffic_ops_golang/api" + "github.com/apache/trafficcontrol/traffic_ops/traffic_ops_golang/auth" "github.com/apache/trafficcontrol/traffic_ops/traffic_ops_golang/config" "github.com/apache/trafficcontrol/traffic_ops/traffic_ops_golang/plugin" "github.com/apache/trafficcontrol/traffic_ops/traffic_ops_golang/routing/middleware" @@ -46,6 +51,28 @@ import ( // RoutePrefix is a prefix that all API routes must match. const RoutePrefix = "^api" // TODO config? +type backendConfigSynced struct { + cfg config.BackendConfig + *sync.RWMutex +} + +// backendCfg stores the current backend config supplied to traffic ops. +var backendCfg = backendConfigSynced{RWMutex: &sync.RWMutex{}} + +// GetBackendConfig returns the current BackendConfig. +func GetBackendConfig() config.BackendConfig { + backendCfg.RLock() + defer backendCfg.RUnlock() + return backendCfg.cfg +} + +// SetBackendConfig sets the BackendConfig to the value supplied. +func SetBackendConfig(backendConfig config.BackendConfig) { + backendCfg.Lock() + defer backendCfg.Unlock() + backendCfg.cfg = backendConfig +} + // A Route defines an association with a client request and a handler for that // request. type Route struct { @@ -85,6 +112,7 @@ type ServerData struct { Profiling *bool // Yes this is a field in the config but we want to live reload this value and NOT the entire config Plugins plugin.Plugins TrafficVault trafficvault.TrafficVault + Mux *http.ServeMux } // CompiledRoute ... @@ -270,8 +298,8 @@ func Handler( } routeCtx := context.WithValue(ctx, api.PathParamsKey, params) + routeCtx = context.WithValue(routeCtx, middleware.RouteID, strconv.Itoa(compiledRoute.ID)) r = r.WithContext(routeCtx) - r.Header.Add(middleware.RouteID, strconv.Itoa(compiledRoute.ID)) compiledRoute.Handler(w, r) return } @@ -280,8 +308,97 @@ func Handler( h.ServeHTTP(w, r) return } + var backendRouteHandled bool + backendConfig := GetBackendConfig() + for i, backendRoute := range backendConfig.Routes { + var params []string + routeParams := map[string]string{} + if backendRoute.Method == r.Method { + for open := strings.Index(backendRoute.Path, "{"); open > 0; open = strings.Index(backendRoute.Path, "{") { + close := strings.Index(backendRoute.Path, "}") + if close < 0 { + panic("malformed route") + } + param := backendRoute.Path[open+1 : close] + params = append(params, param) + backendRoute.Path = backendRoute.Path[:open] + `([^/]+)` + backendRoute.Path[close+1:] + } + regex := regexp.MustCompile(backendRoute.Path) + match := regex.FindStringSubmatch(r.URL.Path) + if len(match) == 0 { + continue + } + for i, v := range params { + routeParams[v] = match[i+1] + } + if backendRoute.Opts.Algorithm == "" || backendRoute.Opts.Algorithm == "roundrobin" { + index := backendRoute.Index % len(backendRoute.Hosts) + host := backendRoute.Hosts[index] + backendRoute.Index++ + backendConfig.Routes[i] = backendRoute + backendRouteHandled = true + rp := httputil.NewSingleHostReverseProxy(&url.URL{ + Host: host.Hostname + ":" + strconv.Itoa(host.Port), + Scheme: host.Protocol, + }) + rp.Transport = &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: backendRoute.Insecure}, + } + rp.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) { + api.HandleErr(w, r, nil, http.StatusInternalServerError, nil, err) + return + } + routeCtx := context.WithValue(ctx, api.DBContextKey, db) + routeCtx = context.WithValue(routeCtx, api.PathParamsKey, routeParams) + routeCtx = context.WithValue(routeCtx, middleware.RouteID, strconv.Itoa(backendRoute.ID)) + r = r.WithContext(routeCtx) + userErr, sysErr, code := HandleBackendRoute(cfg, backendRoute, w, r) + if userErr != nil || sysErr != nil { + h2 := middleware.WrapAccessLog(cfg.Secrets[0], middleware.BackendErrorHandler(code, userErr, sysErr)) + h2.ServeHTTP(w, r) + return + } + backendHandler := middleware.WrapAccessLog(cfg.Secrets[0], rp) + backendHandler.ServeHTTP(w, r) + return + } else { + h2 := middleware.WrapAccessLog(cfg.Secrets[0], middleware.BackendErrorHandler(http.StatusBadRequest, errors.New("only an algorithm of roundrobin is supported by the backend options currently"), nil)) + h2.ServeHTTP(w, r) + return + } + } + } + if !backendRouteHandled { + catchall.ServeHTTP(w, r) + } +} - catchall.ServeHTTP(w, r) +// HandleBackendRoute does all the pre processing for the backend routes. +func HandleBackendRoute(cfg *config.Config, route config.BackendRoute, w http.ResponseWriter, r *http.Request) (error, error, int) { + var userErr, sysErr error + var errCode int + var user auth.CurrentUser + var inf *api.APIInfo + + user, userErr, sysErr, errCode = api.GetUserFromReq(w, r, cfg.Secrets[0]) + if userErr != nil || sysErr != nil { + return userErr, sysErr, errCode + } + if cfg.RoleBasedPermissions { + missingPerms := user.MissingPermissions(route.Permissions...) + if len(missingPerms) != 0 { + msg := strings.Join(missingPerms, ", ") + return fmt.Errorf("missing required Permissions: %s", msg), nil, http.StatusForbidden + } + } + api.AddUserToReq(r, user) + var params []string + inf, userErr, sysErr, errCode = api.NewInfo(r, params, nil) + if userErr != nil || sysErr != nil { + return userErr, sysErr, errCode + } + defer inf.Close() + return nil, nil, http.StatusOK } // IsRequestAPIAndUnknownVersion returns true if the request starts with `/api` and is a version not in the list of versions. @@ -335,7 +452,7 @@ func RegisterRoutes(d ServerData) error { compiledRoutes := CompileRoutes(routes) getReqID := nextReqIDGetter() - http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + d.Mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { Handler(compiledRoutes, versions, catchall, d.DB, &d.Config, getReqID, d.Plugins, d.TrafficVault, w, r) }) return nil diff --git a/traffic_ops/traffic_ops_golang/traffic_ops_golang.go b/traffic_ops/traffic_ops_golang/traffic_ops_golang.go index a04c486d36..42a3acf45e 100644 --- a/traffic_ops/traffic_ops_golang/traffic_ops_golang.go +++ b/traffic_ops/traffic_ops_golang/traffic_ops_golang.go @@ -22,6 +22,7 @@ package main import ( "crypto/tls" "encoding/json" + "errors" "flag" "fmt" "io/ioutil" @@ -64,6 +65,7 @@ func main() { configFileName := flag.String("cfg", "", "The config file path") dbConfigFileName := flag.String("dbcfg", "", "The db config file path") riakConfigFileName := flag.String("riakcfg", "", "The riak config file path (DEPRECATED: use traffic_vault_backend = riak and traffic_vault_config in cdn.conf instead)") + backendConfigFileName := flag.String("backendcfg", "", "The backend config file path") flag.Parse() if *showVersion { @@ -164,7 +166,18 @@ func main() { log.Errorln(debugServer.ListenAndServe()) }() - if err := routing.RegisterRoutes(routing.ServerData{DB: db, Config: cfg, Profiling: &profiling, Plugins: plugins, TrafficVault: trafficVault}); err != nil { + var backendConfig config.BackendConfig + if *backendConfigFileName != "" { + backendConfig, err = config.LoadBackendConfig(*backendConfigFileName) + routing.SetBackendConfig(backendConfig) + if err != nil { + log.Errorf("error loading backend config: %v", err) + } + } + + mux := http.NewServeMux() + d := routing.ServerData{DB: db, Config: cfg, Profiling: &profiling, Plugins: plugins, TrafficVault: trafficVault, Mux: mux} + if err := routing.RegisterRoutes(d); err != nil { log.Errorf("registering routes: %v\n", err) os.Exit(1) } @@ -213,7 +226,7 @@ func main() { } else { file.Close() } - + server.Handler = mux if err := server.ListenAndServeTLS(cfg.CertPath, cfg.KeyPath); err != nil { log.Errorf("stopping server: %v\n", err) os.Exit(1) @@ -232,10 +245,16 @@ func main() { continuousProfile(&profiling, &profilingLocation, cfg.Version) } - reloadProfilingConfig := func() { + reloadProfilingAndBackendConfig := func() { setNewProfilingInfo(*configFileName, &profiling, &profilingLocation, cfg.Version) + backendConfig, err = getNewBackendConfig(backendConfigFileName) + if err != nil { + log.Errorf("could not reload backend config: %v", err) + } else { + routing.SetBackendConfig(backendConfig) + } } - signalReloader(unix.SIGHUP, reloadProfilingConfig) + signalReloader(unix.SIGHUP, reloadProfilingAndBackendConfig) } func setupTrafficVault(riakConfigFileName string, cfg *config.Config) trafficvault.TrafficVault { @@ -293,6 +312,19 @@ func setupTrafficVault(riakConfigFileName string, cfg *config.Config) trafficvau return &disabled.Disabled{} } +func getNewBackendConfig(backendConfigFileName *string) (config.BackendConfig, error) { + if backendConfigFileName == nil { + return config.BackendConfig{}, errors.New("no backend config filename") + } + log.Infof("setting new backend config to %s", *backendConfigFileName) + backendConfig, err := config.LoadBackendConfig(*backendConfigFileName) + if err != nil { + log.Errorf("error reloading config: %v", err) + return backendConfig, err + } + return backendConfig, nil +} + func setNewProfilingInfo(configFileName string, currentProfilingEnabled *bool, currentProfilingLocation *string, version string) { newProfilingEnabled, newProfilingLocation, err := reloadProfilingInfo(configFileName) if err != nil {