├── .gitignore ├── README.md ├── client ├── css │ ├── style.css │ └── ui.css ├── index.html └── js │ ├── reconnecting_websocket.min.js │ ├── status-ui.js │ ├── status.js │ ├── utils.js │ └── websocket.js ├── docs ├── example.png └── init-demo.gif ├── init.tpl ├── main.tf ├── outputs.tf ├── server ├── controller.py ├── requirements.txt ├── services │ ├── bitcoin-for-ord.service │ ├── ord-controller.service │ ├── ord.service │ └── ord.timer └── thread_test.py └── variables.tf /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | *.pyc 3 | *.code-workspace 4 | .env* 5 | lambdas/python 6 | *.zip 7 | env 8 | node_modules 9 | .terraform 10 | env.js 11 | client-env.js.txt 12 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # NOTE: this project is no longer supported 2 | 3 | # OrdControl 4 | This is a one-click AWS deployment to run a Bitcoin full-node and [Ord](https://github.com/casey/ord) instance with a client-controller. The client currently facilitates creating an ord wallet, viewing balance/address info and uploading / inscribing files. 5 | 6 | 7 | ![OrdControl server-built](https://raw.githubusercontent.com/kvnn/OrdControl/master/docs/example.png) 8 | 9 | 10 | ## Quickstart 11 | 1. Have an AWS account set up with the cli : https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-files.html 12 | 2. `git clone git@github.com:kvnn/OrdControl.git` 13 | 3. `cd OrdControl` 14 | 4. `terraform init` 15 | 5. `terraform apply` 16 | 6. open the visibility / control client by opening `index.html` in the browser (for me its at `file:///Users/kevin/Projects/OrdControl/client/index.html`) 17 | 7. visit your server [OPTIONAL]: 18 | 1. copy / paste the `ssh_connection_string` (printed once #5 is complete) to connect to your instance 19 | 2. in instance, run `tail -f /var/log/cloud-init-output.log` to see status of the post-deploy script 20 | 3. wait until you see "ord-server init.tpl finished" in the above before taking any actions (the client will update you as well) 21 | 4. you can manually run ord commands via `/home/ubuntu/ord/target/release/ord --bitcoin-data-dir=/mnt/bitcoin-ord-data/bitcoin --data-dir=/mnt/bitcoin-ord-data/ord {CMD e.g. "info"}` 22 | 23 | 24 | ## Details 25 | - if you restart the instance, you need to make modifications to the public dns in `env.js`. 26 | - its taking me 5-10 minutes from `terraform apply` until Ord is successfully indexing. [A docker container might help.](https://github.com/kvnn/OrdControl/issues/4) 27 | - it sets up a volume at `/mnt/bitcoin-ord-data` with bitcoin and ord data dirs synced up to April 1 2023 28 | - as of March 6 2023, this setup is costing me about $13 / day, which is almost entirely EC2 costs. I'll sometimes run `terraform --auto-approve destroy` when I know I won't be using it 29 | - you can change regions, availability zones and instance types in `variables.tf`. Note that the data drive mount may fail for instances that use `nvme` type drives, and it may fail for other regions. If you have a use-case you need help with, feel free to create an Issue. 30 | - the AMI used is a standard AWS AMI 31 | - see `init.tpl` for the scripting done to your server (e.g. to make sure there are no backdoors here) 32 | 33 | 34 | ## TODO 35 | - [x] Rename to `OrdControl` and have Dall-e generate something dope 36 | - [x] Add UI screenshot or **loop** to README 37 | - server 38 | - [ ] wallet control 39 | - [x] create wallet 40 | - [x] delete wallet (note: see https://github.com/casey/ord/issues/1649) 41 | - [x] instead of saving seed phrase to Dynamo table, save to a flate file on server and allow retrieval 42 | - [ ] allow seed-phrase delete 43 | - [x] generate receive addresses 44 | - [ ] send funds 45 | - [ ] view txs 46 | - [ ] implement Inscription functionality 47 | - [x] basic functionality 48 | - [ ] resilient queueing 49 | - [ ] smart queue consumer 50 | - [ ] light database for managing queued Inscriptions 51 | - [x] alert UI if ec2/boto credentials error occurs, allow server restart 52 | - [ ] allow server restart regardless of the above 53 | - [x] show journalctl alerts / errors in UI 54 | - [x] verify that `bitcoin-cli` work, play with it 55 | - [x] include controller websocket server 56 | - [x] add authentication token via terraform 57 | - [x] actually *use* the auth token 58 | - [x] add "Name: OrdControl" tag to all aws resources 59 | - [x] add Dynamo table 60 | - [x] get fine-grained ord-index status (via strace?) 61 | - [ ] split controller.py into a module, split out the ord-indexing watcher / logger 62 | 63 | - client 64 | - [x] release MVP 65 | - [x] clean up js / css 66 | - [ ] show ssh connection string 67 | - [ ] add feedback / hold mechanism for e.g. create-wallet, create-address 68 | - [ ] wallet UI 69 | - [x] create wallet 70 | - [x] disable wallet (note: see https://github.com/casey/ord/issues/1649) 71 | - [x] show seed phrase 72 | - [x] clean up initial state (when wallet doesn't exist) 73 | - [x] add address 74 | - [x] view addresses 75 | - [ ] implement Inscription functionality 76 | - [x] basic functionality 77 | - [ ] custom parameters (e..g fee_rate) 78 | - [ ] queue visbility 79 | - [ ] Inscription status 80 | - [ ] internal info 81 | - [ ] on-chain info 82 | - [ ] queue controls 83 | - [ ] cancel 84 | - [ ] prioritize / replace tx 85 | - [ ] include `bitcoin-cli` controls? 86 | 87 | 88 | ## ImageMagick tricks 89 | 90 | #### reduce gif size 91 | 92 | ``` 93 | convert repage-orig.gif -coalesce -fuzz 2% +dither -layers Optimize +map repage.gif 94 | ``` 95 | 96 | ## controller.py: example outputs 97 | 98 | lets make this better 99 | 100 | ``` 101 | inscribe output={ 102 | 103 | "commit": "7ed2f88a8c27e67e2721c454a045505b47c2741532fbd5306e865cc10f4a0f53", 104 | 105 | "inscription": "452a3e9b08a7c0d1919fe4b7a9a8d08ebc8dc58ebd1fd56de745a2cbbddfafc5i0", 106 | 107 | "reveal": "452a3e9b08a7c0d1919fe4b7a9a8d08ebc8dc58ebd1fd56de745a2cbbddfafc5", 108 | 109 | "fees": 7965 110 | 111 | } 112 | 113 | , error= 114 | _put_dynamo_item inscribed ∂ßååå.txt: { 115 | 116 | "commit": "7ed2f88a8c27e67e2721c454a045505b47c2741532fbd5306e865cc10f4a0f53", 117 | 118 | "inscription": "452a3e9b08a7c0d1919fe4b7a9a8d08ebc8dc58ebd1fd56de745a2cbbddfafc5i0", 119 | 120 | "reveal": "452a3e9b08a7c0d1919fe4b7a9a8d08ebc8dc58ebd1fd56de745a2cbbddfafc5", 121 | 122 | "fees": 7965 123 | 124 | } 125 | ``` 126 | 127 | -------------------------------------------------------------------------------- /client/css/style.css: -------------------------------------------------------------------------------- 1 | 2 | a { 3 | text-decoration: none; 4 | } 5 | 6 | code code { 7 | font-size: 1rem; 8 | } 9 | 10 | .main { 11 | position: relative; 12 | } 13 | 14 | code { 15 | font-size: 0.7em; 16 | } 17 | 18 | code.black { 19 | color: #333; 20 | } 21 | 22 | code .black { 23 | color: #333; 24 | } 25 | 26 | code p { 27 | margin: 8px 0; 28 | } 29 | 30 | 31 | 32 | #status-websocket { 33 | max-width: 900px; 34 | text-align: center; 35 | font-size: 1em; 36 | } 37 | 38 | #ord-control-status .open { 39 | display: none; 40 | } 41 | 42 | #ord-control-status.open .open { 43 | display: inline-block; 44 | margin-bottom: 4px; 45 | } 46 | 47 | #ord-control-status.open .reconnecting { 48 | display: none; 49 | } 50 | 51 | #ord-control-status.reconnecting .reconnecting { 52 | display: inline; 53 | animation: blinker 3s linear infinite; 54 | } 55 | 56 | #ord-control-status.reconnecting .open { 57 | display: none; 58 | } 59 | 60 | 61 | #ord-indexing-service-status .restart { 62 | display: none; 63 | } 64 | 65 | #bitcoind-status .start { 66 | display: none; 67 | } 68 | 69 | #bitcoind-status .stop { 70 | display: none; 71 | } 72 | 73 | #bitcoind-status.restarting .restart { 74 | display: none; 75 | } 76 | -------------------------------------------------------------------------------- /client/css/ui.css: -------------------------------------------------------------------------------- 1 | 2 | @keyframes blinker { 3 | 50% { 4 | opacity: 0; 5 | } 6 | } 7 | 8 | .blink { 9 | animation: blinker 4s linear infinite; 10 | } 11 | 12 | .blink-slow { 13 | animation: blinker 6s linear infinite; 14 | } 15 | 16 | #ord-wallet { 17 | position: relative; 18 | } 19 | 20 | #ord-wallet .create { 21 | margin: 10px 0 0 10px; 22 | } 23 | 24 | #ord-wallet .show-if-exists { 25 | display: none; 26 | } 27 | 28 | #ord-wallet .show-seed { 29 | position: absolute; 30 | top: 10px; 31 | right: 10px; 32 | } 33 | 34 | #ord-wallet .disable { 35 | display: none; 36 | position: absolute; 37 | bottom: 10px; 38 | right: 10px; 39 | } 40 | 41 | #ord-wallet.exists .create { 42 | display: none; 43 | } 44 | 45 | #ord-wallet.exists .show-if-exists, 46 | #ord-wallet.exists .disable { 47 | display: inline; 48 | } 49 | 50 | #ord-wallet .spent { 51 | text-decoration: line-through; 52 | } 53 | 54 | .status-container { 55 | 56 | } 57 | .status-container h4 { 58 | text-align: center; 59 | } 60 | 61 | .status-container .control { 62 | text-align: right; 63 | } 64 | 65 | #ord-wallet-new { 66 | margin: 8px 0 0 8px; 67 | } 68 | 69 | .status-container .icon-btn { 70 | font-size: 0.8em; 71 | text-decoration: none; 72 | } 73 | 74 | .status-container .icon-btn span { 75 | vertical-align: bottom; 76 | } 77 | 78 | .status-container .icon-btn span:nth-child(2) { 79 | vertical-align: center; 80 | } 81 | 82 | .status-container.waiting .waiting-hide { 83 | display: none; 84 | } 85 | 86 | .status-container.waiting .card { 87 | text-align: center; 88 | animation: blinker 1s linear infinite; 89 | } 90 | 91 | .status-container .card-body { 92 | max-height: 350px; 93 | overflow-y: scroll; 94 | } 95 | 96 | #ord-inscribed-content { 97 | margin-top: 20px; 98 | } 99 | 100 | /* ord-control status */ 101 | .status-container .built { 102 | display: none; 103 | } 104 | 105 | .status-container.finished .building { 106 | display: none; 107 | } 108 | 109 | .status-container.finished .built { 110 | display: block; 111 | } 112 | 113 | #ord-control-status.finished .card-body, 114 | .finished #ord-indexing-service-status-content { 115 | max-height: 81px; 116 | } 117 | 118 | #ord-addresses .icon-btn span:nth-child(2) { 119 | font-size: 1.4em; 120 | } 121 | -------------------------------------------------------------------------------- /client/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | Ord Control 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 |
30 |
31 | 32 |
33 |
34 |

ord controller

35 | 36 |
37 |
38 |
39 |
40 | 41 | cell_tower 42 | 43 | websocket connected 44 |
45 |
46 | 47 | cell_tower 48 | 49 | websocket reconnecting 50 |
51 | 52 | restart_alt 53 | 54 |
55 | 56 |
57 | 58 | server is building... 59 | 60 | 61 | server built 62 | 63 | 64 |
65 |
66 |
67 |
68 | 69 |
70 |

bitcoind

71 |
72 |
73 |
waiting...
74 |
75 |
76 |
77 | 78 |
79 |

80 | system-wide alerts 81 |

82 |
83 | 84 |
85 |
86 |
87 |
waiting...
88 |
89 |
90 |
91 |
92 | 93 |
94 | 95 |
96 |

ord wallet

97 | 121 |
122 | 123 |
124 |

inscription queue

125 |
126 |
127 |
128 |
129 | 130 | 131 |
132 |
133 | 134 |
135 |
136 |
137 |
138 |

outputs

139 |
140 |
141 |
142 |
143 |
144 |
145 |
146 |

inscribed

147 |
148 |
149 |
150 |
151 |
152 |
153 |
154 | 155 |
156 |

ord index

157 |
158 |
159 |
160 | 163 |
164 | waiting... 165 |
166 |
167 |
168 |
169 | 170 |
171 |

event log

172 |
173 |
174 |
waiting...
175 |
176 |
177 |
178 |
179 |
180 | 181 | 182 | 213 | 214 | 215 | 244 | 245 | 246 | 247 | 248 | -------------------------------------------------------------------------------- /client/js/reconnecting_websocket.min.js: -------------------------------------------------------------------------------- 1 | !function(a,b){"function"==typeof define&&define.amd?define([],b):"undefined"!=typeof module&&module.exports?module.exports=b():a.ReconnectingWebSocket=b()}(this,function(){function a(b,c,d){function l(a,b){var c=document.createEvent("CustomEvent");return c.initCustomEvent(a,!1,!1,b),c}var e={debug:!1,automaticOpen:!0,reconnectInterval:1e3,maxReconnectInterval:3e4,reconnectDecay:1.5,timeoutInterval:2e3};d||(d={});for(var f in e)this[f]="undefined"!=typeof d[f]?d[f]:e[f];this.url=b,this.reconnectAttempts=0,this.readyState=WebSocket.CONNECTING,this.protocol=null;var h,g=this,i=!1,j=!1,k=document.createElement("div");k.addEventListener("open",function(a){g.onopen(a)}),k.addEventListener("close",function(a){g.onclose(a)}),k.addEventListener("connecting",function(a){g.onconnecting(a)}),k.addEventListener("message",function(a){g.onmessage(a)}),k.addEventListener("error",function(a){g.onerror(a)}),this.addEventListener=k.addEventListener.bind(k),this.removeEventListener=k.removeEventListener.bind(k),this.dispatchEvent=k.dispatchEvent.bind(k),this.open=function(b){h=new WebSocket(g.url,c||[]),b||k.dispatchEvent(l("connecting")),(g.debug||a.debugAll)&&console.debug("ReconnectingWebSocket","attempt-connect",g.url);var d=h,e=setTimeout(function(){(g.debug||a.debugAll)&&console.debug("ReconnectingWebSocket","connection-timeout",g.url),j=!0,d.close(),j=!1},g.timeoutInterval);h.onopen=function(){clearTimeout(e),(g.debug||a.debugAll)&&console.debug("ReconnectingWebSocket","onopen",g.url),g.protocol=h.protocol,g.readyState=WebSocket.OPEN,g.reconnectAttempts=0;var d=l("open");d.isReconnect=b,b=!1,k.dispatchEvent(d)},h.onclose=function(c){if(clearTimeout(e),h=null,i)g.readyState=WebSocket.CLOSED,k.dispatchEvent(l("close"));else{g.readyState=WebSocket.CONNECTING;var d=l("connecting");d.code=c.code,d.reason=c.reason,d.wasClean=c.wasClean,k.dispatchEvent(d),b||j||((g.debug||a.debugAll)&&console.debug("ReconnectingWebSocket","onclose",g.url),k.dispatchEvent(l("close")));var e=g.reconnectInterval*Math.pow(g.reconnectDecay,g.reconnectAttempts);setTimeout(function(){g.reconnectAttempts++,g.open(!0)},e>g.maxReconnectInterval?g.maxReconnectInterval:e)}},h.onmessage=function(b){(g.debug||a.debugAll)&&console.debug("ReconnectingWebSocket","onmessage",g.url,b.data);var c=l("message");c.data=b.data,k.dispatchEvent(c)},h.onerror=function(b){(g.debug||a.debugAll)&&console.debug("ReconnectingWebSocket","onerror",g.url,b),k.dispatchEvent(l("error"))}},1==this.automaticOpen&&this.open(!1),this.send=function(b){if(h)return(g.debug||a.debugAll)&&console.debug("ReconnectingWebSocket","send",g.url,b),h.send(b);throw"INVALID_STATE_ERR : Pausing to reconnect websocket"},this.close=function(a,b){"undefined"==typeof a&&(a=1e3),i=!0,h&&h.close(a,b)},this.refresh=function(){h&&h.close()}}return a.prototype.onopen=function(){},a.prototype.onclose=function(){},a.prototype.onconnecting=function(){},a.prototype.onmessage=function(){},a.prototype.onerror=function(){},a.debugAll=!1,a.CONNECTING=WebSocket.CONNECTING,a.OPEN=WebSocket.OPEN,a.CLOSING=WebSocket.CLOSING,a.CLOSED=WebSocket.CLOSED,a}); -------------------------------------------------------------------------------- /client/js/status-ui.js: -------------------------------------------------------------------------------- 1 | const MS_PER_MINUTE = 60000; 2 | 3 | window.TX_FEES = null; 4 | window.TX_FEES_UPDATED = null; 5 | window.SAT_PRICE = null; 6 | window.SAT_PRICE_UPDATED = null; 7 | 8 | 9 | async function setFeeOptions() { 10 | if ( 11 | !window.TX_FEES_UPDATED || 12 | window.TX_FEES_UPDATED < new Date() - MS_PER_MINUTE 13 | ) { 14 | let data = await $.get('https://mempool.space/api/v1/fees/recommended'); 15 | window.TX_FEES = data; 16 | window.TX_FEES_UPDATED = new Date(); 17 | 18 | let feeHtml = ''; 19 | let i = 0; 20 | $.each(window.TX_FEES, (key, val) =>{ 21 | let attr = ''; 22 | if (i==3) { 23 | attr = 'selected'; 24 | } 25 | feeHtml += ``; 26 | i++; 27 | }); 28 | 29 | $('#feeRate').html(feeHtml); 30 | } 31 | } 32 | 33 | 34 | async function setBtcPrice() { 35 | if ( 36 | !window.SAT_PRICE_UPDATED || 37 | window.SAT_PRICE_UPDATED < new Date() - MS_PER_MINUTE 38 | ) { 39 | // set price 40 | let data = await $.get('https://api.coingecko.com/api/v3/simple/price?ids=bitcoin&vs_currencies=usd'); 41 | window.SAT_PRICE = parseFloat(data.bitcoin.usd) / (100 * 1000 * 1000); 42 | window.SAT_PRICE_UPDATED = new Date(); 43 | } 44 | } 45 | 46 | function getPsStatusHtml(data) { 47 | let statusHtml = ''; 48 | 49 | data.forEach(row => { 50 | statusHtml += ''; 51 | statusHtml += '
' 52 | statusHtml += `

${row['COMMAND']}

`; 53 | statusHtml += `

PID ${row['PID']} %MEM ${row['%MEM']} %CPU ${row['%CPU']} TIME ${row['TIME']}

`; 54 | 55 | getUnixStateCodeDetails(row['STAT']).forEach((stat) => { 56 | statusHtml += `

${stat}

`; 57 | }) 58 | 59 | statusHtml += '
'; 60 | statusHtml += ` 61 | `; 69 | statusHtml += '
'; 70 | }); 71 | 72 | 73 | return statusHtml; 74 | } 75 | 76 | function setBitcoindStatus(data) { 77 | let statusHtml = ''; 78 | 79 | if (data.length == 0) { 80 | statusHtml = '

process not found

'; 81 | statusHtml += ` 82 |
83 | 84 | start 85 | 86 |
`; 87 | } else { 88 | statusHtml = getPsStatusHtml(data); 89 | } 90 | 91 | $('#bitcoind-status-content').html(statusHtml); 92 | $('#bitcoind-status').removeClass('waiting'); 93 | } 94 | 95 | function setOrdIndexServiceStatus(data) { 96 | // let statusHtml = getPsStatusHtml(data); 97 | let statusHtml = ''; 98 | statusHtml += data; 99 | statusHtml = statusHtml.replaceAll('\\n', '
'); 100 | statusHtml += '
'; 101 | 102 | if (data.indexOf('Deactivated successfully') > -1) { 103 | $('#ord-indexing-service-status').addClass('finished'); 104 | } else { 105 | $('#ord-indexing-service-status').removeClass('finished'); 106 | } 107 | 108 | $('#ord-indexing-service-status-content').html(statusHtml); 109 | $('#ord-indexing-service-status').removeClass('waiting'); 110 | } 111 | 112 | function printOrdInscriptions(content) { 113 | content = ` 114 | inscribed: 115 |

${content}

116 |
`; 117 | $('#ord-inscribed-content').html(content); 118 | $('#ord-inscribed').removeClass('waiting'); 119 | } 120 | 121 | function printOrdOutputs(content) { 122 | content = ` 123 | outputs: 124 |

${content}

125 |
`; 126 | $('#ord-outputs-content').html(content); 127 | $('#ord-outputs').removeClass('waiting'); 128 | } 129 | 130 | function printInscriptionQueue(content) { 131 | content = JSON.parse(content); 132 | let markup = ` 133 | 134 | 135 | 136 | 139 | 140 | 141 | `; 142 | 143 | $.each(content, (idx, itm) => { 144 | let fees = window.SAT_PRICE && window.TX_FEES && Object.values(window.TX_FEES); 145 | let costs; 146 | 147 | costs = fees && fees.map(fee => { 148 | return (SAT_PRICE * (itm.bytes * fee / 4)).toFixed(2); 149 | }) 150 | 151 | markup += ` 152 | 153 | 154 | 162 | 169 | `; 170 | ; 171 | }); 172 | 173 | $('#inscription-queue').removeClass('waiting'); 174 | $('#inscription-queue-content').html(markup); 175 | } 176 | 177 | function printAddresses(data) { 178 | data = JSON.parse(data); 179 | let html = `addresses: 180 | '; 193 | $('#ord-addresses').html(html); 194 | } 195 | 196 | async function setOrdWalletInfo(data) { 197 | let walletHtml; 198 | 199 | await setBtcPrice(); 200 | await setFeeOptions(); 201 | 202 | if (data.file && data.file.length) { 203 | $('#ord-wallet').addClass('exists'); 204 | 205 | filepath = data['file'].split(' ').slice(5).join(' ') 206 | walletHtml = ` 207 |

208 | file:  ${filepath} 209 |

`; 210 | } else { 211 | $('#ord-wallet').removeClass('exists'); 212 | 213 | walletHtml = '

no wallet data

' 214 | } 215 | 216 | let balance = data['balance']; 217 | 218 | walletHtml += `

balance:  ${balance}` 219 | 220 | if (balance && walletHtml.indexOf('cardinal') > -1) { 221 | let usdBalance = JSON.parse(balance); 222 | usdBalance = usdBalance.cardinal * window.SAT_PRICE; 223 | usdBalance = parseInt(usdBalance); 224 | 225 | walletHtml += `[ $${usdBalance} ]`; 226 | } 227 | 228 | walletHtml += '

' 229 | 230 | 231 | $('#ord-wallet-file').html(walletHtml); 232 | $('#ord-wallet').removeClass('waiting'); 233 | 234 | if ('inscriptions' in data) 235 | printOrdInscriptions(data['inscriptions'].replaceAll('\\n', '
')); 236 | 237 | if ('addresses' in data) 238 | printAddresses(data['addresses']) 239 | 240 | if ('outputs' in data) 241 | printOrdOutputs(data['outputs']) 242 | 243 | } 244 | 245 | function setEc2BotoCredsError() { 246 | let statusHtml = '
'; 247 | statusHtml += `

248 | boto3 could not get the ec2 instance's credentials.

249 |

This is an intermitent issue that I haven't found good solutions to.

250 |

It blocks the controller from saving status updates to the Dynamo database, which makes status more opaque.

251 |

It does not stop ord from indexing, or any of the basic functionality from working.

252 |

You can restart the server with the nearby button, which may solve this issue.

`; 253 | statusHtml += ` 254 | restart_alt 255 | `; 256 | statusHtml += '
'; 257 | $('#boto3-alerts-content').html(statusHtml); 258 | $('#system-alerts').removeClass('waiting'); 259 | } 260 | 261 | function setCloudinitStatus(data){ 262 | if (data.indexOf('finished') > -1 && data.indexOf('Cloud-init') > -1) { 263 | // TODO: this has already needed adjustment 1x, would be nice to find a more reliable / less mutable signal 264 | $('#ord-control-status').addClass('finished'); 265 | } 266 | $('#ord-control-server-content').html(data.replaceAll('\n\n', '
').replaceAll('\n','
')); 267 | } 268 | 269 | function setJournalCtlAlerts(data) { 270 | statusHtml = ''; 271 | statusHtml += data.replaceAll('\\n', '
'); 272 | statusHtml += '
'; 273 | $('#journalctl-content').html(statusHtml); 274 | $('#system-alerts').removeClass('waiting'); 275 | return statusHtml 276 | } 277 | 278 | function setControlLog(data) { 279 | let html = ` 280 |
namesize 137 | cost estimate 138 |
${itm.filename}${itm.bytes/1000} kb`; 155 | if (costs) { 156 | markup += `$${Math.min(...costs)} - $${Math.max(...costs)}`; 157 | } else { 158 | markup += 'working...'; 159 | } 160 | 161 | markup += ` 163 | 166 | inscribe 167 | 168 |
281 | 282 | 283 | 284 | 285 | 286 | 287 | 288 | `; 289 | data.forEach(row =>{ 290 | html += ` 291 | 292 | 293 | 294 | `; 295 | }) 296 | html += '
DateAddedNameDetails
${row.DateAdded.S}${row.Name.S}${row.Details.S}
'; 297 | $('#control-log-content').html(html); 298 | $('#control-log').removeClass('waiting'); 299 | } 300 | 301 | function showSeedPhrase(phrase) { 302 | alert(phrase) 303 | } 304 | 305 | $(function(){ 306 | $('#status-websocket').on('click', '.restart', evt=>{ 307 | evt.preventDefault(); 308 | window.socket.send('websocket restart'); 309 | }); 310 | 311 | $('#bitcoind-status').on('click', '.restart', evt=>{ 312 | evt.preventDefault(); 313 | window.socket.send('bitcoind restart'); 314 | $('#bitcoind-status').addClass('restarting') 315 | }); 316 | 317 | // $('#ord-indexing-service-status').on('click', '.start', evt=>{ 318 | // evt.preventDefault(); 319 | // window.socket.send('ord index restart'); 320 | // }); 321 | 322 | // $('#ord-indexing-service-status stop').click(evt=>{ 323 | // evt.preventDefault(); 324 | // window.socket.send('ord stop'); 325 | // }); 326 | 327 | $('#server').on('click', '.restart', evt=>{ 328 | evt.preventDefault(); 329 | window.socket.send('restart restart'); 330 | }); 331 | 332 | $('#ord-wallet') 333 | .on('click', '.create', evt => { 334 | evt.preventDefault(); 335 | window.socket.send('ord wallet create'); 336 | }) 337 | .on('click', '.disable', evt => { 338 | evt.preventDefault(); 339 | let confirm = confirm('Delete your WALLET?!!!!?'); 340 | if (confirm) { 341 | window.socket.send('ord wallet delete'); 342 | } 343 | }) 344 | .on('click', '.show-seed', evt => { 345 | evt.preventDefault(); 346 | window.socket.send('ord wallet seed phrase'); 347 | }) 348 | .on('click', '.new-address', evt => { 349 | evt.preventDefault(); 350 | window.socket.send('ord wallet new address'); 351 | }); 352 | 353 | function getInscriptionFeeRate(){ 354 | return $('#feeRate').val(); 355 | } 356 | 357 | function getInscriptionFilename() { 358 | return $('#inscribe-modal .filename').text(); 359 | } 360 | 361 | function getInscriptionBytes() { 362 | return parseFloat($('#inscribe-modal .bytes').data('bytes')); 363 | } 364 | 365 | $('#inscription-queue').on('click', '.inscribe-btn', evt => { 366 | let $target = $(evt.target); 367 | let numBytes = $target.data('bytes'); 368 | $('#inscribe-modal .filename').text($target.data('filename')); 369 | $('#inscribe-modal .bytes').text(`${numBytes / 1000} kb`); 370 | $('#inscribe-modal .bytes').data('bytes', numBytes); 371 | printInscriptionCost(); 372 | }); 373 | 374 | function printInscriptionCost() { 375 | let feeRate = getInscriptionFeeRate(); 376 | let cost = (SAT_PRICE * (getInscriptionBytes() * feeRate / 4)).toFixed(2); 377 | $('#inscribe-modal .cost').text(`$${cost}`); 378 | } 379 | 380 | $('#feeRate').change(evt => { 381 | printInscriptionCost(); 382 | }); 383 | 384 | 385 | $('#inscribe-inscribe').click(evt => { 386 | evt.preventDefault(); 387 | 388 | let cmd = `ord inscribe ${getInscriptionFilename()} ${getInscriptionBytes()} ${getInscriptionFeeRate()}` 389 | let btcAddress = $('#inscribe-address').val(); 390 | 391 | // TODO: ensure this is a taproot address 392 | if (btcAddress.length > 40) { 393 | cmd = `${cmd} ${btcAddress}` 394 | } 395 | 396 | window.socket.send(cmd); 397 | $('#inscribe-modal').modal('hide'); 398 | $('#executed-modal').modal('show'); 399 | $('#inscribe-address').val(''); 400 | }) 401 | }) -------------------------------------------------------------------------------- /client/js/status.js: -------------------------------------------------------------------------------- 1 | $(function(){ 2 | const searchParams = new URLSearchParams(window.location.search) 3 | const address = searchParams.get('address'); 4 | $('#address').text(address); 5 | }) -------------------------------------------------------------------------------- /client/js/utils.js: -------------------------------------------------------------------------------- 1 | UNIX_STATE_CODES = { 2 | 'D': 'Uninterruptible sleep (usually IO)', 3 | 'R': 'Running or runnable (on run queue)', 4 | 'S': 'Interruptible sleep (waiting for an event to complete)', 5 | 'T': 'Stopped, either by a job control signal or because it is being traced.', 6 | 'W': 'paging (not valid since the 2.6.xx kernel)', 7 | 'X': 'dead (should never be seen)', 8 | 'Z': 'Defunct ("zombie") process, terminated but not reaped by its parent.', 9 | '<': 'high-priority (not nice to other users)', 10 | 'N': 'low-priority (nice to other users)', 11 | 'L': 'has pages locked into memory (for real-time and custom IO)', 12 | 's': 'is a session leader', 13 | 'l': 'is multi-threaded (using CLONE_THREAD, like NPTL pthreads do)', 14 | '+': 'is in the foreground process group', 15 | } 16 | 17 | function getUnixStateCodeDetails(code = []) { 18 | console.log('getUnixStateCodeDetails', code); 19 | let details = []; 20 | 21 | for (var i = 0; i < code.length; i++) { 22 | let char = code.charAt(i); 23 | details.push(`${char}: ${UNIX_STATE_CODES[char]}`) 24 | } 25 | console.log('returning', details); 26 | return details; 27 | } -------------------------------------------------------------------------------- /client/js/websocket.js: -------------------------------------------------------------------------------- 1 | const url = `ws://${window.OrdControl.wsurl}:8765`; 2 | 3 | socket = new ReconnectingWebSocket(url); 4 | 5 | socket.debug = true 6 | 7 | socket.onclose = ()=>{ 8 | $('#ord-control-status').removeClass('open').addClass('reconnecting') 9 | } 10 | 11 | socket.onmessage = (msg)=>{ 12 | let data = {}; 13 | 14 | try { 15 | data = msg.data && JSON.parse(msg.data); 16 | } catch(err) { 17 | console.log('cannot parse websocket data', err); 18 | } 19 | 20 | if (data['bitcoind_status']) { 21 | setBitcoindStatus(data['bitcoind_status']) 22 | } 23 | 24 | if (data['ord_index_service_status']) { 25 | setOrdIndexServiceStatus(data['ord_index_service_status']) 26 | } 27 | 28 | if (data['ord_wallet']) { 29 | setOrdWalletInfo(data['ord_wallet']) 30 | } 31 | 32 | if (data['boto3_credentials_not_found']) { 33 | setEc2BotoCredsError(); 34 | } 35 | 36 | if (data['journalctl_alerts']) { 37 | setJournalCtlAlerts(data['journalctl_alerts']); 38 | } 39 | 40 | if (data['control_log']) { 41 | setControlLog(data['control_log']) 42 | } 43 | 44 | if (data['cloudinit_status']) { 45 | setCloudinitStatus(data['cloudinit_status']) 46 | } 47 | 48 | if (data['seed_phrase']) { 49 | showSeedPhrase(data['seed_phrase']) 50 | } 51 | 52 | if (data['inscription_files']) { 53 | printInscriptionQueue(data['inscription_files']) 54 | } 55 | } 56 | 57 | socket.onopen = ()=>{ 58 | socket.send(`token:${window.OrdControl.password}`); 59 | $('#status-websocket').removeClass('reconnecting').addClass('open') 60 | } 61 | 62 | 63 | window.socket = socket; -------------------------------------------------------------------------------- /docs/example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kvnn/OrdControl/5b343f5aa3d2959da9fe0668baaff58f88ae2515/docs/example.png -------------------------------------------------------------------------------- /docs/init-demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kvnn/OrdControl/5b343f5aa3d2959da9fe0668baaff58f88ae2515/docs/init-demo.gif -------------------------------------------------------------------------------- /init.tpl: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | echo "ord-server init.tpl starting" 3 | 4 | # to view logs in instance: `cat /var/log/cloud-init-output.log` 5 | # to view this script in instance: `sudo cat /var/lib/cloud/instances/{instance_id}/user-data.txt` 6 | 7 | 8 | # install OrdControl linux dependencies 9 | apt-get update 10 | DEBIAN_FRONTEND=noninteractive apt-get --assume-yes install python3-pip 11 | 12 | # wait for OrdControl file transfer to complete ... otherwise, on slow connections, we may get a "directory doesn't exist" error 13 | while [ ! -d /home/ubuntu/OrdControl ]; do echo "waiting for /home/ubuntu/OrdControl to exist..." && sleep 1; done 14 | 15 | # install OrdControl python dependencies 16 | mkdir /home/ubuntu/OrdControl/inscriptions 17 | cd /home/ubuntu/OrdControl 18 | chown -R ubuntu.ubuntu /home/ubuntu/OrdControl 19 | sudo -H -u ubuntu pip3 install -r requirements.txt 20 | 21 | 22 | # set up a mount for our Bitcoin & Ord data dir 23 | mkdir /mnt/bitcoin-ord-data 24 | chown ubuntu.ubuntu /mnt/bitcoin-ord-data 25 | echo "/dev/xvdh /mnt/bitcoin-ord-data xfs defaults 0 2" | tee -a /etc/fstab 26 | mount /dev/xvdh /mnt/bitcoin-ord-data/ 27 | 28 | # set up bitcoin 29 | echo "set up bitcoin" 30 | cd /home/ubuntu 31 | wget https://bitcoincore.org/bin/bitcoin-core-24.0.1/bitcoin-24.0.1-x86_64-linux-gnu.tar.gz 32 | tar xvzf bitcoin-24.0.1-x86_64-linux-gnu.tar.gz 33 | mv bitcoin-24.0.1 /usr/local/bin/bitcoin 34 | mkdir /etc/bitcoin 35 | chmod 755 /etc/bitcoin 36 | cp /usr/local/bin/bitcoin/bitcoin.conf /etc/bitcoin/bitcoin.conf 37 | chown -R ubuntu.ubuntu /etc/bitcoin 38 | 39 | # set up OrdControl services 40 | cd /home/ubuntu/OrdControl 41 | chown -R root.root services 42 | # chmod 755 -R services 43 | mv services/* /etc/systemd/system 44 | systemctl daemon-reload 45 | 46 | # start ord-controller service 47 | sudo /usr/bin/systemctl enable ord-controller.service 48 | sudo /usr/bin/systemctl start ord-controller.service 49 | 50 | # start bitcoind service 51 | sudo /usr/bin/systemctl enable bitcoin-for-ord.service 52 | sudo /usr/bin/systemctl start bitcoin-for-ord.service 53 | 54 | # install low level essentials for Ord 55 | DEBIAN_FRONTEND=noninteractive apt-get --assume-yes install libssl-dev 56 | DEBIAN_FRONTEND=noninteractive apt-get --assume-yes install build-essential 57 | 58 | # install rust for Ord 59 | cd /home/ubuntu 60 | HOME=/home/ubuntu curl https://sh.rustup.rs -sSf | HOME=/home/ubuntu sh -s -- -y --no-modify-path --default-toolchain stable 61 | 62 | # # fix ownership of new /home/ubuntu subdirectories 63 | chown ubuntu.ubuntu -R /home/ubuntu/.cargo /home/ubuntu/.rustup 64 | 65 | # source paths for rust / cargo 66 | source /home/ubuntu/.bashrc 67 | source /home/ubuntu/.cargo/env 68 | 69 | # build ord 70 | git clone https://github.com/casey/ord.git 71 | chown ubuntu.ubuntu /home/ubuntu/ord 72 | cd ord 73 | sudo -H -u ubuntu /home/ubuntu/.cargo/bin/cargo build --release 74 | 75 | # # start ord service 76 | /usr/bin/systemctl enable ord.timer 77 | /usr/bin/systemctl start ord.timer 78 | 79 | echo "ord-control init.tpl finished" -------------------------------------------------------------------------------- /main.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | required_providers { 3 | aws = { 4 | source = "hashicorp/aws" 5 | version = "~> 4.16" 6 | } 7 | } 8 | 9 | required_version = ">= 1.2.0" 10 | } 11 | 12 | provider "aws" { 13 | region = var.region 14 | } 15 | 16 | resource "aws_security_group" "ord_server_ssh_sg" { 17 | name = "ord_server_ssh_sg" 18 | 19 | ingress { # ssh 20 | from_port = 22 21 | to_port = 22 22 | protocol = "tcp" 23 | cidr_blocks = ["0.0.0.0/0"] 24 | } 25 | 26 | ingress { # websocket 27 | from_port = 8765 28 | to_port = 8765 29 | protocol = "tcp" 30 | cidr_blocks = ["0.0.0.0/0"] 31 | } 32 | 33 | egress { 34 | from_port = 0 35 | to_port = 0 36 | protocol = "-1" 37 | cidr_blocks = ["0.0.0.0/0"] 38 | } 39 | 40 | tags = { 41 | Name = var.resource_tag_name 42 | } 43 | } 44 | 45 | resource "tls_private_key" "pk" { 46 | algorithm = "RSA" 47 | rsa_bits = 4096 48 | } 49 | 50 | resource "aws_key_pair" "kp" { 51 | key_name = "ord_server_key" # Create "ord_server_key" in AWS 52 | public_key = tls_private_key.pk.public_key_openssh 53 | 54 | provisioner "local-exec" { # Create "ord_server.pem" locally 55 | command = <<-EOT 56 | echo '${tls_private_key.pk.private_key_pem}' > ~/.ssh/ord_server_${tls_private_key.pk.id}.pem 57 | chmod 400 ~/.ssh/ord_server_${tls_private_key.pk.id}.pem 58 | EOT 59 | } 60 | 61 | tags = { 62 | Name = var.resource_tag_name 63 | } 64 | } 65 | 66 | resource "random_password" "password" { 67 | length = 16 68 | special = false 69 | } 70 | 71 | data "cloudinit_config" "post_deploy" { 72 | part { 73 | content_type = "text/x-shellscript" 74 | content = templatefile("init.tpl", { 75 | # environment = var.env 76 | }) 77 | } 78 | } 79 | 80 | resource "aws_dynamodb_table" "ord_server_table" { 81 | name = "OrdControlTable" 82 | billing_mode = "PROVISIONED" 83 | read_capacity = 20 84 | write_capacity = 20 85 | hash_key = "Id" 86 | range_key = "DateAdded" 87 | 88 | attribute { 89 | name = "Id" 90 | type = "S" 91 | } 92 | 93 | attribute { 94 | name = "DateAdded" 95 | type = "S" 96 | } 97 | 98 | attribute { 99 | name = "Name" 100 | type = "S" 101 | } 102 | 103 | ttl { 104 | attribute_name = "TimeToExist" 105 | enabled = false 106 | } 107 | 108 | global_secondary_index { 109 | name = "NameIndex" 110 | hash_key = "Name" 111 | range_key = "DateAdded" 112 | write_capacity = 10 113 | read_capacity = 10 114 | projection_type = "KEYS_ONLY" 115 | /* non_key_attributes = ["UserId"] */ 116 | } 117 | 118 | tags = { 119 | Name = var.resource_tag_name 120 | } 121 | } 122 | 123 | 124 | # FUN WITH PERMISSIONS 125 | # ec2-role -> policy file -> instance profile -> {instance-profile in ec2 resource} 126 | resource "aws_iam_policy" "ordserver_ec2_policy" { 127 | name = "ordserver_ec2_policy" 128 | description = "ordserver_ec2_policy" 129 | 130 | policy = jsonencode({ 131 | Version = "2012-10-17" 132 | Statement = [ 133 | { 134 | Effect = "Allow" 135 | Action = [ 136 | "dynamodb:*" 137 | ] 138 | Resource = [ 139 | "${aws_dynamodb_table.ord_server_table.arn}" 140 | ] 141 | } 142 | ] 143 | }) 144 | 145 | tags = { 146 | Name = var.resource_tag_name 147 | } 148 | } 149 | 150 | resource "aws_iam_role" "ordserver_ec2_role" { 151 | name = "ordserver_ec2_role" 152 | 153 | # Terraform's "jsonencode" function converts a 154 | # Terraform expression result to valid JSON syntax. 155 | assume_role_policy = jsonencode({ 156 | Version = "2012-10-17" 157 | Statement = [ 158 | { 159 | Action = "sts:AssumeRole" 160 | Effect = "Allow" 161 | Sid = "" 162 | Principal = { 163 | Service = "ec2.amazonaws.com" 164 | } 165 | }, 166 | ] 167 | }) 168 | 169 | tags = { 170 | Name = var.resource_tag_name 171 | } 172 | } 173 | 174 | resource "aws_iam_policy_attachment" "ordserver_ec2_policy_attachment" { 175 | name = "ordserver_ec2_policy_attachment" 176 | policy_arn = aws_iam_policy.ordserver_ec2_policy.arn 177 | roles = [aws_iam_role.ordserver_ec2_role.name] 178 | } 179 | 180 | resource "aws_iam_instance_profile" "ordserver_ec2_instance_profile" { 181 | name = "ordserver_ec2_instance_profile" 182 | role = aws_iam_role.ordserver_ec2_role.name 183 | 184 | tags = { 185 | Name = var.resource_tag_name 186 | } 187 | } 188 | 189 | resource "aws_instance" "ord_server" { 190 | ami = "ami-095413544ce52437d" 191 | instance_type = var.instance_type 192 | availability_zone = var.availability_zone 193 | user_data = data.cloudinit_config.post_deploy.rendered 194 | key_name = aws_key_pair.kp.key_name 195 | iam_instance_profile = aws_iam_instance_profile.ordserver_ec2_instance_profile.name 196 | security_groups = [aws_security_group.ord_server_ssh_sg.name] 197 | 198 | tags = { 199 | Name = var.resource_tag_name 200 | } 201 | 202 | provisioner "file" { 203 | source = "server" 204 | destination = "/home/ubuntu/OrdControl" 205 | 206 | connection { 207 | type = "ssh" 208 | host = aws_instance.ord_server.public_ip 209 | user = "ubuntu" 210 | private_key = file("~/.ssh/ord_server_${tls_private_key.pk.id}.pem") 211 | insecure = true 212 | } 213 | } 214 | 215 | provisioner "local-exec" { 216 | command = <<-EOT 217 | echo 'window.OrdControl = window.OrdControl || {};' > client/js/env.js 218 | echo 'window.OrdControl.password="${random_password.password.result}";' >> client/js/env.js 219 | echo 'window.OrdControl.wsurl="${aws_instance.ord_server.public_dns}";' >> client/js/env.js 220 | echo `window.OrdControl.connectionString = "ssh -o 'StrictHostKeyChecking no' -i ~/.ssh/ord_server_${tls_private_key.pk.id}.pem ubuntu@${aws_instance.ord_server.public_dns}";` >> client/js/env.js 221 | cp client/js/env.js server/client-env.js.txt 222 | EOT 223 | } 224 | } 225 | 226 | resource "aws_ebs_volume" "bitcoin_ord_data" { 227 | # This snapshot is from February 27 2023, & contains fully synced bitcoind & ord data dirs 228 | snapshot_id = var.snapshot_id 229 | availability_zone = var.availability_zone 230 | type = "gp3" 231 | 232 | size = 3123 233 | iops = 4000 234 | 235 | tags = { 236 | Name = var.resource_tag_name 237 | } 238 | } 239 | 240 | resource "aws_volume_attachment" "bitcoin_ord_data_att" { 241 | # note that this device_name is not respected by the instance types that use nvme 242 | device_name = "/dev/xvdh" 243 | volume_id = aws_ebs_volume.bitcoin_ord_data.id 244 | instance_id = aws_instance.ord_server.id 245 | } 246 | -------------------------------------------------------------------------------- /outputs.tf: -------------------------------------------------------------------------------- 1 | output "instance_id" { 2 | description = "ID of the EC2 instance" 3 | value = aws_instance.ord_server.id 4 | } 5 | 6 | output "instance_public_ip" { 7 | description = "Public IP address of the EC2 instance" 8 | value = aws_instance.ord_server.public_dns 9 | } 10 | 11 | output "ssh_connection_string" { 12 | description = "Connection string to connect to instance via ssh" 13 | # value = format("ssh -i %s ubuntu@%s", var.zone, var.cluster_name) 14 | value = "ssh -o 'StrictHostKeyChecking no' -i ~/.ssh/ord_server_${tls_private_key.pk.id}.pem ubuntu@${aws_instance.ord_server.public_dns}" 15 | } 16 | 17 | output "bitcoin_ord_data_volume_device_name" { 18 | description = "Device name for our snapshot'd bitcoin and ord volume" 19 | value = aws_volume_attachment.bitcoin_ord_data_att.device_name 20 | } 21 | -------------------------------------------------------------------------------- /server/controller.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from datetime import datetime 3 | import json 4 | import os 5 | import subprocess 6 | from threading import Thread 7 | 8 | import boto3 9 | from botocore.exceptions import NoCredentialsError 10 | import websockets 11 | 12 | 13 | dynamodb = boto3.client('dynamodb', region_name='us-west-2') 14 | 15 | # globals 16 | CLIENTS = set() 17 | ord_wallet_addresses = [] 18 | ec2_credentials_failure = False 19 | 20 | dynamo_table_name = 'OrdControlTable' 21 | ord_wallet_dir = '/mnt/bitcoin-ord-data/bitcoin/ord' 22 | bitcoincli_cmd = '/usr/local/bin/bitcoin/bin/bitcoin-cli -conf=/etc/bitcoin/bitcoin.conf -datadir=/mnt/bitcoin-ord-data/bitcoin' 23 | ord_cmd = '/home/ubuntu/ord/target/release/ord --bitcoin-data-dir=/mnt/bitcoin-ord-data/bitcoin --data-dir=/mnt/bitcoin-ord-data/ord' 24 | 25 | # get our terraform-generated password (see main.tf) 26 | ourpath = os.path.dirname(os.path.realpath(__file__)) 27 | seed_phrase_filepath = os.path.join(ourpath, 'seed-phrase.txt') 28 | token_filepath = os.path.join(ourpath, 'client-env.js.txt') 29 | token_file = open(token_filepath, 'r') 30 | token = token_file.read() 31 | token = token.split('window.OrdControl.password="')[1].split('";')[0] 32 | 33 | 34 | def _build_dynamo_item(name, details): 35 | now = datetime.utcnow().isoformat() 36 | id = f'{name}-{now}' 37 | return { 38 | 'Id': { 39 | 'S': str(id) 40 | }, 41 | 'DateAdded': { 42 | 'S': str(now) 43 | }, 44 | 'Name': { 45 | 'S': str(name) 46 | }, 47 | 'Details': { 48 | 'S': str(details) 49 | } 50 | } 51 | 52 | 53 | def _put_dynamo_item(name, details=''): 54 | global ec2_credentials_failure 55 | 56 | print(f'_put_dynamo_item {name} {details}') 57 | try: 58 | resp = dynamodb.put_item(TableName=dynamo_table_name, Item=_build_dynamo_item(name, details)) 59 | except NoCredentialsError as e: 60 | # TODO: why can't i find more info on this intermittent problem b/w boto3 and ec2? 61 | print('boto3 could not get ec2 credentials') 62 | ec2_credentials_failure = True 63 | return False 64 | return resp 65 | 66 | def _popen(cmd): 67 | return subprocess.Popen([cmd], shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) 68 | 69 | def _cmd(cmd): 70 | try: 71 | proc = _popen(cmd) 72 | except FileNotFoundError: 73 | print('_cmd FileNotFoundError') 74 | if len(error): error += '\n' 75 | error += f'error: FileNotFound for {cmd}. This happens if the base program is still in the build process.' 76 | return None, error 77 | except Exception as e: 78 | print(f'_cmd Exception: {e}') 79 | if len(error): error += '\n' 80 | error += f'error: another exception occurred: {e}' 81 | return None, error 82 | 83 | # TODO : below this line should be separated into a method like "_make_subprocess_output_readable" 84 | out = '' 85 | errors = '' 86 | out_raw = proc.stdout.readlines() 87 | error_raw = proc.stderr.readlines() 88 | 89 | for line in out_raw: 90 | try: 91 | out += line.decode('ascii') 92 | except: 93 | out += str(line) 94 | out += '\n' 95 | 96 | for line in error_raw: 97 | try: 98 | errors += line.decode('ascii') 99 | except: 100 | errors += str(line) 101 | errors += '\n' 102 | 103 | return out, errors 104 | 105 | 106 | def _cmd_output_or_error(cmd): 107 | output, error = _cmd(cmd) 108 | return output if output else error 109 | 110 | 111 | async def echo(websocket): 112 | async for message in websocket: 113 | await websocket.send(message) 114 | 115 | 116 | async def exec(websocket): 117 | inscription_name = 'inscription' 118 | client_token = await websocket.recv() 119 | client_token = client_token.split('token:')[1] 120 | if token == client_token: 121 | CLIENTS.add(websocket) 122 | async for message in websocket: 123 | print(f'websocket recvd {message}') 124 | try: 125 | if message == 'websocket restart': 126 | _popen('sudo systemctl restart ord-controller.service') 127 | elif message == 'bitcoind restart': 128 | _popen('sudo systemctl restart bitcoin-for-ord.service') 129 | elif message == 'restart restart': 130 | _popen('sudo shutdown -r now') 131 | elif message == 'ord wallet create': 132 | create_ord_wallet() 133 | elif message == 'ord wallet delete': 134 | disable_ord_wallet() 135 | elif message == 'ord wallet seed phrase': 136 | await return_seed_phrase() 137 | elif message == 'ord wallet new address': 138 | create_ord_address() 139 | elif type(message) == bytes: 140 | upload(inscription_name, message) 141 | elif message.startswith('inscription_name:'): 142 | inscription_name = message.split('inscription_name:')[1] 143 | elif message.startswith('ord inscribe'): 144 | inscribe(*message.split(' ')[2:]) 145 | except Exception as e: 146 | print(f'exec error: {e}') 147 | await websocket.send(f'Exception: {e}') 148 | else: 149 | await websocket.close(1011, "authentication failed") 150 | return 151 | 152 | 153 | def get_ps_as_dicts(ps_output): 154 | output = [] 155 | headers = [h for h in ' '.join(ps_output[0].decode('ascii').strip().split()).split() if h] 156 | if len(ps_output) > 1: 157 | for row in ps_output[1:]: 158 | values = row.decode('ascii').replace('\n', '').split(None, len(headers) - 1) 159 | output.append({headers[i]: values[i] for i in range(len(headers))}) 160 | return output 161 | 162 | 163 | ord_index_output = [] 164 | 165 | def get_ord_indexing_details(): 166 | global ord_index_output 167 | ord_index_output.append('looking for ord index...') 168 | ps = _popen('ps aux | head -1; ps aux | grep "[/]home/ubuntu/ord/target/release/ord"').stdout.readlines() 169 | output = get_ps_as_dicts(ps) 170 | if len(output): 171 | pid = output[0]['PID'] 172 | ord_index_output.append(f'found PID {pid}') 173 | # see https://stackoverflow.com/questions/54091396/live-output-stream-from-python-subprocess/54091788#54091788 174 | with _popen(f'sudo strace -qfp {pid} -e trace=write -e write=1,2') as process: 175 | ord_index_output.append('running strace...') 176 | for line in process.stdout: 177 | line_txt = line.decode('ascii') 178 | ord_index_output.append(line_txt) 179 | now = datetime.utcnow().isoformat() 180 | item = { 181 | 'Id': { 182 | 'S': f'Ord_Index_Output_{now}' 183 | }, 184 | 'DateAdded': { 185 | 'S': now 186 | }, 187 | 'Name': { 188 | 'S': 'Ord_Index_Output' 189 | }, 190 | 'Details': { 191 | 'S': line_txt 192 | } 193 | } 194 | if not ec2_credentials_failure: 195 | dynamodb.put_item(TableName=dynamo_table_name, Item=item) 196 | 197 | 198 | def upload(name, bytes): 199 | filepath = os.path.join(ourpath, f'inscriptions/{name}') 200 | with open(filepath, 'wb') as file: 201 | file.write(bytes) 202 | 203 | def inscribe(filename, numbytes, feerate, destination=None): 204 | # TODO: ensure that the numbytes matches the file size, 205 | # to prevent unexpected costs from user error 206 | filepath = os.path.join(ourpath, f'inscriptions/{filename}') 207 | cmd = f'{ord_cmd} wallet inscribe {filepath} --fee-rate {feerate}' 208 | if destination: 209 | cmd += f' --destination {destination}' 210 | output, error = _cmd(cmd) 211 | print(f'inscribe output={output}, error={error}') 212 | if len(error): 213 | _put_dynamo_item('inscription-error', error) 214 | else: 215 | _put_dynamo_item('inscribed', f'{filename}: {output}') 216 | 217 | def get_ord_indexing_output(): 218 | global ord_index_output 219 | return json.dumps({"ord_index_output": ord_index_output}) 220 | 221 | 222 | def get_ord_index_service_status(): 223 | output, error = _cmd('journalctl -r -u ord.service') 224 | return json.dumps({ 225 | "ord_index_service_status": output, 226 | "ord_index_service_status_error": error 227 | }) 228 | 229 | def get_cloudinit_status(): 230 | output, errors = _cmd('tail -n 10 /var/log/cloud-init-output.log') 231 | return json.dumps({ 232 | 'cloudinit_status': errors if errors else output 233 | }) 234 | 235 | 236 | def get_bitcoind_status(): 237 | pid = 99999999 238 | pid_search = _popen("systemctl status bitcoin-for-ord.service | grep 'Main PID' | awk '{print $3}'").stdout.readlines() 239 | 240 | if len(pid_search) == 1: 241 | pid = pid_search[0].decode('ascii').replace('\n', '') 242 | ps = _popen(f'ps -p {pid} -o user,pid,ppid,%cpu,%mem,vsz,rss,tty,stat,start,time,command').stdout.readlines() 243 | output = {"bitcoind_status": get_ps_as_dicts(ps)} 244 | 245 | return json.dumps(output) 246 | 247 | 248 | def get_inscription_files(): 249 | # Get list of all files only in the given directory 250 | output = [] 251 | path = os.path.join(ourpath, f'inscriptions') 252 | fun = lambda x : os.path.isfile(os.path.join(path,x)) 253 | files_list = filter(fun, os.listdir(path)) 254 | 255 | # Create a list of files in directory along with the size 256 | sizes_list = [ 257 | (f,os.stat(os.path.join(path, f)).st_size) 258 | for f in files_list 259 | ] 260 | 261 | sizes_list = sorted(sizes_list, key=lambda tup: tup[1]) 262 | 263 | # Iterate over list of files along with size 264 | # and print them one by one. 265 | for filename, size in sizes_list: 266 | output.append({'filename': filename, 'bytes': round(size,3)}) 267 | 268 | # dirpath = os.path.join(ourpath, f'inscriptions') 269 | # output = _cmd_output_or_error(f'ls -la {dirpath}') 270 | 271 | return json.dumps({'inscription_files': json.dumps(output)}) 272 | 273 | 274 | def create_ord_wallet(): 275 | try: 276 | output, error = _cmd(f'{ord_cmd} wallet create') 277 | 278 | if len(error): 279 | _put_dynamo_item('ord-wallet-created-error', error) 280 | else: 281 | seed_phrase = json.loads(output).get('mnemonic') 282 | 283 | with open(seed_phrase_filepath, 'w', encoding="utf-8") as f: 284 | f.write(seed_phrase) 285 | 286 | except Exception as e: 287 | _put_dynamo_item('ord-wallet-created-error', str(e)) 288 | 289 | 290 | def create_ord_address(): 291 | # TODO: we prob want some sort of feedback here 292 | _cmd(f'{ord_cmd} wallet receive') 293 | 294 | 295 | async def return_seed_phrase(): 296 | try: 297 | with open(seed_phrase_filepath, encoding="utf-8") as f: 298 | seed_phrase = f.read() 299 | except Exception as e: 300 | seed_phrase = f'error: {e}' 301 | _put_dynamo_item('return-seed-phrase-error', seed_phrase) 302 | await broadcast(json.dumps({'seed_phrase': seed_phrase})) 303 | 304 | 305 | def disable_ord_wallet(): 306 | now = datetime.utcnow().isoformat() 307 | newpath = f'/mnt/bitcoin-ord-data/bitcoin/.OLD_ord-wallet-{now}' 308 | _popen(f'mv {ord_wallet_dir} {newpath}') 309 | _put_dynamo_item('ord-wallet-disabled', f'wallet dir moved to {newpath}') 310 | 311 | 312 | def get_ord_wallet(): 313 | global ord_wallet_addresses 314 | 315 | ord_wallet = { 316 | 'file': '' 317 | } 318 | 319 | # find the file, if exists 320 | file_output, error = _cmd(f'ls -la {ord_wallet_dir}/wallet.dat') 321 | 322 | if len(file_output): 323 | ord_wallet['file'] = file_output 324 | 325 | # wallet help 326 | if 'help' not in ord_wallet: 327 | ord_wallet['help'] = _cmd_output_or_error(f'{ord_cmd} wallet help') 328 | 329 | if len(ord_wallet['file']): 330 | ord_wallet['outputs'] = _cmd_output_or_error(f'{ord_cmd} wallet outputs') 331 | ord_wallet['balance'] = _cmd_output_or_error(f'{ord_cmd} wallet balance') 332 | ord_wallet['addresses'] = _cmd_output_or_error(f'{bitcoincli_cmd} listreceivedbyaddress 0 true') 333 | ord_wallet['inscriptions'] = _cmd_output_or_error(f'{ord_cmd} wallet inscriptions') 334 | 335 | return json.dumps({"ord_wallet": ord_wallet}) 336 | 337 | 338 | def get_journalctl_alerts(): 339 | output, error = _cmd('journalctl -r -p 0..4') 340 | return json.dumps({ 341 | 'journalctl_alerts': output, 342 | 'journalctl_errors': error 343 | }) 344 | 345 | 346 | def get_dynamo_items(): 347 | global ec2_credentials_failure 348 | try: 349 | items = dynamodb.scan(TableName=dynamo_table_name) 350 | items = items['Items'] 351 | # we should probably change the dybamodb.scan to a .query and sort there 352 | items.sort(key = lambda x:x['DateAdded']['S'], reverse=True) 353 | return json.dumps({'control_log': items}) 354 | except NoCredentialsError as e: 355 | # TODO: why can't i find more info on this intermittent problem b/w boto3 and ec2? 356 | print('boto3 could not get ec2 credentials') 357 | ec2_credentials_failure = True 358 | return json.dumps({'control_log': [{ 359 | 'DateAdded': { 360 | 'S' : '-' 361 | }, 362 | 'Name': { 363 | 'S': 'dynamo failure' 364 | }, 365 | 'Details': { 366 | 'S': 'boto3 could not load ec2 credentials to connect to dynamo' 367 | } 368 | }]}) 369 | 370 | 371 | async def broadcast(message): 372 | for websocket in CLIENTS.copy(): 373 | try: 374 | await websocket.send(message) 375 | except websockets.ConnectionClosed: 376 | pass 377 | 378 | 379 | async def broadcast_messages(): 380 | global ord_index_output 381 | while True: 382 | print('broadcasting') 383 | await broadcast(get_cloudinit_status()) 384 | await broadcast(get_bitcoind_status()) 385 | await broadcast(get_ord_index_service_status()) 386 | await broadcast(get_ord_wallet()) 387 | await broadcast(get_ord_indexing_output()) 388 | await broadcast(get_journalctl_alerts()) 389 | await broadcast(get_dynamo_items()) 390 | await broadcast(get_inscription_files()) 391 | # print(f'ord_index_output is {ord_index_output}') 392 | 393 | if ec2_credentials_failure: 394 | await broadcast(json.dumps({ 395 | 'boto3_credentials_not_found': True 396 | })) 397 | 398 | await asyncio.sleep(5) 399 | 400 | 401 | async def main(): 402 | async with websockets.serve(exec, "0.0.0.0", 8765): 403 | # await asyncio.Future() # run forever 404 | await broadcast_messages() # runs forever 405 | 406 | 407 | # the ord-indexing-details output just isn't very helpful lately 408 | # t1 = Thread(target=get_ord_indexing_details) 409 | # t1.start() 410 | 411 | asyncio.run(main()) 412 | -------------------------------------------------------------------------------- /server/requirements.txt: -------------------------------------------------------------------------------- 1 | boto3==1.26.72 2 | websockets==10.4 -------------------------------------------------------------------------------- /server/services/bitcoin-for-ord.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Bitcoin daemon 3 | Documentation=https://github.com/bitcoin/bitcoin/blob/master/doc/init.md 4 | # https://www.freedesktop.org/wiki/Software/systemd/NetworkTarget/ 5 | After=network-online.target 6 | Wants=network-online.target 7 | 8 | [Service] 9 | ExecStart=/usr/local/bin/bitcoin/bin/bitcoind -txindex -pid=/mnt/bitcoin-ord-data/bitcoin/bitcoind.pid -conf=/etc/bitcoin/bitcoin.conf -datadir=/mnt/bitcoin-ord-data/bitcoin --daemon 10 | Type=forking 11 | Restart=on-failure 12 | TimeoutStartSec=infinity 13 | TimeoutStopSec=600 14 | User=ubuntu 15 | Group=ubuntu 16 | PrivateTmp=true 17 | ProtectSystem=full 18 | RuntimeDirectoryMode=0710 19 | 20 | [Install] 21 | WantedBy=multi-user.target -------------------------------------------------------------------------------- /server/services/ord-controller.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=OrdControl Controller 3 | Documentation=https://github.com/kvnn/OrdControl/blob/master/README.md 4 | After=network-online.target 5 | Wants=network-online.target 6 | 7 | [Service] 8 | ExecStart=/usr/bin/python3 /home/ubuntu/OrdControl/controller.py 9 | Type=idle 10 | Restart=on-failure 11 | User=ubuntu 12 | Group=ubuntu 13 | 14 | [Install] 15 | WantedBy=multi-user.target -------------------------------------------------------------------------------- /server/services/ord.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | After=network.target 3 | Description=Ord server 4 | StartLimitBurst=120 5 | StartLimitIntervalSec=10m 6 | 7 | [Service] 8 | AmbientCapabilities=CAP_NET_BIND_SERVICE 9 | Environment=RUST_BACKTRACE=1 10 | Environment=RUST_LOG=info 11 | ExecStart=/home/ubuntu/ord/target/release/ord --bitcoin-data-dir=/mnt/bitcoin-ord-data/bitcoin --data-dir=/mnt/bitcoin-ord-data/ord index 12 | Restart=on-failure 13 | # bitcoind may need to finish syncing, so lets keep a long restart time 14 | RestartSec=60s 15 | TimeoutStopSec=3000m 16 | Type=simple 17 | User=ubuntu 18 | Group=ubuntu 19 | 20 | [Install] 21 | WantedBy=multi-user.target -------------------------------------------------------------------------------- /server/services/ord.timer: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Make the Ord indexer run every 10 mins 3 | Requires=ord.service 4 | 5 | [Timer] 6 | Unit=ord.service 7 | OnCalendar=*:0/10 8 | 9 | [Install] 10 | WantedBy=timers.target -------------------------------------------------------------------------------- /server/thread_test.py: -------------------------------------------------------------------------------- 1 | import time 2 | from threading import Thread 3 | 4 | 5 | testvar = [] 6 | 7 | def go(): 8 | global testvar 9 | while True: 10 | testvar.append(time.time()) 11 | time.sleep(2) 12 | 13 | t1 = Thread(target=go) 14 | t1.start() 15 | 16 | while True: 17 | print(f'testvar is {testvar}') 18 | time.sleep(2) -------------------------------------------------------------------------------- /variables.tf: -------------------------------------------------------------------------------- 1 | variable "resource_tag_name" { 2 | description = "Value of the Name tag for AWS resources" 3 | type = string 4 | default = "OrdControl" 5 | } 6 | 7 | 8 | variable "region" { 9 | type = string 10 | default = "us-west-2" 11 | } 12 | 13 | variable "availability_zone" { 14 | type = string 15 | default = "us-west-2c" 16 | } 17 | 18 | variable "snapshot_id" { 19 | type = string 20 | # march 15 2023 : data dir synced w/ bitcoind and ord 21 | default = "snap-053e9c0f45613d523" 22 | } 23 | 24 | variable "instance_type" { 25 | type = string 26 | default = "t2.medium" # t2.large is stable; t2.medium is experimental 27 | } --------------------------------------------------------------------------------