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 }