diff --git a/go/cmd/pathwar/main.go b/go/cmd/pathwar/main.go index 6854762b0..4ca1365c5 100644 --- a/go/cmd/pathwar/main.go +++ b/go/cmd/pathwar/main.go @@ -51,40 +51,40 @@ var ( flagOutput = os.Stderr // flag vars - globalDebug bool - agentForceRecreate bool - agentDaemonClean bool - agentDaemonRunOnce bool - agentDaemonLoopDelay time.Duration - agentName string - agentNginxDockerImage string - agentNginxDomainSuffix string - agentNginxHostIP string - agentNginxHostPort string - agentNginxModeratorPassword string - agentNginxSalt string - apiDBURN string - composeDownKeepVolumes bool - composeDownRemoveImages bool - composeDownWithNginx bool - composePSDepth int - composePrepareNoPush bool - composePreparePrefix string - composePrepareVersion string - composeUpInstanceKey string - composeUpForceRecreate bool - httpAPIAddr string - serverCORSAllowedOrigins string - serverBind string - serverRequestTimeout time.Duration - serverShutdownTimeout time.Duration - serverWithPprof bool - ssoAllowUnsafe bool - ssoClientID string - ssoClientSecret string - ssoPubkey string - ssoRealm string - ssoTokenFile string + globalDebug bool + agentClean bool + agentDomainSuffix string + agentForceRecreate bool + agentHostIP string + agentHostPort string + agentLoopDelay time.Duration + agentModeratorPassword string + agentName string + agentNginxDockerImage string + agentRunOnce bool + agentSalt string + apiDBURN string + composeDownKeepVolumes bool + composeDownRemoveImages bool + composeDownWithNginx bool + composePSDepth int + composePrepareNoPush bool + composePreparePrefix string + composePrepareVersion string + composeUpForceRecreate bool + composeUpInstanceKey string + httpAPIAddr string + serverBind string + serverCORSAllowedOrigins string + serverRequestTimeout time.Duration + serverShutdownTimeout time.Duration + serverWithPprof bool + ssoAllowUnsafe bool + ssoClientID string + ssoClientSecret string + ssoPubkey string + ssoRealm string + ssoTokenFile string ) func main() { @@ -99,9 +99,7 @@ func main() { // setup flags var ( globalFlags = flag.NewFlagSet("pathwar", flag.ExitOnError) - agentDaemonFlags = flag.NewFlagSet("agent daemon", flag.ExitOnError) agentFlags = flag.NewFlagSet("agent", flag.ExitOnError) - agentNginxFlags = flag.NewFlagSet("agent nginx", flag.ExitOnError) apiFlags = flag.NewFlagSet("api", flag.ExitOnError) composeDownFlags = flag.NewFlagSet("compose down", flag.ExitOnError) composeFlags = flag.NewFlagSet("compose", flag.ExitOnError) @@ -114,40 +112,49 @@ func main() { ) globalFlags.SetOutput(flagOutput) // used in main_test.go globalFlags.BoolVar(&globalDebug, "debug", false, "debug mode") - agentDaemonFlags.BoolVar(&agentDaemonClean, "clean", false, "remove all pathwar instances before executing") - agentDaemonFlags.BoolVar(&agentDaemonRunOnce, "once", false, "run once and don't start daemon loop") - agentDaemonFlags.DurationVar(&agentDaemonLoopDelay, "delay", 10*time.Second, "delay between each loop iteration") - agentDaemonFlags.StringVar(&httpAPIAddr, "http-api-addr", defaultHTTPApiAddr, "HTTP API address") - agentDaemonFlags.StringVar(&ssoClientID, "sso-clientid", defaultSSOClientID, "SSO ClientID") - agentDaemonFlags.StringVar(&ssoClientSecret, "sso-clientsecret", defaultSSOClientSecret, "SSO ClientSecret") - agentDaemonFlags.StringVar(&ssoRealm, "sso-realm", defaultSSORealm, "SSO Realm") - agentDaemonFlags.StringVar(&ssoTokenFile, "sso-token-file", defaultTokenFile, "Token file") - agentDaemonFlags.StringVar(&agentName, "agent-name", defaultAgentName, "Agent Name") - agentNginxFlags.StringVar(&agentNginxDockerImage, "docker-image", "docker.io/library/nginx:stable-alpine", "docker image used to generate nginx proxy container") - agentNginxFlags.StringVar(&agentNginxDomainSuffix, "domain-suffix", "local", "Domain suffix to append") - agentNginxFlags.StringVar(&agentNginxHostIP, "host", "0.0.0.0", "HTTP listening addr") - agentNginxFlags.StringVar(&agentNginxHostPort, "port", "8000", "HTTP listening port") - agentNginxFlags.StringVar(&agentNginxModeratorPassword, "moderator-password", "", "Challenge moderator password") - agentNginxFlags.StringVar(&agentNginxSalt, "salt", "", "salt used to generate secure hashes (random if empty)") + + agentFlags.BoolVar(&agentClean, "clean", false, "remove all pathwar instances before executing") + agentFlags.BoolVar(&agentRunOnce, "once", false, "run once and don't start daemon loop") + agentFlags.DurationVar(&agentLoopDelay, "delay", 10*time.Second, "delay between each loop iteration") + agentFlags.StringVar(&httpAPIAddr, "http-api-addr", defaultHTTPApiAddr, "HTTP API address") + agentFlags.StringVar(&ssoClientID, "sso-clientid", defaultSSOClientID, "SSO ClientID") + agentFlags.StringVar(&ssoClientSecret, "sso-clientsecret", defaultSSOClientSecret, "SSO ClientSecret") + agentFlags.StringVar(&ssoRealm, "sso-realm", defaultSSORealm, "SSO Realm") + agentFlags.StringVar(&ssoTokenFile, "sso-token-file", defaultTokenFile, "Token file") + agentFlags.StringVar(&agentName, "agent-name", defaultAgentName, "Agent Name") + agentFlags.StringVar(&agentDomainSuffix, "nginx-domain-suffix", "local", "Domain suffix to append") + agentFlags.StringVar(&agentNginxDockerImage, "docker-image", "docker.io/library/nginx:stable-alpine", "docker image used to generate nginx proxy container") + agentFlags.StringVar(&agentDomainSuffix, "domain-suffix", "local", "Domain suffix to append") + agentFlags.StringVar(&agentHostIP, "host", "0.0.0.0", "HTTP listening addr") + agentFlags.StringVar(&agentHostPort, "port", "8000", "HTTP listening port") + agentFlags.StringVar(&agentModeratorPassword, "moderator-password", "", "Challenge moderator password") + agentFlags.StringVar(&agentSalt, "salt", "", "salt used to generate secure hashes (random if empty)") + apiFlags.BoolVar(&ssoAllowUnsafe, "sso-unsafe", false, "Allow unsafe SSO") apiFlags.StringVar(&apiDBURN, "urn", defaultDBURN, "MySQL URN") apiFlags.StringVar(&ssoClientID, "sso-clientid", defaultSSOClientID, "SSO ClientID") apiFlags.StringVar(&ssoPubkey, "sso-pubkey", "", "SSO Public Key") apiFlags.StringVar(&ssoRealm, "sso-realm", defaultSSORealm, "SSO Realm") + composeDownFlags.BoolVar(&composeDownKeepVolumes, "keep-volumes", false, "keep volumes") composeDownFlags.BoolVar(&composeDownRemoveImages, "rmi", false, "remove images as well") composeDownFlags.BoolVar(&composeDownWithNginx, "with-nginx", false, "down nginx container and proxy network as well") + composePSFlags.IntVar(&composePSDepth, "depth", 0, "depth to display") + composePrepareFlags.BoolVar(&composePrepareNoPush, "no-push", false, "don't push images") composePrepareFlags.StringVar(&composePreparePrefix, "prefix", defaultDockerPrefix, "docker image prefix") composePrepareFlags.StringVar(&composePrepareVersion, "version", "1.0.0", "challenge version") + composeUpFlags.StringVar(&composeUpInstanceKey, "instance-key", "default", "instance key used to generate instance ID") composeUpFlags.BoolVar(&composeUpForceRecreate, "force-recreate", false, "down previously created instances of challenge") + serverFlags.BoolVar(&serverWithPprof, "with-pprof", false, "enable pprof endpoints") serverFlags.DurationVar(&serverRequestTimeout, "request-timeout", 5*time.Second, "request timeout") serverFlags.DurationVar(&serverShutdownTimeout, "shutdown-timeout", 6*time.Second, "shutdown timeout") serverFlags.StringVar(&serverCORSAllowedOrigins, "cors-allowed-origins", "*", "allowed CORS origins") serverFlags.StringVar(&serverBind, "bind", ":8000", "server address") + ssoFlags.BoolVar(&ssoAllowUnsafe, "unsafe", false, "Allow unsafe SSO") ssoFlags.StringVar(&ssoClientID, "clientid", defaultSSOClientID, "SSO ClientID") ssoFlags.StringVar(&ssoPubkey, "pubkey", "", "SSO Public Key") @@ -457,7 +464,16 @@ func main() { return errcode.ErrInitDockerClient.Wrap(err) } - return pwcompose.Up(ctx, string(preparedCompose), composeUpInstanceKey, composeUpForceRecreate, nil, cli, logger) + services, err := pwcompose.Up(ctx, string(preparedCompose), composeUpInstanceKey, composeUpForceRecreate, "", nil, cli, logger) + if err != nil { + return err + } + + for _, service := range services { + fmt.Println(service.ContainerName) + } + + return nil }, } @@ -476,7 +492,7 @@ func main() { return errcode.ErrInitDockerClient.Wrap(err) } - return pwcompose.Down( + return pwcompose.Clean( ctx, args, composeDownRemoveImages, @@ -509,17 +525,18 @@ func main() { compose := &ffcli.Command{ Name: "compose", - Usage: "pathwar [global flags] compose [sso flags] [flags] [args...]", + Usage: "pathwar [global flags] compose [compose flags] [flags] [args...]", Subcommands: []*ffcli.Command{composePrepare, composeUp, composePS, composeDown}, ShortHelp: "manage a challenge", FlagSet: composeFlags, Exec: func([]string) error { return flag.ErrHelp }, } - agentDaemon := &ffcli.Command{ - Name: "daemon", - Usage: "pathwar [global flags] agent [agent flags] daemon [flags]", - FlagSet: agentDaemonFlags, + agent := &ffcli.Command{ + Name: "agent", + Usage: "pathwar [global flags] agent [agent flags] [flags] [args...]", + ShortHelp: "manage an agent node (multiple challenges)", + FlagSet: agentFlags, Exec: func(args []string) error { if err := globalPreRun(); err != nil { return err @@ -535,57 +552,26 @@ func main() { return errcode.TODO.Wrap(err) } - return pwagent.Daemon(ctx, agentDaemonClean, agentDaemonRunOnce, agentDaemonLoopDelay, dockerCli, apiClient, httpAPIAddr, agentName, logger) - }, - } - - agentNginx := &ffcli.Command{ - Name: "nginx", - Usage: "pathwar [global flags] agent [agent flags] nginx [flags] ALLOWED_USERS", - FlagSet: agentNginxFlags, - Exec: func(args []string) error { - if len(args) < 1 { - return flag.ErrHelp - } - - if err := globalPreRun(); err != nil { - return err - } - - // prepare AgentOpts - config := pwagent.AgentOpts{ - HostIP: agentNginxHostIP, - HostPort: agentNginxHostPort, - DomainSuffix: agentNginxDomainSuffix, - ModeratorPassword: agentNginxModeratorPassword, - Salt: agentNginxSalt, + opts := pwagent.Opts{ + HostIP: agentHostIP, + HostPort: agentHostPort, + DomainSuffix: agentDomainSuffix, + ModeratorPassword: agentModeratorPassword, + Salt: agentSalt, ForceRecreate: agentForceRecreate, NginxDockerImage: agentNginxDockerImage, - } - err := json.Unmarshal([]byte(args[0]), &config.AllowedUsers) - if err != nil { - return errcode.ErrInvalidInput.Wrap(err) + Cleanup: agentClean, + RunOnce: agentRunOnce, + LoopDelay: agentLoopDelay, + HTTPAPIAddr: httpAPIAddr, + Name: agentName, + Logger: logger, } - ctx := context.Background() - cli, err := client.NewEnvClient() - if err != nil { - return errcode.ErrInitDockerClient.Wrap(err) - } - - return pwagent.Nginx(ctx, config, cli, logger) + return pwagent.Daemon(ctx, dockerCli, apiClient, opts) }, } - agent := &ffcli.Command{ - Name: "agent", - Usage: "pathwar [global flags] agent [sso flags] [flags] [args...]", - ShortHelp: "manage an agent node (multiple challenges)", - Subcommands: []*ffcli.Command{agentDaemon, agentNginx}, - FlagSet: agentFlags, - Exec: func([]string) error { return flag.ErrHelp }, - } - root := &ffcli.Command{ Usage: "pathwar [global flags] [flags] [args...]", FlagSet: globalFlags, diff --git a/go/pkg/pwagent/api.go b/go/pkg/pwagent/api.go new file mode 100644 index 000000000..2ff143a56 --- /dev/null +++ b/go/pkg/pwagent/api.go @@ -0,0 +1,36 @@ +package pwagent + +import ( + "context" + "fmt" + "io/ioutil" + "net/http" + + "github.com/gogo/protobuf/jsonpb" + "go.uber.org/zap" + "pathwar.land/go/pkg/errcode" + "pathwar.land/go/pkg/pwapi" +) + +func fetchAPIInstances(ctx context.Context, apiClient *http.Client, httpAPIAddr string, agentName string, logger *zap.Logger) (*pwapi.AgentListInstances_Output, error) { + var instances pwapi.AgentListInstances_Output + + resp, err := apiClient.Get(httpAPIAddr + "/agent/list-instances?agent_name=" + agentName) + if err != nil { + return nil, errcode.TODO.Wrap(err) + } + defer resp.Body.Close() + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + return nil, errcode.TODO.Wrap(err) + } + if resp.StatusCode != http.StatusOK { + logger.Error("received API error", zap.String("body", string(body)), zap.Int("code", resp.StatusCode)) + return nil, errcode.TODO.Wrap(fmt.Errorf("received API error")) + } + if err := jsonpb.UnmarshalString(string(body), &instances); err != nil { + return nil, errcode.TODO.Wrap(err) + } + + return &instances, nil +} diff --git a/go/pkg/pwagent/config.go b/go/pkg/pwagent/config.go deleted file mode 100644 index d86597047..000000000 --- a/go/pkg/pwagent/config.go +++ /dev/null @@ -1,24 +0,0 @@ -package pwagent - -type AgentOpts struct { - DomainSuffix string // .127.0.0.1.xip.io, .fr1.pathwar.pw, ... - HostIP string // 0.0.0.0, ... - HostPort string // 8000, 80 ... - ModeratorPassword string // s3cur3 - Salt string // s3cur3-t0o - AllowedUsers map[string][]int64 // map[INSTANCE_ID][]USER_ID, map[42][]string{4242, 4343} - ForceRecreate bool - NginxDockerImage string -} - -type NginxConfigData struct { - Upstreams []NginxUpstream - Opts AgentOpts -} - -type NginxUpstream struct { - Name string - Host string - Port string - Hashes []string -} diff --git a/go/pkg/pwagent/daemon.go b/go/pkg/pwagent/daemon.go index 8de7928de..99cd508b3 100644 --- a/go/pkg/pwagent/daemon.go +++ b/go/pkg/pwagent/daemon.go @@ -2,180 +2,75 @@ package pwagent import ( "context" - "encoding/json" - fmt "fmt" - "io/ioutil" "net/http" - "strconv" "time" "github.com/docker/docker/client" - "github.com/gogo/protobuf/jsonpb" "go.uber.org/zap" "pathwar.land/go/pkg/errcode" - "pathwar.land/go/pkg/pwapi" "pathwar.land/go/pkg/pwcompose" - "pathwar.land/go/pkg/pwdb" - "pathwar.land/go/pkg/pwinit" ) -func Daemon(ctx context.Context, clean bool, runOnce bool, loopDelay time.Duration, cli *client.Client, apiClient *http.Client, httpAPIAddr string, agentName string, logger *zap.Logger) error { +func Daemon(ctx context.Context, cli *client.Client, apiClient *http.Client, opts Opts) error { + started := time.Now() + + err := opts.applyDefaults() + if err != nil { + return errcode.TODO.Wrap(err) + } + + logger := opts.Logger + // FIXME: call API register in gRPC // ret, err := api.AgentRegister(ctx, &pwapi.AgentRegister_Input{Name: "dev", Hostname: "localhost", OS: "lorem ipsum", Arch: "x86_64", Version: "dev", Tags: []string{"dev"}}) - // cleanup - if clean { - err := pwcompose.Down(ctx, []string{}, true, true, true, cli, logger) + if opts.Cleanup { + before := time.Now() + err := pwcompose.DownAll(ctx, cli, logger) if err != nil { return errcode.ErrCleanPathwarInstances.Wrap(err) } + logger.Info("docker cleaned up", zap.Duration("duration", time.Since(before))) } + iteration := 0 for { - instances, err := fetchAPIInstances(ctx, apiClient, httpAPIAddr, agentName, logger) - if err != nil { - logger.Error("fetch instances", zap.Error(err)) + if !opts.RunOnce { + logger.Debug("daemon iteration", zap.Int("number", iteration), zap.Duration("uptime", time.Since(started))) + } - } else { - if err := run(ctx, instances, cli, logger); err != nil { - logger.Error("pwdaemon", zap.Error(err)) - } + err := runOnce(ctx, cli, apiClient, opts) + if err != nil { + logger.Error("daemon iteration", zap.Error(err)) } - if runOnce { + if opts.RunOnce { break } - time.Sleep(loopDelay) + time.Sleep(opts.LoopDelay) } - - // FIXME: agent update state for each updated instances return nil } -func fetchAPIInstances(ctx context.Context, apiClient *http.Client, httpAPIAddr string, agentName string, logger *zap.Logger) (*pwapi.AgentListInstances_Output, error) { - var instances pwapi.AgentListInstances_Output - - resp, err := apiClient.Get(httpAPIAddr + "/agent/list-instances?agent_name=" + agentName) - if err != nil { - return nil, errcode.TODO.Wrap(err) - } - defer resp.Body.Close() - body, err := ioutil.ReadAll(resp.Body) +func runOnce(ctx context.Context, cli *client.Client, apiClient *http.Client, opts Opts) error { + instances, err := fetchAPIInstances(ctx, apiClient, opts.HTTPAPIAddr, opts.Name, opts.Logger) if err != nil { - return nil, errcode.TODO.Wrap(err) - } - if resp.StatusCode != http.StatusOK { - logger.Error("received API error", zap.String("body", string(body)), zap.Int("code", resp.StatusCode)) - return nil, errcode.TODO.Wrap(fmt.Errorf("received API error")) - } - if err := jsonpb.UnmarshalString(string(body), &instances); err != nil { - return nil, errcode.TODO.Wrap(err) + return errcode.TODO.Wrap(err) } - return &instances, nil -} - -func run(ctx context.Context, apiInstances *pwapi.AgentListInstances_Output, cli *client.Client, logger *zap.Logger) error { - // fetch local info from docker daemon - containersInfo, err := pwcompose.GetContainersInfo(ctx, cli) - if err != nil { - return errcode.ErrComposeGetContainersInfo.Wrap(err) - } - - agentOpts := AgentOpts{ - DomainSuffix: "local", - HostIP: "0.0.0.0", - HostPort: "8000", - ModeratorPassword: "", - Salt: "1337supmyman1337", - AllowedUsers: map[string][]int64{}, - ForceRecreate: false, - NginxDockerImage: "docker.io/library/nginx:stable-alpine", - } - - // compute instances that needs to upped / redumped - for _, apiInstance := range apiInstances.GetInstances() { - found := false - needRedump := false - for _, flavor := range containersInfo.RunningFlavors { - apiInstanceFlavor := apiInstance.GetFlavor() - apiInstanceFlavorChallenge := apiInstanceFlavor.GetChallenge() - if apiInstanceFlavor != nil && apiInstanceFlavorChallenge != nil { - if flavor.InstanceKey == strconv.FormatInt(apiInstance.GetID(), 10) { - found = true - if apiInstance.GetStatus() == pwdb.ChallengeInstance_NeedRedump { - needRedump = true - } - } - } - } - if !found || needRedump { - // parse pwinit config - var configData pwinit.InitConfig - err = json.Unmarshal(apiInstance.GetInstanceConfig(), &configData) - if err != nil { - return errcode.ErrParseInitConfig.Wrap(err) - } - - err = pwcompose.Up(ctx, apiInstance.GetFlavor().GetComposeBundle(), strconv.FormatInt(apiInstance.GetID(), 10), true, &configData, cli, logger) - if err != nil { - return errcode.ErrUpPathwarInstance.Wrap(err) - } - } + if err := applyDockerConfig(ctx, instances, cli, opts); err != nil { + return errcode.TODO.Wrap(err) } - // update pathwar infos - containersInfo, err = pwcompose.GetContainersInfo(ctx, cli) - if err != nil { - return errcode.ErrComposeGetContainersInfo.Wrap(err) + if err := applyNginxConfig(ctx, instances, cli, opts); err != nil { + return errcode.TODO.Wrap(err) } - // update nginx configuration - for _, apiInstance := range apiInstances.GetInstances() { - if apiInstanceFlavor := apiInstance.GetFlavor(); apiInstanceFlavor != nil { - if seasonChallenges := apiInstanceFlavor.GetSeasonChallenges(); seasonChallenges != nil { - for _, seasonChallenge := range seasonChallenges { - if subscriptions := seasonChallenge.GetActiveSubscriptions(); subscriptions != nil { - for _, subscription := range subscriptions { - if team := subscription.GetTeam(); team != nil { - if members := team.GetMembers(); members != nil { - for _, member := range members { - for _, flavor := range containersInfo.RunningFlavors { - if flavor.InstanceKey == strconv.FormatInt(apiInstance.GetID(), 10) { - for _, instance := range flavor.Instances { - for _, port := range instance.Ports { - if port.PublicPort != 0 { - // configure nginx - // generate a hash per user for challenge dns prefix, based on their userIDs - instanceName := instance.Names[0][1:] - _, entryFound := agentOpts.AllowedUsers[instanceName] - if !entryFound { - agentOpts.AllowedUsers[instanceName] = []int64{member.GetID()} - } else { - allowedUsersSlice := agentOpts.AllowedUsers[instanceName] - allowedUsersSlice = append(allowedUsersSlice, member.GetID()) - agentOpts.AllowedUsers[instanceName] = allowedUsersSlice - } - } - } - } - } - } - } - } - } - } - } - } - } - } - } - - err = Nginx(ctx, agentOpts, cli, logger) - if err != nil { - return errcode.ErrUpdateNginx.Wrap(err) - } + // FIXME: implement this + /*if err := updateAPIInstancesStatus(ctx, instances, cli, opts); err != nil { + return errcode.TODO.Wrap(err) + }*/ return nil } diff --git a/go/pkg/pwagent/docker.go b/go/pkg/pwagent/docker.go new file mode 100644 index 000000000..be6c88b08 --- /dev/null +++ b/go/pkg/pwagent/docker.go @@ -0,0 +1,107 @@ +package pwagent + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/docker/docker/api/types" + "github.com/docker/docker/client" + "go.uber.org/zap" + "pathwar.land/go/pkg/errcode" + "pathwar.land/go/pkg/pwapi" + "pathwar.land/go/pkg/pwcompose" + "pathwar.land/go/pkg/pwdb" + "pathwar.land/go/pkg/pwinit" +) + +func applyDockerConfig(ctx context.Context, apiInstances *pwapi.AgentListInstances_Output, dockerClient *client.Client, opts Opts) error { + logger := opts.Logger + logger.Debug("apply docker", zap.Any("opts", opts)) + + // fetch local info from docker daemon + containersInfo, err := pwcompose.GetContainersInfo(ctx, dockerClient) + if err != nil { + return errcode.ErrComposeGetContainersInfo.Wrap(err) + } + + runningDockerInstances := map[string]bool{} + for _, flavor := range containersInfo.RunningFlavors { + runningDockerInstances[flavor.InstanceKey] = true + } + + var ( + started = 0 + ignored = 0 + proxyNetworkID string + ) + + { // configure proxy network + networkResources, err := dockerClient.NetworkList(ctx, types.NetworkListOptions{}) + if err != nil { + return errcode.ErrDockerAPINetworkList.Wrap(err) + } + for _, networkResource := range networkResources { + if networkResource.Name == pwcompose.ProxyNetworkName { + proxyNetworkID = networkResource.ID + break + } + } + if proxyNetworkID == "" { + response, err := dockerClient.NetworkCreate(ctx, pwcompose.ProxyNetworkName, types.NetworkCreate{ + CheckDuplicate: true, + }) + if err != nil { + return errcode.ErrDockerAPINetworkCreate.Wrap(err) + } + proxyNetworkID = response.ID + logger.Info("proxy network created", zap.String("name", pwcompose.ProxyNetworkName)) + } + } + + for _, instance := range apiInstances.GetInstances() { + instanceID := fmt.Sprintf("%d", instance.ID) + l := logger.With( + zap.String("id", instanceID), + zap.String("flavor", instance.GetFlavor().NameAndVersion()), + ) + if instance.Status == pwdb.ChallengeInstance_Disabled { + l.Debug("instance disabled") + ignored++ + continue + } + + isRunning := runningDockerInstances[instanceID] + if isRunning && instance.Status != pwdb.ChallengeInstance_NeedRedump { + l.Debug("instance running") + ignored++ + continue + } + + // parse pwinit config + var configData pwinit.InitConfig + err = json.Unmarshal(instance.GetInstanceConfig(), &configData) + if err != nil { + return errcode.ErrParseInitConfig.Wrap(err) + } + + bundle := instance.GetFlavor().GetComposeBundle() + before := time.Now() + started++ + containers, err := pwcompose.Up(ctx, bundle, instanceID, true, proxyNetworkID, &configData, dockerClient, logger) + if err != nil { + return errcode.ErrUpPathwarInstance.Wrap(err) + } + + l.Info( + "started instance", + zap.Duration("duration", time.Since(before)), + zap.Int("containers", len(containers)), + ) + } + + logger.Debug("docker stats", zap.Int("started", started), zap.Int("ignored", ignored)) + + return nil +} diff --git a/go/pkg/pwagent/nginx.go b/go/pkg/pwagent/nginx.go index 4b0e4324e..d8e1d692b 100644 --- a/go/pkg/pwagent/nginx.go +++ b/go/pkg/pwagent/nginx.go @@ -20,131 +20,89 @@ import ( "github.com/moby/moby/pkg/stdcopy" "go.uber.org/zap" "golang.org/x/crypto/sha3" - "pathwar.land/go/internal/randstring" + "moul.io/godev" "pathwar.land/go/pkg/errcode" + "pathwar.land/go/pkg/pwapi" "pathwar.land/go/pkg/pwcompose" + "pathwar.land/go/pkg/pwdb" ) -const nginxConfigTemplate = ` -{{$root := .}} -#user www www; -worker_processes 5; -error_log /proc/self/fd/2; -#pid /tmp/nginx.pid; -worker_rlimit_nofile 8192; - -events { - worker_connections 4096; -} +func applyNginxConfig(ctx context.Context, apiInstances *pwapi.AgentListInstances_Output, dockerClient *client.Client, opts Opts) error { + logger := opts.Logger + logger.Debug("apply nginx", zap.Any("opts", opts)) -http { - types { - text/html html htm shtml; - text/css css; - image/gif gif; - image/jpeg jpeg jpg; - application/x-javascript js; - text/plain txt; - image/png png; - image/x-icon ico; - } - proxy_redirect off; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - client_max_body_size 10m; - client_body_buffer_size 128k; - proxy_connect_timeout 90; - proxy_send_timeout 90; - proxy_read_timeout 90; - proxy_buffers 32 4k; - index index.html index.htm; - - default_type application/octet-stream; - log_format main '$remote_addr - $remote_user [$time_local] $status "$request" $body_bytes_sent "$http_referer" "$http_user_agent" "$http_x_forwarded_for"'; - access_log /proc/self/fd/1 main; - sendfile on; - tcp_nopush on; - server_names_hash_bucket_size 128; - - server { - listen 80 default_server; - server_name _; - error_log /proc/self/fd/2; - access_log /proc/self/fd/1; - return 503; - } - - {{range .Upstreams -}} - upstream upstream_{{.Name}} { server {{.Host}}:{{.Port}}; } - server { - listen 80; - server_name moderator-{{.Name}}{{$root.Opts.DomainSuffix}}; - access_log /proc/self/fd/1; - error_log /proc/self/fd/2; - # FIXME: add auth - location / { - proxy_pass http://upstream_{{.Name}}; - } - } - {{- if not (eq (len .Hashes) 0) }} - server { - listen 80; - server_name{{range .Hashes}} {{.}}{{$root.Opts.DomainSuffix}}{{end}}; - access_log /proc/self/fd/1; - error_log /proc/self/fd/2; - location / { - proxy_pass http://upstream_{{.Name}}; - } - } - {{end}} - {{end -}} -} -` + // start nginx container + if err := ensureNginxContainer(ctx, dockerClient, opts); err != nil { + return errcode.TODO.Wrap(err) + } -func Nginx(ctx context.Context, opts AgentOpts, cli *client.Client, logger *zap.Logger) error { - if opts.Salt == "" { - opts.Salt = randstring.RandString(10) - logger.Warn("random salt generated", zap.String("salt", opts.Salt)) + // generate nginx config + containersInfo, err := pwcompose.GetContainersInfo(ctx, dockerClient) + if err != nil { + return errcode.ErrComposeGetContainersInfo.Wrap(err) } - if opts.ModeratorPassword == "" { - opts.ModeratorPassword = randstring.RandString(10) - logger.Warn("random moderator password generated", zap.String("password", opts.ModeratorPassword)) + if opts.DomainSuffix == "local" { + proxyNetworkIP := containersInfo.NginxProxyInstance.NetworkSettings.Networks[pwcompose.ProxyNetworkName].IPAddress + opts.DomainSuffix = proxyNetworkIP + ".xip.io" + } + config, err := genNginxConfig(apiInstances, containersInfo, opts) + if err != nil { + return errcode.TODO.Wrap(err) + } + if logger.Check(zap.DebugLevel, "") != nil { + fmt.Fprintln(os.Stderr, "config", godev.PrettyJSON(config)) } - logger.Debug("agent nginx", zap.Any("opts", opts)) - // check if proxy network has been created - proxyNetworkID := "" - networkResources, err := cli.NetworkList(ctx, types.NetworkListOptions{}) + // configure nginx binary + buf, err := buildNginxConfigTar(config, logger) if err != nil { - return errcode.ErrDockerAPINetworkList.Wrap(err) + return errcode.ErrBuildNginxConfig.Wrap(err) } - for _, networkResource := range networkResources { - if networkResource.Name == pwcompose.ProxyNetworkName { - proxyNetworkID = networkResource.ID - } + nginxContainer := containersInfo.NginxProxyInstance + logger.Debug("copy nginx config into the container", zap.String("container-id", nginxContainer.ID)) + err = dockerClient.CopyToContainer(ctx, nginxContainer.ID, "/etc/nginx/", buf, types.CopyToContainerOptions{}) + if err != nil { + return errcode.ErrCopyNginxConfigToContainer.Wrap(err) } - if proxyNetworkID == "" { - logger.Debug("proxy network create", zap.String("name", pwcompose.ProxyNetworkName)) - response, err := cli.NetworkCreate(ctx, pwcompose.ProxyNetworkName, types.NetworkCreate{ - CheckDuplicate: true, - }) - proxyNetworkID = response.ID - if err != nil { - return errcode.ErrDockerAPINetworkCreate.Wrap(err) + args := []string{"nginx", "-t", "-c", "/etc/nginx/nginx.conf"} + logger.Debug("send nginx command", zap.Strings("args", args)) + err = nginxSendCommand(ctx, dockerClient, nginxContainer.ID, logger, args...) + if err != nil { + return errcode.ErrNginxSendCommandNewConfigCheck.Wrap(err) + } + // new config hot reload + args = []string{"nginx", "-s", "reload"} + logger.Debug("send nginx command", zap.Strings("args", args)) + err = nginxSendCommand(ctx, dockerClient, nginxContainer.ID, logger, args...) + if err != nil { + return errcode.ErrNginxSendCommandReloadConfig.Wrap(err) + } + if logger.Check(zap.DebugLevel, "") != nil { + for _, upstream := range config.Upstreams { + fmt.Fprintf(os.Stderr, "- %s\n", upstream.Name) + for _, hash := range upstream.Hashes { + fmt.Fprintf(os.Stderr, " - %s.%s\n", hash, opts.DomainSuffix) + } + } } + return nil +} + +func ensureNginxContainer(ctx context.Context, dockerClient *client.Client, opts Opts) error { + logger := opts.Logger + // check if nginx server is started - nginxContainer, err := checkNginxContainer(ctx, cli) + nginxContainer, err := checkNginxContainer(ctx, dockerClient) if err != nil { return errcode.ErrCheckNginxContainer.Wrap(err) } // remove nginx container if forced if opts.ForceRecreate && nginxContainer != nil { - logger.Debug("nginx container remove", zap.String("id", nginxContainer.ID)) - err := cli.ContainerRemove(ctx, nginxContainer.ID, types.ContainerRemoveOptions{ + logger.Debug("remove old nginx", zap.String("id", nginxContainer.ID)) + err := dockerClient.ContainerRemove(ctx, nginxContainer.ID, types.ContainerRemoveOptions{ Force: true, RemoveVolumes: true, }) @@ -156,176 +114,144 @@ func Nginx(ctx context.Context, opts AgentOpts, cli *client.Client, logger *zap. // build nginx container if needed var nginxContainerID string - running := false + var running bool if nginxContainer == nil { - logger.Debug("build nginx container", zap.Any("opts", opts)) - nginxContainerID, err = buildNginxContainer(ctx, cli, opts) + logger.Debug("build nginx", zap.Any("opts", opts)) + nginxContainerID, err = buildNginxContainer(ctx, dockerClient, opts) if err != nil { return errcode.ErrBuildNginxContainer.Wrap(err) } + running = true } else { nginxContainerID = nginxContainer.ID - running = (nginxContainer.State == "running") + running = nginxContainer.State == "running" } // start nginx container if needed - if !running { - logger.Debug("start nginx container", zap.String("id", nginxContainerID)) - err = cli.ContainerStart(ctx, nginxContainerID, types.ContainerStartOptions{}) + if running { + err = dockerClient.ContainerStart(ctx, nginxContainerID, types.ContainerStartOptions{}) if err != nil { return errcode.ErrStartNginxContainer.Wrap(err) } - nginxContainer, err = checkNginxContainer(ctx, cli) + nginxContainer, err = checkNginxContainer(ctx, dockerClient) if err != nil { return errcode.ErrCheckNginxContainer.Wrap(err) } + logger.Info("started nginx", zap.String("id", nginxContainerID)) } // connect nginx container to proxy network - if _, onProxyNetwork := nginxContainer.NetworkSettings.Networks[pwcompose.ProxyNetworkName]; !onProxyNetwork { - logger.Debug("connect nginx to proxy network", zap.String("nginx-id", nginxContainer.ID), zap.String("network-id", proxyNetworkID)) - err = cli.NetworkConnect(ctx, proxyNetworkID, nginxContainer.ID, nil) + if _, found := nginxContainer.NetworkSettings.Networks[pwcompose.ProxyNetworkName]; !found { + var proxyNetworkID string + networkResources, err := dockerClient.NetworkList(ctx, types.NetworkListOptions{}) if err != nil { - return errcode.ErrNginxConnectNetwork.Wrap(err) + return errcode.ErrDockerAPINetworkList.Wrap(err) } - // refresh container struct so it contains network configuration - nginxContainer, err = checkNginxContainer(ctx, cli) + for _, networkResource := range networkResources { + if networkResource.Name == pwcompose.ProxyNetworkName { + proxyNetworkID = networkResource.ID + break + } + } + logger.Debug("connect nginx network", zap.String("nginx-id", nginxContainer.ID), zap.String("network-id", proxyNetworkID)) + err = dockerClient.NetworkConnect(ctx, proxyNetworkID, nginxContainer.ID, nil) if err != nil { - return errcode.ErrCheckNginxContainer.Wrap(err) + return errcode.ErrNginxConnectNetwork.Wrap(err) } } + return nil +} - // update proxy network nginx container IP - proxyNetworkIP := nginxContainer.NetworkSettings.Networks[pwcompose.ProxyNetworkName].IPAddress - - // make sure that exposed containers are connected to proxy network - containersInfo, err := pwcompose.GetContainersInfo(ctx, cli) - if err != nil { - return errcode.ErrAgentGetContainersInfo.Wrap(err) +func genNginxConfig(apiInstances *pwapi.AgentListInstances_Output, containersInfo *pwcompose.ContainersInfo, opts Opts) (*nginxConfig, error) { + config := nginxConfig{ + Opts: opts, + Upstreams: map[string]nginxUpstream{}, } - for _, flavor := range containersInfo.RunningFlavors { - for _, instance := range flavor.Instances { - for _, port := range instance.Ports { - if port.PrivatePort != 0 { - if _, onProxyNetwork := instance.NetworkSettings.Networks[pwcompose.ProxyNetworkName]; !onProxyNetwork { - logger.Debug("connect container to proxy network", zap.String("container-id", instance.ID), zap.String("network-id", proxyNetworkID)) - err = cli.NetworkConnect(ctx, proxyNetworkID, instance.ID, nil) - if err != nil { - return errcode.ErrContainerConnectNetwork.Wrap(err) - } - } - break + + // compute allowed users by instance + allowedUsers := map[string][]int64{} + for _, apiInstance := range apiInstances.GetInstances() { + if apiInstance.Status == pwdb.ChallengeInstance_Disabled { + continue + } + uniqueUsers := map[int64]bool{} + for _, seasonChallenge := range apiInstance.GetFlavor().GetSeasonChallenges() { + for _, subscription := range seasonChallenge.GetActiveSubscriptions() { + for _, member := range subscription.GetTeam().GetMembers() { + uniqueUsers[member.ID] = true } } } + instanceID := fmt.Sprintf("%d", apiInstance.ID) + allowedUsers[instanceID] = make([]int64, len(uniqueUsers)) + i := 0 + for user := range uniqueUsers { + allowedUsers[instanceID][i] = user + i++ + } } - // update domainsuffix for local use if needed - if opts.DomainSuffix == "local" { - opts.DomainSuffix = "." + proxyNetworkIP + ".xip.io" - } - - configData := NginxConfigData{ - Upstreams: []NginxUpstream{}, - Opts: opts, - } - - // update config data with containers infos - containersInfo, err = pwcompose.GetContainersInfo(ctx, cli) - if err != nil { - return errcode.ErrAgentGetContainersInfo.Wrap(err) - } + // compute upstreams for _, flavor := range containersInfo.RunningFlavors { for _, instance := range flavor.Instances { - for _, port := range instance.Ports { - // FIXME: support non-standard ports using labels (later) - upstream := NginxUpstream{ - Hashes: []string{}, - } - if port.PrivatePort != 0 { - upstream.Name = instance.Names[0][1:] - upstream.Host = instance.NetworkSettings.Networks[pwcompose.ProxyNetworkName].IPAddress - upstream.Port = strconv.Itoa(int(port.PrivatePort)) - - // add hash per users to proxy configuration - if _, found := opts.AllowedUsers[instance.Names[0][1:]]; found { - for _, userID := range opts.AllowedUsers[instance.Names[0][1:]] { - hash, err := generatePrefixHash(instance.ID, userID, opts.Salt) - if err != nil { - return errcode.ErrGeneratePrefixHash.Wrap(err) - } - upstream.Hashes = append(upstream.Hashes, hash) - } + for idx, port := range instance.Ports { + if port.PublicPort != 0 { + upstream := nginxUpstream{ + Name: fmt.Sprintf("%s.%d", instance.Names[0][1:], idx), + InstanceID: flavor.InstanceKey, + AllowedUsers: allowedUsers[flavor.InstanceKey], + Host: instance.NetworkSettings.Networks[pwcompose.ProxyNetworkName].IPAddress, + Port: strconv.Itoa(int(port.PrivatePort)), } - configData.Upstreams = append(configData.Upstreams, upstream) - // FIXME: doesn't handle multiple port per instance yet - break + config.Upstreams[upstream.Name] = upstream } } } } - buf, err := buildNginxConfigTar(configData) - if err != nil { - return errcode.ErrBuildNginxConfig.Wrap(err) - } - - logger.Debug("copy nginx config into the container", zap.String("container-id", nginxContainer.ID)) - err = cli.CopyToContainer(ctx, nginxContainer.ID, "/etc/nginx/", buf, types.CopyToContainerOptions{}) - if err != nil { - return errcode.ErrCopyNginxConfigToContainer.Wrap(err) - } - - // check new nginx config - args := []string{"nginx", "-t", "-c", "/etc/nginx/nginx.conf"} - logger.Debug("send nginx command", zap.Strings("args", args)) - err = nginxSendCommand(ctx, cli, nginxContainer.ID, logger, args...) - if err != nil { - return errcode.ErrNginxSendCommandNewConfigCheck.Wrap(err) - } - - // new config hot reload - args = []string{"nginx", "-s", "reload"} - logger.Debug("send nginx command", zap.Strings("args", args)) - err = nginxSendCommand(ctx, cli, nginxContainer.ID, logger, args...) - if err != nil { - return errcode.ErrNginxSendCommandReloadConfig.Wrap(err) - } - - for _, upstream := range configData.Upstreams { - for _, hash := range upstream.Hashes { - fmt.Println(upstream.Name + ": " + hash + opts.DomainSuffix) + for idx, upstream := range config.Upstreams { + upstream.Hashes = make([]string, len(upstream.AllowedUsers)) + for j, userID := range upstream.AllowedUsers { + hash, err := generatePrefixHash(upstream.InstanceID, userID, opts.Salt) + if err != nil { + return nil, errcode.ErrGeneratePrefixHash.Wrap(err) + } + upstream.Hashes[j] = hash } - + config.Upstreams[idx] = upstream } - return nil + return &config, nil } -func buildNginxConfigTar(data interface{}) (*bytes.Buffer, error) { +func buildNginxConfigTar(config *nginxConfig, logger *zap.Logger) (*bytes.Buffer, error) { configTemplate, err := template.New("nginx-config").Parse(nginxConfigTemplate) if err != nil { return nil, errcode.ErrParsingTemplate.Wrap(err) } var configBuf bytes.Buffer - err = configTemplate.Execute(&configBuf, data) + err = configTemplate.Execute(&configBuf, config) if err != nil { return nil, errcode.ErrExecuteTemplate.Wrap(err) } - config := configBuf.Bytes() + configBytes := configBuf.Bytes() + + if logger.Check(zap.DebugLevel, "") != nil { + fmt.Fprintln(os.Stderr, string(configBytes)) + } var buf bytes.Buffer tw := tar.NewWriter(&buf) err = tw.WriteHeader(&tar.Header{ Name: "nginx.conf", Mode: 0755, - Size: int64(len(config)), + Size: int64(len(configBytes)), }) if err != nil { return nil, errcode.ErrWriteConfigFileHeader.Wrap(err) } - if _, err := tw.Write(config); err != nil { + if _, err := tw.Write(configBytes); err != nil { return nil, errcode.ErrWriteConfigFile.Wrap(err) } @@ -337,15 +263,18 @@ func buildNginxConfigTar(data interface{}) (*bytes.Buffer, error) { return &buf, nil } -// https://github.com/pathwar/pathwar/blob/v1.0.0/agent/cmd_agent_run.go -func buildNginxContainer(ctx context.Context, cli *client.Client, opts AgentOpts) (string, error) { +func buildNginxContainer(ctx context.Context, cli *client.Client, opts Opts) (string, error) { + logger := opts.Logger + out, err := cli.ImagePull(ctx, opts.NginxDockerImage, types.ImagePullOptions{}) if err != nil { return "", errcode.ErrDockerAPIImagePull.Wrap(err) } - _, err = io.Copy(os.Stdout, out) - if err != nil { - return "", errcode.TODO.Wrap(err) + if logger.Check(zap.DebugLevel, "") != nil { + _, err = io.Copy(os.Stderr, out) + if err != nil { + return "", errcode.TODO.Wrap(err) + } } hostBinding := nat.PortBinding{ @@ -373,9 +302,7 @@ func buildNginxContainer(ctx context.Context, cli *client.Client, opts AgentOpts } func checkNginxContainer(ctx context.Context, cli *client.Client) (*types.Container, error) { - containers, err := cli.ContainerList(ctx, types.ContainerListOptions{ - All: true, - }) + containers, err := cli.ContainerList(ctx, types.ContainerListOptions{All: true}) if err != nil { return nil, errcode.ErrDockerAPIContainerList.Wrap(err) } @@ -436,7 +363,7 @@ func nginxSendCommand(ctx context.Context, cli *client.Client, nginxContainerID } if stderr != nil && len(stderr) > 0 { - logger.Warn("exec finished with stderr", zap.String("stderr", string(stderr))) + logger.Debug("exec finished with stderr", zap.String("stderr", string(stderr))) } logger.Debug("exec finished", zap.String("stdout", string(stdout)), @@ -466,3 +393,94 @@ func generatePrefixHash(instanceID string, userID int64, salt string) (string, e userHash := strings.ToLower(base36.EncodeBytes(hashBytes))[:8] // we voluntarily expect short hashes here return userHash, nil } + +type nginxConfig struct { + Opts Opts + Upstreams map[string]nginxUpstream +} + +type nginxUpstream struct { + InstanceID string + Name string + Host string + Port string + Hashes []string + AllowedUsers []int64 // map[INSTANCE_ID][]USER_ID, map[42][]string{4242, 4343} +} + +const nginxConfigTemplate = ` +{{$root := .}} +#user www www; +worker_processes 5; +error_log /proc/self/fd/2; +#pid /tmp/nginx.pid; +worker_rlimit_nofile 8192; + +events { + worker_connections 4096; +} + +http { + types { + text/html html htm shtml; + text/css css; + image/gif gif; + image/jpeg jpeg jpg; + application/x-javascript js; + text/plain txt; + image/png png; + image/x-icon ico; + } + proxy_redirect off; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + client_max_body_size 10m; + client_body_buffer_size 128k; + proxy_connect_timeout 90; + proxy_send_timeout 90; + proxy_read_timeout 90; + proxy_buffers 32 4k; + index index.html index.htm; + + default_type application/octet-stream; + log_format main '$remote_addr - $remote_user [$time_local] $status "$request" $body_bytes_sent "$http_referer" "$http_user_agent" "$http_x_forwarded_for"'; + access_log /proc/self/fd/1 main; + sendfile on; + tcp_nopush on; + server_names_hash_bucket_size 128; + + server { + listen 80 default_server; + server_name _; + error_log /proc/self/fd/2; + access_log /proc/self/fd/1; + return 503; + } + + {{range .Upstreams -}} + upstream upstream_{{.Name}} { server {{.Host}}:{{.Port}}; } + server { + listen 80; + server_name moderator-{{.Name}}.{{$root.Opts.DomainSuffix}}; + access_log /proc/self/fd/1; + error_log /proc/self/fd/2; + # FIXME: add auth + location / { + proxy_pass http://upstream_{{.Name}}; + } + } + {{- if not (eq (len .Hashes) 0) }} + server { + listen 80; + server_name{{range .Hashes}} {{.}}.{{$root.Opts.DomainSuffix}}{{end}}; + access_log /proc/self/fd/1; + error_log /proc/self/fd/2; + location / { + proxy_pass http://upstream_{{.Name}}; + } + } + {{end}} + {{end -}} +} +` diff --git a/go/pkg/pwagent/types.go b/go/pkg/pwagent/types.go new file mode 100644 index 000000000..3c300e12d --- /dev/null +++ b/go/pkg/pwagent/types.go @@ -0,0 +1,40 @@ +package pwagent + +import ( + "time" + + "go.uber.org/zap" + "pathwar.land/go/internal/randstring" +) + +type Opts struct { + DomainSuffix string + HostIP string + HostPort string + ModeratorPassword string + Salt string + ForceRecreate bool + NginxDockerImage string + Cleanup bool + RunOnce bool + LoopDelay time.Duration + HTTPAPIAddr string + Name string + + Logger *zap.Logger +} + +func (opts *Opts) applyDefaults() error { + if opts.Logger == nil { + opts.Logger = zap.NewNop() + } + if opts.Salt == "" { + opts.Salt = randstring.RandString(10) + opts.Logger.Warn("random salt generated", zap.String("salt", opts.Salt)) + } + if opts.ModeratorPassword == "" { + opts.ModeratorPassword = randstring.RandString(10) + opts.Logger.Warn("random moderator password generated", zap.String("password", opts.ModeratorPassword)) + } + return nil +} diff --git a/go/pkg/pwapi/api_agent-register_test.go b/go/pkg/pwapi/api_agent-register_test.go index 023dddbc5..a6c36ebf1 100644 --- a/go/pkg/pwapi/api_agent-register_test.go +++ b/go/pkg/pwapi/api_agent-register_test.go @@ -55,13 +55,13 @@ func TestService_AgentRegister(t *testing.T) { ctx := testingSetContextToken(context.Background(), t) first, err := svc.AgentRegister(ctx, &AgentRegister_Input{Name: "test", Hostname: "lorem ipsum"}) - require.NoError(t,err) + require.NoError(t, err) assert.Equal(t, first.Agent.CreatedAt, first.Agent.UpdatedAt) assert.Equal(t, first.Agent.LastSeenAt, first.Agent.LastRegistrationAt) assert.Equal(t, "lorem ipsum", first.Agent.Hostname) second, err := svc.AgentRegister(ctx, &AgentRegister_Input{Name: "test"}) - require.NoError(t,err) + require.NoError(t, err) assert.Equal(t, first.Agent.CreatedAt, second.Agent.CreatedAt) assert.NotEqual(t, second.Agent.CreatedAt, second.Agent.UpdatedAt) assert.NotEqual(t, first.Agent.UpdatedAt, second.Agent.UpdatedAt) diff --git a/go/pkg/pwcompose/compose.go b/go/pkg/pwcompose/compose.go index c75b8ef3c..0dce665c9 100644 --- a/go/pkg/pwcompose/compose.go +++ b/go/pkg/pwcompose/compose.go @@ -71,11 +71,13 @@ func Prepare(challengeDir string, prefix string, noPush bool, version string, lo } // check for error in docker-compose file - args := []string{"-f", origComposePath, "config", "-q"} + args := append(composeCliCommonArgs(origComposePath), "config", "-q") logger.Debug("docker-compose", zap.Strings("args", args)) cmd := exec.Command("docker-compose", args...) - cmd.Stdout = os.Stderr - cmd.Stderr = os.Stderr + if logger.Check(zap.DebugLevel, "") != nil { + cmd.Stdout = os.Stderr + cmd.Stderr = os.Stderr + } err = cmd.Run() if err != nil { return "", errcode.ErrComposeInvalidConfig.Wrap(err) @@ -131,23 +133,27 @@ func Prepare(challengeDir string, prefix string, noPush bool, version string, lo if !noPush { // build and push images to dockerhub (don't forget to setup your credentials just type : "docker login" in bash) - args = []string{"-f", tmpComposePath, "build"} + args = append(composeCliCommonArgs(tmpComposePath), "build") logger.Debug("docker-compose", zap.Strings("args", args)) cmd = exec.Command("docker-compose", args...) cmd.Dir = cleanPath - cmd.Stdout = os.Stderr - cmd.Stderr = os.Stderr + if logger.Check(zap.DebugLevel, "") != nil { + cmd.Stdout = os.Stderr + cmd.Stderr = os.Stderr + } err = cmd.Run() if err != nil { return "", errcode.ErrComposeBuild.Wrap(err) } - args := []string{"-f", tmpComposePath, "bundle", "--push-images"} + args = append(composeCliCommonArgs(tmpComposePath), "bundle", "--push-images") logger.Debug("docker-compose", zap.Strings("args", args)) cmd = exec.Command("docker-compose", args...) cmd.Dir = cleanPath - cmd.Stdout = os.Stderr - cmd.Stderr = os.Stderr + if logger.Check(zap.DebugLevel, "") != nil { + cmd.Stdout = os.Stderr + cmd.Stderr = os.Stderr + } err = cmd.Run() if err != nil { return "", errcode.ErrComposeBundle.Wrap(err) @@ -185,26 +191,18 @@ func Prepare(challengeDir string, prefix string, noPush bool, version string, lo return string(finalData), nil } -func Up( - ctx context.Context, - preparedCompose string, - instanceKey string, - forceRecreate bool, - pwinitConfig *pwinit.InitConfig, - cli *client.Client, - logger *zap.Logger, -) error { +func Up(ctx context.Context, preparedCompose string, instanceKey string, forceRecreate bool, proxyNetworkID string, pwinitConfig *pwinit.InitConfig, cli *client.Client, logger *zap.Logger) (map[string]Service, error) { logger.Debug("up", zap.String("compose", preparedCompose), zap.String("instance-key", instanceKey)) // parse prepared compose yaml preparedComposeStruct := config{} err := yaml.Unmarshal([]byte(preparedCompose), &preparedComposeStruct) if err != nil { - return errcode.ErrComposeParseConfig.Wrap(err) + return nil, errcode.ErrComposeParseConfig.Wrap(err) } - // generate instanceIDs and set them as container_name var challengeID string + // generate instanceIDs and set them as container_name for name, service := range preparedComposeStruct.Services { challengeName := service.Labels[challengeNameLabel] serviceName := service.Labels[serviceNameLabel] @@ -215,22 +213,24 @@ func Up( service.ContainerName = fmt.Sprintf("%s.%s.%s.%s", challengeName, serviceName, imageHash, instanceKey) service.Restart = "unless-stopped" service.Labels[instanceKeyLabel] = instanceKey - challengeID = challengeIDFormatted(service.Labels[challengeNameLabel], service.Labels[challengeVersionLabel]) preparedComposeStruct.Services[name] = service + if challengeID == "" { + challengeID = service.ChallengeID() + } } // down instances if force recreate if forceRecreate { - err = Down(ctx, []string{challengeID}, false, false, false, cli, logger) + err = Clean(ctx, []string{challengeID}, false, false, false, cli, logger) if err != nil { - return errcode.ErrComposeForceRecreateDown.Wrap(err) + return nil, errcode.ErrComposeForceRecreateDown.Wrap(err) } } // create temp dir tmpDir, err := ioutil.TempDir("", "pwcompose") if err != nil { - return errcode.ErrComposeCreateTempDir.Wrap(err) + return nil, errcode.ErrComposeCreateTempDir.Wrap(err) } defer func() { if err = os.RemoveAll(tmpDir); err != nil { @@ -240,7 +240,7 @@ func Up( tmpDirCompose := path.Join(tmpDir, challengeID) err = os.MkdirAll(tmpDirCompose, os.ModePerm) if err != nil { - return errcode.ErrComposeCreateTempDir.Wrap(err) + return nil, errcode.ErrComposeCreateTempDir.Wrap(err) } // generate tmp path @@ -249,33 +249,34 @@ func Up( // create tmp docker-compose file err = updateDockerComposeTempFile(preparedComposeStruct, tmpPreparedComposePath) if err != nil { - return errcode.ErrComposeUpdateTempFile.Wrap(err) + return nil, errcode.ErrComposeUpdateTempFile.Wrap(err) } // create instances - args := []string{"-f", tmpPreparedComposePath, "up", "--no-start"} + args := append(composeCliCommonArgs(tmpPreparedComposePath), "up", "--no-start", "--quiet-pull") logger.Debug("docker-compose", zap.Strings("args", args)) cmd := exec.Command("docker-compose", args...) - cmd.Stdout = os.Stderr - cmd.Stderr = os.Stderr + if logger.Check(zap.DebugLevel, "") != nil { + cmd.Stdout = os.Stderr + cmd.Stderr = os.Stderr + } err = cmd.Run() if err != nil { logger.Error("Error detected while creating containers, it's probably due to a conflict with previously created containers that share the same name. You should retry with --force-recreate flag") - return errcode.ErrComposeRunCreate.Wrap(err) + return nil, errcode.ErrComposeRunCreate.Wrap(err) } // update entrypoints to run pwinit containersInfo, err := GetContainersInfo(ctx, cli) if err != nil { - return errcode.ErrComposeGetContainersInfo.Wrap(err) + return nil, errcode.ErrComposeGetContainersInfo.Wrap(err) } for _, instance := range containersInfo.RunningInstances { - instanceChallengeID := challengeIDFormatted(instance.Labels[challengeNameLabel], instance.Labels[challengeVersionLabel]) - if challengeID == instanceChallengeID { + if challengeID == instance.ChallengeID() { // update entrypoints to run pwinit first imageInspect, _, err := cli.ImageInspectWithRaw(ctx, instance.ImageID) if err != nil { - return errcode.ErrDockerAPIImageInspect.Wrap(err) + return nil, errcode.ErrDockerAPIImageInspect.Wrap(err) } for name, service := range preparedComposeStruct.Services { if name != instance.Labels[serviceNameLabel] { @@ -306,142 +307,164 @@ func Up( // update tmp docker-compose file with new entrypoints err = updateDockerComposeTempFile(preparedComposeStruct, tmpPreparedComposePath) if err != nil { - return errcode.ErrComposeUpdateTempFile.Wrap(err) + return nil, errcode.ErrComposeUpdateTempFile.Wrap(err) } // build definitive instances - args = []string{"-f", tmpPreparedComposePath, "up", "--no-start"} + args = append(composeCliCommonArgs(tmpPreparedComposePath), "up", "--no-start") logger.Debug("docker-compose", zap.Strings("args", args)) cmd = exec.Command("docker-compose", args...) - cmd.Stdout = os.Stderr - cmd.Stderr = os.Stderr + if logger.Check(zap.DebugLevel, "") != nil { + cmd.Stdout = os.Stderr + cmd.Stderr = os.Stderr + } err = cmd.Run() if err != nil { logger.Error("Error detected while creating containers, it's probably due to a conflict with previously created containers that share the same name. You should retry with --force-recreate flag") - return errcode.ErrComposeRunCreate.Wrap(err) + return nil, errcode.ErrComposeRunCreate.Wrap(err) } // copy pathwar binary inside all containers containersInfo, err = GetContainersInfo(ctx, cli) if err != nil { - return errcode.ErrComposeGetContainersInfo.Wrap(err) + return nil, errcode.ErrComposeGetContainersInfo.Wrap(err) } for _, instance := range containersInfo.RunningInstances { - if challengeID == challengeIDFormatted(instance.Labels[challengeNameLabel], instance.Labels[challengeVersionLabel]) { - if pwinitConfig == nil { - pwinitConfig = &pwinit.InitConfig{ - Passphrases: []string{ - fmt.Sprintf("dev-%s", randstring.RandString(10)), - fmt.Sprintf("dev-%s", randstring.RandString(10)), - fmt.Sprintf("dev-%s", randstring.RandString(10)), - fmt.Sprintf("dev-%s", randstring.RandString(10)), - fmt.Sprintf("dev-%s", randstring.RandString(10)), - fmt.Sprintf("dev-%s", randstring.RandString(10)), - fmt.Sprintf("dev-%s", randstring.RandString(10)), - fmt.Sprintf("dev-%s", randstring.RandString(10)), - fmt.Sprintf("dev-%s", randstring.RandString(10)), - fmt.Sprintf("dev-%s", randstring.RandString(10)), - }, - } - } - buf, err := buildPWInitTar(*pwinitConfig) - if err != nil { - return errcode.ErrCopyPWInitToContainer.Wrap(err) - } - logger.Debug("copy pwinit into the container", zap.String("container-id", instance.ID)) - err = cli.CopyToContainer(ctx, instance.ID, "/", buf, types.CopyToContainerOptions{}) - if err != nil { - return errcode.ErrCopyPWInitToContainer.Wrap(err) + if challengeID != instance.ChallengeID() { + continue + } + + if pwinitConfig == nil { + pwinitConfig = &pwinit.InitConfig{ + Passphrases: []string{ + fmt.Sprintf("dev-%s", randstring.RandString(10)), + fmt.Sprintf("dev-%s", randstring.RandString(10)), + fmt.Sprintf("dev-%s", randstring.RandString(10)), + fmt.Sprintf("dev-%s", randstring.RandString(10)), + fmt.Sprintf("dev-%s", randstring.RandString(10)), + fmt.Sprintf("dev-%s", randstring.RandString(10)), + fmt.Sprintf("dev-%s", randstring.RandString(10)), + fmt.Sprintf("dev-%s", randstring.RandString(10)), + fmt.Sprintf("dev-%s", randstring.RandString(10)), + fmt.Sprintf("dev-%s", randstring.RandString(10)), + }, } } + buf, err := buildPWInitTar(*pwinitConfig) + if err != nil { + return nil, errcode.ErrCopyPWInitToContainer.Wrap(err) + } + logger.Debug("copy pwinit into the container", zap.String("container-id", instance.ID)) + err = cli.CopyToContainer(ctx, instance.ID, "/", buf, types.CopyToContainerOptions{}) + if err != nil { + return nil, errcode.ErrCopyPWInitToContainer.Wrap(err) + } + } // start instances - args = []string{"-f", tmpPreparedComposePath, "up", "-d"} + args = append(composeCliCommonArgs(tmpPreparedComposePath), "up", "-d") logger.Debug("docker-compose", zap.Strings("args", args)) cmd = exec.Command("docker-compose", args...) - cmd.Stdout = os.Stderr - cmd.Stderr = os.Stderr + if logger.Check(zap.DebugLevel, "") != nil { + cmd.Stdout = os.Stderr + cmd.Stderr = os.Stderr + } err = cmd.Run() if err != nil { - return errcode.ErrComposeRunUp.Wrap(err) + return nil, errcode.ErrComposeRunUp.Wrap(err) } - // print instanceIDs - for _, service := range preparedComposeStruct.Services { - fmt.Println(service.ContainerName) + // attach networks + containersInfo, err = GetContainersInfo(ctx, cli) // this get containers info can be skipped if using the parsed compose file already in memory + if err != nil { + return nil, errcode.ErrComposeGetContainersInfo.Wrap(err) + } + for _, instance := range containersInfo.RunningInstances { + if challengeID != instance.ChallengeID() { + continue + } + if proxyNetworkID != "" && instance.NeedsNginxProxy() { + err = cli.NetworkConnect(ctx, proxyNetworkID, instance.ID, nil) + if err != nil { + return nil, errcode.ErrContainerConnectNetwork.Wrap(err) + } + } } - return nil + return preparedComposeStruct.Services, nil +} + +func composeCliCommonArgs(path string) []string { + return []string{"-f", path, "--no-ansi", "--log-level=ERROR"} +} + +// Purge cleans up everything related to Pathwar (containers, volumes, images, networks) +func Purge(ctx context.Context, cli *client.Client, logger *zap.Logger) error { + return Clean(ctx, []string{}, true, true, true, cli, logger) +} + +// DownAll cleans up everything related to Pathwar except images (containers, volumes, networks) +func DownAll(ctx context.Context, cli *client.Client, logger *zap.Logger) error { + return Clean(ctx, []string{}, false, true, true, cli, logger) } -func Down( - ctx context.Context, - ids []string, - removeImages bool, - removeVolumes bool, - withNginx bool, - cli *client.Client, - logger *zap.Logger, -) error { - logger.Debug("down", zap.Strings("ids", ids), zap.Bool("rmi", removeImages), zap.Bool("rm -v", removeVolumes), zap.Bool("with-nginx", withNginx)) +// Clean can cleanup specific containers, all the images, all the volumes, and the pathwar's nginx front-end +func Clean(ctx context.Context, containerIDs []string, removeImages bool, removeVolumes bool, withNginx bool, cli *client.Client, logger *zap.Logger) error { + logger.Debug("down", zap.Strings("ids", containerIDs), zap.Bool("rmi", removeImages), zap.Bool("rm -v", removeVolumes), zap.Bool("with-nginx", withNginx)) containersInfo, err := GetContainersInfo(ctx, cli) if err != nil { return errcode.ErrComposeGetContainersInfo.Wrap(err) } - removalLists := dockerRemovalLists{ - containersToRemove: []string{}, - imagesToRemove: []string{}, - } + toRemove := map[string]container{} if withNginx && containersInfo.NginxProxyInstance.ID != "" { - removalLists = updateDockerRemovalLists(removalLists, containersInfo.NginxProxyInstance, removeImages) + toRemove[containersInfo.NginxProxyInstance.ID] = containersInfo.NginxProxyInstance } - if len(ids) == 0 { + if len(containerIDs) == 0 { // all containers for _, container := range containersInfo.RunningInstances { - removalLists = updateDockerRemovalLists(removalLists, container, removeImages) + toRemove[container.ID] = container } - } - - for _, id := range ids { - for _, flavor := range containersInfo.RunningFlavors { - if id == flavor.Name || id == challengeIDFormatted(flavor.Name, flavor.Version) { - for _, instance := range flavor.Instances { - removalLists = updateDockerRemovalLists(removalLists, instance, removeImages) + } else { // only specific ones + for _, id := range containerIDs { + for _, flavor := range containersInfo.RunningFlavors { + if id == flavor.Name || id == flavor.ChallengeID() { + for _, instance := range flavor.Instances { + toRemove[instance.ID] = instance + } } } - } - for _, container := range containersInfo.RunningInstances { - if id == container.ID || id == container.ID[0:7] { - removalLists = updateDockerRemovalLists(removalLists, container, removeImages) + for _, container := range containersInfo.RunningInstances { + if id == container.ID || id == container.ID[0:7] { + toRemove[container.ID] = container + } } } } - for _, instanceID := range removalLists.containersToRemove { - err := cli.ContainerRemove(ctx, instanceID, types.ContainerRemoveOptions{ + for _, container := range toRemove { + err := cli.ContainerRemove(ctx, container.ID, types.ContainerRemoveOptions{ Force: true, RemoveVolumes: removeVolumes, }) if err != nil { return errcode.ErrDockerAPIContainerRemove.Wrap(err) } - fmt.Println("removed container " + instanceID) - } - - for _, imageID := range removalLists.imagesToRemove { - _, err := cli.ImageRemove(ctx, imageID, types.ImageRemoveOptions{ - Force: true, - PruneChildren: true, - }) - if err != nil { - return errcode.ErrDockerAPIImageRemove.Wrap(err) + logger.Debug("container removed", zap.String("ID", container.ID)) + if removeImages { + _, err := cli.ImageRemove(ctx, container.ImageID, types.ImageRemoveOptions{ + Force: false, + PruneChildren: true, + }) + if err != nil { + return errcode.ErrDockerAPIImageRemove.Wrap(err) + } + logger.Debug("image removed", zap.String("ID", container.ImageID)) } - fmt.Println("removed image " + imageID) } if withNginx && containersInfo.NginxProxyNetwork.ID != "" { @@ -449,20 +472,12 @@ func Down( if err != nil { return errcode.ErrDockerAPINetworkRemove.Wrap(err) } - fmt.Println("removed proxy network " + containersInfo.NginxProxyNetwork.ID) + logger.Debug("network removed", zap.String("ID", containersInfo.NginxProxyNetwork.ID)) } return nil } -func updateDockerRemovalLists(removalLists dockerRemovalLists, container types.Container, removeImages bool) dockerRemovalLists { - removalLists.containersToRemove = append(removalLists.containersToRemove, container.ID) - if removeImages { - removalLists.imagesToRemove = append(removalLists.imagesToRemove, container.ImageID) - } - return removalLists -} - func PS(ctx context.Context, depth int, cli *client.Client, logger *zap.Logger) error { logger.Debug("ps", zap.Int("depth", depth)) @@ -485,7 +500,7 @@ func PS(ctx context.Context, depth int, cli *client.Client, logger *zap.Logger) table.Append([]string{ uid[:7], - challengeIDFormatted(flavor.Name, flavor.Version), + flavor.ChallengeID(), container.Labels[serviceNameLabel], strings.Join(ports, ", "), strings.Replace(container.Status, "Up ", "", 1), @@ -557,37 +572,35 @@ func GetContainersInfo(ctx context.Context, cli *client.Client) (*ContainersInfo containersInfo := ContainersInfo{ RunningFlavors: map[string]challengeFlavors{}, - RunningInstances: map[string]types.Container{}, + RunningInstances: map[string]container{}, } - for _, container := range containers { - // find nginx proxy - for _, name := range container.Names { + for _, dockerContainer := range containers { + c := container(dockerContainer) + + // pathwar nginx proxy + for _, name := range c.Names { if name[1:] == NginxContainerName { - containersInfo.NginxProxyInstance = container + containersInfo.NginxProxyInstance = c } } - // continue if container is not a challenge - if _, pwcontainer := container.Labels[challengeNameLabel]; !pwcontainer { + + if _, found := c.Labels[challengeNameLabel]; !found { // not a pathwar container continue } - // handle and sort challenge - flavor := fmt.Sprintf( - "%s:%s", - container.Labels[challengeNameLabel], - container.Labels[challengeVersionLabel], - ) + + flavor := c.ChallengeID() if _, found := containersInfo.RunningFlavors[flavor]; !found { challengeFlavor := challengeFlavors{ - Instances: map[string]types.Container{}, + Instances: map[string]container{}, } - challengeFlavor.Name = container.Labels[challengeNameLabel] - challengeFlavor.Version = container.Labels[challengeVersionLabel] - challengeFlavor.InstanceKey = container.Labels[instanceKeyLabel] + challengeFlavor.Name = c.Labels[challengeNameLabel] + challengeFlavor.Version = c.Labels[challengeVersionLabel] + challengeFlavor.InstanceKey = c.Labels[instanceKeyLabel] containersInfo.RunningFlavors[flavor] = challengeFlavor } - containersInfo.RunningFlavors[flavor].Instances[container.ID] = container - containersInfo.RunningInstances[container.ID] = container + containersInfo.RunningFlavors[flavor].Instances[c.ID] = c + containersInfo.RunningInstances[c.ID] = c } // find proxy network @@ -626,7 +639,3 @@ func updateDockerComposeTempFile(preparedComposeStruct config, tmpPreparedCompos } return nil } - -func challengeIDFormatted(challengeNameLabel string, challengeVersionLabel string) string { - return fmt.Sprintf("%s@%s", challengeNameLabel, challengeVersionLabel) -} diff --git a/go/pkg/pwcompose/config.go b/go/pkg/pwcompose/types.go similarity index 71% rename from go/pkg/pwcompose/config.go rename to go/pkg/pwcompose/types.go index 196e26799..6243dd630 100644 --- a/go/pkg/pwcompose/config.go +++ b/go/pkg/pwcompose/types.go @@ -1,6 +1,8 @@ package pwcompose import ( + fmt "fmt" + "github.com/docker/docker/api/types" ) @@ -9,7 +11,7 @@ type config struct { Version string Networks map[string]network Volumes map[string]volume - Services map[string]service + Services map[string]Service } type network struct { @@ -22,7 +24,7 @@ type volume struct { DriverOpts map[string]string `yaml:"driver_opts,omitempty"` } -type service struct { +type Service struct { ContainerName string `yaml:"container_name,omitempty"` Image string `yaml:",omitempty"` Networks, Ports, Expose, Volumes, Command []string `yaml:",omitempty"` @@ -36,6 +38,10 @@ type service struct { Labels map[string]string `yaml:"labels,omitempty"` } +func (s Service) ChallengeID() string { + return fmt.Sprintf("%s@%s", s.Labels[challengeNameLabel], s.Labels[challengeVersionLabel]) +} + type dabfile struct { Services map[string]dabservice } @@ -46,19 +52,33 @@ type dabservice struct { type ContainersInfo struct { RunningFlavors map[string]challengeFlavors - RunningInstances map[string]types.Container - NginxProxyInstance types.Container + RunningInstances map[string]container + NginxProxyInstance container NginxProxyNetwork types.NetworkResource } +type container types.Container + +func (c container) ChallengeID() string { + return fmt.Sprintf("%s@%s", c.Labels[challengeNameLabel], c.Labels[challengeVersionLabel]) +} + +func (c container) NeedsNginxProxy() bool { + for _, port := range c.Ports { + if port.PrivatePort != 0 { + return true + } + } + return false +} + type challengeFlavors struct { Name string Version string InstanceKey string - Instances map[string]types.Container + Instances map[string]container } -type dockerRemovalLists struct { - containersToRemove []string - imagesToRemove []string +func (cf challengeFlavors) ChallengeID() string { + return fmt.Sprintf("%s@%s", cf.Name, cf.Version) } diff --git a/go/pkg/pwdb/migrations.go b/go/pkg/pwdb/migrations.go index 305ebbed9..a7744a0a5 100644 --- a/go/pkg/pwdb/migrations.go +++ b/go/pkg/pwdb/migrations.go @@ -249,6 +249,20 @@ services: } } + // challenge subscription + subscription := ChallengeSubscription{ + SeasonChallengeID: trainingSQLI.SeasonChallenges[0].ID, + TeamID: staffTeam.ID, + BuyerID: hackSparrow.ID, + Status: ChallengeSubscription_Active, + } + err = tx.Set("gorm:association_autoupdate", true). + Create(&subscription). + Error + if err != nil { + return GormToErrcode(err) + } + // // Achievements // diff --git a/go/pkg/pwdb/model_helpers.go b/go/pkg/pwdb/model_helpers.go index 7b8667123..bc4a26f14 100644 --- a/go/pkg/pwdb/model_helpers.go +++ b/go/pkg/pwdb/model_helpers.go @@ -1,6 +1,9 @@ package pwdb -import "strings" +import ( + "fmt" + "strings" +) func newOfficialChallengeWithFlavor(name string, homepage string, composeBundle string) *ChallengeFlavor { return &ChallengeFlavor{ @@ -35,3 +38,7 @@ func (a *Agent) TagSlice() []string { } return strings.Split(a.Tags, ", ") } + +func (cf ChallengeFlavor) NameAndVersion() string { + return fmt.Sprintf("%s@%s", cf.Challenge.Name, cf.Version) +}