github.com/wtsi-ssg/wrstat@v1.1.4-0.20221008232152-3030622a8cf8/cmd/server.go (about)

     1  /*******************************************************************************
     2   * Copyright (c) 2022 Genome Research Ltd.
     3   *
     4   * Authors:
     5   *	- Sendu Bala <sb10@sanger.ac.uk>
     6   *	- Michael Grace <mg38@sanger.ac.uk>
     7   *
     8   * Permission is hereby granted, free of charge, to any person obtaining
     9   * a copy of this software and associated documentation files (the
    10   * "Software"), to deal in the Software without restriction, including
    11   * without limitation the rights to use, copy, modify, merge, publish,
    12   * distribute, sublicense, and/or sell copies of the Software, and to
    13   * permit persons to whom the Software is furnished to do so, subject to
    14   * the following conditions:
    15   *
    16   * The above copyright notice and this permission notice shall be included
    17   * in all copies or substantial portions of the Software.
    18   *
    19   * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
    20   * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
    21   * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
    22   * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
    23   * CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
    24   * TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
    25   * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
    26   ******************************************************************************/
    27  
    28  package cmd
    29  
    30  import (
    31  	"encoding/csv"
    32  	"errors"
    33  	"io"
    34  	"log/syslog"
    35  	"os"
    36  	"path/filepath"
    37  	"time"
    38  
    39  	"github.com/inconshreveable/log15"
    40  	"github.com/spf13/cobra"
    41  	"github.com/wtsi-ssg/wrstat/server"
    42  )
    43  
    44  const sentinelPollFrequencty = 1 * time.Minute
    45  
    46  // options for this cmd.
    47  var serverLogPath string
    48  var serverBind string
    49  var serverCert string
    50  var serverKey string
    51  var oktaURL string
    52  var oktaOAuthIssuer string
    53  var oktaOAuthClientID string
    54  var oktaOAuthClientSecret string
    55  var areasPath string
    56  
    57  // serverCmd represents the server command.
    58  var serverCmd = &cobra.Command{
    59  	Use:   "server",
    60  	Short: "Start the web server",
    61  	Long: `Start the web server.
    62  
    63  Starting the web server brings up a web interface and REST API that will use the
    64  latest *.dgut.dbs directory inside the given 'wrstat multi' output directory to
    65  answer questions about where data is on the disks. (Provide your
    66  'wrstat multi -f' argument as an unamed argument to this command.)
    67  
    68  Your --bind address should include the port, and for it to work with your
    69  --cert, you probably need to specify it as fqdn:port.
    70  
    71  The server authenticates users using Okta. You must specify all of
    72  --okta_issuer, --okta_id and --okta_secret or env vars OKTA_OAUTH2_ISSUER,
    73  OKTA_OAUTH2_CLIENT_ID and OKTA_OAUTH2_CLIENT_SECRET. You must also specify
    74  --okta_url if that is different to --bind (eg. the service is bound to localhost
    75  and will be behind a proxy accessed at a different domain).
    76  
    77  The server will log all messages (of any severity) to syslog at the INFO level,
    78  except for non-graceful stops of the server, which are sent at the CRIT level or
    79  include 'panic' in the message. The messages are tagged 'wrstat-server', and you
    80  might want to filter away 'STATUS=200' to find problems.
    81  If --logfile is supplied, logs to that file instaed of syslog.
    82  
    83  If --areas is supplied, the group,area csv file pointed to will be used to add
    84  "areas" to the server, allowing clients to specify an area to filter on all
    85  groups with that area.
    86  
    87  The server must be running for 'wrstat where' calls to succeed.
    88  
    89  This command will block forever in the foreground; you can background it with
    90  ctrl-z; bg. Or better yet, use the daemonize program to daemonize this.
    91  
    92  It will monitor a file called ".dgut.dbs.updated" in the given directory and
    93  attempt to reload the databases when the file is updated by another run of
    94  'wrstat multi' with the same output directory. After reloading, will delete the
    95  previous run's database files. It will use the mtime of the file as the data
    96  creation time in reports.
    97  `,
    98  	Run: func(cmd *cobra.Command, args []string) {
    99  		if len(args) != 1 {
   100  			die("you must supply the path to your 'wrstat multi -f' output directory")
   101  		}
   102  
   103  		if serverBind == "" {
   104  			die("you must supply --bind")
   105  		}
   106  
   107  		if serverCert == "" {
   108  			die("you must supply --cert")
   109  		}
   110  
   111  		if serverKey == "" {
   112  			die("you must supply --key")
   113  		}
   114  
   115  		checkOAuthArgs()
   116  
   117  		logWriter := setServerLogger(serverLogPath)
   118  
   119  		s := server.New(logWriter)
   120  
   121  		err := s.EnableAuth(serverCert, serverKey, authenticateDeny)
   122  		if err != nil {
   123  			die("failed to enable authentication: %s", err)
   124  		}
   125  
   126  		if oktaURL == "" {
   127  			oktaURL = serverBind
   128  		}
   129  
   130  		s.AddOIDCRoutes(oktaURL, oktaOAuthIssuer, oktaOAuthClientID, oktaOAuthClientSecret)
   131  
   132  		s.WhiteListGroups(whiteLister)
   133  
   134  		if areasPath != "" {
   135  			s.AddGroupAreas(areasCSVToMap(areasPath))
   136  		}
   137  
   138  		info("opening databases, please wait...")
   139  		dbPaths, err := server.FindLatestDgutDirs(args[0], dgutDBsSuffix)
   140  		if err != nil {
   141  			die("failed to find database paths: %s", err)
   142  		}
   143  
   144  		err = s.LoadDGUTDBs(dbPaths...)
   145  		if err != nil {
   146  			die("failed to load database: %s", err)
   147  		}
   148  
   149  		sentinel := filepath.Join(args[0], dbsSentinelBasename)
   150  
   151  		err = s.EnableDGUTDBReloading(sentinel, args[0], dgutDBsSuffix, sentinelPollFrequencty)
   152  		if err != nil {
   153  			die("failed to set up database reloading: %s", err)
   154  		}
   155  
   156  		err = s.AddTreePage()
   157  		if err != nil {
   158  			die("failed to add tree page: %s", err)
   159  		}
   160  
   161  		defer s.Stop()
   162  
   163  		sayStarted()
   164  
   165  		err = s.Start(serverBind, serverCert, serverKey)
   166  		if err != nil {
   167  			die("non-graceful stop: %s", err)
   168  		}
   169  	},
   170  }
   171  
   172  func init() {
   173  	RootCmd.AddCommand(serverCmd)
   174  
   175  	// flags specific to this sub-command
   176  	serverCmd.Flags().StringVarP(&serverBind, "bind", "b", ":80",
   177  		"address to bind to, eg host:port")
   178  	serverCmd.Flags().StringVarP(&serverCert, "cert", "c", "",
   179  		"path to certificate file")
   180  	serverCmd.Flags().StringVarP(&serverKey, "key", "k", "",
   181  		"path to key file")
   182  	serverCmd.Flags().StringVar(&oktaURL, "okta_url", "",
   183  		"Okta application URL, eg host:port (defaults to --bind)")
   184  	serverCmd.Flags().StringVar(&oktaOAuthIssuer, "okta_issuer", os.Getenv("OKTA_OAUTH2_ISSUER"),
   185  		"URL for Okta Oauth")
   186  	serverCmd.Flags().StringVar(&oktaOAuthClientID, "okta_id", os.Getenv("OKTA_OAUTH2_CLIENT_ID"),
   187  		"Okta Client ID")
   188  	serverCmd.Flags().StringVar(&oktaOAuthClientSecret, "okta_secret", "",
   189  		"Okta Client Secret (default $OKTA_OAUTH2_CLIENT_SECRET)")
   190  	serverCmd.Flags().StringVar(&areasPath, "areas", "", "path to group,area csv file")
   191  	serverCmd.Flags().StringVar(&serverLogPath, "logfile", "",
   192  		"log to this file instead of syslog")
   193  
   194  	serverCmd.SetHelpFunc(func(command *cobra.Command, strings []string) {
   195  		hideGlobalFlags(serverCmd, command, strings)
   196  	})
   197  }
   198  
   199  // checkOAuthArgs ensures we have the necessary args/ env vars for Okta auth.
   200  func checkOAuthArgs() {
   201  	if oktaOAuthClientSecret == "" {
   202  		oktaOAuthClientSecret = os.Getenv("OKTA_OAUTH2_CLIENT_SECRET")
   203  	}
   204  
   205  	if oktaOAuthIssuer == "" || oktaOAuthClientID == "" || oktaOAuthClientSecret == "" {
   206  		die("you must specify all info needed for Okta logins; see --help")
   207  	}
   208  }
   209  
   210  // setServerLogger makes our appLogger log to the given path if non-blank,
   211  // otherwise to syslog. Returns an io.Writer version of our appLogger for the
   212  // server to log to.
   213  func setServerLogger(path string) io.Writer {
   214  	if path == "" {
   215  		logToSyslog()
   216  	} else {
   217  		logToFile(path)
   218  	}
   219  
   220  	return &log15Writer{logger: appLogger}
   221  }
   222  
   223  // logToSyslog sets our applogger to log to syslog, dies if it can't.
   224  func logToSyslog() {
   225  	fh, err := log15.SyslogHandler(syslog.LOG_INFO|syslog.LOG_DAEMON, "wrstat-server", log15.LogfmtFormat())
   226  	if err != nil {
   227  		die("failed to log to syslog: %s", err)
   228  	}
   229  
   230  	appLogger.SetHandler(fh)
   231  }
   232  
   233  // log15Writer wraps a log15.Logger to make it conform to io.Writer interface.
   234  type log15Writer struct {
   235  	logger log15.Logger
   236  }
   237  
   238  // Write conforms to the io.Writer interface.
   239  func (w *log15Writer) Write(p []byte) (n int, err error) {
   240  	w.logger.Info(string(p))
   241  
   242  	return len(p), nil
   243  }
   244  
   245  // authenticateDeny always returns false, since we don't do basic auth, but Okta
   246  // instead.
   247  func authenticateDeny(_, _ string) (bool, string) {
   248  	return false, ""
   249  }
   250  
   251  var whiteListGIDs = map[string]struct{}{
   252  	"0":     {},
   253  	"1105":  {},
   254  	"1313":  {},
   255  	"1818":  {},
   256  	"15306": {},
   257  	"1662":  {},
   258  	"15394": {},
   259  }
   260  
   261  // whiteLister is currently hard-coded to say that membership of certain gids
   262  // means users should be treated like root.
   263  func whiteLister(gid string) bool {
   264  	_, ok := whiteListGIDs[gid]
   265  
   266  	return ok
   267  }
   268  
   269  // areasCSVToMap takes a group,area csv file and converts it in to a map of
   270  // area -> groups slice.
   271  func areasCSVToMap(path string) map[string][]string {
   272  	r, f := makeCSVReader(path)
   273  	defer f.Close()
   274  
   275  	areas := make(map[string][]string)
   276  
   277  	for {
   278  		rec, err := r.Read()
   279  		if errors.Is(err, io.EOF) {
   280  			break
   281  		}
   282  
   283  		if err != nil {
   284  			die("could not read areas csv: %s", err)
   285  		}
   286  
   287  		groups, present := areas[rec[1]]
   288  		if !present {
   289  			groups = []string{}
   290  		}
   291  
   292  		areas[rec[1]] = append(groups, rec[0])
   293  	}
   294  
   295  	return areas
   296  }
   297  
   298  // makeCSVReader opens the given path and returns a CSV reader configured for
   299  // 2 column CSV files. Also returns an *os.File that should you Close() after
   300  // reading.
   301  func makeCSVReader(path string) (*csv.Reader, *os.File) {
   302  	f, err := os.Open(path)
   303  	if err != nil {
   304  		die("could not open areas csv: %s", err)
   305  	}
   306  
   307  	r := csv.NewReader(f)
   308  	r.FieldsPerRecord = 2
   309  	r.ReuseRecord = true
   310  
   311  	return r, f
   312  }
   313  
   314  // sayStarted logs to console that the server stated. It does this a second
   315  // after being calling in a goroutine, when we can assume the server has
   316  // actually started; if it failed, we expect it to do so in less than a second
   317  // and exit.
   318  func sayStarted() {
   319  	<-time.After(1 * time.Second)
   320  
   321  	info("server started")
   322  }