Release: object viewer, roles, cert/firewall automation, API key fix #12
@@ -49,6 +49,10 @@ func (h *OpenAPIHandler) UI(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
var pathParamRe = regexp.MustCompile(`\{([^}]+)\}`)
|
||||
|
||||
// collectionGetRe matches a single-segment collection under /api/v1 (e.g.
|
||||
// /api/v1/rules), which take pagination query parameters.
|
||||
var collectionGetRe = regexp.MustCompile(`^/api/v1/[a-z0-9-]+$`)
|
||||
|
||||
// BuildOpenAPISpec walks the router and assembles an OpenAPI 3.0 document.
|
||||
func BuildOpenAPISpec(router chi.Router) map[string]any {
|
||||
paths := map[string]map[string]any{}
|
||||
@@ -59,6 +63,12 @@ func BuildOpenAPISpec(router chi.Router) map[string]any {
|
||||
return nil
|
||||
}
|
||||
route = strings.TrimSuffix(route, "/*")
|
||||
// chi emits collection roots registered as "/" with a trailing slash
|
||||
// (e.g. /api/v1/rules/); normalize so registry keys and the pagination
|
||||
// matcher line up and the documented path is clean.
|
||||
if len(route) > 1 {
|
||||
route = strings.TrimRight(route, "/")
|
||||
}
|
||||
if route == "" {
|
||||
return nil
|
||||
}
|
||||
@@ -85,17 +95,34 @@ func BuildOpenAPISpec(router chi.Router) map[string]any {
|
||||
"servers": []any{map[string]any{"url": "/", "description": "This server"}},
|
||||
"security": []any{
|
||||
map[string]any{"bearerAuth": []any{}},
|
||||
map[string]any{"apiKeyAuth": []any{}},
|
||||
},
|
||||
"paths": pathsOut,
|
||||
"components": map[string]any{
|
||||
"securitySchemes": map[string]any{
|
||||
"bearerAuth": map[string]any{"type": "http", "scheme": "bearer"},
|
||||
"bearerAuth": map[string]any{
|
||||
"type": "http", "scheme": "bearer",
|
||||
"description": "Session token from POST /api/v1/auth/login, sent as `Authorization: Bearer <token>`.",
|
||||
},
|
||||
"apiKeyAuth": map[string]any{
|
||||
"type": "apiKey", "in": "header", "name": "X-API-Key",
|
||||
"description": "An API key. Read-scoped keys may call GET only.",
|
||||
},
|
||||
},
|
||||
"schemas": openAPISchemas(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// listQueryParams documents the standard pagination query parameters shared by
|
||||
// the collection GET endpoints.
|
||||
func listQueryParams() []any {
|
||||
return []any{
|
||||
map[string]any{"name": "page", "in": "query", "schema": map[string]any{"type": "integer", "default": 1}},
|
||||
map[string]any{"name": "pageSize", "in": "query", "schema": map[string]any{"type": "integer", "default": 20, "maximum": 100}},
|
||||
}
|
||||
}
|
||||
|
||||
// publicRoutes need no auth; their operations advertise empty security.
|
||||
var publicRoutes = map[string]bool{
|
||||
"/health": true,
|
||||
@@ -127,6 +154,9 @@ func operationFor(method, route string) map[string]any {
|
||||
}
|
||||
if len(params) > 0 {
|
||||
op["parameters"] = params
|
||||
} else if method == http.MethodGet && collectionGetRe.MatchString(route) && route != "/api/v1/version" && route != "/api/v1/health" {
|
||||
// Collection GET endpoints accept standard pagination.
|
||||
op["parameters"] = listQueryParams()
|
||||
}
|
||||
if publicRoutes[route] {
|
||||
op["security"] = []any{}
|
||||
@@ -227,6 +257,20 @@ var operationRegistry = map[string]map[string]any{
|
||||
"GET /api/v1/activity": {
|
||||
"summary": "Action feed (filter by ruleId, category, status, actionType, search)",
|
||||
},
|
||||
"POST /api/v1/ad-connections": {"summary": "Create an AD connection", "requestBody": jsonBody("ConnectionInput")},
|
||||
"PUT /api/v1/ad-connections/{id}": {"summary": "Update an AD connection", "requestBody": jsonBody("ConnectionInput")},
|
||||
"POST /api/v1/credentials": {"summary": "Create a credential", "requestBody": jsonBody("CredentialInput")},
|
||||
"PUT /api/v1/credentials/{id}": {"summary": "Update a credential", "requestBody": jsonBody("CredentialInput")},
|
||||
"POST /api/v1/schedules": {"summary": "Create a schedule", "requestBody": jsonBody("ScheduleInput")},
|
||||
"PUT /api/v1/schedules/{id}": {"summary": "Update a schedule", "requestBody": jsonBody("ScheduleInput")},
|
||||
"POST /api/v1/api-keys": {"summary": "Create an API key (returns the key once)", "requestBody": jsonBody("APIKeyInput")},
|
||||
"POST /api/v1/users": {"summary": "Create a user", "requestBody": jsonBody("UserInput")},
|
||||
"PUT /api/v1/users/{id}": {"summary": "Update a user", "requestBody": jsonBody("UserInput")},
|
||||
"PUT /api/v1/settings/{key}": {"summary": "Create or update a setting", "requestBody": jsonBody("SettingInput")},
|
||||
"POST /api/v1/tls/mode": {"summary": "Set the TLS certificate source", "requestBody": jsonBody("TLSModeInput")},
|
||||
"POST /api/v1/tls/certificate": {"summary": "Upload a bring-your-own certificate", "requestBody": jsonBody("TLSCertificateInput")},
|
||||
"POST /api/v1/config/import": {"summary": "Import configuration (wrapped {payload,dryRun} or a bare exported config)", "requestBody": jsonBody("ConfigImport")},
|
||||
"POST /api/v1/ad-connections/{id}/query-preview": {"summary": "Run an ad-hoc LDAP query", "requestBody": jsonBody("QueryPreviewInput")},
|
||||
}
|
||||
|
||||
func openAPISchemas() map[string]any {
|
||||
@@ -300,6 +344,109 @@ func openAPISchemas() map[string]any {
|
||||
},
|
||||
},
|
||||
},
|
||||
"ConnectionInput": map[string]any{
|
||||
"type": "object",
|
||||
"required": []any{"name", "hosts", "rootDn"},
|
||||
"properties": map[string]any{
|
||||
"name": str, "description": str, "isEnabled": boolean,
|
||||
"hosts": map[string]any{"type": "string", "description": "Comma-separated host(s)"},
|
||||
"port": map[string]any{"type": "integer", "example": 636},
|
||||
"useTls": boolean,
|
||||
"useStartTls": boolean,
|
||||
"allowInvalidCerts": boolean,
|
||||
"rootDn": map[string]any{"type": "string", "example": "DC=corp,DC=com"},
|
||||
"bindDn": str,
|
||||
"credentialId": str,
|
||||
"defaultSearchScope": map[string]any{"type": "string", "enum": []any{"Base", "OneLevel", "Subtree"}},
|
||||
"timeoutSeconds": map[string]any{"type": "integer", "example": 30},
|
||||
"pagingEnabled": boolean,
|
||||
"pageSize": map[string]any{"type": "integer", "example": 1000},
|
||||
},
|
||||
},
|
||||
"CredentialInput": map[string]any{
|
||||
"type": "object",
|
||||
"required": []any{"name", "credentialType"},
|
||||
"properties": map[string]any{
|
||||
"name": str, "description": str,
|
||||
"credentialType": map[string]any{"type": "string", "example": "UsernamePassword"},
|
||||
"username": map[string]any{"type": "string", "example": "CORP\\svc-orchestrad"},
|
||||
"password": map[string]any{"type": "string", "description": "Write-only; never returned"},
|
||||
"isEnabled": boolean,
|
||||
},
|
||||
},
|
||||
"ScheduleInput": map[string]any{
|
||||
"type": "object",
|
||||
"required": []any{"name", "scheduleKind"},
|
||||
"properties": map[string]any{
|
||||
"name": str, "description": str, "isEnabled": boolean,
|
||||
"scheduleKind": map[string]any{"type": "string", "enum": []any{"Easy", "Cron"}},
|
||||
"easyIntervalValue": map[string]any{"type": "integer", "example": 15},
|
||||
"easyIntervalUnit": map[string]any{"type": "string", "enum": []any{"Minutes", "Hours", "Days"}},
|
||||
"cronExpression": map[string]any{"type": "string", "example": "0 0 * * * *", "description": "6-field (with seconds), UTC"},
|
||||
"timezoneMode": str,
|
||||
},
|
||||
},
|
||||
"APIKeyInput": map[string]any{
|
||||
"type": "object",
|
||||
"required": []any{"name"},
|
||||
"properties": map[string]any{
|
||||
"name": str,
|
||||
"scope": map[string]any{"type": "string", "enum": []any{"read", "readwrite"}, "default": "readwrite"},
|
||||
"userId": map[string]any{"type": "string", "description": "Defaults to the caller"},
|
||||
"expiresAt": map[string]any{"type": "string", "format": "date-time"},
|
||||
},
|
||||
},
|
||||
"UserInput": map[string]any{
|
||||
"type": "object",
|
||||
"required": []any{"username"},
|
||||
"properties": map[string]any{
|
||||
"username": str, "email": str, "displayName": str,
|
||||
"password": map[string]any{"type": "string", "description": "Write-only"},
|
||||
"isActive": boolean,
|
||||
"roles": map[string]any{"type": "array", "items": str},
|
||||
},
|
||||
},
|
||||
"SettingInput": map[string]any{
|
||||
"type": "object",
|
||||
"required": []any{"value"},
|
||||
"properties": map[string]any{
|
||||
"value": str, "valueType": str, "description": str, "isSensitive": boolean,
|
||||
},
|
||||
},
|
||||
"TLSModeInput": map[string]any{
|
||||
"type": "object",
|
||||
"required": []any{"mode"},
|
||||
"properties": map[string]any{
|
||||
"mode": map[string]any{"type": "string", "enum": []any{"auto", "provided", "windows-store"}},
|
||||
"windowsThumbprint": map[string]any{"type": "string", "description": "Required when mode is windows-store"},
|
||||
},
|
||||
},
|
||||
"TLSCertificateInput": map[string]any{
|
||||
"type": "object",
|
||||
"required": []any{"certificatePem", "privateKeyPem"},
|
||||
"properties": map[string]any{
|
||||
"certificatePem": str, "privateKeyPem": str, "chainPem": str,
|
||||
},
|
||||
},
|
||||
"QueryPreviewInput": map[string]any{
|
||||
"type": "object",
|
||||
"required": []any{"filter"},
|
||||
"properties": map[string]any{
|
||||
"baseDn": str,
|
||||
"scope": map[string]any{"type": "string", "enum": []any{"base", "one", "subtree"}},
|
||||
"filter": map[string]any{"type": "string", "example": "(objectClass=user)"},
|
||||
"attributes": map[string]any{"type": "array", "items": str},
|
||||
"limit": map[string]any{"type": "integer", "example": 100},
|
||||
},
|
||||
},
|
||||
"ConfigImport": map[string]any{
|
||||
"type": "object",
|
||||
"description": "Either {payload:<config>, dryRun:bool} or a bare exported config object.",
|
||||
"properties": map[string]any{
|
||||
"payload": map[string]any{"type": "object"},
|
||||
"dryRun": boolean,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -49,6 +49,48 @@ func TestBuildOpenAPISpecFromRouter(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestCollectionRoutesNormalizeTrailingSlash guards the real router shape where
|
||||
// a collection root is registered as Post("/") under Route("/x"), which chi
|
||||
// emits with a trailing slash. The spec must normalize it so the request body
|
||||
// and pagination params still attach.
|
||||
func TestCollectionRoutesNormalizeTrailingSlash(t *testing.T) {
|
||||
r := chi.NewRouter()
|
||||
noop := func(w http.ResponseWriter, _ *http.Request) {}
|
||||
r.Route("/api/v1", func(r chi.Router) {
|
||||
r.Route("/ad-connections", func(r chi.Router) {
|
||||
r.Get("/", noop)
|
||||
r.Post("/", noop)
|
||||
})
|
||||
})
|
||||
|
||||
paths := BuildOpenAPISpec(r)["paths"].(map[string]any)
|
||||
conn, ok := paths["/api/v1/ad-connections"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("collection route not normalized; got paths: %v", keysOf(paths))
|
||||
}
|
||||
post := conn["post"].(map[string]any)
|
||||
body, ok := post["requestBody"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("POST collection missing requestBody")
|
||||
}
|
||||
ref := body["content"].(map[string]any)["application/json"].(map[string]any)["schema"].(map[string]any)["$ref"]
|
||||
if ref != "#/components/schemas/ConnectionInput" {
|
||||
t.Errorf("unexpected body schema ref: %v", ref)
|
||||
}
|
||||
get := conn["get"].(map[string]any)
|
||||
if _, ok := get["parameters"]; !ok {
|
||||
t.Errorf("GET collection should have pagination parameters")
|
||||
}
|
||||
}
|
||||
|
||||
func keysOf(m map[string]any) []string {
|
||||
out := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
out = append(out, k)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TestOperationRegistryWellFormed guards against typos: every registry key must
|
||||
// be "<METHOD> /api/..." so it can only enrich a real API route.
|
||||
func TestOperationRegistryWellFormed(t *testing.T) {
|
||||
|
||||
@@ -25,26 +25,54 @@ func NewUsersHandler(repo *repository.UserRepository, auditService *audit.Servic
|
||||
return &UsersHandler{repo: repo, auditService: auditService, logger: logger}
|
||||
}
|
||||
|
||||
// UserRequest represents a user create/update request
|
||||
// UserRequest represents a user create/update request. Roles, when present,
|
||||
// replace the user's role assignments.
|
||||
type UserRequest struct {
|
||||
Username string `json:"username"`
|
||||
Email *string `json:"email,omitempty"`
|
||||
DisplayName *string `json:"displayName,omitempty"`
|
||||
Password *string `json:"password,omitempty"`
|
||||
IsActive *bool `json:"isActive,omitempty"`
|
||||
Username string `json:"username"`
|
||||
Email *string `json:"email,omitempty"`
|
||||
DisplayName *string `json:"displayName,omitempty"`
|
||||
Password *string `json:"password,omitempty"`
|
||||
IsActive *bool `json:"isActive,omitempty"`
|
||||
Roles *[]string `json:"roles,omitempty"`
|
||||
}
|
||||
|
||||
// UserResponse represents a user in responses (no password material)
|
||||
type UserResponse struct {
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Email *string `json:"email,omitempty"`
|
||||
DisplayName *string `json:"displayName,omitempty"`
|
||||
IsActive bool `json:"isActive"`
|
||||
IsOIDCUser bool `json:"isOidcUser"`
|
||||
LastLoginUTC *string `json:"lastLoginUtc,omitempty"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Email *string `json:"email,omitempty"`
|
||||
DisplayName *string `json:"displayName,omitempty"`
|
||||
IsActive bool `json:"isActive"`
|
||||
IsOIDCUser bool `json:"isOidcUser"`
|
||||
Roles []string `json:"roles"`
|
||||
LastLoginUTC *string `json:"lastLoginUtc,omitempty"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// RoleResponse is a role offered for assignment.
|
||||
type RoleResponse struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
// ListRoles handles GET /api/v1/roles — the assignable roles.
|
||||
func (h *UsersHandler) ListRoles(w http.ResponseWriter, r *http.Request) {
|
||||
roles, err := h.repo.ListRoles()
|
||||
if err != nil {
|
||||
h.logger.Error("UsersHandler", "ListRoles failed: %v", err)
|
||||
WriteError(w, http.StatusInternalServerError, ErrCodeInternalError, "Failed to list roles")
|
||||
return
|
||||
}
|
||||
resp := make([]RoleResponse, 0, len(roles))
|
||||
for _, ro := range roles {
|
||||
rr := RoleResponse{Name: ro.Name}
|
||||
if ro.Description != nil {
|
||||
rr.Description = *ro.Description
|
||||
}
|
||||
resp = append(resp, rr)
|
||||
}
|
||||
WriteJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// List handles GET /api/v1/users
|
||||
@@ -59,7 +87,11 @@ func (h *UsersHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
resp := make([]UserResponse, 0, len(users))
|
||||
for i := range users {
|
||||
resp = append(resp, userToResponse(&users[i]))
|
||||
ur := userToResponse(&users[i])
|
||||
if names, err := h.repo.GetRoleNames(users[i].ID); err == nil {
|
||||
ur.Roles = names
|
||||
}
|
||||
resp = append(resp, ur)
|
||||
}
|
||||
WriteList(w, resp, p.Page, p.PageSize, total)
|
||||
}
|
||||
@@ -77,7 +109,11 @@ func (h *UsersHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
WriteError(w, http.StatusNotFound, ErrCodeNotFound, "User not found")
|
||||
return
|
||||
}
|
||||
WriteJSON(w, http.StatusOK, userToResponse(user))
|
||||
resp := userToResponse(user)
|
||||
if names, err := h.repo.GetRoleNames(user.ID); err == nil {
|
||||
resp.Roles = names
|
||||
}
|
||||
WriteJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// Create handles POST /api/v1/users
|
||||
@@ -128,9 +164,18 @@ func (h *UsersHandler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
WriteError(w, http.StatusInternalServerError, ErrCodeInternalError, "Failed to create user")
|
||||
return
|
||||
}
|
||||
if req.Roles != nil {
|
||||
if err := h.repo.SetRoles(user.ID, *req.Roles); err != nil {
|
||||
h.logger.Warn("UsersHandler", "SetRoles failed for %s: %v", user.ID, err)
|
||||
}
|
||||
}
|
||||
emitAudit(h.auditService, r, audit.EventCreate, "User", user.ID, "Create", true,
|
||||
map[string]any{"username": user.Username}, "")
|
||||
WriteJSON(w, http.StatusCreated, userToResponse(user))
|
||||
resp := userToResponse(user)
|
||||
if names, err := h.repo.GetRoleNames(user.ID); err == nil {
|
||||
resp.Roles = names
|
||||
}
|
||||
WriteJSON(w, http.StatusCreated, resp)
|
||||
}
|
||||
|
||||
// Update handles PUT /api/v1/users/{id}
|
||||
@@ -176,9 +221,18 @@ func (h *UsersHandler) Update(w http.ResponseWriter, r *http.Request) {
|
||||
WriteError(w, http.StatusInternalServerError, ErrCodeInternalError, "Failed to update user")
|
||||
return
|
||||
}
|
||||
if req.Roles != nil {
|
||||
if err := h.repo.SetRoles(existing.ID, *req.Roles); err != nil {
|
||||
h.logger.Warn("UsersHandler", "SetRoles failed for %s: %v", existing.ID, err)
|
||||
}
|
||||
}
|
||||
emitAudit(h.auditService, r, audit.EventUpdate, "User", existing.ID, "Update", true,
|
||||
map[string]any{"username": existing.Username}, "")
|
||||
WriteJSON(w, http.StatusOK, userToResponse(existing))
|
||||
resp := userToResponse(existing)
|
||||
if names, err := h.repo.GetRoleNames(existing.ID); err == nil {
|
||||
resp.Roles = names
|
||||
}
|
||||
WriteJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// Delete handles DELETE /api/v1/users/{id}
|
||||
|
||||
@@ -147,7 +147,7 @@ func runServer(ctx context.Context) error {
|
||||
RunRepo: repository.NewRuleRunRepository(database.Conn()),
|
||||
UserRepo: repository.NewUserRepository(database.Conn()),
|
||||
APIKeyService: services.NewAPIKeyService(database.Conn(), logger),
|
||||
BackupService: services.NewBackupService(database, filepath.Join(cfg.DataPath, "backups"), 10, logger),
|
||||
BackupService: services.NewBackupService(database, filepath.Join(cfg.DataPath, "backups"), 3, logger),
|
||||
SettingsService: settingsService,
|
||||
DashboardService: services.NewDashboardService(database.Conn(), logger),
|
||||
ActivityService: services.NewActivityService(database.Conn(), logger),
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
//go:build !windows
|
||||
|
||||
package cli
|
||||
|
||||
// EnsureFirewallRule is a no-op off Windows (firewall management is handled by
|
||||
// the platform's own tooling, e.g. firewalld/ufw/iptables).
|
||||
func EnsureFirewallRule(port int) error { return nil }
|
||||
|
||||
// RemoveFirewallRule is a no-op off Windows.
|
||||
func RemoveFirewallRule() error { return nil }
|
||||
@@ -0,0 +1,51 @@
|
||||
//go:build windows
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
|
||||
"github.com/Grace-Solutions/OrchestrAD/internal/logging"
|
||||
)
|
||||
|
||||
const firewallRuleName = "OrchestrAD"
|
||||
|
||||
// firewallRemoteIP scopes the inbound rule to RFC 1918 private ranges plus the
|
||||
// carrier-grade NAT range (100.64.0.0/10). Loopback is exempt from the firewall
|
||||
// so localhost is unaffected.
|
||||
const firewallRemoteIP = "10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,100.64.0.0/10"
|
||||
|
||||
// EnsureFirewallRule idempotently creates the inbound allow rule for the service
|
||||
// port. It first deletes any rule of the same name so the rule always reflects
|
||||
// the current port, then adds it. Requires administrative rights (the MSI custom
|
||||
// action and the service run elevated).
|
||||
func EnsureFirewallRule(port int) error {
|
||||
// Best-effort delete of a prior rule (ignore "no rules match").
|
||||
_ = exec.Command("netsh", "advfirewall", "firewall", "delete", "rule",
|
||||
"name="+firewallRuleName).Run()
|
||||
|
||||
out, err := exec.Command("netsh", "advfirewall", "firewall", "add", "rule",
|
||||
"name="+firewallRuleName,
|
||||
"dir=in", "action=allow", "protocol=TCP",
|
||||
"localport="+strconv.Itoa(port),
|
||||
"remoteip="+firewallRemoteIP,
|
||||
"profile=any",
|
||||
"description=OrchestrAD inbound (RFC1918 + CGNAT)",
|
||||
).CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("netsh add rule failed: %v: %s", err, out)
|
||||
}
|
||||
logging.Info("Firewall", "Inbound rule '%s' allows TCP %d from %s", firewallRuleName, port, firewallRemoteIP)
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveFirewallRule idempotently deletes the inbound rule. A missing rule is
|
||||
// not an error.
|
||||
func RemoveFirewallRule() error {
|
||||
_ = exec.Command("netsh", "advfirewall", "firewall", "delete", "rule",
|
||||
"name="+firewallRuleName).Run()
|
||||
logging.Info("Firewall", "Inbound rule '%s' removed", firewallRuleName)
|
||||
return nil
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/Grace-Solutions/OrchestrAD/internal/config"
|
||||
"github.com/Grace-Solutions/OrchestrAD/internal/logging"
|
||||
"github.com/kardianos/service"
|
||||
)
|
||||
@@ -143,6 +144,8 @@ func initializeService() error {
|
||||
logging.Info("Service", "Service already installed")
|
||||
}
|
||||
|
||||
ensureFirewall()
|
||||
|
||||
if status == service.StatusRunning {
|
||||
logging.Info("Service", "Service already running")
|
||||
return nil
|
||||
@@ -154,6 +157,19 @@ func initializeService() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensureFirewall creates the inbound allow rule for the configured listen port.
|
||||
// Best-effort: a failure (e.g. missing privileges) is logged but does not block
|
||||
// service provisioning.
|
||||
func ensureFirewall() {
|
||||
port := 18090
|
||||
if cfg, err := config.Load(); err == nil {
|
||||
port = cfg.Server.Port
|
||||
}
|
||||
if err := EnsureFirewallRule(port); err != nil {
|
||||
logging.Warn("Firewall", "Could not create inbound firewall rule (%v); open TCP %d manually if needed", err, port)
|
||||
}
|
||||
}
|
||||
|
||||
// removeService idempotently ensures the service is stopped and removed.
|
||||
// Re-running it is safe: a not-installed service is treated as already removed,
|
||||
// and a stop failure on an already-stopped service does not block removal. This
|
||||
@@ -182,6 +198,7 @@ func removeService() error {
|
||||
if err := s.Uninstall(); err != nil {
|
||||
return fmt.Errorf("removing service: %w", err)
|
||||
}
|
||||
_ = RemoveFirewallRule()
|
||||
logging.Info("Service", "Service removed")
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -107,7 +107,7 @@ func Load() (*Config, error) {
|
||||
TrustedProxies: splitCSV(getEnv("ORCHESTRAD_TRUSTED_PROXIES", "local")),
|
||||
},
|
||||
Database: DatabaseConfig{
|
||||
Path: filepath.Join(dataPath, "orchestrad.db"),
|
||||
Path: filepath.Join(dataPath, "db", "orchestrad.db"),
|
||||
MaxOpenConns: getEnvInt("ORCHESTRAD_DB_MAX_OPEN_CONNS", 25),
|
||||
MaxIdleConns: getEnvInt("ORCHESTRAD_DB_MAX_IDLE_CONNS", 5),
|
||||
WALMode: true,
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"database/sql"
|
||||
"embed"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/Grace-Solutions/OrchestrAD/internal/config"
|
||||
"github.com/Grace-Solutions/OrchestrAD/internal/logging"
|
||||
@@ -26,6 +28,16 @@ type DB struct {
|
||||
|
||||
// New creates a new database connection with the given configuration
|
||||
func New(cfg config.DatabaseConfig, logger *logging.Logger) (*DB, error) {
|
||||
// Ensure the database directory exists (SQLite will not create it), and
|
||||
// migrate a legacy database from the data-root into the db/ subdirectory so
|
||||
// existing installs keep their data after the path change.
|
||||
if dir := filepath.Dir(cfg.Path); dir != "" && dir != "." {
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("creating database directory: %w", err)
|
||||
}
|
||||
migrateLegacyDBLocation(cfg.Path, logger)
|
||||
}
|
||||
|
||||
logger.Info("Database", "Opening database at %s", cfg.Path)
|
||||
|
||||
// Build connection string with pragmas. modernc.org/sqlite applies these
|
||||
@@ -57,6 +69,30 @@ func New(cfg config.DatabaseConfig, logger *logging.Logger) (*DB, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
// migrateLegacyDBLocation moves a database (and its -wal/-shm sidecars) from the
|
||||
// old data-root location into the new db/ subdirectory, once, when the new file
|
||||
// does not yet exist. It is best-effort: failures are logged, not fatal.
|
||||
func migrateLegacyDBLocation(newPath string, logger *logging.Logger) {
|
||||
if _, err := os.Stat(newPath); err == nil {
|
||||
return // new database already present
|
||||
}
|
||||
dbDir := filepath.Dir(newPath)
|
||||
legacy := filepath.Join(filepath.Dir(dbDir), filepath.Base(newPath))
|
||||
if _, err := os.Stat(legacy); err != nil {
|
||||
return // nothing to migrate
|
||||
}
|
||||
for _, suffix := range []string{"", "-wal", "-shm"} {
|
||||
from, to := legacy+suffix, newPath+suffix
|
||||
if _, err := os.Stat(from); err != nil {
|
||||
continue
|
||||
}
|
||||
if err := os.Rename(from, to); err != nil {
|
||||
logger.Warn("Database", "Could not move legacy database file %s: %v", from, err)
|
||||
}
|
||||
}
|
||||
logger.Info("Database", "Migrated existing database into %s", dbDir)
|
||||
}
|
||||
|
||||
// Conn returns the underlying sql.DB connection
|
||||
func (db *DB) Conn() *sql.DB {
|
||||
return db.conn
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/Grace-Solutions/OrchestrAD/internal/logging"
|
||||
)
|
||||
|
||||
func TestMigrateLegacyDBLocation(t *testing.T) {
|
||||
data := t.TempDir()
|
||||
newPath := filepath.Join(data, "db", "orchestrad.db")
|
||||
if err := os.MkdirAll(filepath.Dir(newPath), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Seed a legacy database + WAL/SHM sidecars in the data root.
|
||||
for _, suffix := range []string{"", "-wal", "-shm"} {
|
||||
if err := os.WriteFile(filepath.Join(data, "orchestrad.db"+suffix), []byte("x"+suffix), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
migrateLegacyDBLocation(newPath, logging.Default())
|
||||
|
||||
// Files moved into db/, originals gone.
|
||||
for _, suffix := range []string{"", "-wal", "-shm"} {
|
||||
if _, err := os.Stat(newPath + suffix); err != nil {
|
||||
t.Errorf("expected %s in db/, got error: %v", newPath+suffix, err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(data, "orchestrad.db"+suffix)); !os.IsNotExist(err) {
|
||||
t.Errorf("legacy file orchestrad.db%s should be gone", suffix)
|
||||
}
|
||||
}
|
||||
|
||||
// Second run is a no-op (new file already exists).
|
||||
migrateLegacyDBLocation(newPath, logging.Default())
|
||||
if _, err := os.Stat(newPath); err != nil {
|
||||
t.Errorf("new db should still exist after second run: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
// Package ldap - human-readable formatting for binary directory attributes.
|
||||
package ldap
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
goldap "github.com/go-ldap/ldap/v3"
|
||||
)
|
||||
|
||||
// binaryAttrsGUID / binaryAttrsSID name the well-known AD attributes stored as
|
||||
// raw binary that must be decoded to be legible.
|
||||
var (
|
||||
guidAttrs = map[string]bool{"objectguid": true}
|
||||
sidAttrs = map[string]bool{"objectsid": true, "sidhistory": true}
|
||||
)
|
||||
|
||||
// FormatAttributeValues returns display-friendly string values for an attribute:
|
||||
// objectGUID as a GUID, objectSid/sIDHistory as S-1-… strings, and any other
|
||||
// value that isn't valid printable UTF-8 as base64. Printable values pass
|
||||
// through unchanged.
|
||||
func FormatAttributeValues(attr *goldap.EntryAttribute) []string {
|
||||
name := strings.ToLower(attr.Name)
|
||||
|
||||
if guidAttrs[name] {
|
||||
out := make([]string, 0, len(attr.ByteValues))
|
||||
for _, b := range attr.ByteValues {
|
||||
out = append(out, formatGUID(b))
|
||||
}
|
||||
if len(out) > 0 {
|
||||
return out
|
||||
}
|
||||
}
|
||||
if sidAttrs[name] {
|
||||
out := make([]string, 0, len(attr.ByteValues))
|
||||
for _, b := range attr.ByteValues {
|
||||
out = append(out, formatSID(b))
|
||||
}
|
||||
if len(out) > 0 {
|
||||
return out
|
||||
}
|
||||
}
|
||||
|
||||
// Generic: keep printable text, base64 anything binary.
|
||||
out := make([]string, len(attr.Values))
|
||||
for i, v := range attr.Values {
|
||||
if isPrintable(v) {
|
||||
out[i] = v
|
||||
} else if i < len(attr.ByteValues) {
|
||||
out[i] = "base64:" + base64.StdEncoding.EncodeToString(attr.ByteValues[i])
|
||||
} else {
|
||||
out[i] = "base64:" + base64.StdEncoding.EncodeToString([]byte(v))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// formatGUID renders a 16-byte AD objectGUID as its canonical string, honoring
|
||||
// the mixed-endian layout of the first three groups.
|
||||
func formatGUID(b []byte) string {
|
||||
if len(b) != 16 {
|
||||
return "base64:" + base64.StdEncoding.EncodeToString(b)
|
||||
}
|
||||
return fmt.Sprintf("%02x%02x%02x%02x-%02x%02x-%02x%02x-%02x%02x-%02x%02x%02x%02x%02x%02x",
|
||||
b[3], b[2], b[1], b[0], b[5], b[4], b[7], b[6],
|
||||
b[8], b[9], b[10], b[11], b[12], b[13], b[14], b[15])
|
||||
}
|
||||
|
||||
// formatSID renders a binary NT security identifier as S-R-IA-SA1-SA2-…
|
||||
func formatSID(b []byte) string {
|
||||
if len(b) < 8 {
|
||||
return "base64:" + base64.StdEncoding.EncodeToString(b)
|
||||
}
|
||||
revision := b[0]
|
||||
subCount := int(b[1])
|
||||
// Identifier authority: 48-bit big-endian in bytes 2..7.
|
||||
var authority uint64
|
||||
for i := 2; i < 8; i++ {
|
||||
authority = authority<<8 | uint64(b[i])
|
||||
}
|
||||
sid := fmt.Sprintf("S-%d-%d", revision, authority)
|
||||
// Sub-authorities: 32-bit little-endian, subCount of them.
|
||||
for i := 0; i < subCount; i++ {
|
||||
off := 8 + i*4
|
||||
if off+4 > len(b) {
|
||||
break
|
||||
}
|
||||
sub := uint32(b[off]) | uint32(b[off+1])<<8 | uint32(b[off+2])<<16 | uint32(b[off+3])<<24
|
||||
sid += fmt.Sprintf("-%d", sub)
|
||||
}
|
||||
return sid
|
||||
}
|
||||
|
||||
func isPrintable(s string) bool {
|
||||
if !utf8.ValidString(s) {
|
||||
return false
|
||||
}
|
||||
for _, r := range s {
|
||||
if r == '\t' || r == '\n' || r == '\r' {
|
||||
continue
|
||||
}
|
||||
if r < 0x20 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -51,10 +51,13 @@ func (c *Client) Connect() error {
|
||||
var err error
|
||||
|
||||
if c.config.UseTLS {
|
||||
tlsConfig := &tls.Config{
|
||||
// ServerName lets certificate verification succeed when the DC cert
|
||||
// is actually trusted; it is ignored when AllowInvalidCerts skips
|
||||
// verification (the common non-domain-joined case).
|
||||
conn, err = ldap.DialTLS("tcp", addr, &tls.Config{
|
||||
InsecureSkipVerify: c.config.AllowInvalidCerts,
|
||||
}
|
||||
conn, err = ldap.DialTLS("tcp", addr, tlsConfig)
|
||||
ServerName: host,
|
||||
})
|
||||
} else {
|
||||
conn, err = ldap.Dial("tcp", addr)
|
||||
}
|
||||
@@ -69,6 +72,7 @@ func (c *Client) Connect() error {
|
||||
if c.config.UseStartTLS {
|
||||
tlsConfig := &tls.Config{
|
||||
InsecureSkipVerify: c.config.AllowInvalidCerts,
|
||||
ServerName: host,
|
||||
}
|
||||
if err := conn.StartTLS(tlsConfig); err != nil {
|
||||
conn.Close()
|
||||
|
||||
@@ -19,6 +19,88 @@ func NewUserRepository(db *sql.DB) *UserRepository {
|
||||
return &UserRepository{db: db}
|
||||
}
|
||||
|
||||
// ListRoles returns all defined roles, ordered by their privilege tier.
|
||||
func (r *UserRepository) ListRoles() ([]models.Role, error) {
|
||||
rows, err := r.db.Query(`
|
||||
SELECT id, name, description, is_system_role, created_utc, updated_utc
|
||||
FROM roles
|
||||
ORDER BY CASE name
|
||||
WHEN 'SuperAdmin' THEN 0 WHEN 'Admin' THEN 1
|
||||
WHEN 'Operator' THEN 2 WHEN 'Viewer' THEN 3 ELSE 4 END, name`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var roles []models.Role
|
||||
for rows.Next() {
|
||||
var role models.Role
|
||||
var isSystem int
|
||||
var createdStr, updatedStr string
|
||||
if err := rows.Scan(&role.ID, &role.Name, &role.Description, &isSystem, &createdStr, &updatedStr); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
role.IsSystemRole = isSystem != 0
|
||||
role.CreatedUTC = parseTimeOrZero(createdStr)
|
||||
role.UpdatedUTC = parseTimeOrZero(updatedStr)
|
||||
roles = append(roles, role)
|
||||
}
|
||||
return roles, rows.Err()
|
||||
}
|
||||
|
||||
// GetRoleNames returns the names of the roles assigned to a user.
|
||||
func (r *UserRepository) GetRoleNames(userID string) ([]string, error) {
|
||||
rows, err := r.db.Query(`
|
||||
SELECT ro.name FROM user_roles ur
|
||||
JOIN roles ro ON ro.id = ur.role_id
|
||||
WHERE ur.user_id = ?
|
||||
ORDER BY ro.name`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var names []string
|
||||
for rows.Next() {
|
||||
var n string
|
||||
if err := rows.Scan(&n); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
names = append(names, n)
|
||||
}
|
||||
return names, rows.Err()
|
||||
}
|
||||
|
||||
// SetRoles replaces a user's role assignments with the named roles (unknown
|
||||
// names are ignored). Runs in a transaction so the set change is atomic.
|
||||
func (r *UserRepository) SetRoles(userID string, roleNames []string) error {
|
||||
tx, err := r.db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
if _, err := tx.Exec(`DELETE FROM user_roles WHERE user_id = ?`, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
for _, name := range roleNames {
|
||||
var roleID string
|
||||
err := tx.QueryRow(`SELECT id FROM roles WHERE name = ?`, name).Scan(&roleID)
|
||||
if err == sql.ErrNoRows {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(`INSERT OR IGNORE INTO user_roles (user_id, role_id, created_utc) VALUES (?, ?, ?)`,
|
||||
userID, roleID, now); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// Create creates a new user
|
||||
func (r *UserRepository) Create(user *models.User) error {
|
||||
if user.ID == "" {
|
||||
|
||||
@@ -175,6 +175,9 @@ func (s *Server) setupRoutes() {
|
||||
r.Delete("/{id}", usersHandler.Delete)
|
||||
})
|
||||
|
||||
// Assignable roles (built-in RBAC roles)
|
||||
r.Get("/roles", usersHandler.ListRoles)
|
||||
|
||||
// Credentials
|
||||
credentialsHandler := api.NewCredentialsHandler(s.deps.CredService, s.deps.AuditService, s.logger)
|
||||
r.Route("/credentials", func(r chi.Router) {
|
||||
|
||||
@@ -217,14 +217,20 @@ func (s *APIKeyService) List(userID string) ([]APIKey, error) {
|
||||
for rows.Next() {
|
||||
var k APIKey
|
||||
var isEnabled int
|
||||
var createdStr string
|
||||
var expires, lastUsed, revoked sql.NullString
|
||||
// created_utc is scanned as a string: the modernc.org/sqlite driver
|
||||
// returns TEXT timestamps as strings, not time.Time.
|
||||
if err := rows.Scan(
|
||||
&k.ID, &k.UserID, &k.Name, &k.KeyPrefix, &expires,
|
||||
&isEnabled, &k.Scope, &lastUsed, &k.CreatedAt, &revoked,
|
||||
&isEnabled, &k.Scope, &lastUsed, &createdStr, &revoked,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
k.IsEnabled = isEnabled != 0
|
||||
if t, err := time.Parse(time.RFC3339, createdStr); err == nil {
|
||||
k.CreatedAt = t
|
||||
}
|
||||
k.ExpiresAt = parseNullTimeStr(expires)
|
||||
k.LastUsedAt = parseNullTimeStr(lastUsed)
|
||||
k.RevokedAt = parseNullTimeStr(revoked)
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/Grace-Solutions/OrchestrAD/internal/config"
|
||||
"github.com/Grace-Solutions/OrchestrAD/internal/db"
|
||||
"github.com/Grace-Solutions/OrchestrAD/internal/logging"
|
||||
"github.com/Grace-Solutions/OrchestrAD/internal/models"
|
||||
"github.com/Grace-Solutions/OrchestrAD/internal/repository"
|
||||
)
|
||||
|
||||
// TestAPIKeyListRoundTrip guards the modernc string-timestamp scan on the API
|
||||
// key list (created_utc must not be scanned straight into time.Time).
|
||||
func TestAPIKeyListRoundTrip(t *testing.T) {
|
||||
database, err := db.New(config.DatabaseConfig{
|
||||
Path: filepath.Join(t.TempDir(), "apikey_test.db"),
|
||||
MaxOpenConns: 2, MaxIdleConns: 2, WALMode: true, ForeignKeys: true,
|
||||
}, logging.Default())
|
||||
if err != nil {
|
||||
t.Fatalf("db.New: %v", err)
|
||||
}
|
||||
defer database.Close()
|
||||
if err := database.Migrate(); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
conn := database.Conn()
|
||||
|
||||
// A user to satisfy the api_keys.user_id FK.
|
||||
userRepo := repository.NewUserRepository(conn)
|
||||
u := &models.User{Username: "keyowner", IsActive: true}
|
||||
if err := userRepo.Create(u); err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
|
||||
svc := NewAPIKeyService(conn, logging.Default())
|
||||
created, err := svc.Create(CreateAPIKeyInput{UserID: u.ID, Name: "k1", Scope: "read"})
|
||||
if err != nil {
|
||||
t.Fatalf("create key: %v", err)
|
||||
}
|
||||
if created.FullKey == "" {
|
||||
t.Fatal("expected a plaintext key on create")
|
||||
}
|
||||
|
||||
keys, err := svc.List(u.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("list keys: %v", err)
|
||||
}
|
||||
if len(keys) != 1 {
|
||||
t.Fatalf("expected 1 key, got %d", len(keys))
|
||||
}
|
||||
if keys[0].CreatedAt.IsZero() {
|
||||
t.Errorf("created_utc not parsed")
|
||||
}
|
||||
if keys[0].Scope != "read" {
|
||||
t.Errorf("scope = %q, want read", keys[0].Scope)
|
||||
}
|
||||
}
|
||||
@@ -266,7 +266,7 @@ func (s *ConnectionService) QueryPreview(conn *models.ADConnection, input QueryP
|
||||
Attributes: map[string][]string{},
|
||||
}
|
||||
for _, attr := range entry.Attributes {
|
||||
preview.Attributes[attr.Name] = attr.Values
|
||||
preview.Attributes[attr.Name] = ldap.FormatAttributeValues(attr)
|
||||
}
|
||||
result.Entries = append(result.Entries, preview)
|
||||
}
|
||||
|
||||
@@ -200,6 +200,15 @@ func (m *Manager) ensureAuto(pfxPassword string) error {
|
||||
PrivateKey: chain.Leaf.Key,
|
||||
Leaf: chain.Leaf.Certificate,
|
||||
})
|
||||
|
||||
// Install (or refresh) the self-managed root and intermediate into the host
|
||||
// trust stores so local clients trust the served chain. Best-effort on
|
||||
// Windows (needs admin); a no-op elsewhere.
|
||||
if err := InstallTrustAnchors(chain.Root.DER, chain.Intermediate.DER); err != nil {
|
||||
m.logf("could not install CA into the system trust store (%v); TLS still works, but clients on this host may not trust it", err)
|
||||
} else {
|
||||
m.logf("Self-managed CA present in the system trust store")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -15,3 +15,6 @@ func ListWindowsCerts() ([]StoreCert, error) { return nil, errWindowsOnly }
|
||||
|
||||
// WindowsStoreSupported reports whether the Windows store is usable here.
|
||||
func WindowsStoreSupported() bool { return false }
|
||||
|
||||
// InstallTrustAnchors is a no-op off Windows (no system trust store to manage).
|
||||
func InstallTrustAnchors(rootDER, interDER []byte) error { return nil }
|
||||
|
||||
@@ -46,8 +46,59 @@ var (
|
||||
procCertGetCertificateContextProperty = crypt32.NewProc("CertGetCertificateContextProperty")
|
||||
procNCryptSignHash = ncrypt.NewProc("NCryptSignHash")
|
||||
procNCryptFreeObject = ncrypt.NewProc("NCryptFreeObject")
|
||||
procCertAddEncodedCertToStore = crypt32.NewProc("CertAddEncodedCertificateToStore")
|
||||
)
|
||||
|
||||
const (
|
||||
x509ASNEncoding = 0x00000001
|
||||
certStoreAddReplaceExisting = 3
|
||||
)
|
||||
|
||||
// InstallTrustAnchors installs the self-managed root and intermediate into the
|
||||
// LocalMachine "Root" (Trusted Root CAs) and "CA" (Intermediate CAs) stores so
|
||||
// clients on this host trust the served chain. Safe to call repeatedly — the
|
||||
// replace disposition makes re-adding an unchanged certificate a no-op and a
|
||||
// renewed CA supersedes the previous one. Requires administrative rights
|
||||
// (the Windows service runs as LocalSystem, which has them).
|
||||
func InstallTrustAnchors(rootDER, interDER []byte) error {
|
||||
if err := addToSystemStore("ROOT", rootDER); err != nil {
|
||||
return fmt.Errorf("installing root CA: %w", err)
|
||||
}
|
||||
if err := addToSystemStore("CA", interDER); err != nil {
|
||||
return fmt.Errorf("installing intermediate CA: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func addToSystemStore(name string, der []byte) error {
|
||||
if len(der) == 0 {
|
||||
return nil
|
||||
}
|
||||
namePtr, err := windows.UTF16PtrFromString(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Open the LocalMachine system store read-write (no readonly flag).
|
||||
store, err := windows.CertOpenStore(
|
||||
certStoreProvSystemW, 0, 0, certSystemStoreLocalMac,
|
||||
uintptr(unsafe.Pointer(namePtr)),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("opening %s store: %w", name, err)
|
||||
}
|
||||
defer windows.CertCloseStore(store, 0)
|
||||
|
||||
r, _, e := procCertAddEncodedCertToStore.Call(
|
||||
uintptr(store), x509ASNEncoding,
|
||||
uintptr(unsafe.Pointer(&der[0])), uintptr(len(der)),
|
||||
certStoreAddReplaceExisting, 0,
|
||||
)
|
||||
if r == 0 {
|
||||
return fmt.Errorf("adding to %s store: %v", name, e)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// prevKey holds the NCrypt key handle currently in use so it can be released
|
||||
// when the manager reloads to a new certificate.
|
||||
var (
|
||||
|
||||
@@ -111,6 +111,20 @@ export default function ConnectionFormDialog({ open, connection, onClose, onSave
|
||||
/>
|
||||
);
|
||||
|
||||
// Toggling LDAPS auto-flips the port to the standard (636 on / 389 off) when
|
||||
// it is still the other default, and — because clients that are not domain
|
||||
// members usually cannot validate the DC's LDAPS certificate — defaults to
|
||||
// skipping certificate validation so LDAPS works without importing any certs.
|
||||
const onLdapsToggle = (v: boolean) => {
|
||||
setForm((f) => {
|
||||
const next: Partial<ADConnection> = { ...f, useTls: v };
|
||||
if (v && (f.port === 389 || f.port == null)) next.port = 636;
|
||||
if (!v && f.port === 636) next.port = 389;
|
||||
if (v && !f.allowInvalidCerts) next.allowInvalidCerts = true;
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} fullWidth maxWidth="md">
|
||||
<DialogTitle>{connection ? "Edit Connection" : "New Connection"}</DialogTitle>
|
||||
@@ -131,10 +145,18 @@ export default function ConnectionFormDialog({ open, connection, onClose, onSave
|
||||
{numField("timeoutSeconds", "Timeout (s)", "30")}
|
||||
</Stack>
|
||||
<Stack direction="row" spacing={2} flexWrap="wrap">
|
||||
{swField("useTls", "LDAPS (TLS)")}
|
||||
<FormControlLabel
|
||||
control={<Switch checked={Boolean(form.useTls)} onChange={(_, v) => onLdapsToggle(v)} />}
|
||||
label="LDAPS (TLS)"
|
||||
/>
|
||||
{swField("useStartTls", "StartTLS")}
|
||||
{swField("allowInvalidCerts", "Allow invalid certs")}
|
||||
</Stack>
|
||||
<Typography variant="caption" color="textSecondary">
|
||||
LDAPS uses port 636 (auto-set). If this host is not a domain member it usually can't
|
||||
validate the DC's certificate, so “Allow invalid certs” is enabled automatically — the
|
||||
connection is still encrypted, the certificate just isn't verified.
|
||||
</Typography>
|
||||
|
||||
<Divider />
|
||||
<Typography variant="subtitle2">Directory</Typography>
|
||||
|
||||
@@ -29,6 +29,7 @@ const Menuitems = [
|
||||
bgcolor: 'warning',
|
||||
children: [
|
||||
{ id: uniqueId(), title: 'Connections', href: '/connections' },
|
||||
{ id: uniqueId(), title: 'Object Viewer', href: '/objects' },
|
||||
{ id: uniqueId(), title: 'Credentials', href: '/credentials' },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -56,6 +56,13 @@ const Menuitems: NavGroup[] = [
|
||||
href: "/connections",
|
||||
bgcolor: "warning",
|
||||
},
|
||||
{
|
||||
id: uniqueId(),
|
||||
title: "Object Viewer",
|
||||
icon: "magnifer-line-duotone",
|
||||
href: "/objects",
|
||||
bgcolor: "secondary",
|
||||
},
|
||||
{
|
||||
id: uniqueId(),
|
||||
title: "Credentials",
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
"use client";
|
||||
|
||||
import Alert from "@mui/material/Alert";
|
||||
import Box from "@mui/material/Box";
|
||||
import CircularProgress from "@mui/material/CircularProgress";
|
||||
import Dialog from "@mui/material/Dialog";
|
||||
import DialogContent from "@mui/material/DialogContent";
|
||||
import DialogTitle from "@mui/material/DialogTitle";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Table from "@mui/material/Table";
|
||||
import TableBody from "@mui/material/TableBody";
|
||||
import TableCell from "@mui/material/TableCell";
|
||||
import TableHead from "@mui/material/TableHead";
|
||||
import TablePagination from "@mui/material/TablePagination";
|
||||
import TableRow from "@mui/material/TableRow";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import Tooltip from "@mui/material/Tooltip";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import ContentCopyIcon from "@mui/icons-material/ContentCopy";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { ApiError } from "@/lib/api/client";
|
||||
import { ConnectionsApi } from "@/lib/api/resources";
|
||||
|
||||
interface Props {
|
||||
connectionId: string;
|
||||
dn: string | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
// ObjectDetailDialog shows every attribute of a single directory object so the
|
||||
// operator can inspect real values and copy attribute names/values into a rule
|
||||
// filter.
|
||||
export default function ObjectDetailDialog({ connectionId, dn, onClose }: Props) {
|
||||
const [attrs, setAttrs] = useState<Record<string, string[]> | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [filter, setFilter] = useState("");
|
||||
const [page, setPage] = useState(0);
|
||||
const [rowsPerPage, setRowsPerPage] = useState(25);
|
||||
|
||||
useEffect(() => {
|
||||
if (!dn) return;
|
||||
setAttrs(null);
|
||||
setError(null);
|
||||
setFilter("");
|
||||
setPage(0);
|
||||
setLoading(true);
|
||||
ConnectionsApi.queryPreview(connectionId, {
|
||||
baseDn: dn,
|
||||
scope: "base",
|
||||
filter: "(objectClass=*)",
|
||||
attributes: ["*"],
|
||||
limit: 1,
|
||||
})
|
||||
.then((res) => setAttrs(res.entries[0]?.attributes ?? {}))
|
||||
.catch((err) => setError(err instanceof ApiError ? err.message : "Failed to load object"))
|
||||
.finally(() => setLoading(false));
|
||||
}, [connectionId, dn]);
|
||||
|
||||
const rows = useMemo(() => {
|
||||
if (!attrs) return [];
|
||||
const f = filter.trim().toLowerCase();
|
||||
return Object.entries(attrs)
|
||||
.filter(([name]) => !f || name.toLowerCase().includes(f))
|
||||
.sort((a, b) => a[0].localeCompare(b[0]));
|
||||
}, [attrs, filter]);
|
||||
|
||||
const copy = (text: string) => { void navigator.clipboard?.writeText(text).catch(() => {}); };
|
||||
|
||||
return (
|
||||
<Dialog open={dn != null} onClose={onClose} fullWidth maxWidth="md">
|
||||
<DialogTitle sx={{ wordBreak: "break-all", fontSize: 15 }}>{dn}</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
|
||||
{loading ? (
|
||||
<Box display="flex" justifyContent="center" py={4}><CircularProgress /></Box>
|
||||
) : (
|
||||
<>
|
||||
<TextField size="small" fullWidth label="Filter attributes" value={filter}
|
||||
onChange={(e) => { setFilter(e.target.value); setPage(0); }} sx={{ mb: 2 }} placeholder="e.g. department" />
|
||||
<Box sx={{ overflowX: "auto" }}>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Attribute</TableCell>
|
||||
<TableCell>Value</TableCell>
|
||||
<TableCell align="right" />
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{rows.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage).map(([name, values]) => (
|
||||
<TableRow key={name} hover>
|
||||
<TableCell sx={{ fontFamily: "monospace", fontSize: 12, verticalAlign: "top", whiteSpace: "nowrap" }}>
|
||||
{name}
|
||||
</TableCell>
|
||||
<TableCell sx={{ fontSize: 12, wordBreak: "break-word" }}>
|
||||
<Stack spacing={0.5}>
|
||||
{values.map((v, i) => <span key={i}>{v}</span>)}
|
||||
</Stack>
|
||||
</TableCell>
|
||||
<TableCell align="right" sx={{ whiteSpace: "nowrap", verticalAlign: "top" }}>
|
||||
<Tooltip title="Copy attribute name">
|
||||
<IconButton size="small" onClick={() => copy(name)}><ContentCopyIcon sx={{ fontSize: 14 }} /></IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Copy value">
|
||||
<IconButton size="small" onClick={() => copy(values.join("; "))}><ContentCopyIcon sx={{ fontSize: 14 }} /></IconButton>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{rows.length === 0 && (
|
||||
<TableRow><TableCell colSpan={3}><Typography variant="body2" color="textSecondary">No attributes.</Typography></TableCell></TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<TablePagination
|
||||
component="div"
|
||||
count={rows.length}
|
||||
page={page}
|
||||
onPageChange={(_, p) => setPage(p)}
|
||||
rowsPerPage={rowsPerPage}
|
||||
onRowsPerPageChange={(e) => { setRowsPerPage(parseInt(e.target.value, 10)); setPage(0); }}
|
||||
rowsPerPageOptions={[10, 25, 50, 100]}
|
||||
/>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
"use client";
|
||||
|
||||
import Alert from "@mui/material/Alert";
|
||||
import Box from "@mui/material/Box";
|
||||
import Button from "@mui/material/Button";
|
||||
import Chip from "@mui/material/Chip";
|
||||
import CircularProgress from "@mui/material/CircularProgress";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Table from "@mui/material/Table";
|
||||
import TableBody from "@mui/material/TableBody";
|
||||
import TableCell from "@mui/material/TableCell";
|
||||
import TableContainer from "@mui/material/TableContainer";
|
||||
import TableHead from "@mui/material/TableHead";
|
||||
import TablePagination from "@mui/material/TablePagination";
|
||||
import TableRow from "@mui/material/TableRow";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import Tooltip from "@mui/material/Tooltip";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import SearchOutlinedIcon from "@mui/icons-material/SearchOutlined";
|
||||
import VisibilityOutlinedIcon from "@mui/icons-material/VisibilityOutlined";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import PageContainer from "@/app/components/container/PageContainer";
|
||||
import DashboardCard from "@/app/components/shared/DashboardCard";
|
||||
import { ApiError } from "@/lib/api/client";
|
||||
import { ConnectionsApi } from "@/lib/api/resources";
|
||||
import type { ADConnection, QueryPreviewEntry } from "@/lib/api/types";
|
||||
|
||||
import DirectoryPicker from "../rules/DirectoryPicker";
|
||||
import ObjectDetailDialog from "./ObjectDetailDialog";
|
||||
|
||||
const OBJECT_TYPES = [
|
||||
{ value: "User", label: "Users", filter: "(&(objectCategory=person)(objectClass=user))" },
|
||||
{ value: "Computer", label: "Computers", filter: "(objectClass=computer)" },
|
||||
{ value: "Group", label: "Groups", filter: "(objectClass=group)" },
|
||||
{ value: "OU", label: "Organizational Units", filter: "(objectClass=organizationalUnit)" },
|
||||
{ value: "Any", label: "Any object", filter: "(objectClass=*)" },
|
||||
];
|
||||
|
||||
const LIST_ATTRS = ["cn", "name", "distinguishedName", "sAMAccountName", "objectClass", "canonicalName", "mail", "description", "userPrincipalName"];
|
||||
|
||||
// Escape RFC 4515 specials in a user-typed search term (we add our own * wildcards).
|
||||
function esc(s: string): string {
|
||||
return s.replace(/\\/g, "\\5c").replace(/\*/g, "\\2a").replace(/\(/g, "\\28").replace(/\)/g, "\\29").replace(/\0/g, "\\00");
|
||||
}
|
||||
|
||||
function firstAttr(e: QueryPreviewEntry, ...names: string[]): string {
|
||||
for (const n of names) {
|
||||
const v = e.attributes[n];
|
||||
if (v && v.length) return v[0];
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function objectKind(e: QueryPreviewEntry): string {
|
||||
const classes = (e.attributes.objectClass ?? []).map((c) => c.toLowerCase());
|
||||
if (classes.includes("computer")) return "Computer";
|
||||
if (classes.includes("group")) return "Group";
|
||||
if (classes.includes("organizationalunit")) return "OU";
|
||||
if (classes.includes("user")) return "User";
|
||||
return classes[classes.length - 1] ?? "object";
|
||||
}
|
||||
|
||||
// The Object Viewer lets an operator browse a directory — pick a connection,
|
||||
// search by name, and drill into any object's attributes — to discover the
|
||||
// attributes and values worth putting in a rule filter.
|
||||
export default function ObjectViewerPage() {
|
||||
const [connections, setConnections] = useState<ADConnection[]>([]);
|
||||
const [connectionId, setConnectionId] = useState("");
|
||||
const [objectType, setObjectType] = useState("User");
|
||||
const [q, setQ] = useState("");
|
||||
const [baseDn, setBaseDn] = useState("");
|
||||
const [rawFilter, setRawFilter] = useState("");
|
||||
|
||||
const [entries, setEntries] = useState<QueryPreviewEntry[]>([]);
|
||||
const [truncated, setTruncated] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [searched, setSearched] = useState(false);
|
||||
const [detailDn, setDetailDn] = useState<string | null>(null);
|
||||
const [page, setPage] = useState(0);
|
||||
const [rowsPerPage, setRowsPerPage] = useState(25);
|
||||
|
||||
useEffect(() => {
|
||||
ConnectionsApi.list({ pageSize: 200 })
|
||||
.then((r) => {
|
||||
setConnections(r.items);
|
||||
if (r.items.length === 1) setConnectionId(r.items[0].id);
|
||||
})
|
||||
.catch(() => setConnections([]));
|
||||
}, []);
|
||||
|
||||
const buildFilter = (): string => {
|
||||
if (rawFilter.trim()) return rawFilter.trim();
|
||||
const base = OBJECT_TYPES.find((t) => t.value === objectType)?.filter ?? "(objectClass=*)";
|
||||
const term = q.trim();
|
||||
if (!term) return base;
|
||||
const e = esc(term);
|
||||
const name = `(|(cn=*${e}*)(sAMAccountName=*${e}*)(displayName=*${e}*)(name=*${e}*)(mail=*${e}*))`;
|
||||
return `(&${base}${name})`;
|
||||
};
|
||||
|
||||
const search = async () => {
|
||||
if (!connectionId) { setError("Select a connection first"); return; }
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setSearched(true);
|
||||
try {
|
||||
const res = await ConnectionsApi.queryPreview(connectionId, {
|
||||
baseDn: baseDn || undefined,
|
||||
scope: "subtree",
|
||||
filter: buildFilter(),
|
||||
attributes: LIST_ATTRS,
|
||||
limit: 200,
|
||||
});
|
||||
setEntries(res.entries);
|
||||
setTruncated(res.truncated);
|
||||
setPage(0);
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "Search failed");
|
||||
setEntries([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<PageContainer title="Object Viewer" description="Browse and inspect directory objects">
|
||||
<DashboardCard title="Object Viewer" subtitle="Search the directory and drill into objects to build your filters">
|
||||
<Box>
|
||||
{error && <Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>{error}</Alert>}
|
||||
|
||||
<Stack spacing={2} sx={{ mb: 2 }}>
|
||||
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
|
||||
<TextField select label="Connection" size="small" sx={{ minWidth: 220 }}
|
||||
value={connectionId} onChange={(e) => setConnectionId(e.target.value)}>
|
||||
<MenuItem value=""><em>Select…</em></MenuItem>
|
||||
{connections.map((c) => <MenuItem key={c.id} value={c.id}>{c.name}</MenuItem>)}
|
||||
</TextField>
|
||||
<TextField select label="Type" size="small" sx={{ minWidth: 180 }}
|
||||
value={objectType} onChange={(e) => setObjectType(e.target.value)} disabled={!!rawFilter.trim()}>
|
||||
{OBJECT_TYPES.map((t) => <MenuItem key={t.value} value={t.value}>{t.label}</MenuItem>)}
|
||||
</TextField>
|
||||
<TextField label="Search" size="small" sx={{ flex: 1, minWidth: 200 }}
|
||||
value={q} onChange={(e) => setQ(e.target.value)} disabled={!!rawFilter.trim()}
|
||||
placeholder="name, logon, email…"
|
||||
onKeyDown={(e) => { if (e.key === "Enter") void search(); }} />
|
||||
</Stack>
|
||||
<Box sx={{ maxWidth: 640 }}>
|
||||
<DirectoryPicker connectionId={connectionId} type="OU" label="Search base (optional)"
|
||||
value={baseDn} onChange={setBaseDn} helperText="Limit the search to a subtree." />
|
||||
</Box>
|
||||
<TextField label="Advanced: raw LDAP filter (overrides type & search)" size="small" fullWidth
|
||||
value={rawFilter} onChange={(e) => setRawFilter(e.target.value)}
|
||||
placeholder="(&(objectClass=user)(department=Sales))"
|
||||
slotProps={{ htmlInput: { style: { fontFamily: "monospace", fontSize: 12 } } }} />
|
||||
<Box>
|
||||
<Button variant="contained" startIcon={<SearchOutlinedIcon />} onClick={() => void search()} disabled={loading || !connectionId}>
|
||||
{loading ? "Searching…" : "Search"}
|
||||
</Button>
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
{loading ? (
|
||||
<Box display="flex" justifyContent="center" py={4}><CircularProgress /></Box>
|
||||
) : searched && entries.length === 0 ? (
|
||||
<Typography variant="body2" color="textSecondary">No objects matched.</Typography>
|
||||
) : entries.length > 0 ? (
|
||||
<>
|
||||
<Typography variant="caption" color="textSecondary">
|
||||
{entries.length} object(s){truncated ? " (truncated — narrow your search)" : ""}
|
||||
</Typography>
|
||||
<TableContainer>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Name</TableCell>
|
||||
<TableCell>Type</TableCell>
|
||||
<TableCell>Canonical / DN</TableCell>
|
||||
<TableCell align="right">Attributes</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{entries.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage).map((e) => (
|
||||
<TableRow key={e.dn} hover sx={{ cursor: "pointer" }} onClick={() => setDetailDn(e.dn)}>
|
||||
<TableCell>{firstAttr(e, "cn", "name", "sAMAccountName") || e.dn}</TableCell>
|
||||
<TableCell><Chip size="small" variant="outlined" label={objectKind(e)} /></TableCell>
|
||||
<TableCell sx={{ fontFamily: "monospace", fontSize: 12, maxWidth: 480, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||
<Tooltip title={e.dn}><span>{firstAttr(e, "canonicalName") || e.dn}</span></Tooltip>
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
<Tooltip title="View attributes">
|
||||
<IconButton size="small" onClick={(ev) => { ev.stopPropagation(); setDetailDn(e.dn); }}>
|
||||
<VisibilityOutlinedIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<TablePagination
|
||||
component="div"
|
||||
count={entries.length}
|
||||
page={page}
|
||||
onPageChange={(_, p) => setPage(p)}
|
||||
rowsPerPage={rowsPerPage}
|
||||
onRowsPerPageChange={(e) => { setRowsPerPage(parseInt(e.target.value, 10)); setPage(0); }}
|
||||
rowsPerPageOptions={[10, 25, 50, 100]}
|
||||
/>
|
||||
</TableContainer>
|
||||
</>
|
||||
) : null}
|
||||
</Box>
|
||||
</DashboardCard>
|
||||
|
||||
<ObjectDetailDialog connectionId={connectionId} dn={detailDn} onClose={() => setDetailDn(null)} />
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import Table from "@mui/material/Table";
|
||||
import TableBody from "@mui/material/TableBody";
|
||||
import TableCell from "@mui/material/TableCell";
|
||||
import TableHead from "@mui/material/TableHead";
|
||||
import TablePagination from "@mui/material/TablePagination";
|
||||
import TableRow from "@mui/material/TableRow";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { useEffect, useState } from "react";
|
||||
@@ -33,11 +34,14 @@ export default function RulePreviewDialog({ rule, onClose }: Props) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [result, setResult] = useState<RulePreviewResult | null>(null);
|
||||
const [page, setPage] = useState(0);
|
||||
const [rowsPerPage, setRowsPerPage] = useState(25);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setError(null);
|
||||
setResult(null);
|
||||
setPage(0);
|
||||
}, [open, rule]);
|
||||
|
||||
const run = async () => {
|
||||
@@ -117,7 +121,7 @@ export default function RulePreviewDialog({ rule, onClose }: Props) {
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{result.matchedObjects.slice(0, 100).map((m, i) => (
|
||||
{result.matchedObjects.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage).map((m, i) => (
|
||||
<TableRow key={i}>
|
||||
<TableCell sx={{ fontSize: 12 }}>{m.canonicalName || "—"}</TableCell>
|
||||
<TableCell sx={{ fontFamily: "monospace", fontSize: 12 }}>{m.dn}</TableCell>
|
||||
@@ -127,6 +131,17 @@ export default function RulePreviewDialog({ rule, onClose }: Props) {
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
{result.matchedObjects.length > 0 && (
|
||||
<TablePagination
|
||||
component="div"
|
||||
count={result.matchedObjects.length}
|
||||
page={page}
|
||||
onPageChange={(_, p) => setPage(p)}
|
||||
rowsPerPage={rowsPerPage}
|
||||
onRowsPerPageChange={(e) => { setRowsPerPage(parseInt(e.target.value, 10)); setPage(0); }}
|
||||
rowsPerPageOptions={[10, 25, 50, 100]}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Divider />
|
||||
<Typography variant="subtitle2">
|
||||
|
||||
@@ -8,6 +8,9 @@ import CircularProgress from "@mui/material/CircularProgress";
|
||||
import Divider from "@mui/material/Divider";
|
||||
import FormControlLabel from "@mui/material/FormControlLabel";
|
||||
import Grid from "@mui/material/Grid";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import InputAdornment from "@mui/material/InputAdornment";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
import Radio from "@mui/material/Radio";
|
||||
import RadioGroup from "@mui/material/RadioGroup";
|
||||
import Stack from "@mui/material/Stack";
|
||||
@@ -16,16 +19,19 @@ import Table from "@mui/material/Table";
|
||||
import TableBody from "@mui/material/TableBody";
|
||||
import TableCell from "@mui/material/TableCell";
|
||||
import TableHead from "@mui/material/TableHead";
|
||||
import TablePagination from "@mui/material/TablePagination";
|
||||
import TableRow from "@mui/material/TableRow";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import Tooltip from "@mui/material/Tooltip";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import ContentCopyIcon from "@mui/icons-material/ContentCopy";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import PageContainer from "@/app/components/container/PageContainer";
|
||||
import DashboardCard from "@/app/components/shared/DashboardCard";
|
||||
import { ApiError } from "@/lib/api/client";
|
||||
import { OidcApi, TlsApi } from "@/lib/api/resources";
|
||||
import type { OidcConfig, TlsStatus, WindowsStoreCert } from "@/lib/api/types";
|
||||
import { OidcApi, RolesApi, TlsApi } from "@/lib/api/resources";
|
||||
import type { OidcConfig, Role, TlsStatus, WindowsStoreCert } from "@/lib/api/types";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
|
||||
export default function SecurityPage() {
|
||||
@@ -56,11 +62,19 @@ const emptyOidc: OidcConfig = {
|
||||
|
||||
function OidcCard() {
|
||||
const [cfg, setCfg] = useState<OidcConfig>(emptyOidc);
|
||||
const [roles, setRoles] = useState<Role[]>([]);
|
||||
const [secretDirty, setSecretDirty] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
// The callback/redirect URI to register at the identity provider. It is
|
||||
// derived from the URL the browser is using (which matches the public URL
|
||||
// behind a proxy), so it stays correct without manual configuration.
|
||||
const callbackUrl =
|
||||
typeof window !== "undefined" ? `${window.location.origin}/api/v1/auth/oidc/callback` : "";
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -75,8 +89,19 @@ function OidcCard() {
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
load();
|
||||
RolesApi.list().then(setRoles).catch(() => setRoles([]));
|
||||
}, [load]);
|
||||
|
||||
const copyCallback = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(callbackUrl);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
/* clipboard unavailable */
|
||||
}
|
||||
};
|
||||
|
||||
const set = (patch: Partial<OidcConfig>) => {
|
||||
setSaved(false);
|
||||
setCfg((c) => ({ ...c, ...patch }));
|
||||
@@ -91,7 +116,6 @@ function OidcCard() {
|
||||
enabled: cfg.enabled,
|
||||
issuer: cfg.issuer,
|
||||
clientId: cfg.clientId,
|
||||
redirectUrl: cfg.redirectUrl,
|
||||
scopes: cfg.scopes,
|
||||
usernameClaim: cfg.usernameClaim,
|
||||
emailClaim: cfg.emailClaim,
|
||||
@@ -132,12 +156,18 @@ function OidcCard() {
|
||||
/>
|
||||
<Grid container spacing={2}>
|
||||
<Grid size={{ xs: 12, md: 8 }}>
|
||||
<TextField fullWidth label="Issuer URL" placeholder="https://login.example.com/…"
|
||||
<TextField fullWidth label="Issuer URL"
|
||||
placeholder="https://login.microsoftonline.com/<tenant-id>/v2.0"
|
||||
helperText="e.g. Entra ID: https://login.microsoftonline.com/<tenant>/v2.0 · Okta: https://<org>.okta.com · Google: https://accounts.google.com"
|
||||
value={cfg.issuer} onChange={(e) => set({ issuer: e.target.value })} />
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, md: 4 }}>
|
||||
<TextField fullWidth label="Default role (new users)" value={cfg.defaultRole}
|
||||
onChange={(e) => set({ defaultRole: e.target.value })} />
|
||||
<TextField select fullWidth label="Default role (new users)" value={cfg.defaultRole}
|
||||
helperText="Assigned to users provisioned via SSO"
|
||||
onChange={(e) => set({ defaultRole: e.target.value })}>
|
||||
<MenuItem value=""><em>None</em></MenuItem>
|
||||
{roles.map((r) => <MenuItem key={r.name} value={r.name}>{r.name}</MenuItem>)}
|
||||
</TextField>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<TextField fullWidth label="Client ID" value={cfg.clientId}
|
||||
@@ -150,9 +180,20 @@ function OidcCard() {
|
||||
onChange={(e) => { setSecretDirty(true); set({ clientSecret: e.target.value }); }} />
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, md: 8 }}>
|
||||
<TextField fullWidth label="Redirect URL (optional)"
|
||||
placeholder="Derived from the request if blank"
|
||||
value={cfg.redirectUrl} onChange={(e) => set({ redirectUrl: e.target.value })} />
|
||||
<TextField fullWidth label="Redirect / callback URL" value={callbackUrl}
|
||||
helperText="Register this exact URL in your identity provider. Derived automatically from this server's address."
|
||||
InputProps={{
|
||||
readOnly: true,
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<Tooltip title={copied ? "Copied!" : "Copy"}>
|
||||
<IconButton size="small" onClick={copyCallback} edge="end">
|
||||
<ContentCopyIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</InputAdornment>
|
||||
),
|
||||
}} />
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, md: 4 }}>
|
||||
<TextField fullWidth label="Scopes" value={cfg.scopes}
|
||||
@@ -200,6 +241,8 @@ function TlsCard() {
|
||||
|
||||
// Windows store.
|
||||
const [winCerts, setWinCerts] = useState<WindowsStoreCert[] | null>(null);
|
||||
const [certPage, setCertPage] = useState(0);
|
||||
const [certRpp, setCertRpp] = useState(10);
|
||||
const [winErr, setWinErr] = useState<string | null>(null);
|
||||
const [thumb, setThumb] = useState("");
|
||||
|
||||
@@ -238,6 +281,7 @@ function TlsCard() {
|
||||
const loadWinCerts = async () => {
|
||||
setWinErr(null);
|
||||
setWinCerts(null);
|
||||
setCertPage(0);
|
||||
try {
|
||||
setWinCerts(await TlsApi.windowsCerts());
|
||||
} catch (err) {
|
||||
@@ -245,6 +289,15 @@ function TlsCard() {
|
||||
}
|
||||
};
|
||||
|
||||
// Auto-list the Windows store certificates as soon as that source is chosen,
|
||||
// so the operator does not have to hit a separate button to see them.
|
||||
useEffect(() => {
|
||||
if (mode === "windows-store" && status?.windowsStoreSupported && winCerts === null) {
|
||||
void loadWinCerts();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [mode, status?.windowsStoreSupported]);
|
||||
|
||||
return (
|
||||
<DashboardCard title="TLS Certificate">
|
||||
{loading ? (
|
||||
@@ -325,7 +378,7 @@ function TlsCard() {
|
||||
<Stack spacing={2}>
|
||||
<Box>
|
||||
<Button variant="outlined" onClick={loadWinCerts} disabled={busy}>
|
||||
List certificates in the Windows store
|
||||
{winCerts === null ? "Loading certificates…" : "Refresh list"}
|
||||
</Button>
|
||||
</Box>
|
||||
{winErr ? <Alert severity="warning">{winErr}</Alert> : null}
|
||||
@@ -342,7 +395,7 @@ function TlsCard() {
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{winCerts.map((c) => (
|
||||
{winCerts.slice(certPage * certRpp, certPage * certRpp + certRpp).map((c) => (
|
||||
<TableRow key={c.thumbprint} hover selected={thumb === c.thumbprint}
|
||||
onClick={() => setThumb(c.thumbprint)} sx={{ cursor: "pointer" }}>
|
||||
<TableCell padding="checkbox">
|
||||
@@ -359,6 +412,15 @@ function TlsCard() {
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<TablePagination
|
||||
component="div"
|
||||
count={winCerts.length}
|
||||
page={certPage}
|
||||
onPageChange={(_, p) => setCertPage(p)}
|
||||
rowsPerPage={certRpp}
|
||||
onRowsPerPageChange={(e) => { setCertRpp(parseInt(e.target.value, 10)); setCertPage(0); }}
|
||||
rowsPerPageOptions={[10, 25, 50]}
|
||||
/>
|
||||
</Box>
|
||||
) : winCerts ? (
|
||||
<Typography variant="body2" color="textSecondary">No certificates found.</Typography>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import Alert from "@mui/material/Alert";
|
||||
import Autocomplete from "@mui/material/Autocomplete";
|
||||
import Button from "@mui/material/Button";
|
||||
import CircularProgress from "@mui/material/CircularProgress";
|
||||
import Dialog from "@mui/material/Dialog";
|
||||
@@ -15,8 +16,8 @@ import Typography from "@mui/material/Typography";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { ApiError } from "@/lib/api/client";
|
||||
import { UsersApi } from "@/lib/api/resources";
|
||||
import type { User } from "@/lib/api/types";
|
||||
import { RolesApi, UsersApi } from "@/lib/api/resources";
|
||||
import type { Role, User } from "@/lib/api/types";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
@@ -31,6 +32,7 @@ interface FormState {
|
||||
email: string;
|
||||
password: string;
|
||||
isActive: boolean;
|
||||
roles: string[];
|
||||
}
|
||||
|
||||
const EMPTY: FormState = {
|
||||
@@ -39,11 +41,13 @@ const EMPTY: FormState = {
|
||||
email: "",
|
||||
password: "",
|
||||
isActive: true,
|
||||
roles: [],
|
||||
};
|
||||
|
||||
export default function UserFormDialog({ open, userId, onClose, onSaved }: Props) {
|
||||
const [form, setForm] = useState<FormState>(EMPTY);
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [roles, setRoles] = useState<Role[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -53,6 +57,7 @@ export default function UserFormDialog({ open, userId, onClose, onSaved }: Props
|
||||
setError(null);
|
||||
setForm(EMPTY);
|
||||
setUser(null);
|
||||
RolesApi.list().then(setRoles).catch(() => setRoles([]));
|
||||
if (!userId) return;
|
||||
setLoading(true);
|
||||
UsersApi.get(userId)
|
||||
@@ -64,6 +69,7 @@ export default function UserFormDialog({ open, userId, onClose, onSaved }: Props
|
||||
email: u.email ?? "",
|
||||
password: "",
|
||||
isActive: u.isActive,
|
||||
roles: u.roles ?? [],
|
||||
});
|
||||
})
|
||||
.catch((err) => setError(err instanceof ApiError ? err.message : "Failed to load user"))
|
||||
@@ -83,6 +89,7 @@ export default function UserFormDialog({ open, userId, onClose, onSaved }: Props
|
||||
email: form.email.trim() || undefined,
|
||||
isActive: form.isActive,
|
||||
password: form.password || undefined,
|
||||
roles: form.roles,
|
||||
});
|
||||
} else {
|
||||
await UsersApi.create({
|
||||
@@ -90,6 +97,7 @@ export default function UserFormDialog({ open, userId, onClose, onSaved }: Props
|
||||
password: form.password,
|
||||
displayName: form.displayName.trim() || undefined,
|
||||
email: form.email.trim() || undefined,
|
||||
roles: form.roles,
|
||||
});
|
||||
}
|
||||
await onSaved();
|
||||
@@ -137,6 +145,17 @@ export default function UserFormDialog({ open, userId, onClose, onSaved }: Props
|
||||
onChange={(e) => setForm((f) => ({ ...f, password: e.target.value }))}
|
||||
/>
|
||||
)}
|
||||
<Autocomplete
|
||||
multiple
|
||||
size="small"
|
||||
options={roles.map((r) => r.name)}
|
||||
value={form.roles}
|
||||
onChange={(_, v) => setForm((f) => ({ ...f, roles: v }))}
|
||||
renderInput={(params) => (
|
||||
<TextField {...params} label="Roles" placeholder="Assign roles"
|
||||
helperText="SuperAdmin/Admin/Operator/Viewer control what this user can do." />
|
||||
)}
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={<Switch checked={form.isActive} onChange={(_, v) => setForm((f) => ({ ...f, isActive: v }))} />}
|
||||
label="Active"
|
||||
|
||||
@@ -96,6 +96,7 @@ export default function UsersPage() {
|
||||
<TableCell>Username</TableCell>
|
||||
<TableCell>Display Name</TableCell>
|
||||
<TableCell>Email</TableCell>
|
||||
<TableCell>Roles</TableCell>
|
||||
<TableCell>Source</TableCell>
|
||||
<TableCell>Last Login</TableCell>
|
||||
<TableCell>Active</TableCell>
|
||||
@@ -112,6 +113,13 @@ export default function UsersPage() {
|
||||
</TableCell>
|
||||
<TableCell>{u.displayName ?? "-"}</TableCell>
|
||||
<TableCell>{u.email ?? "-"}</TableCell>
|
||||
<TableCell>
|
||||
<Stack direction="row" spacing={0.5} flexWrap="wrap" useFlexGap>
|
||||
{(u.roles ?? []).length > 0
|
||||
? u.roles.map((r) => <Chip key={r} size="small" variant="outlined" label={r} />)
|
||||
: <Typography variant="caption" color="textSecondary">—</Typography>}
|
||||
</Stack>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Chip
|
||||
size="small"
|
||||
|
||||
@@ -26,6 +26,8 @@ import {
|
||||
RuleSummary,
|
||||
DirectoryObject,
|
||||
AttributeInfo,
|
||||
QueryPreviewResult,
|
||||
Role,
|
||||
Schedule,
|
||||
User,
|
||||
UserInfo,
|
||||
@@ -91,7 +93,7 @@ export const ConnectionsApi = {
|
||||
test: (id: string) =>
|
||||
api.post<ConnectionTestResult>(`/api/v1/ad-connections/${id}/test`).then((r) => r.data),
|
||||
queryPreview: (id: string, body: Record<string, unknown>) =>
|
||||
api.post<unknown>(`/api/v1/ad-connections/${id}/query-preview`, body).then((r) => r.data),
|
||||
api.post<QueryPreviewResult>(`/api/v1/ad-connections/${id}/query-preview`, body).then((r) => r.data),
|
||||
directory: (id: string, type: string, q: string, limit = 25) =>
|
||||
api
|
||||
.get<DirectoryObject[]>(`/api/v1/ad-connections/${id}/directory`, { query: { type, q, limit } })
|
||||
@@ -163,14 +165,18 @@ export const ActivityApi = {
|
||||
export const UsersApi = {
|
||||
list: (params?: PageParams) => getList<User>("/api/v1/users", params),
|
||||
get: (id: string) => api.get<User>(`/api/v1/users/${id}`).then((r) => r.data),
|
||||
create: (body: { username: string; password: string; displayName?: string; email?: string }) =>
|
||||
create: (body: { username: string; password: string; displayName?: string; email?: string; roles?: string[] }) =>
|
||||
api.post<User>("/api/v1/users", body).then((r) => r.data),
|
||||
update: (id: string, body: Partial<User> & { password?: string }) =>
|
||||
update: (id: string, body: Partial<User> & { password?: string; roles?: string[] }) =>
|
||||
api.put<User>(`/api/v1/users/${id}`, body).then((r) => r.data),
|
||||
remove: (id: string) =>
|
||||
api.delete<{ deleted: boolean }>(`/api/v1/users/${id}`).then((r) => r.data),
|
||||
};
|
||||
|
||||
export const RolesApi = {
|
||||
list: () => api.get<Role[]>("/api/v1/roles").then((r) => r.data ?? []),
|
||||
};
|
||||
|
||||
export const ApiKeysApi = {
|
||||
list: (params?: PageParams & { userId?: string }) =>
|
||||
api.get<APIKey[]>("/api/v1/api-keys", { query: params as Record<string, any> }).then((r) => r.data ?? []),
|
||||
|
||||
@@ -213,11 +213,17 @@ export interface User {
|
||||
displayName?: string;
|
||||
isActive: boolean;
|
||||
isOidcUser: boolean;
|
||||
roles: string[];
|
||||
lastLoginUtc?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface Role {
|
||||
name: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface CountPair {
|
||||
total: number;
|
||||
enabled: number;
|
||||
@@ -415,6 +421,18 @@ export interface AttributeInfo {
|
||||
description?: string;
|
||||
}
|
||||
|
||||
// Ad-hoc LDAP query results (object viewer / query preview).
|
||||
export interface QueryPreviewEntry {
|
||||
dn: string;
|
||||
attributes: Record<string, string[]>;
|
||||
}
|
||||
|
||||
export interface QueryPreviewResult {
|
||||
entries: QueryPreviewEntry[];
|
||||
truncated: boolean;
|
||||
count: number;
|
||||
}
|
||||
|
||||
// The full create/update payload the editor sends (superset of Rule scalars
|
||||
// plus the authored filter and actions).
|
||||
export interface RuleConditionInput {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<!--
|
||||
OrchestrAD Windows installer (WiX v4+ / built with WiX 5 + the UI extension).
|
||||
|
||||
Wizard: Welcome -> License -> Install directory -> Network (listen address +
|
||||
Wizard: Welcome -> Install directory -> Network (listen address +
|
||||
port) -> Ready -> Finish. The install directory, listen address, and listen
|
||||
port are recorded under HKLM\Software\Grace Solutions\OrchestrAD; the service
|
||||
reads the address/port from there (env vars still override). The finish page
|
||||
@@ -93,7 +93,9 @@
|
||||
<Custom Action="InitializeService" After="WriteRegistryValues" Condition="NOT REMOVE="ALL"" />
|
||||
</InstallExecuteSequence>
|
||||
|
||||
<!-- UI: install-dir wizard plus a custom network-binding dialog. -->
|
||||
<!-- UI: install-dir wizard plus a custom network-binding dialog. The license
|
||||
page is skipped (see the WelcomeDlg publish below); this variable only
|
||||
keeps the stock license control resolvable at build time. -->
|
||||
<WixVariable Id="WixUILicenseRtf" Value="installer/license.rtf" />
|
||||
<ui:WixUI Id="WixUI_InstallDir" InstallDirectory="INSTALLDIR" />
|
||||
|
||||
@@ -132,6 +134,11 @@
|
||||
</Control>
|
||||
</Dialog>
|
||||
|
||||
<!-- Skip the (blank) license page: go straight from Welcome to the
|
||||
install-directory dialog and route its Back button back to Welcome. -->
|
||||
<Publish Dialog="WelcomeDlg" Control="Next" Event="NewDialog" Value="InstallDirDlg" Order="10" Condition="NOT Installed" />
|
||||
<Publish Dialog="InstallDirDlg" Control="Back" Event="NewDialog" Value="WelcomeDlg" Order="10" Condition="NOT Installed" />
|
||||
|
||||
<!-- Insert NetworkDlg between the install-directory and ready pages. The
|
||||
higher Order (3) makes these transitions win over the standard ones. -->
|
||||
<Publish Dialog="InstallDirDlg" Control="Next" Event="NewDialog" Value="NetworkDlg" Order="3" Condition="WIXUI_INSTALLDIR_VALID="1"" />
|
||||
|
||||
+10
-677
@@ -1,677 +1,10 @@
|
||||
{
|
||||
tf1ansideff0{onttbl{0 Consolas;}}s16
|
||||
GNU GENERAL PUBLIC LICENSEpar
|
||||
Version 3, 29 June 2007par
|
||||
par
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>par
|
||||
Everyone is permitted to copy and distribute verbatim copiespar
|
||||
of this license document, but changing it is not allowed.par
|
||||
par
|
||||
Preamblepar
|
||||
par
|
||||
The GNU General Public License is a free, copyleft license forpar
|
||||
software and other kinds of works.par
|
||||
par
|
||||
The licenses for most software and other practical works are designedpar
|
||||
to take away your freedom to share and change the works. By contrast,par
|
||||
the GNU General Public License is intended to guarantee your freedom topar
|
||||
share and change all versions of a program--to make sure it remains freepar
|
||||
software for all its users. We, the Free Software Foundation, use thepar
|
||||
GNU General Public License for most of our software; it applies also topar
|
||||
any other work released this way by its authors. You can apply it topar
|
||||
your programs, too.par
|
||||
par
|
||||
When we speak of free software, we are referring to freedom, notpar
|
||||
price. Our General Public Licenses are designed to make sure that youpar
|
||||
have the freedom to distribute copies of free software (and charge forpar
|
||||
them if you wish), that you receive source code or can get it if youpar
|
||||
want it, that you can change the software or use pieces of it in newpar
|
||||
free programs, and that you know you can do these things.par
|
||||
par
|
||||
To protect your rights, we need to prevent others from denying youpar
|
||||
these rights or asking you to surrender the rights. Therefore, you havepar
|
||||
certain responsibilities if you distribute copies of the software, or ifpar
|
||||
you modify it: responsibilities to respect the freedom of others.par
|
||||
par
|
||||
For example, if you distribute copies of such a program, whetherpar
|
||||
gratis or for a fee, you must pass on to the recipients the samepar
|
||||
freedoms that you received. You must make sure that they, too, receivepar
|
||||
or can get the source code. And you must show them these terms so theypar
|
||||
know their rights.par
|
||||
par
|
||||
Developers that use the GNU GPL protect your rights with two steps:par
|
||||
(1) assert copyright on the software, and (2) offer you this Licensepar
|
||||
giving you legal permission to copy, distribute and/or modify it.par
|
||||
par
|
||||
For the developers' and authors' protection, the GPL clearly explainspar
|
||||
that there is no warranty for this free software. For both users' andpar
|
||||
authors' sake, the GPL requires that modified versions be marked aspar
|
||||
changed, so that their problems will not be attributed erroneously topar
|
||||
authors of previous versions.par
|
||||
par
|
||||
Some devices are designed to deny users access to install or runpar
|
||||
modified versions of the software inside them, although the manufacturerpar
|
||||
can do so. This is fundamentally incompatible with the aim ofpar
|
||||
protecting users' freedom to change the software. The systematicpar
|
||||
pattern of such abuse occurs in the area of products for individuals topar
|
||||
use, which is precisely where it is most unacceptable. Therefore, wepar
|
||||
have designed this version of the GPL to prohibit the practice for thosepar
|
||||
products. If such problems arise substantially in other domains, wepar
|
||||
stand ready to extend this provision to those domains in future versionspar
|
||||
of the GPL, as needed to protect the freedom of users.par
|
||||
par
|
||||
Finally, every program is threatened constantly by software patents.par
|
||||
States should not allow patents to restrict development and use ofpar
|
||||
software on general-purpose computers, but in those that do, we wish topar
|
||||
avoid the special danger that patents applied to a free program couldpar
|
||||
make it effectively proprietary. To prevent this, the GPL assures thatpar
|
||||
patents cannot be used to render the program non-free.par
|
||||
par
|
||||
The precise terms and conditions for copying, distribution andpar
|
||||
modification follow.par
|
||||
par
|
||||
TERMS AND CONDITIONSpar
|
||||
par
|
||||
0. Definitions.par
|
||||
par
|
||||
"This License" refers to version 3 of the GNU General Public License.par
|
||||
par
|
||||
"Copyright" also means copyright-like laws that apply to other kinds ofpar
|
||||
works, such as semiconductor masks.par
|
||||
par
|
||||
"The Program" refers to any copyrightable work licensed under thispar
|
||||
License. Each licensee is addressed as "you". "Licensees" andpar
|
||||
"recipients" may be individuals or organizations.par
|
||||
par
|
||||
To "modify" a work means to copy from or adapt all or part of the workpar
|
||||
in a fashion requiring copyright permission, other than the making of anpar
|
||||
exact copy. The resulting work is called a "modified version" of thepar
|
||||
earlier work or a work "based on" the earlier work.par
|
||||
par
|
||||
A "covered work" means either the unmodified Program or a work basedpar
|
||||
on the Program.par
|
||||
par
|
||||
To "propagate" a work means to do anything with it that, withoutpar
|
||||
permission, would make you directly or secondarily liable forpar
|
||||
infringement under applicable copyright law, except executing it on apar
|
||||
computer or modifying a private copy. Propagation includes copying,par
|
||||
distribution (with or without modification), making available to thepar
|
||||
public, and in some countries other activities as well.par
|
||||
par
|
||||
To "convey" a work means any kind of propagation that enables otherpar
|
||||
parties to make or receive copies. Mere interaction with a user throughpar
|
||||
a computer network, with no transfer of a copy, is not conveying.par
|
||||
par
|
||||
An interactive user interface displays "Appropriate Legal Notices"par
|
||||
to the extent that it includes a convenient and prominently visiblepar
|
||||
feature that (1) displays an appropriate copyright notice, and (2)par
|
||||
tells the user that there is no warranty for the work (except to thepar
|
||||
extent that warranties are provided), that licensees may convey thepar
|
||||
work under this License, and how to view a copy of this License. Ifpar
|
||||
the interface presents a list of user commands or options, such as apar
|
||||
menu, a prominent item in the list meets this criterion.par
|
||||
par
|
||||
1. Source Code.par
|
||||
par
|
||||
The "source code" for a work means the preferred form of the workpar
|
||||
for making modifications to it. "Object code" means any non-sourcepar
|
||||
form of a work.par
|
||||
par
|
||||
A "Standard Interface" means an interface that either is an officialpar
|
||||
standard defined by a recognized standards body, or, in the case ofpar
|
||||
interfaces specified for a particular programming language, one thatpar
|
||||
is widely used among developers working in that language.par
|
||||
par
|
||||
The "System Libraries" of an executable work include anything, otherpar
|
||||
than the work as a whole, that (a) is included in the normal form ofpar
|
||||
packaging a Major Component, but which is not part of that Majorpar
|
||||
Component, and (b) serves only to enable use of the work with thatpar
|
||||
Major Component, or to implement a Standard Interface for which anpar
|
||||
implementation is available to the public in source code form. Apar
|
||||
"Major Component", in this context, means a major essential componentpar
|
||||
(kernel, window system, and so on) of the specific operating systempar
|
||||
(if any) on which the executable work runs, or a compiler used topar
|
||||
produce the work, or an object code interpreter used to run it.par
|
||||
par
|
||||
The "Corresponding Source" for a work in object code form means allpar
|
||||
the source code needed to generate, install, and (for an executablepar
|
||||
work) run the object code and to modify the work, including scripts topar
|
||||
control those activities. However, it does not include the work'spar
|
||||
System Libraries, or general-purpose tools or generally available freepar
|
||||
programs which are used unmodified in performing those activities butpar
|
||||
which are not part of the work. For example, Corresponding Sourcepar
|
||||
includes interface definition files associated with source files forpar
|
||||
the work, and the source code for shared libraries and dynamicallypar
|
||||
linked subprograms that the work is specifically designed to require,par
|
||||
such as by intimate data communication or control flow between thosepar
|
||||
subprograms and other parts of the work.par
|
||||
par
|
||||
The Corresponding Source need not include anything that userspar
|
||||
can regenerate automatically from other parts of the Correspondingpar
|
||||
Source.par
|
||||
par
|
||||
The Corresponding Source for a work in source code form is thatpar
|
||||
same work.par
|
||||
par
|
||||
2. Basic Permissions.par
|
||||
par
|
||||
All rights granted under this License are granted for the term ofpar
|
||||
copyright on the Program, and are irrevocable provided the statedpar
|
||||
conditions are met. This License explicitly affirms your unlimitedpar
|
||||
permission to run the unmodified Program. The output from running apar
|
||||
covered work is covered by this License only if the output, given itspar
|
||||
content, constitutes a covered work. This License acknowledges yourpar
|
||||
rights of fair use or other equivalent, as provided by copyright law.par
|
||||
par
|
||||
You may make, run and propagate covered works that you do notpar
|
||||
convey, without conditions so long as your license otherwise remainspar
|
||||
in force. You may convey covered works to others for the sole purposepar
|
||||
of having them make modifications exclusively for you, or provide youpar
|
||||
with facilities for running those works, provided that you comply withpar
|
||||
the terms of this License in conveying all material for which you dopar
|
||||
not control copyright. Those thus making or running the covered workspar
|
||||
for you must do so exclusively on your behalf, under your directionpar
|
||||
and control, on terms that prohibit them from making any copies ofpar
|
||||
your copyrighted material outside their relationship with you.par
|
||||
par
|
||||
Conveying under any other circumstances is permitted solely underpar
|
||||
the conditions stated below. Sublicensing is not allowed; section 10par
|
||||
makes it unnecessary.par
|
||||
par
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.par
|
||||
par
|
||||
No covered work shall be deemed part of an effective technologicalpar
|
||||
measure under any applicable law fulfilling obligations under articlepar
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, orpar
|
||||
similar laws prohibiting or restricting circumvention of suchpar
|
||||
measures.par
|
||||
par
|
||||
When you convey a covered work, you waive any legal power to forbidpar
|
||||
circumvention of technological measures to the extent such circumventionpar
|
||||
is effected by exercising rights under this License with respect topar
|
||||
the covered work, and you disclaim any intention to limit operation orpar
|
||||
modification of the work as a means of enforcing, against the work'spar
|
||||
users, your or third parties' legal rights to forbid circumvention ofpar
|
||||
technological measures.par
|
||||
par
|
||||
4. Conveying Verbatim Copies.par
|
||||
par
|
||||
You may convey verbatim copies of the Program's source code as youpar
|
||||
receive it, in any medium, provided that you conspicuously andpar
|
||||
appropriately publish on each copy an appropriate copyright notice;par
|
||||
keep intact all notices stating that this License and anypar
|
||||
non-permissive terms added in accord with section 7 apply to the code;par
|
||||
keep intact all notices of the absence of any warranty; and give allpar
|
||||
recipients a copy of this License along with the Program.par
|
||||
par
|
||||
You may charge any price or no price for each copy that you convey,par
|
||||
and you may offer support or warranty protection for a fee.par
|
||||
par
|
||||
5. Conveying Modified Source Versions.par
|
||||
par
|
||||
You may convey a work based on the Program, or the modifications topar
|
||||
produce it from the Program, in the form of source code under thepar
|
||||
terms of section 4, provided that you also meet all of these conditions:par
|
||||
par
|
||||
a) The work must carry prominent notices stating that you modifiedpar
|
||||
it, and giving a relevant date.par
|
||||
par
|
||||
b) The work must carry prominent notices stating that it ispar
|
||||
released under this License and any conditions added under sectionpar
|
||||
7. This requirement modifies the requirement in section 4 topar
|
||||
"keep intact all notices".par
|
||||
par
|
||||
c) You must license the entire work, as a whole, under thispar
|
||||
License to anyone who comes into possession of a copy. Thispar
|
||||
License will therefore apply, along with any applicable section 7par
|
||||
additional terms, to the whole of the work, and all its parts,par
|
||||
regardless of how they are packaged. This License gives nopar
|
||||
permission to license the work in any other way, but it does notpar
|
||||
invalidate such permission if you have separately received it.par
|
||||
par
|
||||
d) If the work has interactive user interfaces, each must displaypar
|
||||
Appropriate Legal Notices; however, if the Program has interactivepar
|
||||
interfaces that do not display Appropriate Legal Notices, yourpar
|
||||
work need not make them do so.par
|
||||
par
|
||||
A compilation of a covered work with other separate and independentpar
|
||||
works, which are not by their nature extensions of the covered work,par
|
||||
and which are not combined with it such as to form a larger program,par
|
||||
in or on a volume of a storage or distribution medium, is called anpar
|
||||
"aggregate" if the compilation and its resulting copyright are notpar
|
||||
used to limit the access or legal rights of the compilation's userspar
|
||||
beyond what the individual works permit. Inclusion of a covered workpar
|
||||
in an aggregate does not cause this License to apply to the otherpar
|
||||
parts of the aggregate.par
|
||||
par
|
||||
6. Conveying Non-Source Forms.par
|
||||
par
|
||||
You may convey a covered work in object code form under the termspar
|
||||
of sections 4 and 5, provided that you also convey thepar
|
||||
machine-readable Corresponding Source under the terms of this License,par
|
||||
in one of these ways:par
|
||||
par
|
||||
a) Convey the object code in, or embodied in, a physical productpar
|
||||
(including a physical distribution medium), accompanied by thepar
|
||||
Corresponding Source fixed on a durable physical mediumpar
|
||||
customarily used for software interchange.par
|
||||
par
|
||||
b) Convey the object code in, or embodied in, a physical productpar
|
||||
(including a physical distribution medium), accompanied by apar
|
||||
written offer, valid for at least three years and valid for aspar
|
||||
long as you offer spare parts or customer support for that productpar
|
||||
model, to give anyone who possesses the object code either (1) apar
|
||||
copy of the Corresponding Source for all the software in thepar
|
||||
product that is covered by this License, on a durable physicalpar
|
||||
medium customarily used for software interchange, for a price nopar
|
||||
more than your reasonable cost of physically performing thispar
|
||||
conveying of source, or (2) access to copy thepar
|
||||
Corresponding Source from a network server at no charge.par
|
||||
par
|
||||
c) Convey individual copies of the object code with a copy of thepar
|
||||
written offer to provide the Corresponding Source. Thispar
|
||||
alternative is allowed only occasionally and noncommercially, andpar
|
||||
only if you received the object code with such an offer, in accordpar
|
||||
with subsection 6b.par
|
||||
par
|
||||
d) Convey the object code by offering access from a designatedpar
|
||||
place (gratis or for a charge), and offer equivalent access to thepar
|
||||
Corresponding Source in the same way through the same place at nopar
|
||||
further charge. You need not require recipients to copy thepar
|
||||
Corresponding Source along with the object code. If the place topar
|
||||
copy the object code is a network server, the Corresponding Sourcepar
|
||||
may be on a different server (operated by you or a third party)par
|
||||
that supports equivalent copying facilities, provided you maintainpar
|
||||
clear directions next to the object code saying where to find thepar
|
||||
Corresponding Source. Regardless of what server hosts thepar
|
||||
Corresponding Source, you remain obligated to ensure that it ispar
|
||||
available for as long as needed to satisfy these requirements.par
|
||||
par
|
||||
e) Convey the object code using peer-to-peer transmission, providedpar
|
||||
you inform other peers where the object code and Correspondingpar
|
||||
Source of the work are being offered to the general public at nopar
|
||||
charge under subsection 6d.par
|
||||
par
|
||||
A separable portion of the object code, whose source code is excludedpar
|
||||
from the Corresponding Source as a System Library, need not bepar
|
||||
included in conveying the object code work.par
|
||||
par
|
||||
A "User Product" is either (1) a "consumer product", which means anypar
|
||||
tangible personal property which is normally used for personal, family,par
|
||||
or household purposes, or (2) anything designed or sold for incorporationpar
|
||||
into a dwelling. In determining whether a product is a consumer product,par
|
||||
doubtful cases shall be resolved in favor of coverage. For a particularpar
|
||||
product received by a particular user, "normally used" refers to apar
|
||||
typical or common use of that class of product, regardless of the statuspar
|
||||
of the particular user or of the way in which the particular userpar
|
||||
actually uses, or expects or is expected to use, the product. A productpar
|
||||
is a consumer product regardless of whether the product has substantialpar
|
||||
commercial, industrial or non-consumer uses, unless such uses representpar
|
||||
the only significant mode of use of the product.par
|
||||
par
|
||||
"Installation Information" for a User Product means any methods,par
|
||||
procedures, authorization keys, or other information required to installpar
|
||||
and execute modified versions of a covered work in that User Product frompar
|
||||
a modified version of its Corresponding Source. The information mustpar
|
||||
suffice to ensure that the continued functioning of the modified objectpar
|
||||
code is in no case prevented or interfered with solely becausepar
|
||||
modification has been made.par
|
||||
par
|
||||
If you convey an object code work under this section in, or with, orpar
|
||||
specifically for use in, a User Product, and the conveying occurs aspar
|
||||
part of a transaction in which the right of possession and use of thepar
|
||||
User Product is transferred to the recipient in perpetuity or for apar
|
||||
fixed term (regardless of how the transaction is characterized), thepar
|
||||
Corresponding Source conveyed under this section must be accompaniedpar
|
||||
by the Installation Information. But this requirement does not applypar
|
||||
if neither you nor any third party retains the ability to installpar
|
||||
modified object code on the User Product (for example, the work haspar
|
||||
been installed in ROM).par
|
||||
par
|
||||
The requirement to provide Installation Information does not include apar
|
||||
requirement to continue to provide support service, warranty, or updatespar
|
||||
for a work that has been modified or installed by the recipient, or forpar
|
||||
the User Product in which it has been modified or installed. Access to apar
|
||||
network may be denied when the modification itself materially andpar
|
||||
adversely affects the operation of the network or violates the rules andpar
|
||||
protocols for communication across the network.par
|
||||
par
|
||||
Corresponding Source conveyed, and Installation Information provided,par
|
||||
in accord with this section must be in a format that is publiclypar
|
||||
documented (and with an implementation available to the public inpar
|
||||
source code form), and must require no special password or key forpar
|
||||
unpacking, reading or copying.par
|
||||
par
|
||||
7. Additional Terms.par
|
||||
par
|
||||
"Additional permissions" are terms that supplement the terms of thispar
|
||||
License by making exceptions from one or more of its conditions.par
|
||||
Additional permissions that are applicable to the entire Program shallpar
|
||||
be treated as though they were included in this License, to the extentpar
|
||||
that they are valid under applicable law. If additional permissionspar
|
||||
apply only to part of the Program, that part may be used separatelypar
|
||||
under those permissions, but the entire Program remains governed bypar
|
||||
this License without regard to the additional permissions.par
|
||||
par
|
||||
When you convey a copy of a covered work, you may at your optionpar
|
||||
remove any additional permissions from that copy, or from any part ofpar
|
||||
it. (Additional permissions may be written to require their ownpar
|
||||
removal in certain cases when you modify the work.) You may placepar
|
||||
additional permissions on material, added by you to a covered work,par
|
||||
for which you have or can give appropriate copyright permission.par
|
||||
par
|
||||
Notwithstanding any other provision of this License, for material youpar
|
||||
add to a covered work, you may (if authorized by the copyright holders ofpar
|
||||
that material) supplement the terms of this License with terms:par
|
||||
par
|
||||
a) Disclaiming warranty or limiting liability differently from thepar
|
||||
terms of sections 15 and 16 of this License; orpar
|
||||
par
|
||||
b) Requiring preservation of specified reasonable legal notices orpar
|
||||
author attributions in that material or in the Appropriate Legalpar
|
||||
Notices displayed by works containing it; orpar
|
||||
par
|
||||
c) Prohibiting misrepresentation of the origin of that material, orpar
|
||||
requiring that modified versions of such material be marked inpar
|
||||
reasonable ways as different from the original version; orpar
|
||||
par
|
||||
d) Limiting the use for publicity purposes of names of licensors orpar
|
||||
authors of the material; orpar
|
||||
par
|
||||
e) Declining to grant rights under trademark law for use of somepar
|
||||
trade names, trademarks, or service marks; orpar
|
||||
par
|
||||
f) Requiring indemnification of licensors and authors of thatpar
|
||||
material by anyone who conveys the material (or modified versions ofpar
|
||||
it) with contractual assumptions of liability to the recipient, forpar
|
||||
any liability that these contractual assumptions directly impose onpar
|
||||
those licensors and authors.par
|
||||
par
|
||||
All other non-permissive additional terms are considered "furtherpar
|
||||
restrictions" within the meaning of section 10. If the Program as youpar
|
||||
received it, or any part of it, contains a notice stating that it ispar
|
||||
governed by this License along with a term that is a furtherpar
|
||||
restriction, you may remove that term. If a license document containspar
|
||||
a further restriction but permits relicensing or conveying under thispar
|
||||
License, you may add to a covered work material governed by the termspar
|
||||
of that license document, provided that the further restriction doespar
|
||||
not survive such relicensing or conveying.par
|
||||
par
|
||||
If you add terms to a covered work in accord with this section, youpar
|
||||
must place, in the relevant source files, a statement of thepar
|
||||
additional terms that apply to those files, or a notice indicatingpar
|
||||
where to find the applicable terms.par
|
||||
par
|
||||
Additional terms, permissive or non-permissive, may be stated in thepar
|
||||
form of a separately written license, or stated as exceptions;par
|
||||
the above requirements apply either way.par
|
||||
par
|
||||
8. Termination.par
|
||||
par
|
||||
You may not propagate or modify a covered work except as expresslypar
|
||||
provided under this License. Any attempt otherwise to propagate orpar
|
||||
modify it is void, and will automatically terminate your rights underpar
|
||||
this License (including any patent licenses granted under the thirdpar
|
||||
paragraph of section 11).par
|
||||
par
|
||||
However, if you cease all violation of this License, then yourpar
|
||||
license from a particular copyright holder is reinstated (a)par
|
||||
provisionally, unless and until the copyright holder explicitly andpar
|
||||
finally terminates your license, and (b) permanently, if the copyrightpar
|
||||
holder fails to notify you of the violation by some reasonable meanspar
|
||||
prior to 60 days after the cessation.par
|
||||
par
|
||||
Moreover, your license from a particular copyright holder ispar
|
||||
reinstated permanently if the copyright holder notifies you of thepar
|
||||
violation by some reasonable means, this is the first time you havepar
|
||||
received notice of violation of this License (for any work) from thatpar
|
||||
copyright holder, and you cure the violation prior to 30 days afterpar
|
||||
your receipt of the notice.par
|
||||
par
|
||||
Termination of your rights under this section does not terminate thepar
|
||||
licenses of parties who have received copies or rights from you underpar
|
||||
this License. If your rights have been terminated and not permanentlypar
|
||||
reinstated, you do not qualify to receive new licenses for the samepar
|
||||
material under section 10.par
|
||||
par
|
||||
9. Acceptance Not Required for Having Copies.par
|
||||
par
|
||||
You are not required to accept this License in order to receive orpar
|
||||
run a copy of the Program. Ancillary propagation of a covered workpar
|
||||
occurring solely as a consequence of using peer-to-peer transmissionpar
|
||||
to receive a copy likewise does not require acceptance. However,par
|
||||
nothing other than this License grants you permission to propagate orpar
|
||||
modify any covered work. These actions infringe copyright if you dopar
|
||||
not accept this License. Therefore, by modifying or propagating apar
|
||||
covered work, you indicate your acceptance of this License to do so.par
|
||||
par
|
||||
10. Automatic Licensing of Downstream Recipients.par
|
||||
par
|
||||
Each time you convey a covered work, the recipient automaticallypar
|
||||
receives a license from the original licensors, to run, modify andpar
|
||||
propagate that work, subject to this License. You are not responsiblepar
|
||||
for enforcing compliance by third parties with this License.par
|
||||
par
|
||||
An "entity transaction" is a transaction transferring control of anpar
|
||||
organization, or substantially all assets of one, or subdividing anpar
|
||||
organization, or merging organizations. If propagation of a coveredpar
|
||||
work results from an entity transaction, each party to thatpar
|
||||
transaction who receives a copy of the work also receives whateverpar
|
||||
licenses to the work the party's predecessor in interest had or couldpar
|
||||
give under the previous paragraph, plus a right to possession of thepar
|
||||
Corresponding Source of the work from the predecessor in interest, ifpar
|
||||
the predecessor has it or can get it with reasonable efforts.par
|
||||
par
|
||||
You may not impose any further restrictions on the exercise of thepar
|
||||
rights granted or affirmed under this License. For example, you maypar
|
||||
not impose a license fee, royalty, or other charge for exercise ofpar
|
||||
rights granted under this License, and you may not initiate litigationpar
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging thatpar
|
||||
any patent claim is infringed by making, using, selling, offering forpar
|
||||
sale, or importing the Program or any portion of it.par
|
||||
par
|
||||
11. Patents.par
|
||||
par
|
||||
A "contributor" is a copyright holder who authorizes use under thispar
|
||||
License of the Program or a work on which the Program is based. Thepar
|
||||
work thus licensed is called the contributor's "contributor version".par
|
||||
par
|
||||
A contributor's "essential patent claims" are all patent claimspar
|
||||
owned or controlled by the contributor, whether already acquired orpar
|
||||
hereafter acquired, that would be infringed by some manner, permittedpar
|
||||
by this License, of making, using, or selling its contributor version,par
|
||||
but do not include claims that would be infringed only as apar
|
||||
consequence of further modification of the contributor version. Forpar
|
||||
purposes of this definition, "control" includes the right to grantpar
|
||||
patent sublicenses in a manner consistent with the requirements ofpar
|
||||
this License.par
|
||||
par
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-freepar
|
||||
patent license under the contributor's essential patent claims, topar
|
||||
make, use, sell, offer for sale, import and otherwise run, modify andpar
|
||||
propagate the contents of its contributor version.par
|
||||
par
|
||||
In the following three paragraphs, a "patent license" is any expresspar
|
||||
agreement or commitment, however denominated, not to enforce a patentpar
|
||||
(such as an express permission to practice a patent or covenant not topar
|
||||
sue for patent infringement). To "grant" such a patent license to apar
|
||||
party means to make such an agreement or commitment not to enforce apar
|
||||
patent against the party.par
|
||||
par
|
||||
If you convey a covered work, knowingly relying on a patent license,par
|
||||
and the Corresponding Source of the work is not available for anyonepar
|
||||
to copy, free of charge and under the terms of this License, through apar
|
||||
publicly available network server or other readily accessible means,par
|
||||
then you must either (1) cause the Corresponding Source to be sopar
|
||||
available, or (2) arrange to deprive yourself of the benefit of thepar
|
||||
patent license for this particular work, or (3) arrange, in a mannerpar
|
||||
consistent with the requirements of this License, to extend the patentpar
|
||||
license to downstream recipients. "Knowingly relying" means you havepar
|
||||
actual knowledge that, but for the patent license, your conveying thepar
|
||||
covered work in a country, or your recipient's use of the covered workpar
|
||||
in a country, would infringe one or more identifiable patents in thatpar
|
||||
country that you have reason to believe are valid.par
|
||||
par
|
||||
If, pursuant to or in connection with a single transaction orpar
|
||||
arrangement, you convey, or propagate by procuring conveyance of, apar
|
||||
covered work, and grant a patent license to some of the partiespar
|
||||
receiving the covered work authorizing them to use, propagate, modifypar
|
||||
or convey a specific copy of the covered work, then the patent licensepar
|
||||
you grant is automatically extended to all recipients of the coveredpar
|
||||
work and works based on it.par
|
||||
par
|
||||
A patent license is "discriminatory" if it does not include withinpar
|
||||
the scope of its coverage, prohibits the exercise of, or ispar
|
||||
conditioned on the non-exercise of one or more of the rights that arepar
|
||||
specifically granted under this License. You may not convey a coveredpar
|
||||
work if you are a party to an arrangement with a third party that ispar
|
||||
in the business of distributing software, under which you make paymentpar
|
||||
to the third party based on the extent of your activity of conveyingpar
|
||||
the work, and under which the third party grants, to any of thepar
|
||||
parties who would receive the covered work from you, a discriminatorypar
|
||||
patent license (a) in connection with copies of the covered workpar
|
||||
conveyed by you (or copies made from those copies), or (b) primarilypar
|
||||
for and in connection with specific products or compilations thatpar
|
||||
contain the covered work, unless you entered into that arrangement,par
|
||||
or that patent license was granted, prior to 28 March 2007.par
|
||||
par
|
||||
Nothing in this License shall be construed as excluding or limitingpar
|
||||
any implied license or other defenses to infringement that maypar
|
||||
otherwise be available to you under applicable patent law.par
|
||||
par
|
||||
12. No Surrender of Others' Freedom.par
|
||||
par
|
||||
If conditions are imposed on you (whether by court order, agreement orpar
|
||||
otherwise) that contradict the conditions of this License, they do notpar
|
||||
excuse you from the conditions of this License. If you cannot convey apar
|
||||
covered work so as to satisfy simultaneously your obligations under thispar
|
||||
License and any other pertinent obligations, then as a consequence you maypar
|
||||
not convey it at all. For example, if you agree to terms that obligate youpar
|
||||
to collect a royalty for further conveying from those to whom you conveypar
|
||||
the Program, the only way you could satisfy both those terms and thispar
|
||||
License would be to refrain entirely from conveying the Program.par
|
||||
par
|
||||
13. Use with the GNU Affero General Public License.par
|
||||
par
|
||||
Notwithstanding any other provision of this License, you havepar
|
||||
permission to link or combine any covered work with a work licensedpar
|
||||
under version 3 of the GNU Affero General Public License into a singlepar
|
||||
combined work, and to convey the resulting work. The terms of thispar
|
||||
License will continue to apply to the part which is the covered work,par
|
||||
but the special requirements of the GNU Affero General Public License,par
|
||||
section 13, concerning interaction through a network will apply to thepar
|
||||
combination as such.par
|
||||
par
|
||||
14. Revised Versions of this License.par
|
||||
par
|
||||
The Free Software Foundation may publish revised and/or new versions ofpar
|
||||
the GNU General Public License from time to time. Such new versions willpar
|
||||
be similar in spirit to the present version, but may differ in detail topar
|
||||
address new problems or concerns.par
|
||||
par
|
||||
Each version is given a distinguishing version number. If thepar
|
||||
Program specifies that a certain numbered version of the GNU Generalpar
|
||||
Public License "or any later version" applies to it, you have thepar
|
||||
option of following the terms and conditions either of that numberedpar
|
||||
version or of any later version published by the Free Softwarepar
|
||||
Foundation. If the Program does not specify a version number of thepar
|
||||
GNU General Public License, you may choose any version ever publishedpar
|
||||
by the Free Software Foundation.par
|
||||
par
|
||||
If the Program specifies that a proxy can decide which futurepar
|
||||
versions of the GNU General Public License can be used, that proxy'spar
|
||||
public statement of acceptance of a version permanently authorizes youpar
|
||||
to choose that version for the Program.par
|
||||
par
|
||||
Later license versions may give you additional or differentpar
|
||||
permissions. However, no additional obligations are imposed on anypar
|
||||
author or copyright holder as a result of your choosing to follow apar
|
||||
later version.par
|
||||
par
|
||||
15. Disclaimer of Warranty.par
|
||||
par
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BYpar
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHTpar
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTYpar
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,par
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULARpar
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAMpar
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OFpar
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.par
|
||||
par
|
||||
16. Limitation of Liability.par
|
||||
par
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITINGpar
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYSpar
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANYpar
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THEpar
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OFpar
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRDpar
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),par
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OFpar
|
||||
SUCH DAMAGES.par
|
||||
par
|
||||
17. Interpretation of Sections 15 and 16.par
|
||||
par
|
||||
If the disclaimer of warranty and limitation of liability providedpar
|
||||
above cannot be given local legal effect according to their terms,par
|
||||
reviewing courts shall apply local law that most closely approximatespar
|
||||
an absolute waiver of all civil liability in connection with thepar
|
||||
Program, unless a warranty or assumption of liability accompanies apar
|
||||
copy of the Program in return for a fee.par
|
||||
par
|
||||
END OF TERMS AND CONDITIONSpar
|
||||
par
|
||||
How to Apply These Terms to Your New Programspar
|
||||
par
|
||||
If you develop a new program, and you want it to be of the greatestpar
|
||||
possible use to the public, the best way to achieve this is to make itpar
|
||||
free software which everyone can redistribute and change under these terms.par
|
||||
par
|
||||
To do so, attach the following notices to the program. It is safestpar
|
||||
to attach them to the start of each source file to most effectivelypar
|
||||
state the exclusion of warranty; and each file should have at leastpar
|
||||
the "copyright" line and a pointer to where the full notice is found.par
|
||||
par
|
||||
<one line to give the program's name and a brief idea of what it does.>par
|
||||
Copyright (C) <year> <name of author>par
|
||||
par
|
||||
This program is free software: you can redistribute it and/or modifypar
|
||||
it under the terms of the GNU General Public License as published bypar
|
||||
the Free Software Foundation, either version 3 of the License, orpar
|
||||
(at your option) any later version.par
|
||||
par
|
||||
This program is distributed in the hope that it will be useful,par
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty ofpar
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See thepar
|
||||
GNU General Public License for more details.par
|
||||
par
|
||||
You should have received a copy of the GNU General Public Licensepar
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.par
|
||||
par
|
||||
Also add information on how to contact you by electronic and paper mail.par
|
||||
par
|
||||
If the program does terminal interaction, make it output a shortpar
|
||||
notice like this when it starts in an interactive mode:par
|
||||
par
|
||||
<program> Copyright (C) <year> <name of author>par
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.par
|
||||
This is free software, and you are welcome to redistribute itpar
|
||||
under certain conditions; type `show c' for details.par
|
||||
par
|
||||
The hypothetical commands `show w' and `show c' should show the appropriatepar
|
||||
parts of the General Public License. Of course, your program's commandspar
|
||||
might be different; for a GUI interface, you would use an "about box".par
|
||||
par
|
||||
You should also get your employer (if you work as a programmer) or school,par
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.par
|
||||
For more information on this, and how to apply and follow the GNU GPL, seepar
|
||||
<https://www.gnu.org/licenses/>.par
|
||||
par
|
||||
The GNU General Public License does not permit incorporating your programpar
|
||||
into proprietary programs. If your program is a subroutine library, youpar
|
||||
may consider it more useful to permit linking proprietary applications withpar
|
||||
the library. If this is what you want to do, use the GNU Lesser Generalpar
|
||||
Public License instead of this License. But first, please readpar
|
||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.par
|
||||
|
||||
{\rtf1\ansi\deff0{\fonttbl{\f0 Segoe UI;}}
|
||||
\f0\fs20
|
||||
OrchestrAD\line
|
||||
\line
|
||||
Copyright (c) Grace Solutions. All rights reserved.\line
|
||||
\line
|
||||
This software is provided "as is", without warranty of any kind, express or implied, including but not limited to the warranties of merchantability, fitness for a particular purpose and noninfringement. In no event shall the authors or copyright holders be liable for any claim, damages or other liability arising from, out of or in connection with the software or the use or other dealings in the software.\line
|
||||
\line
|
||||
By installing this software you agree to use it in accordance with your organization's policies and applicable law.\line
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user