224 lines
6.9 KiB
Nim
224 lines
6.9 KiB
Nim
import algorithm, asyncdispatch, bcrypt, jester, json, jwt, os, osproc,
|
|
sequtils, strutils, tempfile, times, unittest
|
|
|
|
import logging
|
|
import ./configuration, ./core, private/util
|
|
|
|
type Worker = object
|
|
process*: Process
|
|
workingDir*: string
|
|
|
|
type
|
|
Session = object
|
|
user*: UserRef
|
|
issuedAt*, expires*: Time
|
|
|
|
#const ISO_TIME_FORMAT = "yyyy-MM-dd'T'HH:mm:ss"
|
|
const JSON = "application/json"
|
|
|
|
proc makeJsonResp(status: HttpCode, details: string = ""): string =
|
|
result = $(%* {
|
|
"statusCode": status.int,
|
|
"status": $status,
|
|
"details": details
|
|
})
|
|
|
|
proc newSession*(user: UserRef): Session =
|
|
result = Session(
|
|
user: user,
|
|
issuedAt: getTime(),
|
|
expires: daysForward(7).toTime())
|
|
|
|
proc toJWT*(cfg: StrawBossConfig, session: Session): string =
|
|
# result = toJWT(%* {
|
|
# "header": {
|
|
# "alg": "HS256",
|
|
# "typ": "JWT" },
|
|
# "claims": {
|
|
# "sub": session.user.name,
|
|
# "iat": session.issuedAt.toSeconds().int,
|
|
# "exp": session.expires.toSeconds().int } })
|
|
|
|
var jwt = JWT(
|
|
header: JOSEHeader(alg: HS256, typ: "jwt"),
|
|
claims: toClaims(%*{
|
|
"sub": session.user.name,
|
|
"iat": session.issuedAt.toSeconds().int,
|
|
"exp": session.expires.toSeconds().int }))
|
|
|
|
jwt.sign(cfg.authSecret)
|
|
result = $jwt
|
|
|
|
proc fromJWT*(cfg: StrawBossConfig, strTok: string): Session =
|
|
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: fromSeconds(jwt.claims["iat"].node.num),
|
|
expires: fromSeconds(jwt.claims["exp"].node.num))
|
|
|
|
proc extractSession(cfg: StrawBossConfig, request: Request): Session =
|
|
|
|
# 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 spawnWorker(req: RunRequest): Worker =
|
|
let dir = mkdtemp()
|
|
var args = @["run", req.projectName, req.stepName, "-r", req.buildRef, "-w", dir]
|
|
if req.forceRebuild: args.add("-f")
|
|
result = Worker(
|
|
process: startProcess("strawboss", ".", args, loadEnv(), {poUsePath}),
|
|
workingDir: dir)
|
|
|
|
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 =
|
|
if uname == nil or pwd == nil:
|
|
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"
|
|
result = toJWT(cfg, newSession(user))
|
|
|
|
template withSession(body: untyped): untyped =
|
|
var session {.inject.}: Session
|
|
var authed = false
|
|
|
|
try:
|
|
session = extractSession(cfg, request)
|
|
authed = true
|
|
except:
|
|
debug "Auth failed: " & getCurrentExceptionMsg()
|
|
resp(Http401, makeJsonResp(Http401), JSON)
|
|
|
|
if authed: body
|
|
|
|
proc start*(cfg: StrawBossConfig): void =
|
|
|
|
let stopFuture = newFuture[void]()
|
|
var workers: seq[Worker] = @[]
|
|
|
|
settings:
|
|
port = Port(8180)
|
|
appName = "/api"
|
|
|
|
routes:
|
|
|
|
get "/ping": resp($(%*"pong"), JSON)
|
|
|
|
get "/auth-token":
|
|
try:
|
|
let authToken = makeAuthToken(cfg, @"username", @"password")
|
|
resp("\"" & $authToken & "\"", JSON)
|
|
except: resp(Http401, makeJsonResp(Http401, getCurrentExceptionMsg()), JSON)
|
|
|
|
get "/verify-auth": withSession:
|
|
resp(Http200, $(%*{ "username": session.user.name }), JSON)
|
|
|
|
get "/projects": withSession:
|
|
# List project summaries (ProjectDefs only)
|
|
resp($(%(cfg.projects)), JSON)
|
|
|
|
post "/projects": withSession:
|
|
# Create a new project definition
|
|
resp(Http501, makeJsonResp(Http501), JSON)
|
|
|
|
get "/project/@projectName/@version?": withSession:
|
|
## Get a detailed project record including step definitions (ProjectConfig).
|
|
|
|
# Make sure we know about that project
|
|
var project: ProjectDef
|
|
try: project = cfg.findProject(@"projectName")
|
|
except: resp(Http404, makeJsonResp(Http404, getCurrentExceptionMsg()), JSON)
|
|
|
|
# Given version
|
|
|
|
var cachedFilePath: string
|
|
if @"version" != "":
|
|
cachedFilePath = cfg.artifactsRepo & "/" & project.name &
|
|
"/configuration." & @"version" & ".json"
|
|
|
|
if not existsFile(cachedFilePath):
|
|
resp(Http404,
|
|
makeJsonResp(Http404, "I have never built version " & @"version"),
|
|
JSON)
|
|
|
|
# No version requested, use "latest"
|
|
else:
|
|
let confFilePaths = toSeq(walkFiles("configuration.*.json"))
|
|
if confFilePaths.len == 0:
|
|
resp(Http404, makeJsonResp(Http404, "I have not built any versions of " & project.name), JSON)
|
|
let modTimes = confFilePaths.mapIt(it.getLastModificationTime)
|
|
cachedFilePath = sorted(zip(confFilePaths, modTimes),
|
|
proc (a, b: tuple): int = cmp(a.b, b.b))[0].a
|
|
|
|
try: resp(readFile(cachedFilePath), JSON)
|
|
except:
|
|
debug "Could not serve cached project configuration at: " &
|
|
cachedFilePath & "\n\t Reason: " & getCurrentExceptionMsg()
|
|
resp(Http500, makeJsonResp(Http500, "could not read cached project configuration"), JSON)
|
|
|
|
get "/api/project/@projectName/active": withSession:
|
|
# List all currently active runs
|
|
resp(Http501, makeJsonResp(Http501), JSON)
|
|
|
|
get "/api/project/@projectName/@stepName": withSession:
|
|
|
|
# Get step details including runs.
|
|
resp(Http501, makeJsonResp(Http501), JSON)
|
|
|
|
get "/api/project/@projectName/@stepName/run/@buildRef": withSession:
|
|
# Get detailed information about a run
|
|
resp(Http501, makeJsonResp(Http501), JSON)
|
|
|
|
post "/project/@projectName/@stepName/run/@buildRef?":
|
|
# Kick off a run
|
|
workers.add(spawnWorker(RunRequest(
|
|
projectName: @"projectName",
|
|
stepName: @"stepName",
|
|
buildRef: if @"buildRef" != "": @"buildRef" else: nil,
|
|
forceRebuild: false))) # TODO support this with optional query params
|
|
|
|
post "/service/debug/stop":
|
|
if not cfg.debug: resp(Http404, makeJsonResp(Http404), JSON)
|
|
else:
|
|
callSoon(proc(): void = complete(stopFuture))
|
|
resp($(%*"shutting down"), JSON)
|
|
|
|
#[
|
|
get re".*":
|
|
resp(Http404, makeJsonResp(Http404), JSON)
|
|
|
|
post re".*":
|
|
resp(Http404, makeJsonResp(Http404), JSON)
|
|
]#
|
|
|
|
waitFor(stopFuture)
|