import asyncdispatch, bcrypt, cliutils, jester, json, jwt, logging, md5, options, os, osproc, sequtils, strutils, tempfile, times, unittest, uuids from mimetypes import getMimeType from asyncfile import openAsync, readToStream, close from asyncnet import send from re import re, find from timeutils import trimNanoSec import ./configuration, ./core, ./version type Session = object user*: UserRef issuedAt*, expires*: Time #const ISO_TIME_FORMAT = "yyyy-MM-dd'T'HH:mm:ss" const JSON = "application/json" proc newSession*(user: UserRef): Session = result = Session( user: user, issuedAt: getTime().local.trimNanoSec.toTime, expires: daysForward(7).trimNanoSec.toTime) template halt(code: HttpCode, headers: RawHeaders, content: string): typed = ## Immediately replies with the specified request. This means any further ## code will not be executed after calling this template in the current ## route. bind TCActionSend, newHttpHeaders result[0] = CallbackAction.TCActionSend result[1] = code result[2] = some(headers) result[3] = content result.matched = true break allRoutes template jsonResp(code: HttpCode, details: string = "", headers: RawHeaders = @{:} ) = halt( code, headers & @{"Content-Type": JSON}, $(%* { "statusCode": code.int, "status": $code, "details": details }) ) template json500Resp(ex: ref Exception, details: string = ""): void = when not defined(release): debug ex.getStackTrace() error details & ":\n" & ex.msg jsonResp(Http500) proc toJWT*(cfg: StrawBossConfig, session: Session): string = ## Make a JST token for this session. var jwt = JWT( header: JOSEHeader(alg: HS256, typ: "jwt"), claims: toClaims(%*{ "sub": session.user.name, "iat": session.issuedAt.toUnix.int, "exp": session.expires.toUnix.int })) jwt.sign(cfg.authSecret) result = $jwt proc fromJWT*(cfg: StrawBossConfig, strTok: string): Session = ## Validate a given JWT and extract the session data. let jwt = toJWT(strTok) var secret = cfg.authSecret if not jwt.verify(secret): raiseEx "Unable to verify auth token." jwt.verifyTimeClaims() # Find the user record (if authenticated) let username = jwt.claims["sub"].node.str let users = cfg.users.filterIt(it.name == username) if users.len != 1: raiseEx "Could not find session user." result = Session( user: users[0], issuedAt: fromUnix(jwt.claims["iat"].node.num), expires: fromUnix(jwt.claims["exp"].node.num)) proc extractSession(cfg: StrawBossConfig, request: Request): Session = ## Helper to extract a session from a reqest. # Find the auth header if not request.headers.hasKey("Authorization"): raiseEx "No auth token." # Read and verify the JWT token let headerVal = request.headers["Authorization"] if not headerVal.startsWith("Bearer "): raiseEx "Invalid Authentication type (only 'Bearer' is supported)." result = fromJWT(cfg, headerVal[7..^1]) proc hashPwd*(pwd: string, cost: int8): string = let salt = genSalt(cost) result = hash(pwd, salt) proc validatePwd*(u: UserRef, givenPwd: string): bool = let salt = u.hashedPwd[0..28] # TODO: magic numbers result = compare(u.hashedPwd, hash(givenPwd, salt)) proc makeAuthToken*(cfg: StrawBossConfig, uname, pwd: string): string = ## Given a username and pwd, validate the combination and generate a JWT ## token string. if uname.len == 0 or pwd.len == 0: raiseEx "fields 'username' and 'password' required" # find the user record let users = cfg.users.filterIt(it.name == uname) if users.len != 1: raiseEx "invalid username or password" let user = users[0] if not validatePwd(user, pwd): raiseEx "invalid username or password" let session = newSession(user) result = toJWT(cfg, session) proc makeApiKey*(cfg: StrawBossConfig, uname: string): string = ## Given a username, make an API token (JWT token string that does not ## expire). Note that this does not validate the username/pwd combination. It ## is not intended to be exposed publicly via the API, but serve as a utility ## function for an administrator to setup a unsupervised account (git access ## for example). if uname.len == 0: raiseEx "no username given" # find the user record let users = cfg.users.filterIt(it.name == uname) if users.len != 1: raiseEx "invalid username" let session = Session( user: users[0], issuedAt: getTime(), expires: daysForward(365 * 1000).toTime()) result = toJWT(cfg, session); template checkAuth() = ## Check this request for authentication and authorization information. ## Injects the session into the running context. If the request is not ## authorized, this template returns an appropriate 401 response. var session {.inject.}: Session try: session = extractSession(cfg, request) except: debug "Auth failed: " & getCurrentExceptionMsg() jsonResp(Http401, "Unauthorized", @{"WWW-Authenticate": "Bearer"}) proc start*(cfg: StrawBossConfig): void = var stopFuture = newFuture[void]() var workers: seq[Worker] = @[] settings: port = Port(8180) appName = "/api" routes: get "/version": resp($(%("strawboss v" & SB_VERSION)), JSON) post "/auth-token": var uname, pwd: string try: let jsonBody = parseJson(request.body) uname = jsonBody["username"].getStr pwd = jsonBody["password"].getStr except: jsonResp(Http400) try: let authToken = makeAuthToken(cfg, uname, pwd) resp($(%authToken), JSON) except: jsonResp(Http401, getCurrentExceptionMsg()) get "/verify-auth": checkAuth() resp(Http200, $(%*{ "username": session.user.name }), JSON) get "/projects": ## List project summaries (ProjectDefs only) checkAuth() resp($(%cfg.projects), JSON) post "/projects": ## Create a new project definition checkAuth() # TODO jsonResp(Http501) get "/project/@projectName": ## Return a project's configuration, as well as it's versions. checkAuth() # Make sure we know about that project var projDef: ProjectDef try: projDef = cfg.getProject(@"projectName") except: try: raise getCurrentException() except NotFoundException: jsonResp(Http404, getCurrentExceptionMsg()) except: let msg = "unable to load project definition for project " & @"projectName" json500Resp(getCurrentException(), msg) var projConf: ProjectConfig try: projConf = getProjectConfig(cfg, @"projectName", "") except: discard "" let respJson = newJObject() respJson["definition"] = %projDef respJson["versions"] = %listVersions(cfg, @"projectName") if projConf.name.len > 0: respJson["latestConfig"] = %projConf resp(pretty(respJson), JSON) get "/project/@projectName/versions": ## Get a list of all versions that we have built checkAuth() try: resp($(%listVersions(cfg, @"projectName")), JSON) except: try: raise getCurrentException() except NotFoundException: jsonResp(Http404, getCurrentExceptionMsg()) except: let msg = "unable to list versions for project " & @"projectName" json500Resp(getCurrentException(), msg) get "/project/@projectName/version/@version?": ## Get a detailed project record including step definitions (ProjectConfig). checkAuth() # Make sure we know about that project try: resp($(%getProjectConfig(cfg, @"projectName", @"version")), JSON) except: jsonResp(Http404, getCurrentExceptionMsg()) get "/project/@projectName/runs": ## List all runs checkAuth() try: resp($(%listRuns(cfg, @"projectName")), JSON) except: jsonResp(Http404, getCurrentExceptionMsg()) get "/project/@projectName/runs/active": ## List all currently active runs checkAuth() try: let activeRuns = workers .filterIt(it.process.running and it.projectName == @"projectName") .mapIt(cfg.getRun(@"projecName", $it.runId)); resp($(%activeRuns), JSON) except: try: raise getCurrentException() except NotFoundException: jsonResp(Http404, getCurrentExceptionMsg()) except: json500Resp(getCurrentException(), "problem loading active runs") get "/project/@projectName/run/@runId": ## Details for a specific run checkAuth() # Make sure we know about that project try: discard cfg.getProject(@"projectName") except: jsonResp(Http404, getCurrentExceptionMsg()) if not existsRun(cfg, @"projectName", @"runId"): jsonResp(Http404, "no such run for project") try: resp($getRun(cfg, @"projectName", @"runId"), JSON) except: json500Resp(getCurrentException(), "unable to load run details for project " & @"projectName" & " run " & @"runId") get "/project/@projectName/run/@runId/logs": ## Get logs from a specific run checkAuth() try: discard cfg.getProject(@"projectName") except: jsonResp(Http404, getCurrentExceptionMsg()) if not existsRun(cfg, @"projectName", @"runId"): jsonResp(Http404, "no such run for project") try: resp($getLogs(cfg, @"projectName", @"runId")) except: json500Resp(getCurrentException(), "unable to load run logs for " & @"projectName" & " run " & @"runId") get "/project/@projectName/step/@stepName/artifacts/@version": ## Get the list of artifacts that were built for checkAuth() debug "Matched artifacts list request: " & $(%*{ "project": @"projectName", "step": @"stepName", "version": @"version" }) try: resp($(%listArtifacts(cfg, @"projectName", @"stepName", @"version")), JSON) except: try: raise getCurrentException() except NotFoundException: jsonResp(Http404, getCurrentExceptionMsg()) except: json500Resp(getCurrentException(), "unable to list artifacts for " & @"projectName" & ":" & @"stepName" & "@" & @"buildRef") get "/project/@projectName/step/@stepName/artifact/@version/@artifactName": ## Get a specific artifact that was built. checkAuth() var artifactPath: string try: artifactPath = getArtifactPath(cfg, @"projectName", @"stepName", @"version", @"artifactName") except: try: raise getCurrentException() except NotFoundException: jsonResp(Http404, getCurrentExceptionMsg()) except: json500Resp(getCurrentException(), "unable to check artifact path for " & @"projectName" & ":" & @"stepName" & "@" & @"version") enableRawMode debug "Preparing: " & artifactPath let fileSize = getFileSize(artifactPath) let mimetype = request.settings.mimes.getMimetype(artifactPath.splitFile.ext[1 .. ^1]) if fileSize < 10_000_000: # 10 mb var file = readFile(artifactPath) var hashed = getMD5(file) # If the user has a cached version of this file and it matches our # version, let them use it if request.headers.hasKey("If-None-Match") and request.headers["If-None-Match"] == hashed: resp(Http304) else: resp(Http200, [ ("Content-Disposition", "; filename=\"" & @"artifactName" & "\""), ("Content-Type", mimetype), ("ETag", hashed )], file) else: let headers = @{ "Content-Disposition": "; filename=\"" & @"artifactName" & "\"", "Content-Type": mimetype, "Content-Length": $fileSize } request.sendHeaders(Http200, headers) var fileStream = newFutureStream[string]("sendStaticIfExists") var file = openAsync(artifactPath, fmRead) # Let `readToStream` write file data into fileStream in the # background. asyncCheck file.readToStream(fileStream) # The `writeFromStream` proc will complete once all the data in the # `bodyStream` has been written to the file. while true: let (hasValue, value) = await fileStream.read() if hasValue: request.send(value) else: break file.close() get "/project/@projectName/step/@stepName/status/@buildRef": ## Get detailed information about the status of a step (assuming it has been built) checkAuth() try: resp($cfg.getBuildStatus(@"projectName", @"stepName", @"buildRef"), JSON) except: try: raise getCurrentException() except NotFoundException: jsonResp(Http404, getCurrentExceptionMsg()) except: json500Resp(getCurrentException(), "unable to load the build state for " & @"projectName" & ":" & @"stepName" & "@" & @"buildRef") #get "/project/@projectName/step/@stepName/status/@buildRef.svg": ## Get an image representing the status of a build ## TODO: how do we want to handle auth for this? Unlike #checkAuth(): if not authed: return true post "/project/@projectName/step/@stepName/run/@buildRef?": # Kick off a run checkAuth() let runRequest = RunRequest( runId: genUUID(), projectName: @"projectName", stepName: @"stepName", buildRef: if @"buildRef" != "": @"buildRef" else: "", timestamp: getTime().local, forceRebuild: false) # TODO support this with optional query params # TODO: instead of immediately spawning a worker, add the request to a # queue to be picked up by a worker. Allows capping the number of worker # prcesses, distributing, etc. try: let (status, worker) = spawnWorker(cfg, runRequest) workers.add(worker) resp($Run( id: runRequest.runId, request: runRequest, status: status), JSON) except: try: raise getCurrentException() except NotFoundException: jsonResp(Http404, getCurrentExceptionMsg()) except: jsonResp(Http400, getCurrentExceptionMsg()) post "/service/debug/stop": if not cfg.debug: jsonResp(Http404) else: let shutdownFut = sleepAsync(100) shutdownFut.callback = proc(): void = complete(stopFuture) resp($(%"shutting down"), JSON) get re".*": jsonResp(Http404) post re".*": jsonResp(Http404) proc performMaintenance(cfg: StrawBossConfig): void = # Prune workers workers = workers.filterIt(it.process.running()) debug "Performing maintanance: " & $len(workers) & " active workers after pruning." let fut = sleepAsync(cfg.maintenancePeriod) fut.callback = proc(): void = callSoon(proc(): void = performMaintenance(cfg)) info "StrawBoss is bossing people around." callSoon(proc(): void = performMaintenance(cfg)) waitFor(stopFuture)