├── .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 | 
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 |
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 |
77 |
78 |
79 |
80 | system-wide alerts
81 |
82 |
83 |
84 |
85 |
90 |
91 |
92 |
93 |
94 |
95 |
96 |
ord wallet
97 |
121 |
122 |
123 |
124 |
inscription queue
125 |
126 |
127 |
128 |
132 |
133 |
134 |
135 |
136 |
137 |
138 |
outputs
139 |
144 |
145 |
146 |
inscribed
147 |
152 |
153 |
154 |
155 |
156 |
ord index
157 |
158 |
159 |
160 |
161 | fully indexed
162 |
163 |
164 | waiting...
165 |
166 |
167 |
168 |
169 |
170 |
171 |
event log
172 |
177 |
178 |
179 |
180 |
181 |
182 |
183 |
184 |
185 |
189 |
190 |
191 | filename:
192 |
193 |
194 | size:
195 |
196 |
197 |
198 |
199 |
200 | cost:
201 |
202 |
203 |
204 |
205 |
206 |
210 |
211 |
212 |
213 |
214 |
215 |
216 |
217 |
218 |
221 |
222 |
223 | You are on your own now.
224 |
225 |
226 | If inscription was successfully broadcast, its transactions should show under "outputs" .
227 |
228 |
229 | If not, there should be output in the "event log" (scroll down) .
230 |
231 |
232 | Until the change-transaction processes, your wallet will show a lower than actual balance.
233 |
234 |
235 | Feel free to add an Issue at github.com/kvnn/OrdControl/issues
236 |
237 |
238 |
241 |
242 |
243 |
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 |
`;
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 | name |
135 | size |
136 |
137 | cost estimate
138 | |
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 | ${itm.filename} |
153 | ${itm.bytes/1000} kb |
154 | `;
155 | if (costs) {
156 | markup += `$${Math.min(...costs)} - $${Math.max(...costs)}`;
157 | } else {
158 | markup += 'working...';
159 | }
160 |
161 | markup += ` |
162 |
163 |
166 | inscribe
167 |
168 | |
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 |
281 |
282 |
283 | DateAdded |
284 | Name |
285 | Details |
286 |
287 |
288 | `;
289 | data.forEach(row =>{
290 | html += `
291 | ${row.DateAdded.S} |
292 | ${row.Name.S} |
293 | ${row.Details.S} |
294 |
`;
295 | })
296 | html += '
';
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 | }
--------------------------------------------------------------------------------