diff --git a/README.md b/README.md index b09114f..92f1c08 100644 --- a/README.md +++ b/README.md @@ -68,8 +68,10 @@ run time — never commit filled-in values. | [`od-smb-cred.md`](od-smb-cred.md) | Open Dental SMB share — stored-credential fix | | [`od-cfg-persist.md`](od-cfg-persist.md) | Open Dental — persist "Do not show this window on startup" (writable FreeDentalConfig.xml) | | [`od-scan-duplex.md`](od-scan-duplex.md) | Open Dental — duplex ADF scanner captures only one side (TWAIN, Show TWAIN UI branches) | +| [`od-db-backup.md`](od-db-backup.md) | Open Dental — rock-solid cold backup of the database + images (stop/copy/start MySQL/MariaDB) | | [`scripts/cg-disable.ps1`](scripts/cg-disable.ps1) | Disable Credential Guard, then reboot (prompts to confirm) | | [`scripts/od-cfg-acl.ps1`](scripts/od-cfg-acl.ps1) | Grant Users Modify on FreeDentalConfig.xml (Option B of od-cfg-persist) | +| [`scripts/od-db-backup.ps1`](scripts/od-db-backup.ps1) | Cold backup: stop MySQL/MariaDB, copy whole data dir + OpenDentImages, always restart (od-db-backup) | ## Tiers diff --git a/od-db-backup.md b/od-db-backup.md new file mode 100644 index 0000000..aa3ea18 --- /dev/null +++ b/od-db-backup.md @@ -0,0 +1,128 @@ +# Runbook: Open Dental — Rock-solid database + images backup (Windows) + +**Applies to:** The Windows machine hosting the Open Dental MySQL/MariaDB database (the "server"). +**Goal:** A fully consistent, restorable backup of both halves of an Open Dental practice — the **database** and the **A-to-Z images folder** — following Open Dental and MySQL/MariaDB best practice, including cleanly **stopping the database during the copy and restarting it after**. + +A complete Open Dental backup is **two things**, and both must be captured together: + +| Component | Default location | Holds | +|---|---|---| +| MySQL/MariaDB **data directory** | `C:\mysql\data\` (contains the `opendental` DB) | All clinical/financial data | +| **A-to-Z / OpenDentImages** folder | `C:\OpenDentImages\` | Scanned docs, images, attachments | + +A database backup **without** the images folder (or vice-versa) is not a usable restore. + +**Placeholders:** + +| Placeholder | Meaning | +|---|---| +| `` | Name of the MySQL/MariaDB Windows service (e.g. `MySQL`, `MySQL57`, `MariaDB`) | +| `` | MySQL data directory — the folder **containing** the `opendental` subfolder (commonly `C:\mysql\data`) | +| `` | A-to-Z images folder (commonly `C:\OpenDentImages`) | +| `` | Backup destination — separate physical disk or UNC path, ideally replicated off-site | +| `` / `` | A MySQL account for `mysqldump` (supplemental method) — from the password manager, never committed | + +--- + +## Why "cold copy" is the rock-solid method + +Open Dental databases run on **MyISAM or InnoDB**. For a *file-level* backup to be consistent: + +- A **hot copy** (copying the data directory while the service runs) can capture InnoDB mid-write → **corrupt, unrestorable** backup. Open Dental's built-in Backup tool and most "online" file backups **cannot even restore InnoDB**. +- A **cold copy** — stop the service so it flushes and closes cleanly, copy, restart — is **consistent for both engines**. This is the gold-standard local backup. + +Two details that make or break a cold copy: + +1. **Copy the *entire* data directory, not just `data\opendental\`.** InnoDB's shared tablespace and redo logs (`ibdata1`, `ib_logfile*`) live at the **root** of the data directory. Copy only the `opendental` subfolder and an InnoDB restore will fail. +2. **Verify the service actually stopped before copying.** If it won't stop, do **not** copy — you'd capture a live datadir. + +The script below does both, and **always restarts the service** (even if the copy fails), so the practice is never left down. + +--- + +## 1. Pre-flight + +- Run **on the database server**, in an **elevated** PowerShell, **off-hours** — the copy causes downtime; Open Dental is unavailable on every workstation while the service is stopped. +- Make sure **no one is in Open Dental** (fully closed on all workstations). +- Have a `` on a **different physical disk** (or UNC share) with enough free space for the data directory **+** images. + +## 2. Run the cold backup (primary method) + +From an **elevated** PowerShell on the server: + +``` +irm https://gitea.ivangodwin.com/ops/rb/raw/branch/main/scripts/od-db-backup.ps1 | iex +``` + +It will: + +1. Auto-detect the ``, ``, and `` (prompting to confirm/override). +2. Report sizes and destination free space, then confirm before doing anything. +3. **Stop `` and verify it reached *Stopped*** — aborting the copy if it doesn't. +4. `robocopy` the **whole** data directory to `\od-backup-\data`, then the images to `…\OpenDentImages`. +5. **Restart ``** in a `finally` block — this runs even if the copy fails or is interrupted. +6. Write `MANIFEST.txt` and `backup.log` into the backup folder. + +### Manual equivalent (offline / if you can't fetch the script) + +Elevated PowerShell, no one in Open Dental: + +``` +net stop +Get-Service # confirm Status = Stopped BEFORE copying +robocopy "" "\od-backup\data" /E /COPY:DAT /R:2 /W:5 +robocopy "" "\od-backup\OpenDentImages" /E /COPY:DAT /R:2 /W:5 +net start +Get-Service # confirm Status = Running +``` + +> Copy `` itself (the parent of `opendental`), **not** `\opendental` — you need `ibdata1` / `ib_logfile*` at the datadir root for InnoDB. If `net start` fails, start it immediately: the practice cannot work until the DB is back up. + +## 3. Supplemental logical backup (mysqldump) + +A cold copy is engine-perfect but physical — a portable **logical** dump is a valuable second line (survives binary corruption, restores to any server/version). It runs **while the service is up**, so schedule it separately (e.g. mid-day incremental in addition to the nightly cold copy). Slight slowness while it runs. + +``` +mysqldump -u -p --single-transaction --quick --max-allowed-packet=1024M --default-character-set=utf8 --routines --events opendental > "\opendental-.sql" +``` + +- `--single-transaction` gives a consistent snapshot of **InnoDB** without locking the practice out. (For **MyISAM**, that flag does not guarantee consistency — use the cold copy as the source of truth.) +- Compress the `.sql` afterward; it shrinks dramatically. +- The dump does **not** include the images folder — always pair it with an `` copy. + +## 4. Schedule it (daily minimum) + +Open Dental's floor is **at least one backup per day**; combine an automated nightly job with an off-site copy. + +Register the cold backup as a nightly **Task Scheduler** job (runs off-hours, as SYSTEM/admin). Because `irm | iex` prompts interactively, schedule a **local copy** of the script with the paths baked in (keep that filled-in copy in the **private tier**, not here), e.g.: + +``` +schtasks /Create /TN "OD Nightly Cold Backup" /TR "powershell -NoProfile -ExecutionPolicy Bypass -File C:\ops\od-db-backup-local.ps1" /SC DAILY /ST 23:30 /RU SYSTEM /RL HIGHEST +``` + +## 5. Verify — a backup you haven't restored is a guess + +- **Test-restore** to an **isolated** machine periodically (matching Open Dental + MySQL/MariaDB versions): `net stop`, rename the existing `opendental` folder aside, drop in the backup's `data\` contents, `net start`, launch Open Dental, spot-check patients/images. +- **Never restore over a live production database** — data loss is irreversible. +- Confirm the nightly job is actually producing dated folders and that they leave the building (off-site / immutable copy) — ransomware that reaches the server will reach on-line backups too. + +## 6. Retention & off-site (3-2-1) + +- **3** copies, **2** media, **1** off-site. The `` disk alone is not a backup strategy. +- Keep several daily generations plus weekly/monthly rollups; prune old `od-backup-` folders on a schedule. +- These files contain **PHI** — encrypt at rest and in transit; restrict access. HIPAA applies. + +--- + +## Security note + +Backup media and dumps hold the **entire practice's PHI**. Treat them as the crown jewels: encrypt the destination, lock down share permissions, and keep at least one copy **off-line/immutable** so ransomware can't encrypt your backups along with production. Don't store `` in the scheduled command line — use a MySQL option file / limited account. + +## References + +- Open Dental manual — Backups (overview): +- Open Dental manual — Manual Backups: +- Open Dental manual — Backup Tool: +- Open Dental — InnoDB (backup implications): +- Open Dental manual — MySQL Data Directory Management: +- MySQL — `mysqldump`, `--single-transaction`: diff --git a/scripts/od-db-backup.ps1 b/scripts/od-db-backup.ps1 new file mode 100644 index 0000000..778431e --- /dev/null +++ b/scripts/od-db-backup.ps1 @@ -0,0 +1,230 @@ +<# +.SYNOPSIS + Rock-solid cold backup of an Open Dental server: stop MySQL/MariaDB, copy the + entire data directory and the A-to-Z (OpenDentImages) folder, then restart the + service. Guarantees the service is restarted even if the copy fails. + +.DESCRIPTION + The only fully consistent, engine-agnostic file backup of a MySQL/MariaDB + database is a COLD copy: the service is cleanly stopped (which flushes InnoDB), + the data directory is copied, then the service is restarted. This works for + both MyISAM and InnoDB, unlike a hot file copy or Open Dental's built-in Backup + tool (which cannot restore InnoDB). + + Two correctness points this script gets right: + - It copies the ENTIRE data directory, not just the `opendental` subfolder. + InnoDB's shared tablespace and redo logs (ibdata1, ib_logfile*) live at the + datadir root; copying only `opendental` yields an unrestorable backup. + - The service is stopped and VERIFIED stopped before any copy begins. A copy + of a running InnoDB datadir is corrupt. If the service will not stop, the + script aborts without copying. + + The service restart runs in a finally block, so an interrupted or failed copy + never leaves the practice's database down. + + This is the primary "gold" backup. Pair it with an off-hours mysqldump for a + portable logical copy and with off-site/immutable retention — see + od-db-backup.md. + +.NOTES + Run ON THE DATABASE SERVER, elevated, when no one is using Open Dental: + irm https://gitea.ivangodwin.com/ops/rb/raw/branch/main/scripts/od-db-backup.ps1 | iex + + Because `irm | iex` runs in the caller's session: no param() block (prompts via + Read-Host), and #Requires is not enforced (admin is checked manually). + + Causes DOWNTIME for the duration of the copy — Open Dental is unavailable on + every workstation while the service is stopped. Run it off-hours. + + PUBLIC REPO: no client specifics hard-coded. All paths are auto-detected or + prompted at run time. +#> + +$ErrorActionPreference = 'Stop' + +# --- Admin check (do not rely on #Requires under iex) --- +$isAdmin = ([Security.Principal.WindowsPrincipal] ` + [Security.Principal.WindowsIdentity]::GetCurrent() + ).IsInRole([Security.Principal.WindowsBuiltinRole]::Administrator) +if (-not $isAdmin) { + Write-Warning 'This script needs an elevated PowerShell session. Re-run as Administrator.' + return +} + +Write-Host '== Open Dental: cold database + images backup ==' -ForegroundColor Cyan +Write-Host 'Run this ON THE DB SERVER, off-hours. It stops MySQL/MariaDB (downtime).' -ForegroundColor Yellow +Write-Host '' + +# --- 1. Resolve the MySQL/MariaDB service --- +$dbServices = @(Get-Service | Where-Object { $_.Name -match 'mysql|mariadb' -or $_.DisplayName -match 'mysql|mariadb' }) +if ($dbServices.Count -eq 1) { + $svcName = $dbServices[0].Name + Write-Host "Detected database service: $svcName ($($dbServices[0].DisplayName)) [$($dbServices[0].Status)]" -ForegroundColor Green + if ((Read-Host "Use this service? (Y/n)") -eq 'n') { $svcName = $null } +} +elseif ($dbServices.Count -gt 1) { + Write-Host 'Multiple database services found:' -ForegroundColor Yellow + $dbServices | ForEach-Object { Write-Host " - $($_.Name) ($($_.DisplayName)) [$($_.Status)]" } + $svcName = $null +} +else { + Write-Warning 'No service matching mysql/mariadb found. Enter the name manually.' + $svcName = $null +} +if (-not $svcName) { + $svcName = Read-Host 'MySQL/MariaDB service name (e.g. MySQL, MySQL57, MariaDB)' +} +$svc = Get-Service -Name $svcName # throws if it doesn't exist + +# --- 2. Resolve the data directory (copy the WHOLE dir, not just opendental) --- +$dataGuesses = @('C:\mysql\data', 'C:\Program Files\MySQL\MySQL Server*\data', + 'C:\ProgramData\MySQL\MySQL Server*\Data', 'C:\Program Files\MariaDB*\data') | + ForEach-Object { Get-Item $_ -ErrorAction SilentlyContinue } | + Where-Object { $_ } | Select-Object -ExpandProperty FullName +$dataDir = $dataGuesses | Where-Object { Test-Path (Join-Path $_ 'opendental') } | Select-Object -First 1 +if ($dataDir) { + Write-Host "Detected data directory: $dataDir" -ForegroundColor Green + if ((Read-Host "Use this data directory? (Y/n)") -eq 'n') { $dataDir = $null } +} +if (-not $dataDir) { + $dataDir = Read-Host 'MySQL data directory (the folder CONTAINING the opendental subfolder, e.g. C:\mysql\data)' +} +if (-not (Test-Path (Join-Path $dataDir 'opendental'))) { + Write-Warning "No 'opendental' subfolder under: $dataDir" + if ((Read-Host 'Continue anyway? (y/N)') -ne 'y') { return } +} + +# --- 3. Resolve the A-to-Z / OpenDentImages folder (optional) --- +$imgGuess = @('C:\OpenDentImages', 'D:\OpenDentImages') | Where-Object { Test-Path $_ } | Select-Object -First 1 +$imgDir = Read-Host "OpenDentImages (A-to-Z) folder [blank to skip]$(if($imgGuess){" (detected: $imgGuess)"})" +if (-not $imgDir -and $imgGuess) { $imgDir = $imgGuess } +if ($imgDir -and -not (Test-Path $imgDir)) { + Write-Warning "Images path not found: $imgDir — it will be skipped." + $imgDir = $null +} + +# --- 4. Backup destination --- +$destRoot = Read-Host 'Backup destination root (external/second disk or UNC path, e.g. E:\OD-Backups)' +if (-not (Test-Path $destRoot)) { + if ((Read-Host "Destination does not exist. Create it? (Y/n)") -eq 'n') { return } + New-Item -ItemType Directory -Path $destRoot -Force | Out-Null +} +$stamp = Get-Date -Format 'yyyyMMdd-HHmmss' +$backupDir = Join-Path $destRoot "od-backup-$stamp" + +# --- 5. Size / free-space sanity check --- +function Get-DirSizeGB($path) { + try { + $bytes = (Get-ChildItem -LiteralPath $path -Recurse -Force -File -ErrorAction SilentlyContinue | + Measure-Object -Property Length -Sum).Sum + [math]::Round(($bytes / 1GB), 2) + } catch { $null } +} +Write-Host 'Measuring source size (may take a moment for large image folders)...' +$dataGB = Get-DirSizeGB $dataDir +$imgGB = if ($imgDir) { Get-DirSizeGB $imgDir } else { 0 } +$needGB = [math]::Round(($dataGB + $imgGB), 2) +try { + $destQualifier = (Split-Path -Qualifier $backupDir) + $freeGB = [math]::Round(((Get-PSDrive ($destQualifier.TrimEnd(':'))).Free / 1GB), 2) +} catch { $freeGB = $null } + +Write-Host '' +Write-Host 'Plan:' -ForegroundColor Cyan +Write-Host " Service to cycle : $svcName [$($svc.Status)]" +Write-Host " Data directory : $dataDir (~$dataGB GB)" +Write-Host " Images folder : $(if($imgDir){"$imgDir (~$imgGB GB)"}else{'(skipped)'})" +Write-Host " Destination : $backupDir" +Write-Host " Approx needed : ~$needGB GB$(if($freeGB){" (free at dest: $freeGB GB)"})" +if ($freeGB -and $needGB -gt $freeGB) { + Write-Warning 'Destination may not have enough free space for this backup.' +} +Write-Host '' +Write-Host 'This STOPS the database (Open Dental goes down on all workstations) for the copy.' -ForegroundColor Yellow +if ((Read-Host 'Proceed? (y/N)') -ne 'y') { Write-Host 'Aborted. No changes made.'; return } + +New-Item -ItemType Directory -Path $backupDir -Force | Out-Null +$logPath = Join-Path $backupDir 'backup.log' +function Log($msg) { + $line = "$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') $msg" + Write-Host $line + Add-Content -LiteralPath $logPath -Value $line +} + +# robocopy: /E all subdirs incl empty, /COPY:DAT+/DCOPY:DAT data/attrs/timestamps, +# /R:2 /W:5 short retries, /NFL /NDL /NP quieter. Exit code >= 8 = failure. +function Copy-Tree($src, $dst, $label) { + Log "Copying $label : $src -> $dst" + robocopy $src $dst /E /COPY:DAT /DCOPY:DAT /R:2 /W:5 /NFL /NDL /NP /NJH /NJS | Out-Null + $code = $LASTEXITCODE + Log "robocopy ($label) exit code $code" + if ($code -ge 8) { throw "robocopy failed copying $label (exit $code)." } +} + +$wasRunning = ($svc.Status -eq 'Running') +$ok = $false +try { + # --- 6. Stop the service and VERIFY it stopped before copying --- + if ($svc.Status -ne 'Stopped') { + Log "Stopping service $svcName ..." + Stop-Service -Name $svcName -Force + $svc.WaitForStatus('Stopped', [TimeSpan]::FromSeconds(120)) # throws on timeout + } + (Get-Service -Name $svcName).Refresh() + if ((Get-Service -Name $svcName).Status -ne 'Stopped') { + throw "Service $svcName did not reach Stopped — aborting before copy." + } + Log "Service $svcName confirmed Stopped. Beginning cold copy." + + # --- 7. Copy the whole data directory, then images --- + Copy-Tree $dataDir (Join-Path $backupDir 'data') 'data directory' + if ($imgDir) { Copy-Tree $imgDir (Join-Path $backupDir 'OpenDentImages') 'OpenDentImages' } + + $ok = $true + Log 'Cold copy completed successfully.' +} +catch { + Log "ERROR: $($_.Exception.Message)" + Write-Warning "Backup failed: $($_.Exception.Message)" +} +finally { + # --- 8. ALWAYS restart the service if it was running (never leave DB down) --- + if ($wasRunning) { + try { + Log "Restarting service $svcName ..." + Start-Service -Name $svcName + (Get-Service -Name $svcName).WaitForStatus('Running', [TimeSpan]::FromSeconds(120)) + Log "Service $svcName is Running again." + } + catch { + Write-Warning "CRITICAL: could not restart $svcName. Start it manually NOW: Start-Service $svcName" + Log "CRITICAL: restart failed: $($_.Exception.Message)" + } + } + else { + Log "Service was not running at start; left as-found (Stopped)." + } +} + +# --- 9. Write a manifest and report --- +if ($ok) { + $manifest = @" +Open Dental cold backup + Created : $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') + Host : $env:COMPUTERNAME + Service : $svcName + Data directory : $dataDir (~$dataGB GB) + Images folder : $(if($imgDir){"$imgDir (~$imgGB GB)"}else{'(skipped)'}) + Backup folder : $backupDir + Method : cold copy (service stopped + verified), whole data dir incl InnoDB shared tablespace/logs +"@ + Set-Content -LiteralPath (Join-Path $backupDir 'MANIFEST.txt') -Value $manifest + Write-Host '' + Write-Host "Backup complete: $backupDir" -ForegroundColor Green + Write-Host 'Next: copy this folder OFF-SITE, and TEST-RESTORE it periodically on an isolated machine.' -ForegroundColor Green + Write-Host 'Never restore over a live production database.' -ForegroundColor Yellow +} +else { + Write-Warning "Backup did NOT complete. See log: $logPath" + Write-Warning "Confirm the database service is running: Get-Service $svcName" +}