Skip to content

Commit 000dc3d

Browse files
authored
feat(license-verifier): add license verifier (#352)
## 📝 Description Adds `license-verifier` service to EE installation for validating the license via the license server. **Changes** - Introduced `license-verifier` pod that polls the license server using a license stored in a secret. - App services (`front`, `github_hooks`, `hooks_receiver`) check the verifier endpoint to enforce license validity. - On missing, invalid or expired license: - Frontend shows a top banner. - GitHub, Bitbucket, and GitLab hooks are rejected. Related [task](renderedtext/project-tasks#2393). ## ✅ Checklist - [x] I have tested this change - [ ] This change requires documentation update
1 parent f638d68 commit 000dc3d

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

56 files changed

+2543
-17
lines changed

bootstrapper/cmd/serve.go

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
package cmd
2+
3+
import (
4+
"fmt"
5+
"log"
6+
"net"
7+
"os"
8+
"strconv"
9+
10+
license "github.com/semaphoreio/semaphore/bootstrapper/pkg/license"
11+
"github.com/spf13/cobra"
12+
"google.golang.org/grpc"
13+
)
14+
15+
var (
16+
grpcPort int
17+
licenseServerURL string
18+
licenseFile string
19+
enableGRPC bool
20+
)
21+
22+
const (
23+
defaultGRPCPort = 50051
24+
defaultLicenseFile = "/app/config/app.license"
25+
)
26+
27+
var serveCmd = &cobra.Command{
28+
Use: "serve",
29+
Short: "Start the license verification gRPC server",
30+
PreRun: func(cmd *cobra.Command, args []string) {
31+
if envPort := os.Getenv("BOOTSTRAPPER_GRPC_PORT"); envPort != "" {
32+
if port, err := strconv.Atoi(envPort); err == nil {
33+
grpcPort = port
34+
} else {
35+
log.Fatalf("Invalid gRPC port: %s", envPort)
36+
}
37+
} else {
38+
grpcPort = defaultGRPCPort
39+
}
40+
41+
licenseServerURL = os.Getenv("BOOTSTRAPPER_LICENSE_SERVER_URL")
42+
if envLicenseFile := os.Getenv("BOOTSTRAPPER_LICENSE_FILE"); envLicenseFile != "" {
43+
licenseFile = envLicenseFile
44+
} else {
45+
licenseFile = defaultLicenseFile
46+
}
47+
enableGRPC = os.Getenv("BOOTSTRAPPER_ENABLE_GRPC") == "true"
48+
},
49+
RunE: func(cmd *cobra.Command, args []string) error {
50+
if !enableGRPC {
51+
log.Println("gRPC server is disabled")
52+
return nil
53+
}
54+
grpcServer := grpc.NewServer()
55+
56+
licenseServer := license.NewServer(licenseServerURL, licenseFile)
57+
license.RegisterServer(grpcServer, licenseServer)
58+
59+
lis, err := net.Listen("tcp", fmt.Sprintf(":%d", grpcPort))
60+
if err != nil {
61+
return fmt.Errorf("failed to listen: %v", err)
62+
}
63+
64+
log.Printf("Starting gRPC server on port %d", grpcPort)
65+
if err := grpcServer.Serve(lis); err != nil {
66+
return fmt.Errorf("failed to serve: %v", err)
67+
}
68+
69+
return nil
70+
},
71+
}
72+
73+
func init() {
74+
RootCmd.AddCommand(serveCmd)
75+
}
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
{{- if eq .Values.global.edition "ee" }}
2+
apiVersion: apps/v1
3+
kind: Deployment
4+
metadata:
5+
name: license-checker
6+
namespace: {{ .Release.Namespace }}
7+
labels:
8+
app: license-checker
9+
spec:
10+
replicas: {{ .Values.licenseChecker.replicaCount | default 1 }}
11+
selector:
12+
matchLabels:
13+
app: license-checker
14+
template:
15+
metadata:
16+
labels:
17+
app: license-checker
18+
spec:
19+
serviceAccountName: license-checker
20+
{{- if .Values.imagePullSecrets }}
21+
imagePullSecrets:
22+
{{- range .Values.imagePullSecrets }}
23+
- name: {{ . }}
24+
{{- end }}
25+
{{- end }}
26+
automountServiceAccountToken: true
27+
containers:
28+
- name: license-checker
29+
image: "{{ .Values.global.image.registry }}/{{ .Values.image }}:{{ .Values.imageTag }}"
30+
imagePullPolicy: {{ .Values.imagePullPolicy | default "IfNotPresent" }}
31+
command:
32+
- /app/build/bootstrapper
33+
args:
34+
- "serve"
35+
ports:
36+
- name: grpc
37+
containerPort: {{ .Values.licenseChecker.grpc.port }}
38+
envFrom:
39+
- configMapRef:
40+
name: {{ .Values.global.internalApi.configMapName }}
41+
env:
42+
- name: BOOTSTRAPPER_GRPC_PORT
43+
value: "{{ .Values.licenseChecker.grpc.port }}"
44+
- name: BOOTSTRAPPER_LICENSE_SERVER_URL
45+
value: {{ .Values.global.licenseServerUrl }}
46+
- name: BOOTSTRAPPER_LICENSE_FILE
47+
value: "/app/app.license"
48+
- name: BOOTSTRAPPER_ENABLE_GRPC
49+
value: "true"
50+
- name: CE_VERSION
51+
value: {{ .Chart.Version }}
52+
- name: KUBERNETES_NAMESPACE
53+
valueFrom:
54+
fieldRef:
55+
fieldPath: metadata.namespace
56+
volumeMounts:
57+
- name: license
58+
mountPath: /app/app.license
59+
readOnly: true
60+
subPath: app.license
61+
resources:
62+
{{- toYaml .Values.licenseChecker.resources | nindent 12 }}
63+
volumes:
64+
- name: license
65+
secret:
66+
secretName: "{{ .Release.Name }}-license"
67+
items:
68+
- key: license
69+
path: app.license
70+
---
71+
apiVersion: v1
72+
kind: Service
73+
metadata:
74+
name: license-checker
75+
namespace: {{ .Release.Namespace }}
76+
labels:
77+
app: license-checker
78+
spec:
79+
type: ClusterIP
80+
ports:
81+
- name: grpc
82+
port: {{ .Values.licenseChecker.grpc.port }}
83+
targetPort: grpc
84+
selector:
85+
app: license-checker
86+
---
87+
apiVersion: v1
88+
kind: ServiceAccount
89+
metadata:
90+
name: license-checker
91+
namespace: {{ .Release.Namespace }}
92+
{{- end }}

bootstrapper/helm/values.yaml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,18 @@ resources:
99
requests:
1010
cpu: '0.01'
1111
memory: 25Mi
12+
13+
licenseChecker:
14+
enabled: true
15+
replicaCount: 1
16+
resources:
17+
limits:
18+
cpu: 100m
19+
memory: 100Mi
20+
requests:
21+
cpu: 50m
22+
memory: 30Mi
23+
licenseServerUrl: "http://license-server"
24+
licenseFile: "/app/config/app.license"
25+
grpc:
26+
port: 50051

bootstrapper/pkg/clients/instance_config_client.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,41 @@ func (c *InstanceConfigClient) ConfigureInstallationDefaults(params map[string]s
4545
return c.configure(params, protoconfig.ConfigType_CONFIG_TYPE_INSTALLATION_DEFAULTS, "Installation defaults")
4646
}
4747

48+
// GetInstallationID returns the installation ID from the instance configuration.
49+
// If the configuration is not found or not in configured state, returns an empty string.
50+
func (c *InstanceConfigClient) GetInstallationID() string {
51+
return c.getConfigField("installation_id")
52+
}
53+
54+
// getConfigField retrieves a specific field from the installation defaults configuration.
55+
// If the configuration is not found or not in configured state, returns an empty string.
56+
func (c *InstanceConfigClient) getConfigField(field string) string {
57+
ctx, cancel := context.WithTimeout(context.Background(), time.Second*10)
58+
defer cancel()
59+
60+
req := &protoconfig.ListConfigsRequest{
61+
Types: []protoconfig.ConfigType{protoconfig.ConfigType_CONFIG_TYPE_INSTALLATION_DEFAULTS},
62+
}
63+
64+
resp, err := c.client.ListConfigs(ctx, req)
65+
if err != nil {
66+
log.Errorf("Failed to list installation configurations: %v", err)
67+
return ""
68+
}
69+
70+
for _, config := range resp.Configs {
71+
if config.State == protoconfig.State_STATE_CONFIGURED {
72+
for _, f := range config.Fields {
73+
if f.Key == field {
74+
return f.Value
75+
}
76+
}
77+
}
78+
}
79+
log.Errorf("Failed to get configuration field: %v", field)
80+
return ""
81+
}
82+
4883
func (c *InstanceConfigClient) configure(params map[string]string, configType protoconfig.ConfigType, configName string) error {
4984
ctx, cancel := context.WithTimeout(context.Background(), time.Second*10)
5085
defer cancel()

bootstrapper/pkg/clients/org_client.go

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,8 @@ func (c *OrgClient) Describe(orgID, orgUsername string) (*organization.Organizat
4747
defer cancel()
4848

4949
req := &organization.DescribeRequest{
50-
OrgId: orgID,
51-
OrgUsername: orgUsername,
50+
OrgId: orgID,
51+
OrgUsername: orgUsername,
5252
IncludeQuotas: false,
5353
}
5454

@@ -73,3 +73,45 @@ func (c *OrgClient) Describe(orgID, orgUsername string) (*organization.Organizat
7373
log.Debugf("Found organization: username=%s, orgId=%s", org.GetOrgUsername(), org.GetOrgId())
7474
return org, nil
7575
}
76+
77+
// ListOrganizations returns a list of all organizations.
78+
// If pageSize is 0, a default page size will be used.
79+
// If pageToken is empty, it will fetch the first page.
80+
func (c *OrgClient) ListOrganizations(pageSize int32, pageToken string) ([]*organization.Organization, string, error) {
81+
ctx, cancel := context.WithTimeout(context.Background(), time.Second*10)
82+
defer cancel()
83+
84+
req := &organization.ListRequest{
85+
PageSize: pageSize,
86+
PageToken: pageToken,
87+
}
88+
89+
resp, err := c.client.List(ctx, req)
90+
if err != nil {
91+
log.Errorf("Failed to list organizations: %v", err)
92+
return nil, "", err
93+
}
94+
95+
log.Debugf("Retrieved %d organizations", len(resp.GetOrganizations()))
96+
return resp.GetOrganizations(), resp.GetNextPageToken(), nil
97+
}
98+
99+
func (c *OrgClient) ListAllOrganizations() ([]*organization.Organization, error) {
100+
organizations := []*organization.Organization{}
101+
pageSize := int32(100)
102+
pageToken := ""
103+
for {
104+
orgs, nextToken, err := c.ListOrganizations(pageSize, pageToken)
105+
if err != nil {
106+
return nil, err
107+
}
108+
109+
organizations = append(organizations, orgs...)
110+
if nextToken == "" {
111+
break
112+
}
113+
pageToken = nextToken
114+
}
115+
116+
return organizations, nil
117+
}

bootstrapper/pkg/clients/user_client.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,3 +58,26 @@ func (c *UserClient) RegenerateToken(userId string) (string, error) {
5858
log.Infof("Regenerated token for user: ID=%s", userId)
5959
return resp.GetApiToken(), nil
6060
}
61+
62+
// SearchUsers searches for users based on a query string.
63+
// The query can match against user's name, email, or other searchable fields.
64+
// limit specifies the maximum number of users to return (0 means use server default).
65+
func (c *UserClient) SearchUsers(query string, limit int32) ([]*user.User, error) {
66+
ctx, cancel := context.WithTimeout(context.Background(), time.Second*10)
67+
defer cancel()
68+
69+
req := &user.SearchUsersRequest{
70+
Query: query,
71+
Limit: limit,
72+
}
73+
74+
resp, err := c.client.SearchUsers(ctx, req)
75+
if err != nil {
76+
log.Errorf("Failed to search users with query '%s': %v", query, err)
77+
return nil, err
78+
}
79+
80+
users := resp.GetUsers()
81+
log.Debugf("Found %d users matching query '%s'", len(users), query)
82+
return users, nil
83+
}

bootstrapper/pkg/kubernetes/kubernetes.go

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ func (c *KubernetesClient) CreateSecretWithLabelsIfNotExists(secretName string,
7676
}
7777

7878
// createSecret is an internal helper function that creates a new secret with the given name, data, and labels.
79-
// It handles the common logic for creating a Kubernetes secret used by both UpsertSecretWithLabels and
79+
// It handles the common logic for creating a Kubernetes secret used by both UpsertSecretWithLabels and
8080
// CreateSecretWithLabelsIfNotExists.
8181
func (c *KubernetesClient) createSecret(secretName string, data map[string]string, labels map[string]string) error {
8282
log.Infof("Secret %s does not exist in namespace %s - creating a new one", secretName, c.Namespace)
@@ -143,3 +143,15 @@ func (c *KubernetesClient) UpsertSecretWithLabels(secretName string, data map[st
143143
log.Infof("Secret %s updated", secretName)
144144
return nil
145145
}
146+
147+
// GetKubeVersion returns the Kubernetes server version as a string.
148+
// If there's an error getting the version, it returns an empty string and logs the error.
149+
func (c *KubernetesClient) GetKubeVersion() string {
150+
serverVersion, err := c.Clientset.Discovery().ServerVersion()
151+
if err != nil {
152+
log.Errorf("Failed to get Kubernetes server version: %v", err)
153+
return ""
154+
}
155+
156+
return serverVersion.String()
157+
}

bootstrapper/pkg/license/client.go

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
package license
2+
3+
import (
4+
"bytes"
5+
"encoding/json"
6+
"fmt"
7+
"net/http"
8+
"time"
9+
)
10+
11+
type Client struct {
12+
httpClient *http.Client
13+
serverURL string
14+
}
15+
16+
func NewClient(serverURL string, httpClient *http.Client) *Client {
17+
if httpClient == nil {
18+
httpClient = &http.Client{
19+
Timeout: 10 * time.Second,
20+
}
21+
}
22+
return &Client{
23+
httpClient: httpClient,
24+
serverURL: serverURL,
25+
}
26+
}
27+
28+
func (c *Client) VerifyLicense(req LicenseVerificationRequest) (*LicenseVerificationResponse, error) {
29+
jsonData, err := json.Marshal(req)
30+
if err != nil {
31+
return nil, fmt.Errorf("failed to marshal request: %w", err)
32+
}
33+
34+
resp, err := c.httpClient.Post(
35+
fmt.Sprintf("%s/api/v1/verify/license", c.serverURL),
36+
"application/json",
37+
bytes.NewBuffer(jsonData),
38+
)
39+
if err != nil {
40+
return nil, fmt.Errorf("failed to make request: %w", err)
41+
}
42+
defer resp.Body.Close()
43+
44+
if resp.StatusCode != http.StatusOK {
45+
return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
46+
}
47+
48+
var verificationResp LicenseVerificationResponse
49+
if err := json.NewDecoder(resp.Body).Decode(&verificationResp); err != nil {
50+
return nil, fmt.Errorf("failed to decode response: %w", err)
51+
}
52+
53+
return &verificationResp, nil
54+
}

0 commit comments

Comments
 (0)