├── .env.example ├── .gitignore ├── README.md ├── api ├── cloudflare.ts └── gandi.ts ├── bun.lockb ├── index.ts ├── package.json ├── prettier.config.cjs ├── renovate.json └── tsconfig.json /.env.example: -------------------------------------------------------------------------------- 1 | GANDI_API_KEY= 2 | CLOUDFLARE_API_TOKEN= 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore 2 | 3 | # Logs 4 | 5 | logs 6 | _.log 7 | npm-debug.log_ 8 | yarn-debug.log* 9 | yarn-error.log* 10 | lerna-debug.log* 11 | .pnpm-debug.log* 12 | 13 | # Diagnostic reports (https://nodejs.org/api/report.html) 14 | 15 | report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json 16 | 17 | # Runtime data 18 | 19 | pids 20 | _.pid 21 | _.seed 22 | \*.pid.lock 23 | 24 | # Directory for instrumented libs generated by jscoverage/JSCover 25 | 26 | lib-cov 27 | 28 | # Coverage directory used by tools like istanbul 29 | 30 | coverage 31 | \*.lcov 32 | 33 | # nyc test coverage 34 | 35 | .nyc_output 36 | 37 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 38 | 39 | .grunt 40 | 41 | # Bower dependency directory (https://bower.io/) 42 | 43 | bower_components 44 | 45 | # node-waf configuration 46 | 47 | .lock-wscript 48 | 49 | # Compiled binary addons (https://nodejs.org/api/addons.html) 50 | 51 | build/Release 52 | 53 | # Dependency directories 54 | 55 | node_modules/ 56 | jspm_packages/ 57 | 58 | # Snowpack dependency directory (https://snowpack.dev/) 59 | 60 | web_modules/ 61 | 62 | # TypeScript cache 63 | 64 | \*.tsbuildinfo 65 | 66 | # Optional npm cache directory 67 | 68 | .npm 69 | 70 | # Optional eslint cache 71 | 72 | .eslintcache 73 | 74 | # Optional stylelint cache 75 | 76 | .stylelintcache 77 | 78 | # Microbundle cache 79 | 80 | .rpt2_cache/ 81 | .rts2_cache_cjs/ 82 | .rts2_cache_es/ 83 | .rts2_cache_umd/ 84 | 85 | # Optional REPL history 86 | 87 | .node_repl_history 88 | 89 | # Output of 'npm pack' 90 | 91 | \*.tgz 92 | 93 | # Yarn Integrity file 94 | 95 | .yarn-integrity 96 | 97 | # dotenv environment variable files 98 | 99 | .env 100 | .env.development.local 101 | .env.test.local 102 | .env.production.local 103 | .env.local 104 | 105 | # parcel-bundler cache (https://parceljs.org/) 106 | 107 | .cache 108 | .parcel-cache 109 | 110 | # Next.js build output 111 | 112 | .next 113 | out 114 | 115 | # Nuxt.js build / generate output 116 | 117 | .nuxt 118 | dist 119 | 120 | # Gatsby files 121 | 122 | .cache/ 123 | 124 | # Comment in the public line in if your project uses Gatsby and not Next.js 125 | 126 | # https://nextjs.org/blog/next-9-1#public-directory-support 127 | 128 | # public 129 | 130 | # vuepress build output 131 | 132 | .vuepress/dist 133 | 134 | # vuepress v2.x temp and cache directory 135 | 136 | .temp 137 | .cache 138 | 139 | # Docusaurus cache and generated files 140 | 141 | .docusaurus 142 | 143 | # Serverless directories 144 | 145 | .serverless/ 146 | 147 | # FuseBox cache 148 | 149 | .fusebox/ 150 | 151 | # DynamoDB Local files 152 | 153 | .dynamodb/ 154 | 155 | # TernJS port file 156 | 157 | .tern-port 158 | 159 | # Stores VSCode versions used for testing VSCode extensions 160 | 161 | .vscode-test 162 | 163 | # yarn v2 164 | 165 | .yarn/cache 166 | .yarn/unplugged 167 | .yarn/build-state.yml 168 | .yarn/install-state.gz 169 | .pnp.\* 170 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # domain-sync 2 | 3 | > This is a tiny script for my use that migrates all my Gandi domains to use Cloudflare DNS. 4 | 5 | To install dependencies, ensure you have the [latest version of bun](https://bun.sh/) installed, and then run: 6 | 7 | ```bash 8 | bun install 9 | ``` 10 | 11 | Create a file called `.env` with your Gandi API token and a Cloudflare token with the following permissions: 12 | 13 | - User > Memberships > Edit 14 | - Zone > Zone Settings > Edit 15 | - Zone > Zone > Edit 16 | 17 | Then simply run: 18 | 19 | ```bash 20 | bun run index.ts 21 | ``` 22 | 23 | This script is idempotent; it's safe to run more than once as it will skip domains that already have their nameservers updated to CF. 24 | -------------------------------------------------------------------------------- /api/cloudflare.ts: -------------------------------------------------------------------------------- 1 | import { $fetch } from 'ofetch' 2 | import { z } from 'zod' 3 | 4 | const api = $fetch.create({ 5 | baseURL: 'https://api.cloudflare.com/client/v4', 6 | headers: { 7 | Authorization: `Bearer ${Bun.env.CLOUDFLARE_API_TOKEN}`, 8 | }, 9 | }) 10 | 11 | const zoneSchema = z.object({ 12 | result: z.object({ 13 | id: z.string(), 14 | name: z.string(), 15 | status: z.string(), 16 | paused: z.boolean(), 17 | type: z.string(), 18 | development_mode: z.number(), 19 | name_servers: z.array(z.string()), 20 | original_name_servers: z.array(z.string()).nullable(), 21 | original_registrar: z.string().nullable(), 22 | original_dnshost: z.string().nullable(), 23 | modified_on: z.string(), 24 | created_on: z.string(), 25 | activated_on: z.string().nullable(), 26 | meta: z.object({ 27 | step: z.number(), 28 | custom_certificate_quota: z.number(), 29 | page_rule_quota: z.number(), 30 | phishing_detected: z.boolean(), 31 | multiple_railguns_allowed: z.boolean().optional(), 32 | }), 33 | owner: z.object({ 34 | id: z.string().nullable(), 35 | type: z.string(), 36 | email: z.string().nullable(), 37 | }), 38 | account: z.object({ 39 | id: z.string(), 40 | name: z.string(), 41 | }), 42 | tenant: z.object({ 43 | id: z.string().nullable(), 44 | name: z.string().nullable(), 45 | }), 46 | tenant_unit: z.object({ 47 | id: z.string().nullable(), 48 | }), 49 | permissions: z.array(z.string()), 50 | plan: z.object({ 51 | id: z.string(), 52 | name: z.string(), 53 | price: z.number(), 54 | currency: z.string(), 55 | frequency: z.string(), 56 | is_subscribed: z.boolean(), 57 | can_subscribe: z.boolean(), 58 | legacy_id: z.string(), 59 | legacy_discount: z.boolean(), 60 | externally_managed: z.boolean(), 61 | }), 62 | }), 63 | success: z.boolean(), 64 | errors: z.array(z.unknown()), 65 | messages: z.array(z.unknown()), 66 | }) 67 | 68 | export async function createNewDomain(domain: string) { 69 | return await api('/zones', { 70 | method: 'POST', 71 | body: { 72 | name: domain, 73 | jump_start: true, 74 | type: 'full', 75 | }, 76 | }).then(zone => zoneSchema.parse(zone)) 77 | } 78 | -------------------------------------------------------------------------------- /api/gandi.ts: -------------------------------------------------------------------------------- 1 | import { $fetch } from 'ofetch' 2 | import { z } from 'zod' 3 | 4 | const api = $fetch.create({ 5 | baseURL: 'https://api.gandi.net/v5/domain/domains', 6 | headers: { 7 | Authorization: `Apikey ${Bun.env.GANDI_API_KEY}`, 8 | }, 9 | }) 10 | 11 | const domain = z.object({ 12 | fqdn: z.string(), 13 | tld: z.string(), 14 | status: z.array(z.string()), 15 | dates: z.object({ 16 | created_at: z.string(), 17 | registry_created_at: z.string(), 18 | registry_ends_at: z.string(), 19 | updated_at: z.string(), 20 | }), 21 | nameserver: z.object({ 22 | current: z.string(), 23 | }), 24 | autorenew: z.boolean(), 25 | domain_owner: z.string(), 26 | orga_owner: z.string(), 27 | owner: z.string(), 28 | id: z.string(), 29 | tags: z.array(z.string()), 30 | href: z.string(), 31 | fqdn_unicode: z.string(), 32 | }) 33 | 34 | export async function listDomains() { 35 | const domains: Array = [] 36 | let chunk 37 | let page = 1 38 | do { 39 | chunk = await api('/', { query: { page: page++ } }).then(domains => z.array(domain).parse(domains)) 40 | domains.push(...chunk) 41 | } while (chunk.length) 42 | return domains 43 | } 44 | 45 | export async function setNameservers(domain: string, nameservers: string[]) { 46 | return await api(`/${domain}/nameservers`, { 47 | method: 'PUT', 48 | body: { 49 | nameservers, 50 | }, 51 | }) 52 | } 53 | 54 | export async function getNameservers(domain: string) { 55 | return (await api(`/${domain}/nameservers`)) as string[] 56 | } 57 | -------------------------------------------------------------------------------- /bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danielroe/domain-sync/00c40430730cc1bb8547339afdd6d7f1588b81c2/bun.lockb -------------------------------------------------------------------------------- /index.ts: -------------------------------------------------------------------------------- 1 | import { createNewDomain } from './api/cloudflare' 2 | import { getNameservers, listDomains, setNameservers } from './api/gandi' 3 | 4 | const domains = await listDomains() 5 | 6 | for (const domain of domains) { 7 | console.log(`--- ${domain.fqdn} ---`) 8 | 9 | if (domain.nameserver.current === 'other') { 10 | const nameservers = await getNameservers(domain.fqdn) 11 | if (nameservers.some(n => n.includes('cloudflare'))) { 12 | continue 13 | } 14 | } 15 | 16 | console.log('Creating new domain on Cloudflare') 17 | const response = await createNewDomain(domain.fqdn) 18 | 19 | console.log('Setting new nameservers') 20 | await setNameservers(domain.fqdn, response.result.name_servers) 21 | } 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "ofetch": "^1.1.0", 4 | "zod": "^3.21.4" 5 | }, 6 | "devDependencies": { 7 | "bun-types": "1.2.15" 8 | }, 9 | "name": "domain-sync", 10 | "module": "index.ts", 11 | "type": "module", 12 | "peerDependencies": { 13 | "typescript": "^5.0.0" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /prettier.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | semi: false, 3 | singleQuote: true, 4 | printWidth: 100, 5 | trailingComma: 'es5', 6 | arrowParens: 'avoid', 7 | } 8 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "github>danielroe/renovate" 5 | ] 6 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["ESNext"], 4 | "module": "esnext", 5 | "target": "esnext", 6 | "moduleResolution": "bundler", 7 | "moduleDetection": "force", 8 | "allowImportingTsExtensions": true, 9 | "strict": true, 10 | "downlevelIteration": true, 11 | "skipLibCheck": true, 12 | "jsx": "preserve", 13 | "allowSyntheticDefaultImports": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "allowJs": true, 16 | "noEmit": true, 17 | "types": [ 18 | "bun-types" // add Bun global 19 | ] 20 | } 21 | } 22 | --------------------------------------------------------------------------------