server/admin/api/settings.go

444 lines
18 KiB
Go
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package api
import (
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"path"
"reflect"
"strconv"
"time"
"srs.epita.fr/fic-server/admin/generation"
"srs.epita.fr/fic-server/admin/sync"
"srs.epita.fr/fic-server/libfic"
"srs.epita.fr/fic-server/settings"
"github.com/gin-gonic/gin"
)
var IsProductionEnv = false
func declareSettingsRoutes(router *gin.RouterGroup) {
router.GET("/challenge.json", getChallengeInfo)
router.PUT("/challenge.json", saveChallengeInfo)
router.GET("/settings-ro.json", getROSettings)
router.GET("/settings.json", getSettings)
router.PUT("/settings.json", saveSettings)
router.DELETE("/settings.json", func(c *gin.Context) {
err := ResetSettings()
if err != nil {
log.Println("Unable to ResetSettings:", err.Error())
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": "An error occurs during setting reset."})
return
}
c.JSON(http.StatusOK, true)
})
router.GET("/settings-next", listNextSettings)
apiNextSettingsRoutes := router.Group("/settings-next/:ts")
apiNextSettingsRoutes.Use(NextSettingsHandler)
apiNextSettingsRoutes.GET("", getNextSettings)
apiNextSettingsRoutes.DELETE("", deleteNextSettings)
router.POST("/reset", reset)
router.POST("/full-generation", fullGeneration)
router.GET("/prod", func(c *gin.Context) {
c.JSON(http.StatusOK, IsProductionEnv)
})
router.PUT("/prod", func(c *gin.Context) {
err := c.ShouldBindJSON(&IsProductionEnv)
if err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": err.Error()})
return
}
c.JSON(http.StatusOK, IsProductionEnv)
})
}
func NextSettingsHandler(c *gin.Context) {
ts, err := strconv.ParseInt(string(c.Params.ByName("ts")), 10, 64)
if err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": "Invalid next settings identifier"})
return
}
nsf, err := settings.ReadNextSettingsFile(path.Join(settings.SettingsDir, fmt.Sprintf("%d.json", ts)), ts)
if err != nil {
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Next settings not found"})
return
}
c.Set("next-settings", nsf)
c.Next()
}
func fullGeneration(c *gin.Context) {
resp, err := generation.FullGeneration()
if err != nil {
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{
"errmsg": err.Error(),
})
return
}
defer resp.Body.Close()
v, _ := io.ReadAll(resp.Body)
c.JSON(resp.StatusCode, gin.H{
"errmsg": string(v),
})
}
func getROSettings(c *gin.Context) {
syncMtd := "Disabled"
if sync.GlobalImporter != nil {
syncMtd = sync.GlobalImporter.Kind()
}
var syncId *string
if sync.GlobalImporter != nil {
syncId = sync.GlobalImporter.Id()
}
c.JSON(http.StatusOK, gin.H{
"sync-type": reflect.TypeOf(sync.GlobalImporter).Name(),
"sync-id": syncId,
"sync": syncMtd,
})
}
func GetChallengeInfo() (*settings.ChallengeInfo, error) {
var challengeinfo string
var err error
if sync.GlobalImporter == nil {
if fd, err := os.Open(path.Join(settings.SettingsDir, settings.ChallengeFile)); err == nil {
defer fd.Close()
var buf []byte
buf, err = io.ReadAll(fd)
if err == nil {
challengeinfo = string(buf)
}
}
} else {
challengeinfo, err = sync.GetFileContent(sync.GlobalImporter, settings.ChallengeFile)
}
if err != nil {
log.Println("Unable to retrieve challenge.json:", err.Error())
return nil, fmt.Errorf("Unable to retrive challenge.json: %w", err)
}
s, err := settings.ReadChallengeInfo(challengeinfo)
if err != nil {
log.Println("Unable to ReadChallengeInfo:", err.Error())
return nil, fmt.Errorf("Unable to read challenge info: %w", err)
}
return s, nil
}
func getChallengeInfo(c *gin.Context) {
if s, err := GetChallengeInfo(); err != nil {
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": err.Error()})
} else {
c.JSON(http.StatusOK, s)
}
}
func saveChallengeInfo(c *gin.Context) {
var info *settings.ChallengeInfo
err := c.ShouldBindJSON(&info)
if err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": err.Error()})
return
}
if sync.GlobalImporter != nil {
jenc, err := json.Marshal(info)
if err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": err.Error()})
return
}
err = sync.WriteFileContent(sync.GlobalImporter, "challenge.json", jenc)
if err != nil {
log.Println("Unable to SaveChallengeInfo:", err.Error())
// Ignore the error, try to continue
}
err = sync.ImportChallengeInfo(info, DashboardDir)
if err != nil {
log.Println("Unable to ImportChallengeInfo:", err.Error())
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": fmt.Sprintf("Something goes wrong when trying to import related files: %s", err.Error())})
return
}
}
if err := settings.SaveChallengeInfo(path.Join(settings.SettingsDir, settings.ChallengeFile), info); err != nil {
log.Println("Unable to SaveChallengeInfo:", err.Error())
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": fmt.Sprintf("Unable to save distributed challenge info: %s", err.Error())})
return
}
c.JSON(http.StatusOK, info)
}
func getSettings(c *gin.Context) {
s, err := settings.ReadSettings(path.Join(settings.SettingsDir, settings.SettingsFile))
if err != nil {
log.Println("Unable to ReadSettings:", err.Error())
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": fmt.Sprintf("Unable to read settings: %s", err.Error())})
return
}
s.WorkInProgress = !IsProductionEnv
c.Writer.Header().Add("X-FIC-Time", fmt.Sprintf("%d", time.Now().Unix()))
c.JSON(http.StatusOK, s)
}
func saveSettings(c *gin.Context) {
var config *settings.Settings
err := c.ShouldBindJSON(&config)
if err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": err.Error()})
return
}
// Is this a future setting?
if c.Request.URL.Query().Has("t") {
t, err := time.Parse(time.RFC3339, c.Request.URL.Query().Get("t"))
if err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": err.Error()})
return
}
// Load current settings to perform diff later
init_settings, err := settings.ReadSettings(path.Join(settings.SettingsDir, settings.SettingsFile))
if err != nil {
log.Println("Unable to ReadSettings:", err.Error())
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": fmt.Sprintf("Unable to read settings: %s", err.Error())})
return
}
current_settings := init_settings
// Apply already registered settings
nsu, err := settings.MergeNextSettingsUntil(&t)
if err == nil {
current_settings = settings.MergeSettings(*init_settings, nsu)
} else {
log.Println("Unable to MergeNextSettingsUntil:", err.Error())
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": fmt.Sprintf("Unable to merge next settings: %s", err.Error())})
return
}
// Keep only diff
diff := settings.DiffSettings(current_settings, config)
hasItems := false
for _, _ = range diff {
hasItems = true
break
}
if !hasItems {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": "No difference to apply."})
return
}
if !c.Request.URL.Query().Has("erase") {
// Check if there is already diff to apply at the given time
if nsf, err := settings.ReadNextSettingsFile(path.Join(settings.SettingsDir, fmt.Sprintf("%d.json", t.Unix())), t.Unix()); err == nil {
for k, v := range nsf.Values {
if _, ok := diff[k]; !ok {
diff[k] = v
}
}
}
}
// Save the diff
settings.SaveSettings(path.Join(settings.SettingsDir, fmt.Sprintf("%d.json", t.Unix())), diff)
// Return current settings
c.JSON(http.StatusOK, current_settings)
} else {
// Just apply settings right now!
if err := settings.SaveSettings(path.Join(settings.SettingsDir, settings.SettingsFile), config); err != nil {
log.Println("Unable to SaveSettings:", err.Error())
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": fmt.Sprintf("Unable to save settings: %s", err.Error())})
return
}
ApplySettings(config)
c.JSON(http.StatusOK, config)
}
}
func listNextSettings(c *gin.Context) {
nsf, err := settings.ListNextSettingsFiles()
if err != nil {
log.Println("Unable to ListNextSettingsFiles:", err.Error())
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": fmt.Sprintf("Unable to list next settings files: %s", err.Error())})
return
}
c.JSON(http.StatusOK, nsf)
}
func getNextSettings(c *gin.Context) {
c.JSON(http.StatusOK, c.MustGet("next-settings").(*settings.NextSettingsFile))
}
func deleteNextSettings(c *gin.Context) {
nsf := c.MustGet("next-settings").(*settings.NextSettingsFile)
err := os.Remove(path.Join(settings.SettingsDir, fmt.Sprintf("%d.json", nsf.Id)))
if err != nil {
log.Println("Unable to remove the file:", err.Error())
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": fmt.Sprintf("Unable to remove the file: %s", err.Error())})
return
}
c.JSON(http.StatusOK, true)
}
func ApplySettings(config *settings.Settings) {
fic.PartialValidation = config.PartialValidation
fic.UnlockedChallengeDepth = config.UnlockedChallengeDepth
fic.UnlockedChallengeUpTo = config.UnlockedChallengeUpTo
fic.DisplayAllFlags = config.DisplayAllFlags
fic.HideCaseSensitivity = config.HideCaseSensitivity
fic.UnlockedStandaloneExercices = config.UnlockedStandaloneExercices
fic.UnlockedStandaloneExercicesByThemeStepValidation = config.UnlockedStandaloneExercicesByThemeStepValidation
fic.UnlockedStandaloneExercicesByStandaloneExerciceValidation = config.UnlockedStandaloneExercicesByStandaloneExerciceValidation
fic.DisplayMCQBadCount = config.DisplayMCQBadCount
fic.FirstBlood = config.FirstBlood
fic.SubmissionCostBase = config.SubmissionCostBase
fic.HintCoefficient = config.HintCurCoefficient
fic.WChoiceCoefficient = config.WChoiceCurCoefficient
fic.ExerciceCurrentCoefficient = config.ExerciceCurCoefficient
fic.GlobalScoreCoefficient = config.GlobalScoreCoefficient
fic.SubmissionCostBase = config.SubmissionCostBase
fic.SubmissionUniqueness = config.SubmissionUniqueness
fic.CountOnlyNotGoodTries = config.CountOnlyNotGoodTries
if config.DiscountedFactor != fic.DiscountedFactor {
fic.DiscountedFactor = config.DiscountedFactor
if err := fic.DBRecreateDiscountedView(); err != nil {
log.Println("Unable to recreate exercices_discounted view:", err.Error())
}
}
}
func ResetSettings() error {
return settings.SaveSettings(path.Join(settings.SettingsDir, settings.SettingsFile), &settings.Settings{
WorkInProgress: IsProductionEnv,
FirstBlood: fic.FirstBlood,
SubmissionCostBase: fic.SubmissionCostBase,
ExerciceCurCoefficient: 1,
HintCurCoefficient: 1,
WChoiceCurCoefficient: 1,
GlobalScoreCoefficient: 1,
DiscountedFactor: 0,
UnlockedStandaloneExercices: 10,
UnlockedStandaloneExercicesByThemeStepValidation: 1,
UnlockedStandaloneExercicesByStandaloneExerciceValidation: 0,
AllowRegistration: false,
CanJoinTeam: false,
DenyTeamCreation: false,
DenyNameChange: false,
AcceptNewIssue: true,
QAenabled: false,
EnableResolutionRoute: false,
PartialValidation: true,
UnlockedChallengeDepth: 0,
SubmissionUniqueness: true,
CountOnlyNotGoodTries: true,
DisplayAllFlags: false,
DisplayMCQBadCount: false,
EventKindness: false,
})
}
func ResetChallengeInfo() error {
return settings.SaveChallengeInfo(path.Join(settings.SettingsDir, settings.ChallengeFile), &settings.ChallengeInfo{
Title: "Challenge forensic",
SubTitle: "sous le patronage du commandement de la cyberdéfense",
Authors: "Laboratoire SRS, ÉPITA",
VideosLink: "",
Description: `<p>Le challenge <em>forensic</em> vous place dans la peau de <strong>spécialistes en investigation numérique</strong>. Nous mettons à votre disposition une <strong>vingtaine de scénarios différents</strong>, dans lesquels vous devrez faire les différentes étapes <strong>de la caractérisation dune réponse à incident</strong> proposées.</p>
<p>Chaque scénario met en scène un contexte d<strong>entreprise</strong>, ayant découvert récemment quelle a été <strong>victime dune cyberattaque</strong>. Elle vous demande alors de laider à <strong>caractériser</strong>, afin de mieux comprendre <strong>la situation</strong>, notamment le <strong>mode opératoire de ladversaire</strong>, les <strong>impacts</strong> de la cyberattaque, le <strong>périmètre technique compromis</strong>, etc. Il faudra parfois aussi léclairer sur les premières étapes de la réaction.</p>`,
Rules: `<h3>Déroulement</h3>
<p>Pendant toute la durée du challenge, vous aurez <strong>accès à tous les scénarios</strong>, mais seulement à la première des 5 étapes. <strong>Chaque étape</strong> supplémentaire <strong>est débloquée lorsque vous validez lintégralité de létape précédente</strong>. Toutefois, pour dynamiser le challenge toutes les étapes et tous les scénarios seront débloquées pour la dernière heure du challenge.</p>
<p>Nous mettons à votre disposition une <strong>plateforme</strong> sur laquelle vous pourrez <strong>obtenir les informations sur le contexte</strong> de lentreprise et, généralement, une <strong>série de fichiers</strong> qui semblent appropriés pour avancer dans linvestigation.</p>
<p>La <strong>validation dune étape</strong> se fait sur la plateforme, après avoir analysé les informations fournies, en <strong>répondant à des questions</strong> plus ou moins précises. Il sagit le plus souvent des <strong>mots-clefs</strong> que lon placerait dans un <strong>rapport</strong>.</p>
<p>Pour vous débloquer ou accélérer votre investigation, vous pouvez accéder à quelques <strong><em>indices</em></strong>, en échange dune décote sur votre score dun certain nombre de points préalablement affichés.</p>
<h3>Calcul des points, bonus, malus et classement</h3>
<p>Chaque équipe dispose dun <strong>compteur de points</strong> dans lintervalle ]-∞;+∞[ (aux détails techniques près), à partir duquel <strong>le classement est établi</strong>.</p>
<p>Vous <strong>perdez des points</strong> en <strong>dévoilant des indices</strong>, en <strong>demandant des propositions de réponses</strong> en remplacement de certains champs de texte, ou en <strong>essayant un trop grand nombre de fois une réponse</strong>.</p>
<p>Le nombre de points que vous fait perdre un indice dépend habituellement de laide quil vous apportera et est indiqué avant de le dévoiler, car il peut fluctuer en fonction de lavancement du challenge.</p>
<p>Pour chaque champ de texte, vous disposez de 10 tentatives avant de perdre des points (vous perdez les points même si vous ne validez pas létape) pour chaque tentative supplémentaire : -0,25&nbsp;point entre 11 et 20, -0,5 entre 21 et 30, -0,75 entre 31 et 40,&nbsp;…</p>
<p>La seule manière de <strong>gagner des points</strong> est de <strong>valider une étape dun scénario dans son intégralité</strong>. Le nombre de points gagnés <strong>dépend de la difficulté théorique</strong> de létape ainsi que <strong>déventuels bonus</strong>. Un bonus de <strong>10&nbsp;%</strong> est accordé à la première équipe qui valide une étape. D<strong>autres bonus</strong> peuvent ponctuer le challenge, détaillé dans la partie suivante.</p>
<p>Le classement est établi par équipe, selon le nombre de points récoltés et perdus par tous les membres. En cas dégalité au score, les équipes sont départagées en fonction de leur ordre darrivée à ce score.</p>
<h3>Temps forts</h3>
<p>Le challenge <em>forensic</em> est jalonné de plusieurs temps forts durant lesquels <strong>certains calculs</strong> détaillés dans la partie précédente <strong>peuvent être altérés</strong>. Léquipe danimation du challenge vous <strong>avertira</strong> environ <strong>15 minutes avant</strong> le début de la modification.</p>
<p>Chaque modification se répercute instantanément dans votre interface, attendez simplement quelle apparaisse afin dêtre certain den bénéficier. Un compte à rebours est généralement affiché sur les écrans pour indiquer la fin dun temps fort. La fin dapplication dun bonus est déterminé par lheure darrivée de votre demande sur nos serveurs.</p>
<p>Sans y être limité ou assuré, sachez que durant les précédentes éditions du challenge <em>forensic</em>, nous avons par exemple : <strong>doublé les points</strong> de défis peu tentés, <strong>doublé les points de tous les défis</strong> pendant 30 minutes, <strong>réduit le coût des indices</strong> pendant 15 minutes, etc.</p>
<p></p>
<p>Tous les étudiants de la majeure Système, Réseaux et Sécurité de lÉPITA, son équipe enseignante ainsi que le commandement de la cyberdéfense vous souhaitent bon courage pour cette nouvelle éditions du challenge !</p>`,
YourMission: `<h4>Bienvenue au challenge forensic&nbsp;!</h4>
<p>Vous voici aujourd'hui dans la peau de <strong>spécialistes en investigation numérique</strong>. Vous avez à votre disposition une vingtaine de scénarios différents dans lesquels vous devrez faire les différentes étapes <strong>de la caractérisation dune réponse à incident</strong>.</p>
<p>Chaque scénario est découpé en 5 grandes <strong>étapes de difficulté croissante</strong>. Un certain nombre de points est attribué à chaque étape, avec un processus de validation automatique.</p>
<p>Un classement est établi en temps réel, tenant compte des différents bonus, en fonction du nombre de points de chaque équipe.</p>`,
})
}
func reset(c *gin.Context) {
var m map[string]string
err := c.ShouldBindJSON(&m)
if err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": err.Error()})
return
}
t, ok := m["type"]
if !ok {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": "Field type not found"})
}
switch t {
case "teams":
err = fic.ResetTeams()
case "challenges":
err = fic.ResetExercices()
case "game":
err = fic.ResetGame()
case "annexes":
err = fic.ResetAnnexes()
case "settings":
err = ResetSettings()
case "challengeInfo":
err = ResetChallengeInfo()
default:
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": "Unknown reset type"})
return
}
if err != nil {
log.Printf("Unable to reset (type=%q): %s", t, err)
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": fmt.Sprintf("Unable to performe the reset: %s", err.Error())})
return
}
c.JSON(http.StatusOK, true)
}