469 lines
15 KiB
Nim

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)