Release: object viewer, roles, cert/firewall automation, API key fix #12

Merged
gsadmin merged 11 commits from development into main 2026-09-03 02:26:20 +00:00
33 changed files with 1302 additions and 724 deletions
+148 -1
View File
@@ -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,
},
},
}
}
+42
View File
@@ -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) {
+73 -19
View File
@@ -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}
+1 -1
View File
@@ -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),
+10
View File
@@ -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 }
+51
View File
@@ -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
}
+17
View File
@@ -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
}
+1 -1
View File
@@ -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,
+36
View File
@@ -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
+42
View File
@@ -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
}
+7 -3
View File
@@ -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()
+82
View File
@@ -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 == "" {
+3
View File
@@ -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) {
+7 -1
View File
@@ -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)
}
+9
View File
@@ -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
}
+3
View File
@@ -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 }
+51
View File
@@ -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&apos;t
validate the DC&apos;s certificate, so &ldquo;Allow invalid certs&rdquo; is enabled automatically the
connection is still encrypted, the certificate just isn&apos;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>
);
}
+222
View File
@@ -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">
+73 -11
View File
@@ -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"
+8
View File
@@ -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"
+9 -3
View File
@@ -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 ?? []),
+18
View File
@@ -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 {
+9 -2
View File
@@ -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=&quot;ALL&quot;" />
</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=&quot;1&quot;" />
+10 -677
View File
@@ -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
}